8 Commits

Author SHA1 Message Date
julian 215a3ac405 fix(ci): publish desktop installers via Gitea Release, not upload-artifact
Build desktop / desktop (push) Successful in 4m17s
CI / check (push) Successful in 39s
actions/upload-artifact@v4's backend fails on the Gitea runner (Upload installers
step errored). Mirror release.yml's proven path instead: curl + the built-in token
to the Releases API, into a ROLLING per-branch prerelease (tag desktop-<branch>,
deleted+recreated each push). Installers renamed space-free
(parking-desktop-<branch>-<sha>.{deb,AppImage}). Signed v* releases unchanged.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 10:32:29 +02:00
julian e0cfeb5e71 fix(ci): unsigned desktop build must disable updater artifacts
CI / check (push) Successful in 38s
Build desktop / desktop (push) Failing after 3m56s
createUpdaterArtifacts:true (for release.yml's .sig signing) makes `tauri build`
demand TAURI_SIGNING_PRIVATE_KEY and fail without it — even though the .deb/.AppImage
built fine. Override it off for the unsigned per-commit build via
--config '{"bundle":{"createUpdaterArtifacts":false}}'. release.yml keeps signing.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 10:24:47 +02:00
julian 8129b63a8c feat(profile): self-service name/email/password + desktop installers in CI
Build desktop / desktop (push) Failing after 5m2s
Build & push images / images (push) Successful in 3m1s
CI / check (push) Successful in 40s
Self-service profile: any signed-in user edits their OWN fullName/email and
changes their OWN password (proving the current one), without any user:*
permission. New routes PUT /api/auth/profile + /api/auth/password act only on
req.user.sub (cannot touch username/role), CSRF-guarded; SPA screen at /profile
reachable from the header username chip. email added to the session view +
SessionUser. 7 tests (routes/profile.test.ts); 148 server tests green.

Desktop in CI: new .gitea/workflows/build-desktop.yml builds .deb + .AppImage
on every push to dev/main and uploads them as unsigned workflow artifacts
(per-commit test build). Signed/versioned release stays on release.yml (tag v*).

Wiki: local-jwt-auth (self-service routes), desktop-shell-tauri (two-workflow CI
split), log entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 10:15:34 +02:00
julian f9bd586265 docs(wiki): session context — first booth go-live (user split, Docker deploy, web access)
appliance-provisioning.md: new §5c (admin/operator OS user split — verified; strip
lxd/lpadmin/docker from the operator) + fleshed-out §6 runtime (resolute codename caveat,
the standalone deploy dir + .env, the deploy commands, seed-admin, healthy-startup signal,
and the web-access gotchas). log.md: the [2026-06-23] go-live entry (CI uv fix, compose env
passthrough, relative /api, Caddy proxy). Container-deployment "Web access" section already
landed last commit.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 19:29:36 +02:00
julian aa546235fb docs(wiki): container-deployment — relative /api + Caddy proxy web-access section
Build & push images / images (push) Successful in 2m40s
CI / check (push) Successful in 34s
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 19:15:44 +02:00
julian c637b2783c feat(deploy): Caddy reverse proxy — clean port-80 URL, server internal
Operators/admins reach the booth at http://<name-or-ip>/ (no :3000). Adds a caddy:2-alpine
proxy to the prod override that reverse-proxies :80 → server:3000 (the /api/ws WebSocket
upgrades pass through natively); the server is now `expose: 3000` (internal, no published
port), vision stays internal. The Caddyfile binds `:80` so it matches ANY hostname/IP —
works for the booth IP, localhost, AND parksystems.msai.al (pointed at the booth via
hosts/DNS on-site; no domain baked into any image). TLS later = swap `:80` for the real
hostname + uncomment :443 → Caddy auto-provisions HTTPS.

Pairs with the relative-/api SPA fix (77b2acb): together verified end-to-end locally —
through Caddy on :80 with Host: parksystems.msai.al, GET / serves the SPA, assets/health
200, and POST /api/auth/login reaches the server (real 401, no CORS/connection error).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 19:15:23 +02:00
julian 77b2acb1ca fix(docker): SPA must use same-origin API base in the server image (CORS)
apps/web/.env.production sets VITE_API_BASE=http://127.0.0.1:3000 for the TAURI
desktop build (which loads from tauri://localhost and needs an absolute backend
origin). But Vite auto-loads .env.production for ANY `vite build`, so the server
image baked 127.0.0.1:3000 into the browser bundle — loading the UI from a real
host (e.g. http://parksystems.msai.al) then made the browser call 127.0.0.1:3000
cross-origin and fail the Same-Origin Policy on /api/auth/login.

Fix: the server Dockerfile writes apps/web/.env.production.local with an empty
VITE_API_BASE before the web build (.local has higher Vite precedence), so the SPA
served by Fastify stays relative/same-origin (/api/...). The desktop build is
unaffected (it doesn't use this Dockerfile). Verified: 127.0.0.1:3000 no longer in
the built bundle; /api/auth/login is relative.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 18:58:53 +02:00
julian 10923164ad fix(compose): pass COOKIE_SECURE, WS_ALLOWED_ORIGINS, EVENT_SIGNING_KEY, VISION_ENABLED
The base compose only forwarded DATABASE_URL/VISION_URL/JWT_SECRET, so a booth deploy
was missing the vars that actually make it usable on the plain-HTTP LAN:
- COOKIE_SECURE (default 0) — without it auth cookies are HTTPS-only and operators
  CANNOT log in over http. The #1 booth-deploy footgun.
- WS_ALLOWED_ORIGINS — the live-feed WS rejects the browser Origin without it.
- EVENT_SIGNING_KEY — dedicated ledger key (falls back to JWT_SECRET if empty).
- VISION_ENABLED=1 — the server's ANPR master switch.
All driven from .env; verified via `docker compose config` that the seven vars resolve.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 18:37:52 +02:00
17 changed files with 825 additions and 15 deletions
+134
View File
@@ -0,0 +1,134 @@
name: Build desktop
# Build the Tauri desktop installers (.deb + .AppImage) on every push to dev/main and
# upload them as workflow ARTIFACTS — a downloadable, per-commit build for testing the
# native shell. This is NOT a release: it's unsigned (no updater key) and creates no Gitea
# Release. Signed, versioned releases stay on release.yml (tag v* → .deb/.rpm/.AppImage +
# latest.json for the auto-updater). See wiki/decisions/desktop-shell-tauri.md.
on:
push:
branches: [dev, main]
paths:
- 'apps/desktop/**'
- 'apps/web/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.gitea/workflows/build-desktop.yml'
workflow_dispatch:
jobs:
desktop:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node 22
uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
- name: Install Tauri system deps
# Same set release.yml uses (verified): WebKitGTK 4.1 + libsoup-3 + the GTK/
# appindicator/rsvg stack + AppImage tooling (patchelf, file).
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
file \
build-essential \
curl \
wget
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo + target
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
apps/desktop/src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build desktop bundle (.deb + .AppImage)
# Unsigned — no TAURI_SIGNING_* here (this is a test artifact, not an updater
# release). The config sets createUpdaterArtifacts:true (release.yml signs them),
# which makes tauri DEMAND the signing key and fail without it — so override it to
# false for this build via --config (a JSON patch merged over tauri.conf.json).
# --bundles restricts to the two installers we ship; tauri builds the web SPA
# first (beforeBuildCommand), so the desktop UI matches.
run: >
pnpm --filter @parking/desktop bundle
--bundles deb,appimage
--config '{"bundle":{"createUpdaterArtifacts":false}}'
- name: Collect installers
id: collect
# Copy out the two installers under SPACE-FREE names (tauri names them
# "Parking System_0.0.0_amd64.deb" — spaces break asset URLs). Short SHA in the
# name so a downloaded file is traceable to its commit.
run: |
set -e
BUNDLE=apps/desktop/src-tauri/target/release/bundle
SHA="$(echo "${GITHUB_SHA}" | cut -c1-7)"
mkdir -p dist
deb=$(find "$BUNDLE/deb" -name '*.deb' | head -1)
app=$(find "$BUNDLE/appimage" -name '*.AppImage' | head -1)
cp "$deb" "dist/parking-desktop-${GITHUB_REF_NAME}-${SHA}.deb"
cp "$app" "dist/parking-desktop-${GITHUB_REF_NAME}-${SHA}.AppImage"
echo "Artifacts:"; ls -la dist/
- name: Publish to a rolling per-branch pre-release
# actions/upload-artifact's backend isn't reliable on this Gitea runner, so we
# publish to a Gitea RELEASE via the API instead (the proven pattern from
# release.yml — built-in token, plain curl). One ROLLING pre-release per branch
# (tag desktop-<branch>): delete + recreate each push so it always holds the
# latest dev/main installer. This is NOT the signed updater release (release.yml,
# tag v*) — it's a prerelease, unsigned, with no latest.json.
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.api_url }}
REPO: ${{ github.repository }}
TAG: desktop-${{ github.ref_name }}
run: |
set -e
auth="Authorization: token ${TOKEN}"
# Drop any existing rolling release for this branch (ignore if absent) so its
# tag + stale assets don't pile up; recreate it fresh below.
OLD=$(curl -sS -H "$auth" "${API}/repos/${REPO}/releases/tags/${TAG}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -n "$OLD" ]; then
curl -sS -X DELETE -H "$auth" "${API}/repos/${REPO}/releases/${OLD}" || true
# Also delete the tag itself so the recreate points at this commit.
curl -sS -X DELETE -H "$auth" "${API}/repos/${REPO}/git/refs/tags/${TAG}" || true
fi
REL=$(curl -sS -X POST -H "$auth" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"target_commitish\":\"${GITHUB_SHA}\",\"name\":\"Desktop build (${GITHUB_REF_NAME})\",\"body\":\"Unsigned per-commit desktop installers from ${GITHUB_REF_NAME} @ ${GITHUB_SHA}. Rolling — overwritten each push. Not an updater release.\",\"draft\":false,\"prerelease\":true}" \
"${API}/repos/${REPO}/releases")
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
echo "release id: ${REL_ID}"
for f in dist/*; do
name=$(basename "$f")
echo "uploading ${name}"
curl -sS -X POST -H "$auth" -H "Content-Type: application/octet-stream" \
--data-binary @"${f}" \
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
done
echo "done"
+13
View File
@@ -0,0 +1,13 @@
# Booth reverse proxy. `:80` matches ANY hostname/IP, so the booth is reachable as
# http://<booth-ip>/, http://localhost/, or http://parksystems.msai.al/ (the name pointed
# at the booth's IP via hosts/DNS on-site) — with no domain baked into any image. The SPA
# uses a relative /api base, so everything (HTTP + the /api/ws WebSocket, which Caddy
# upgrades automatically) just flows through to the server container.
#
# TLS later: replace `:80` with the real hostname (e.g. `parksystems.msai.al`), uncomment
# Caddy's :443 in docker-compose.prod.yml, and Caddy auto-provisions HTTPS. For a private
# CA / internal cert, use `tls /path/cert.pem /path/key.pem`.
:80 {
encode gzip
reverse_proxy server:3000
}
+6
View File
@@ -27,6 +27,12 @@ ENV CI=true
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --offline
# Force the SPA to use a SAME-ORIGIN (relative) API base for THIS image. Vite auto-loads
# apps/web/.env.production, which sets VITE_API_BASE=http://127.0.0.1:3000 for the TAURI
# DESKTOP build — but here Fastify serves the SPA same-origin, so an absolute base would
# make the browser hit 127.0.0.1:3000 cross-origin and fail CORS. `.env.production.local`
# has higher precedence than `.env.production`, so this empties it for the server image only.
RUN echo 'VITE_API_BASE=' > apps/web/.env.production.local
# Builds shared/db/devices, the server dist, AND the web SPA dist (apps/web/dist).
RUN pnpm turbo run build --filter=@parking/server --filter=@parking/web
# `pnpm deploy` produces a SELF-CONTAINED prod bundle for the server in /deploy: a hoisted
+77
View File
@@ -29,6 +29,33 @@ interface ThemeBody {
theme: Theme;
}
// Self-service profile: a signed-in user edits their OWN display name + email. This is
// NOT the admin user-management path (routes/users.ts) — it only ever touches the caller
// (req.user.sub), needs no `user:*` permission, and can't change username, role, or any
// other account. "" clears a field (→ null). See wiki/entities/local-jwt-auth.md.
interface ProfileBody {
fullName?: string | null;
email?: string | null;
}
// Self-service password change: the user proves they hold the CURRENT password before
// setting a new one — unlike the admin reset (users.ts), which sets it outright. This is
// why it lives here and not behind a permission: it's account-self-care, not admin power.
interface PasswordBody {
currentPassword: string;
newPassword: string;
}
const MIN_PASSWORD = 8;
/** Trim a self-service profile string; "" (or whitespace) → null (clear the field).
* Returns undefined for an absent key so an update only touches what was sent. */
function cleanProfileField(v: string | null | undefined): string | null | undefined {
if (v === undefined) return undefined;
const trimmed = typeof v === "string" ? v.trim() : "";
return trimmed === "" ? null : trimmed;
}
/** The session shape the SPA bootstraps from: identity + role + its permission
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
* permissions are the source of truth. */
@@ -41,6 +68,7 @@ function sessionView(
language: string;
theme: string;
fullName?: string | null;
email?: string | null;
},
) {
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
@@ -54,6 +82,7 @@ function sessionView(
language: user.language,
theme: user.theme,
fullName: user.fullName ?? null,
email: user.email ?? null,
};
}
@@ -141,4 +170,52 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
return { theme };
},
);
// Edit MY own display name / email (any signed-in user; no permission needed — it only
// touches the caller). Cannot change username or role — those stay admin-only (users.ts).
app.put<{ Body: ProfileBody }>(
"/api/auth/profile",
{ preHandler: requireAuth },
async (req, reply) => {
const fullName = cleanProfileField(req.body?.fullName);
const email = cleanProfileField(req.body?.email);
const patch: Record<string, string | null> = {};
if (fullName !== undefined) patch.fullName = fullName;
if (email !== undefined) patch.email = email;
if (Object.keys(patch).length === 0) {
return reply.code(400).send({ error: "nothing to update" });
}
await db.update(users).set(patch).where(eq(users.id, req.user.sub)).run();
const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
if (!row) return reply.code(401).send({ error: "session no longer valid" });
return sessionView(db, row);
},
);
// Change MY own password — must prove the CURRENT one first (defends against a walked-up,
// already-logged-in booth: a passerby can't silently re-key the account). New password
// >= MIN_PASSWORD. Distinct from the admin reset (users.ts), which needs no current pw.
app.put<{ Body: PasswordBody }>(
"/api/auth/password",
{ preHandler: requireAuth },
async (req, reply) => {
const currentPassword = req.body?.currentPassword ?? "";
const newPassword = req.body?.newPassword ?? "";
if (newPassword.length < MIN_PASSWORD) {
return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` });
}
const row = await db.select().from(users).where(eq(users.id, req.user.sub)).get();
if (!row) {
clearAuthCookies(reply);
return reply.code(401).send({ error: "session no longer valid" });
}
const ok = await bcrypt.compare(currentPassword, row.passwordHash);
if (!ok) {
return reply.code(403).send({ error: "current password is incorrect" });
}
const passwordHash = await bcrypt.hash(newPassword, 12);
await db.update(users).set({ passwordHash }).where(eq(users.id, req.user.sub)).run();
return { ok: true };
},
);
}
+130
View File
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { eq, users, type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../server.js";
import { seedUser, login } from "../test-helpers.js";
// Self-service profile (routes/auth.ts): /api/auth/profile + /api/auth/password. These act
// ONLY on the signed-in user, need NO `user:*` permission (any role), and the password change
// must prove the current password. Distinct from admin user-management (routes/users.ts).
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
describe("PUT /api/auth/profile (self-service)", () => {
it("a permission-less user can edit their OWN name + email", async () => {
// 'viewer' role with NO user:* permission — profile is not gated on it.
const { username, password } = await seedUser(db, {
username: "cashier", roleId: "viewer", permissions: [],
});
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "PUT", url: "/api/auth/profile",
headers: { cookie, "x-csrf-token": csrf },
payload: { fullName: "Mon Kukaleshi", email: "mon@example.com" },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.fullName).toBe("Mon Kukaleshi");
expect(body.email).toBe("mon@example.com");
// Persisted to the caller's own row.
const row = db.select().from(users).where(eq(users.username, "cashier")).get();
expect(row?.fullName).toBe("Mon Kukaleshi");
expect(row?.email).toBe("mon@example.com");
});
it('clears a field when sent ""', async () => {
const { username, password } = await seedUser(db, { username: "u2", roleId: "viewer", permissions: [] });
const { cookie, csrf } = await login(app, username, password);
// First set a name…
await app.inject({
method: "PUT", url: "/api/auth/profile",
headers: { cookie, "x-csrf-token": csrf },
payload: { fullName: "Old Name" },
});
// …then clear it with whitespace (→ null).
const res = await app.inject({
method: "PUT", url: "/api/auth/profile",
headers: { cookie, "x-csrf-token": csrf },
payload: { fullName: " " },
});
expect(res.statusCode).toBe(200);
expect(res.json().fullName).toBeNull();
});
it("rejects an empty patch (nothing to update)", async () => {
const { username, password } = await seedUser(db, { username: "u3", roleId: "viewer", permissions: [] });
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "PUT", url: "/api/auth/profile",
headers: { cookie, "x-csrf-token": csrf },
payload: {},
});
expect(res.statusCode).toBe(400);
});
it("requires a session (401 without a token)", async () => {
const res = await app.inject({ method: "PUT", url: "/api/auth/profile", payload: { fullName: "x" } });
expect(res.statusCode).toBe(401);
});
});
describe("PUT /api/auth/password (self-service)", () => {
it("changes the password when the current one is correct, and the new one then logs in", async () => {
const { username, password } = await seedUser(db, { username: "p1", roleId: "viewer", permissions: [] });
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "PUT", url: "/api/auth/password",
headers: { cookie, "x-csrf-token": csrf },
payload: { currentPassword: password, newPassword: "brand-new-pw-123" },
});
expect(res.statusCode).toBe(200);
// Old password no longer works; new one does.
const oldTry = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } });
expect(oldTry.statusCode).toBe(401);
const newTry = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password: "brand-new-pw-123" } });
expect(newTry.statusCode).toBe(200);
});
it("refuses when the current password is wrong (403) and leaves the password unchanged", async () => {
const { username, password } = await seedUser(db, { username: "p2", roleId: "viewer", permissions: [] });
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "PUT", url: "/api/auth/password",
headers: { cookie, "x-csrf-token": csrf },
payload: { currentPassword: "not-it", newPassword: "brand-new-pw-123" },
});
expect(res.statusCode).toBe(403);
// Original password still works.
const still = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } });
expect(still.statusCode).toBe(200);
});
it("rejects a too-short new password (400)", async () => {
const { username, password } = await seedUser(db, { username: "p3", roleId: "viewer", permissions: [] });
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "PUT", url: "/api/auth/password",
headers: { cookie, "x-csrf-token": csrf },
payload: { currentPassword: password, newPassword: "short" },
});
expect(res.statusCode).toBe(400);
});
});
+171
View File
@@ -0,0 +1,171 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { changeMyPassword, updateMyProfile, type SessionUser } from "./api.js";
// Self-service profile: the signed-in user edits their OWN display name + email and
// changes their OWN password (proving the current one). This is NOT the admin
// user-manager (UsersManager.tsx) — it never touches another account, username, or
// role, and needs no `user:*` permission. See routes/auth.ts (/api/auth/profile,
// /api/auth/password) and wiki/entities/local-jwt-auth.md.
const MIN_PASSWORD = 8;
export function Profile({
user,
setUser,
}: {
user: SessionUser;
setUser: (u: SessionUser | null) => void;
}) {
const { t } = useTranslation();
// --- Account (name / email) ---
const [fullName, setFullName] = useState(user.fullName ?? "");
const [email, setEmail] = useState(user.email ?? "");
const [accountMsg, setAccountMsg] = useState<string | null>(null);
const [savingAccount, setSavingAccount] = useState(false);
async function saveAccount() {
setAccountMsg(null);
setSavingAccount(true);
try {
const next = await updateMyProfile({ fullName, email });
// Keep the router-context user in sync so the header reflects the change.
setUser(next);
setFullName(next.fullName ?? "");
setEmail(next.email ?? "");
setAccountMsg(t("profile.profileSaved"));
} catch (e) {
setAccountMsg((e as Error).message);
} finally {
setSavingAccount(false);
}
}
// --- Password ---
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [pwMsg, setPwMsg] = useState<string | null>(null);
const [savingPw, setSavingPw] = useState(false);
async function changePassword() {
setPwMsg(null);
if (next.length < MIN_PASSWORD) {
setPwMsg(t("profile.passwordTooShort", { min: MIN_PASSWORD }));
return;
}
if (next !== confirm) {
setPwMsg(t("profile.passwordsDontMatch"));
return;
}
setSavingPw(true);
try {
await changeMyPassword(current, next);
setCurrent("");
setNext("");
setConfirm("");
setPwMsg(t("profile.passwordChanged"));
} catch (e) {
setPwMsg((e as Error).message);
} finally {
setSavingPw(false);
}
}
return (
<div className="mx-auto flex max-w-xl flex-col gap-6">
<h1 className="text-lg text-term-text">{t("profile.title")}</h1>
{/* Account: display name + email (username + role are read-only — admin-managed). */}
<section className="card flex flex-col gap-3 p-4">
<h2 className="text-sm uppercase tracking-wider text-term-muted">
{t("profile.accountSection")}
</h2>
<div className="grid grid-cols-2 gap-3 text-[11px] text-term-muted">
<div>
<span className="block">{t("profile.username")}</span>
<span className="text-sm text-term-text">{user.username}</span>
</div>
<div>
<span className="block">{t("profile.role")}</span>
<span className="text-sm text-term-text">{user.roleName}</span>
</div>
</div>
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
{t("profile.fullName")}
<input
className="input"
value={fullName}
placeholder={t("profile.fullNamePh")}
onChange={(e) => setFullName(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
{t("profile.email")}
<input
className="input"
type="email"
value={email}
placeholder={t("profile.emailPh")}
onChange={(e) => setEmail(e.target.value)}
/>
</label>
<div className="flex items-center gap-3">
<button type="button" className="btn btn-primary btn-sm" onClick={saveAccount} disabled={savingAccount}>
{t("profile.saveProfile")}
</button>
{accountMsg && <span className="text-[11px] text-term-muted">{accountMsg}</span>}
</div>
</section>
{/* Password: requires the current one (server enforces). */}
<section className="card flex flex-col gap-3 p-4">
<h2 className="text-sm uppercase tracking-wider text-term-muted">
{t("profile.passwordSection")}
</h2>
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
{t("profile.currentPassword")}
<input
className="input"
type="password"
autoComplete="current-password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
{t("profile.newPassword")}
<input
className="input"
type="password"
autoComplete="new-password"
value={next}
onChange={(e) => setNext(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1 text-[11px] text-term-muted">
{t("profile.confirmPassword")}
<input
className="input"
type="password"
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
</label>
<div className="flex items-center gap-3">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={changePassword}
disabled={savingPw || !current || !next || !confirm}
>
{t("profile.changePassword")}
</button>
{pwMsg && <span className="text-[11px] text-term-muted">{pwMsg}</span>}
</div>
</section>
</div>
);
}
+25
View File
@@ -75,6 +75,8 @@ export interface SessionUser {
theme: Theme;
/** Optional display name (profile metadata); null if unset. */
fullName: string | null;
/** Optional contact email (profile metadata); null if unset. */
email: string | null;
}
/** Does this session grant the permission? Central authz check for the SPA. */
@@ -103,6 +105,29 @@ export function setThemePref(theme: Theme): Promise<{ theme: Theme }> {
return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) });
}
/** Edit MY own profile (display name / email). Returns the refreshed session.
* Self-service — touches only the signed-in user; no `user:*` permission needed. */
export function updateMyProfile(patch: {
fullName?: string | null;
email?: string | null;
}): Promise<SessionUser> {
return apiFetch<SessionUser>("/api/auth/profile", {
method: "PUT",
body: JSON.stringify(patch),
});
}
/** Change MY own password — proves the current one first (server enforces). */
export function changeMyPassword(
currentPassword: string,
newPassword: string,
): Promise<{ ok: boolean }> {
return apiFetch("/api/auth/password", {
method: "PUT",
body: JSON.stringify({ currentPassword, newPassword }),
});
}
/** Returns the current user, or null if not authenticated. */
export async function fetchMe(): Promise<SessionUser | null> {
try {
+21
View File
@@ -58,6 +58,27 @@ export const en: Catalog = {
reports: "Reports",
recycleBin: "Recycle bin",
logs: "Logs",
profile: "Profile",
},
profile: {
title: "My profile",
accountSection: "Account",
fullName: "Full name",
fullNamePh: "First and last name",
email: "Email",
emailPh: "you@example.com",
username: "Username",
role: "Role",
saveProfile: "Save profile",
profileSaved: "Profile saved.",
passwordSection: "Change password",
currentPassword: "Current password",
newPassword: "New password",
confirmPassword: "Confirm password",
changePassword: "Change password",
passwordChanged: "Password changed.",
passwordsDontMatch: "Passwords don't match.",
passwordTooShort: "Password must be at least {{min}} characters.",
},
status: {
live: "LIVE",
+21
View File
@@ -60,6 +60,27 @@ export const sq = {
reports: "Raportet",
recycleBin: "Koshi",
logs: "Loget",
profile: "Profili",
},
profile: {
title: "Profili im",
accountSection: "Llogaria",
fullName: "Emri i plotë",
fullNamePh: "Emri dhe mbiemri",
email: "Email",
emailPh: "ti@shembull.com",
username: "Përdoruesi",
role: "Roli",
saveProfile: "Ruaj profilin",
profileSaved: "Profili u ruajt.",
passwordSection: "Ndrysho fjalëkalimin",
currentPassword: "Fjalëkalimi aktual",
newPassword: "Fjalëkalimi i ri",
confirmPassword: "Konfirmo fjalëkalimin",
changePassword: "Ndrysho fjalëkalimin",
passwordChanged: "Fjalëkalimi u ndryshua.",
passwordsDontMatch: "Fjalëkalimet nuk përputhen.",
passwordTooShort: "Fjalëkalimi duhet të jetë të paktën {{min}} karaktere.",
},
status: {
live: "LIVE",
+23 -3
View File
@@ -31,6 +31,7 @@ import { RolesManager } from "./RolesManager.js";
import { ShiftsHistory } from "./ShiftsHistory.js";
import { LogsViewer } from "./LogsViewer.js";
import { RecycleBin } from "./RecycleBin.js";
import { Profile } from "./Profile.js";
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
// initial bundle and only downloads when an admin opens /setup/reports.
const Reports = lazy(() => import("./Reports.js").then((m) => ({ default: m.Reports })));
@@ -399,9 +400,15 @@ function RootLayout() {
{user && <LanguageToggle user={user} setUser={setUser} />}
{user && <ThemeToggle user={user} setUser={setUser} />}
<StatusDot />
<span className="text-[11px] text-term-muted">
{user?.username} · {user?.roleName}
</span>
{user && (
<Link
to="/profile"
title={t("nav.profile")}
className="text-[11px] text-term-muted hover:text-term-text [&.active]:text-term-amber"
>
{user.username} · {user.roleName}
</Link>
)}
<button
type="button"
className="btn btn-ghost btn-sm"
@@ -635,10 +642,23 @@ const logsRoute = createRoute({
component: LogsViewer,
});
// My profile — self-service for ANY signed-in user (no permission gate). Edits only
// the caller's own name/email/password. See Profile.tsx and routes/auth.ts.
const profileRoute = createRoute({
getParentRoute: () => rootRoute,
path: "profile",
component: function ProfileRoute() {
const { user, setUser } = rootRoute.useRouteContext();
if (!user) return null;
return <Profile user={user} setUser={setUser} />;
},
});
const routeTree = rootRoute.addChildren([
indexRoute,
boothRoute,
...legacyRedirects,
profileRoute,
shiftRoute,
reportsRoute,
subscriptionsRoute.addChildren([
+35 -4
View File
@@ -1,14 +1,41 @@
# PROD override: pull pinned registry images (no local build), restart always, real
# recognizer, and keep vision INTERNAL (only the server port is published). Use with the
# base file and pin TAG to the branch/SHA you deploy:
# recognizer, and a CADDY reverse proxy in front so operators reach the booth on a clean
# port-80 URL (no :3000) — and a path to real TLS later. Server + vision stay INTERNAL
# (only Caddy publishes a port). Use with the base file and pin TAG to the branch you deploy:
# REGISTRY=git.infra.msai.al/mca/parking_solution TAG=main \
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# See wiki/decisions/container-deployment.md.
services:
server:
# Reverse proxy: :80 → server:3000 (WebSocket /api/ws upgrades pass through natively).
# Caddy is a single static binary with a one-line proxy config; swapping http:// for the
# site's real hostname later enables automatic HTTPS. The booth is reached at
# http://<name-or-ip>/ (the name set via hosts/DNS on-site — NOT baked into any image).
proxy:
image: caddy:2-alpine
restart: always
ports:
- "3000:3000"
- "80:80"
# - "443:443" # uncomment when moving to TLS (and set a real hostname in Caddyfile)
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
- server
networks:
- parking
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
server:
restart: always
# No published port — only the proxy reaches the server, over the private network.
expose:
- "3000"
logging:
driver: json-file
options:
@@ -26,3 +53,7 @@ services:
options:
max-size: "10m"
max-file: "3"
volumes:
caddy-data:
caddy-config:
+11
View File
@@ -15,8 +15,19 @@ services:
DATABASE_URL: /data/parking.sqlite
# Reach the vision service over the private compose network by service name.
VISION_URL: http://vision:8089
VISION_ENABLED: ${VISION_ENABLED:-1}
# JWT signing secret MUST be provided at deploy (no insecure default — see auth.ts).
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in the env/.env}
# Dedicated ledger-signing key. Falls back to JWT_SECRET (with a warning) if empty;
# set a distinct one in prod. See apps/server/.env.example + local-jwt-auth.
EVENT_SIGNING_KEY: ${EVENT_SIGNING_KEY:-}
# CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT,
# so without COOKIE_SECURE=0 the auth cookie is never sent over http and operators
# CANNOT LOG IN. Leave unset only behind TLS. See disk-os-hardening "deploy-time runbook".
COOKIE_SECURE: ${COOKIE_SECURE:-0}
# The booth WS live feed checks the browser Origin — must list the address operators
# actually hit (e.g. http://<booth-ip>:3000), or the live feed is rejected.
WS_ALLOWED_ORIGINS: ${WS_ALLOWED_ORIGINS:-}
volumes:
- parking-data:/data
depends_on:
+61 -8
View File
@@ -148,20 +148,73 @@ pressing `e` at the menu prompts for `admin` + password. Store the GRUB password
> OS hardening on the first unit is now COMPLETE: LUKS FDE + TPM auto-unlock (PCR 7) + Secure Boot
> (Deployed) + GRUB edit-lock.
## 5c. OS user model — admin vs operator (VERIFIED 2026-06-23)
The OS has TWO roles and they must be different identities ([[threat-model]]: the operator is the
adversary). Create a dedicated **admin** (real password, sudo, NO auto-login) and keep the
**operator** as an auto-login, UNPRIVILEGED account.
```bash
sudo adduser admin && sudo usermod -aG sudo admin
# VERIFY in a second session: log in as admin → `sudo whoami` prints root — BEFORE the next step:
sudo deluser <operator> sudo # demote the auto-login operator
groups <operator> # confirm: no 'sudo'
```
⚠ Order matters: confirm the new admin's sudo works **before** demoting the operator, or you lock
yourself out. Keep auto-login on the OPERATOR, not admin. **Leave root password disabled** (Ubuntu
default) — `admin`+sudo IS the root path; enabling root adds risk, no gain.
> Strip latent escalation groups from the operator: **`sudo deluser <operator> lxd`** (lxd group =
> launch a privileged container that mounts host `/` as root — undoes the no-sudo hardening) and
> `lpadmin` (printer admin, unneeded). And NEVER add the operator to `docker` (also root-equivalent).
## 5b. Further hardening (TODO — not yet done)
- **Key-based SSH only** (disable password auth) if SSH is enabled at all.
- **No/locked-down desktop** — single-purpose; autostart the kiosk ([[desktop-shell-tauri]]).
- **No/locked-down desktop + kiosk autostart** — single-purpose; the operator never reaches a shell
([[desktop-shell-tauri]]).
- Consider moving the host **event-signing key into the TPM** (non-extractable) — [[tpm]], [[open-questions]] #12.
- `sudo apt autoremove` the leftover old kernel (`linux-*-7.0.0-14`) once the new one is proven.
- `sudo apt autoremove` the leftover old kernel once the new one is proven.
## 6. Runtime — Docker stack
## 6. Runtime — Docker stack (VERIFIED 2026-06-23)
Per [[container-deployment]]: install Docker Engine + compose, then run the `parking-server` +
`parking-vision` images via `docker-compose.yml -f docker-compose.prod.yml`. Provide a real
`JWT_SECRET` (`openssl rand -hex 32`) and `COOKIE_SECURE=0` (plain-http booth LAN — see
[[disk-os-hardening]] deploy-time runbook). Images are published to the Gitea registry by
`build-images.yml` on push to dev/main.
Install Docker Engine + compose (as `admin`). NB Ubuntu 26.04 codename is **`resolute`**, which
download.docker.com may not yet publish — pin the repo line to `noble`, OR use Ubuntu's `docker.io`.
Add only `admin` to the `docker` group (root-equivalent — NEVER the operator).
Deploy from a standalone dir (hand-copied; no repo on the appliance), e.g. `/opt/parking_solution`:
`docker-compose.yml` + `docker-compose.prod.yml` (the Caddy/prod override) + `Caddyfile` + a `.env`
(chmod 600). The `.env` (driven into the containers by the base compose):
```
JWT_SECRET=<openssl rand -hex 32> # server REFUSES to boot without (>=32, no insecure default)
EVENT_SIGNING_KEY=<a DIFFERENT openssl rand -hex 32>
COOKIE_SECURE=0 # CRITICAL on plain-http or the auth cookie never sends → no login
WS_ALLOWED_ORIGINS=http://<name-or-ip> # any REMOTE origin admins use (same-origin always passes)
VISION_ENABLED=1
# REGISTRY/TAG default to git.infra.msai.al/mca/parking_solution + dev; set TAG=main to pin.
```
```bash
docker login git.infra.msai.al # a read-only package token, not the account password
docker compose -f docker-compose.yml -f docker-compose.prod.yml config # dry-run: verify the merged env
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# Seed the FIRST admin (DB starts empty → nobody can log in until this runs; idempotent):
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec \
-e ADMIN_USER=admin -e ADMIN_PASS='<strong-pw>' server node scripts/seed-admin.mjs
```
Healthy startup logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked
weights), server `[migrate] done` → `SPA static serving enabled` → `Server listening`. The transient
`vision-service -> offline` at boot then `-> ready (fast_alpr)` ~8s later is normal (monitor polls
before vision finishes loading). Reach the UI at **`http://<name-or-ip>/`** (Caddy on :80).
**Web-access gotchas (all fixed in the images/compose — see [[container-deployment]] "Web access"):**
the SPA uses a RELATIVE `/api` base (works from any host; do NOT bake a domain) + a Caddy proxy gives
the clean port-80 URL; the domain (`parksystems.msai.al`) is pointed at the booth's LAN IP via
`hosts`/DNS ON-SITE, never an image rebuild.
## Quick-reference: the gotchas, in order they bit us
+15
View File
@@ -72,6 +72,21 @@ The **desktop** app stays on its own tag-only `release.yml` (Tauri installers),
THEN run `python -c "from fast_alpr import ALPR; ALPR()"` so weights land in `/home/vision/.cache`
— exactly where the runtime reads. Verify the boot log shows NO "Downloading …onnx".
## Web access — relative API + Caddy proxy (2026-06-23)
- **The server-image SPA uses a RELATIVE `/api` base** (no baked origin), so the UI works loaded
from any hostname/IP. The Dockerfile empties `VITE_API_BASE` via `apps/web/.env.production.local`
before the web build — because Vite auto-loads `apps/web/.env.production`, which sets
`VITE_API_BASE=http://127.0.0.1:3000` for the **Tauri desktop** build only. Without the override
the browser bundle baked `127.0.0.1:3000` and failed Same-Origin Policy from any other host. **Do
NOT bake the domain via a build var** — relative means naming is controlled by hosts/DNS at deploy,
never a rebuild.
- **A Caddy reverse proxy** (prod override) publishes `:80` → `server:3000` (server is `expose`-only,
internal); `/api/ws` upgrades pass through. `Caddyfile` binds `:80` so it matches ANY host — booth
IP, localhost, or `parksystems.msai.al` (pointed at the booth IP via hosts/DNS on-site). TLS later:
swap `:80` for the real hostname + uncomment Caddy `:443` → auto-HTTPS.
- `WS_ALLOWED_ORIGINS` (env) must list any REMOTE origin admins use (same-origin always passes).
## Invariants (must hold)
- **Never bake the live DB.** `.dockerignore` excludes `**/parking.sqlite*` (incl. `-wal`/`-shm`/
+31
View File
@@ -167,3 +167,34 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
Windows/macOS "unknown publisher", and from the [[atecc608]]/[[tpm]] **event** signing.)*
- **Still deferred:** the actual update-hosting URL, OS-level installer signing
(Windows/macOS publisher trust), and the Windows kiosk-browser fallback path.
### Desktop in CI — two workflows, two purposes (added 2026-06-24)
The desktop bundle now runs in CI under **two distinct workflows** — keep the split clear:
- **`.gitea/workflows/release.yml`** (tag `v*`) — the **signed, versioned release**: builds
`.deb`/`.rpm`/`.AppImage` **+ their `.sig`** (updater key from secrets), assembles `latest.json`,
and publishes a Gitea Release. This is what the auto-updater consumes. Unchanged.
- **`.gitea/workflows/build-desktop.yml`** (push to `dev`/`main`) — a **per-commit test build**:
compiles `.deb` + `.AppImage` only (`pnpm --filter @parking/desktop bundle --bundles deb,appimage`)
and publishes them to a **rolling per-branch pre-release** (tag `desktop-<branch>`). **Unsigned** —
no `TAURI_SIGNING_*`, no `latest.json` — so it must NEVER be wired to the updater (an unsigned
artifact would be rejected anyway). It exists so each branch push yields a downloadable installer
for manual testing of the native shell, and catches a broken Tauri/Rust build early. Same
system-deps + cargo cache as `release.yml`. The container images (`build-images.yml`) and the
desktop installers are deliberately separate pipelines — the desktop app is **not** containerized
([[container-deployment]]).
- **Delivery: a rolling pre-release, NOT `actions/upload-artifact`.** That action's artifact
backend isn't reliable on the Gitea runner (the *Upload installers* step failed). Instead the
workflow mirrors `release.yml`'s proven path — plain `curl` + the built-in `GITHUB_TOKEN` to the
**Releases API**. It DELETEs any existing `desktop-<branch>` release + tag, recreates it against
the new commit as a **prerelease**, and uploads the two installers (renamed space-free,
`parking-desktop-<branch>-<sha>.{deb,AppImage}`). So `desktop-dev` always holds the newest dev
build; `v*` tags remain the only *signed* releases.
- **Gotcha (the unsigned build still demands the key).** `tauri.conf.json` sets
`bundle.createUpdaterArtifacts: true` (so `release.yml` produces the `.sig` updater signatures).
With that on, `tauri build` **fails** if `TAURI_SIGNING_PRIVATE_KEY` is absent — *"A public key
has been found, but no private key"* — even though the `.deb`/`.AppImage` themselves built fine.
The unsigned CI build therefore overrides it off with
`--config '{"bundle":{"createUpdaterArtifacts":false}}'` (a JSON patch merged over the config),
so no `.sig` is attempted and no key is required. `release.yml` keeps the config default (signs).
+15
View File
@@ -69,6 +69,21 @@ The SPA never sees the JWT. Login (`POST /api/auth/login`) verifies bcrypt and s
requires header == cookie == the signed claim (**double-submit CSRF**). Safe reads are exempt.
Routes: `login`, `logout` (clears cookies), `me` (bootstraps SPA session on load). The dev
**Self-service profile (added 2026-06-24).** Alongside the admin user-manager (`routes/users.ts`,
gated on `user:*`), any signed-in user has two **self-only** routes (no permission needed — they
act solely on `req.user.sub`):
- `PUT /api/auth/profile` — edit own `fullName` / `email` (`""` clears → null). Returns the
refreshed session (so the SPA header updates). **Cannot** touch `username` or `role` — those stay
admin-only, so this is not a privilege-escalation surface.
- `PUT /api/auth/password` — change own password, but **must prove the current one** first
(`bcrypt.compare`) → defends a walked-up, already-logged-in booth from a silent re-key. New
password ≥ 8 chars. Distinct from the admin reset (`PUT /api/users/:id/password`), which needs no
current password but DOES need `user:update` + the no-escalation guard.
Both are still CSRF-guarded (mutations). The SPA surfaces them at `/profile` (`apps/web/src/Profile.tsx`),
reachable from the header username chip. Covered by `apps/server/src/routes/profile.test.ts`.
The dev
[[react-vite-spa|Vite]] proxy and the prod **nginx** reverse proxy keep the SPA and API
**same-origin**, so the cookies work without CORS. (This replaced an earlier dev-only
`SETUP_AUTH_BYPASS` shim, now removed.)
+36
View File
@@ -1516,3 +1516,39 @@ prompts for admin+password. OS hardening on unit 1 is now COMPLETE: LUKS FDE + T
step, §5b further-hardening TODO: SSH key-only, kiosk lockdown, signing key→TPM, autoremove old
kernel) + [[disk-os-hardening]]. STILL TODO on the box: Docker install + run the parking stack (needs
the images pushed — dev push + registry secrets pending).
## [2026-06-23] deploy | First booth GO-LIVE — Docker stack running + web-access fixes (CI uv, compose env, relative /api, Caddy)
Deployed the two images onto the hardened booth (Dell 7070, Ubuntu 26.04) and worked through the
real-world bring-up issues. (1) Operator/admin OS user split: created a dedicated sudo `admin` user,
removed the auto-login operator from `sudo` (and should drop `lxd`/`lpadmin` — lxd is a root-escape
path); admin is the only sudo, operator auto-logs in unprivileged. (2) Docker 29.6 installed; deploy
dir /opt/parking_solution with hand-copied compose + .env; registry login to git.infra.msai.al; the
stack came up clean — vision fast_alpr loaded from the BAKED cache (0 downloads → offline-first
confirmed on real hardware), server migrated /data, both healthy. (3) Seeded the first admin via
`docker compose exec server node scripts/seed-admin.mjs` (bcrypt, writes users table — NOT the signed
ledger). FIXES committed this session: CI `astral-sh/setup-uv` action failed on the Gitea runner →
install uv via its official curl script instead (both ci.yml + build-images.yml) [0a22eab]; the base
compose only forwarded JWT_SECRET/DATABASE_URL/VISION_URL → added COOKIE_SECURE (CRITICAL on plain-
http or login cookies never send), WS_ALLOWED_ORIGINS, EVENT_SIGNING_KEY, VISION_ENABLED [1092316];
the SPA had VITE_API_BASE=http://127.0.0.1:3000 baked in (leaked from apps/web/.env.production, which
is for the TAURI build but Vite auto-loads it for every build) → server Dockerfile now empties it via
.env.production.local so the SPA uses RELATIVE /api and works from ANY host [77b2acb]; added a CADDY
reverse proxy (prod override) so the booth is reached on a clean port-80 URL, server goes internal,
Caddyfile binds :80 to match any hostname incl. parksystems.msai.al [c637b27]. NET RESULT: no domain
baked into any image — naming controlled by hosts/DNS on-site; admin can reach it from another LAN PC.
Verified the relative-/api + Caddy fix end-to-end locally (Host: parksystems.msai.al through :80 →
SPA + /api/auth/login reach the server, no CORS). See [[container-deployment]] "Web access",
[[appliance-provisioning]]. REMAINING on the box: push dev so CI rebuilds parking-server:dev with the
relative-/api fix, then pull on the booth; kiosk autostart; operator user lxd/lpadmin cleanup.
## [2026-06-24] build | Self-service user profile + desktop installers in CI
Two app-side additions. (1) **Self-service profile** — any signed-in user can now edit their OWN
`fullName`/`email` and change their OWN password (proving the current one), without any `user:*`
permission. New routes `PUT /api/auth/profile` + `PUT /api/auth/password` (act only on `req.user.sub`;
cannot touch username/role; CSRF-guarded), SPA screen `apps/web/src/Profile.tsx` at `/profile` (header
username chip links to it), `email` added to the session view + `SessionUser`. 7 new tests
(`routes/profile.test.ts`); server 148/148 green. Distinct from the admin user-manager (`routes/users.ts`,
`user:*`-gated). See [[local-jwt-auth]]. (2) **Desktop in CI** — new `.gitea/workflows/build-desktop.yml`
builds `.deb` + `.AppImage` on every push to dev/main and uploads them as UNSIGNED workflow artifacts
(per-commit test build); the signed/versioned release stays on `release.yml` (tag `v*`). See
[[desktop-shell-tauri]] "Desktop in CI".