From 4a7029cea669a39d45f903171538c625e7180539 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Thu, 3 Sep 2026 16:04:57 +0200 Subject: [PATCH 1/8] chore(resources): bump stage TAG to 7317042 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes park-buzi + park-2 to the just-merged desktop-app fixes (login, mixed-content routing, WS origin) and the WS_ALLOWED_ORIGINS fix — none of this was on stage before. Wait for build-images.yml to confirm the image actually exists before syncing/deploying in Komodo. --- komodo/resources.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/komodo/resources.toml b/komodo/resources.toml index f2ec97b..e3a3f94 100644 --- a/komodo/resources.toml +++ b/komodo/resources.toml @@ -49,7 +49,7 @@ REGISTRY=git.infra.msai.al/mca/parking_solution # Staging booth: pinned immutable stage-. After each promotion (merge dev → stage, CI builds # :stage-), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag # exists as the pointer; we deploy the sha, not the mover. -TAG=stage-28bd838 +TAG=stage-7317042 COOKIE_SECURE=0 VISION_ENABLED=1 # Desktop app WS handshake: Origin is tauri://localhost (set explicitly by @@ -82,7 +82,7 @@ REGISTRY=git.infra.msai.al/mca/parking_solution # Staging booth: pinned immutable stage-. After each promotion (merge dev → stage, CI builds # :stage-), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag # exists as the pointer; we deploy the sha, not the mover. -TAG=stage-28bd838 +TAG=stage-7317042 COOKIE_SECURE=0 VISION_ENABLED=1 # Desktop app WS handshake: Origin is tauri://localhost (set explicitly by From 969bf2b191d21d624cf2fc39a09c19e82ccb9329 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Thu, 3 Sep 2026 18:22:07 +0200 Subject: [PATCH 2/8] chore(resources): bump stage TAG to 7d67934 Promotes park-buzi + park-2 to the WS_ALLOWED_ORIGINS fix and the desktop version badge. build-images.yml confirmed green for this sha before bumping. --- komodo/resources.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/komodo/resources.toml b/komodo/resources.toml index e3a3f94..be633f3 100644 --- a/komodo/resources.toml +++ b/komodo/resources.toml @@ -49,7 +49,7 @@ REGISTRY=git.infra.msai.al/mca/parking_solution # Staging booth: pinned immutable stage-. After each promotion (merge dev → stage, CI builds # :stage-), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag # exists as the pointer; we deploy the sha, not the mover. -TAG=stage-7317042 +TAG=stage-7d67934 COOKIE_SECURE=0 VISION_ENABLED=1 # Desktop app WS handshake: Origin is tauri://localhost (set explicitly by @@ -82,7 +82,7 @@ REGISTRY=git.infra.msai.al/mca/parking_solution # Staging booth: pinned immutable stage-. After each promotion (merge dev → stage, CI builds # :stage-), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag # exists as the pointer; we deploy the sha, not the mover. -TAG=stage-7317042 +TAG=stage-7d67934 COOKIE_SECURE=0 VISION_ENABLED=1 # Desktop app WS handshake: Origin is tauri://localhost (set explicitly by From 5c6a21e2c3f91771e61771f9375dfc054d10daba Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Fri, 4 Sep 2026 10:32:03 +0200 Subject: [PATCH 3/8] feat(desktop): runtime-configurable backend server address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop shell is one generic .deb/.AppImage distributed via mca/public_releases, not built per-booth, but the backend origin was baked in at build time (VITE_API_BASE, hardcoded to http://127.0.0.1:3000) — the same installer could never point at a different appliance without a rebuild. Adds ConnectScreen (shown before Login in Tauri when no backend is saved), backed by tauri-plugin-store persisting the operator-entered URL across restarts. CSP's connect-src tightens to 'self' only — all backend traffic already routes through tauri-plugin-http/websocket, which run Rust-side and are outside connect-src's reach anyway — and the real access boundary moves to capabilities/default.json's http:default scope, wildcarded so an operator-chosen host is actually reachable. Adds a "Change server" control in Setup (desktop-only) to repoint an already-configured install. While tracing the desktop auth path for this: tauri-plugin-http's fetch() runs through Rust's reqwest, which keeps its own cookie jar separate from the webview, so document.cookie on tauri://localhost never sees the parking_csrf cookie the server sets (open upstream bug, tauri-apps/tauri#13045/#11518). This means the desktop app has likely been silently sending no CSRF header on every mutation since the shell was first built — pre-existing, independent of this change. Fixed by having sessionView() (routes/auth.ts) also echo the CSRF value in the login/me JSON body; the desktop client stashes it in memory and echoes that instead of reading document.cookie. assertCsrf() itself is untouched. Verified end-to-end against a real LAN-bound dev server: login returns a csrfToken matching the cookie, a mutation using the body-sourced token in X-CSRF-Token succeeds (200), and the same mutation without it still correctly 403s. --- apps/desktop/src-tauri/Cargo.lock | 29 +++++ apps/desktop/src-tauri/Cargo.toml | 4 + .../src-tauri/capabilities/default.json | 8 +- apps/desktop/src-tauri/src/lib.rs | 16 ++- apps/desktop/src-tauri/tauri.conf.json | 4 +- apps/server/src/routes/auth.ts | 24 +++- apps/web/package.json | 1 + apps/web/src/App.tsx | 36 +++++- apps/web/src/ConnectScreen.tsx | 116 ++++++++++++++++++ apps/web/src/api.ts | 53 ++++++-- apps/web/src/lib/backend-config.ts | 88 +++++++++++++ apps/web/src/lib/desktop-updater.ts | 6 +- apps/web/src/lib/i18n/en.ts | 14 +++ apps/web/src/lib/i18n/sq.ts | 14 +++ apps/web/src/lib/origin.ts | 43 +++++-- apps/web/src/lib/platform-ws.ts | 7 +- apps/web/src/lib/tauri-env.ts | 6 + apps/web/src/router.tsx | 50 ++++++++ pnpm-lock.yaml | 10 ++ wiki/decisions/desktop-shell-tauri.md | 69 ++++++++++- wiki/log.md | 24 ++++ 21 files changed, 575 insertions(+), 47 deletions(-) create mode 100644 apps/web/src/ConnectScreen.tsx create mode 100644 apps/web/src/lib/backend-config.ts create mode 100644 apps/web/src/lib/tauri-env.ts diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index c554d90..bb76ad1 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -2305,6 +2305,7 @@ dependencies = [ "tauri-build", "tauri-plugin-http", "tauri-plugin-process", + "tauri-plugin-store", "tauri-plugin-updater", "tauri-plugin-websocket", ] @@ -3793,6 +3794,22 @@ dependencies = [ "tauri-plugin", ] +[[package]] +name = "tauri-plugin-store" +version = "2.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6708afbe549f176b712066e71648ba8fafba20789453718260c7ca356733cb0c" +dependencies = [ + "dunce", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.1" @@ -4301,9 +4318,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "tracing-core" version = "0.1.36" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 1012772..c23b6d8 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -33,6 +33,10 @@ tauri-plugin-http = "2" # (ws://127.0.0.1:3000 from the secure tauri://localhost origin) — HTTP and WS # are separate browser checks, so this needs its own plugin. tauri-plugin-websocket = "2" +# Persists the operator-configured backend URL (host:port of the Fastify +# server this install talks to) across restarts. Read before any API call — +# see apps/web/src/lib/backend-config.ts. +tauri-plugin-store = "2" [features] # Used by `tauri dev`/CLI for hot-reload of the Rust side. diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index e3fbaa5..e05bd24 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -8,11 +8,15 @@ "updater:default", "process:default", "websocket:default", + "store:default", { "identifier": "http:default", + "//": "Backend address is operator-configured at runtime (backend-config.ts) so the exact host:port can't be allow-listed at build time. Wildcarded to any host — the CSP forces ALL backend traffic through this plugin (see tauri.conf.json), so this scope is the real boundary; a compromised/malicious page still can't reach anything the operator hasn't pointed the app at, since the app only ever calls the one configured origin. All 4 forms needed: a known Tauri scope-matching quirk drops http://*:PORT unless both bare and :* variants are listed.", "allow": [ - { "url": "http://127.0.0.1:3000" }, - { "url": "http://localhost:3000" } + { "url": "http://*" }, + { "url": "https://*" }, + { "url": "http://*:*" }, + { "url": "https://*:*" } ] } ] diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 605cf80..a0ce99f 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -3,9 +3,10 @@ // Intentionally minimal: build the default Tauri app and run it. The window // config (kiosk, fullscreen, which URL/assets to load) lives in tauri.conf.json. // No custom commands are registered — the renderer (the @parking/web SPA) reaches -// the backend over HTTP to the local Fastify server, NOT through Tauri IPC. This -// keeps the shell a thin presentation wrapper with a deny-by-default native -// surface (see wiki/decisions/desktop-shell-tauri.md). +// the backend over HTTP to a Fastify server (address operator-configured at +// runtime, not baked in — see apps/web/src/lib/backend-config.ts), NOT through +// Tauri IPC. This keeps the shell a thin presentation wrapper with a +// deny-by-default native surface (see wiki/decisions/desktop-shell-tauri.md). #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -16,13 +17,16 @@ pub fn run() { // endpoint + signing pubkey live in tauri.conf.json. .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) - // Routes the SPA's fetch()/WS calls to the local Fastify server through - // Tauri's native HTTP client — see the Cargo.toml comment on why the - // webview's own fetch() can't reach http://127.0.0.1:3000 directly. + // Routes the SPA's fetch()/WS calls to the operator-configured Fastify + // server through Tauri's native HTTP client — see the Cargo.toml + // comment on why the webview's own fetch() can't reach it directly. .plugin(tauri_plugin_http::init()) // Live-feed WebSocket — same mixed-content reason as the HTTP plugin // above, but WS needs its own plugin (separate browser check). .plugin(tauri_plugin_websocket::init()) + // Persists the operator-configured backend URL across restarts (JSON + // file in the app's config dir) — see backend-config.ts. + .plugin(tauri_plugin_store::Builder::new().build()) .run(tauri::generate_context!()) .expect("error while running the Parking System desktop shell"); } diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 723dfe3..20f42ec 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -7,7 +7,7 @@ "devUrl": "http://localhost:5173", "frontendDist": "../../web/dist", "beforeDevCommand": "pnpm --filter @parking/web dev", - "beforeBuildCommand": "VITE_API_BASE=http://127.0.0.1:3000 pnpm --filter @parking/web build" + "beforeBuildCommand": "pnpm --filter @parking/web build" }, "app": { "windows": [ @@ -24,7 +24,7 @@ } ], "security": { - "csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:3000 http://localhost:3000 ws://127.0.0.1:3000 ws://localhost:3000" + "csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self'" } }, "bundle": { diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index b47f439..6a07d7a 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -65,7 +65,21 @@ function cleanProfileField(v: string | null | undefined): string | null | undefi /** 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. */ + * permissions are the source of truth. + * + * `csrf`, when passed, echoes the SAME value already sent as the readable + * parking_csrf cookie — not a new secret, just a second channel to learn it. + * The desktop shell needs this: tauri-plugin-http's fetch() runs through + * Rust's reqwest, which keeps its own cookie jar separate from the webview, + * so document.cookie on the tauri://localhost page never sees a cookie set + * on a plugin-routed response (open upstream bug, tauri-apps/tauri#13045). + * The cookie itself IS still sent back to the server by reqwest on + * subsequent requests — only the *client-side read* is broken — so + * api.ts's desktop path stashes this body value in memory instead of + * reading document.cookie, and echoes it in X-CSRF-Token exactly as the + * browser path echoes the cookie. See lib/api.ts and assertCsrf() in + * ../auth.ts (unchanged — this never touches verification, only how the + * desktop client learns what to send). */ function sessionView( db: Db, user: { @@ -78,6 +92,7 @@ function sessionView( fullName?: string | null; email?: string | null; }, + csrf?: string, ) { const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get(); const permissions = [...permissionsFor(user.roleId)]; @@ -92,6 +107,7 @@ function sessionView( fontScale: user.fontScale, fullName: user.fullName ?? null, email: user.email ?? null, + ...(csrf ? { csrfToken: csrf } : {}), }; } @@ -126,7 +142,7 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise { setAuthCookies(reply, token, csrf); // `language` is NOT in the JWT (identity/role only) — it's a mutable preference // read from the DB, so changing it needs no token refresh. - return sessionView(db, user); + return sessionView(db, user, csrf); }); app.post("/api/auth/logout", async (_req, reply) => { @@ -146,7 +162,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise { clearAuthCookies(reply); return reply.code(401).send({ error: "session no longer valid" }); } - return sessionView(db, row); + // req.user.csrf is the value bound into the JWT at login (see assertCsrf in + // ../auth.ts) — same value as the cookie, re-surfaced for the desktop path. + return sessionView(db, row, req.user.csrf); }, ); diff --git a/apps/web/package.json b/apps/web/package.json index 0eb0686..bfe298f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-http": "^2.5.2", "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-store": "^2.4.0", "@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-websocket": "^2.3.0", "i18next": "^26.3.1", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 779d416..6baf52a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,25 +3,42 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider } from "@tanstack/react-router"; import { fetchMe, type SessionUser } from "./api.js"; import { Login } from "./Login.js"; +import { ConnectScreen } from "./ConnectScreen.js"; import { queryClient } from "./lib/query.js"; import { setLanguage } from "./lib/i18n/index.js"; import { applyTheme, applyFontScale } from "./lib/theme.js"; import { router } from "./router.js"; +import { initApiBase, inTauri } from "./lib/origin.js"; // App root: bootstraps the session (cookie-based, from /api/auth/me), then hands // off to TanStack Router inside the QueryClient provider. The router renders the // terminal chrome + screens; auth gating stays here (Login until signed in), and // the signed-in user flows into the router context for role-based route guards. // See wiki/entities/react-vite-spa.md and local-jwt-auth.md. +// +// Desktop shell only: BEFORE any of that, the backend origin itself must be +// known — the same installer is used at every booth (see lib/origin.ts / +// backend-config.ts), so on first launch (or after the operator clears it) +// there is no server to call fetchMe() against yet. ConnectScreen gates that; +// a browser build always has a same-origin backend, so `needsConnect` is +// always false there and this is skipped entirely. export function App() { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); + const [needsConnect, setNeedsConnect] = useState(false); useEffect(() => { - fetchMe() - .then(setUser) - .finally(() => setLoading(false)); + initApiBase().then((saved) => { + if (inTauri() && !saved) { + setNeedsConnect(true); + setLoading(false); + return; + } + fetchMe() + .then(setUser) + .finally(() => setLoading(false)); + }); }, []); // Apply the signed-in user's preferred language + theme + font scale whenever they @@ -41,6 +58,19 @@ export function App() { if (loading) { return
loading…
; } + if (needsConnect) { + return ( + { + setNeedsConnect(false); + setLoading(true); + fetchMe() + .then(setUser) + .finally(() => setLoading(false)); + }} + /> + ); + } if (!user) { return ( diff --git a/apps/web/src/ConnectScreen.tsx b/apps/web/src/ConnectScreen.tsx new file mode 100644 index 0000000..34ed7ac --- /dev/null +++ b/apps/web/src/ConnectScreen.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { setApiBase } from "./lib/origin.js"; + +// Desktop-only gate shown BEFORE Login whenever no backend has been +// configured yet (first launch of a generic .deb/.AppImage install, or after +// the operator clears it from Settings). Same installer works at any booth — +// see backend-config.ts for why this can't be a build-time value. +// +// backend-config.ts is imported dynamically (not at module top-level) purely +// to keep bundling consistent with origin.ts/router.tsx's other Tauri-only +// imports — this component itself only ever renders inside Tauri anyway, so +// it's not a functional requirement, just avoids an INEFFECTIVE_DYNAMIC_IMPORT +// warning from Vite (a static import here would defeat those other dynamic +// imports' chunk-splitting intent). + +function normalizeHost(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return trimmed; + return /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; +} + +export function ConnectScreen({ onConnected }: { onConnected: () => void }) { + const { t } = useTranslation(); + const [host, setHost] = useState(""); + const [testing, setTesting] = useState(false); + const [saving, setSaving] = useState(false); + const [result, setResult] = useState<"ok" | "unreachable" | "bad_response" | null>(null); + const [detail, setDetail] = useState(undefined); + + const url = normalizeHost(host); + const canSubmit = url.length > 0 && !testing && !saving; + + async function handleTest(e: React.FormEvent) { + e.preventDefault(); + if (!canSubmit) return; + setTesting(true); + setResult(null); + setDetail(undefined); + try { + const { testBackendUrl } = await import("./lib/backend-config.js"); + const check = await testBackendUrl(url); + setResult(check.ok ? "ok" : (check.reason ?? "unreachable")); + setDetail(check.detail); + } finally { + setTesting(false); + } + } + + async function handleSave() { + setSaving(true); + try { + const { saveBackendUrl } = await import("./lib/backend-config.js"); + await saveBackendUrl(url); + setApiBase(url); + onConnected(); + } finally { + setSaving(false); + } + } + + return ( +
+
+

+ {t("connect.title")} +

+

{t("connect.hint")}

+ +
+ + { + setHost(e.target.value); + setResult(null); + }} + placeholder="192.168.1.50:3000" + autoFocus + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + /> +
+ + {result === "ok" && ( +

{t("connect.testOk")}

+ )} + {result === "unreachable" && ( +

+ {t("connect.testUnreachable")} + {detail ? ` (${detail})` : ""} +

+ )} + {result === "bad_response" && ( +

{t("connect.testBadResponse")}

+ )} + +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index b43083e..9df39d2 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1,12 +1,25 @@ // Thin API client for the operator/admin UI. // -// Auth is cookie-based: the JWT lives in an HttpOnly cookie the browser sends -// automatically (credentials: 'include'). For mutations we echo the readable -// CSRF cookie back in the X-CSRF-Token header (double-submit). See +// Auth is cookie-based: the JWT lives in an HttpOnly cookie sent automatically +// (credentials: 'include'). For mutations we echo the readable CSRF cookie +// back in the X-CSRF-Token header (double-submit). See // wiki/entities/local-jwt-auth.md. +// +// Desktop shell exception: tauri-plugin-http's fetch() runs through Rust's +// reqwest, which keeps its OWN cookie jar separate from the webview — +// document.cookie on the tauri://localhost page never sees a cookie set on a +// plugin-routed response (open upstream bug, tauri-apps/tauri#13045). The +// cookie itself IS still sent back to the server by reqwest on later +// requests (only the client-side *read* is broken), so the server also +// echoes the token in the login/me response BODY (sessionView's csrfToken — +// see routes/auth.ts) purely as a second channel for the desktop client to +// learn the value; desktopCsrfToken below stashes it in memory and +// setSessionUser() (called wherever a SessionUser is received) keeps it +// current. The browser path is untouched — it still reads document.cookie. import { logFailedRequest } from "./lib/logger.js"; import { apiUrl, platformFetch } from "./lib/origin.js"; +import { inTauri } from "./lib/tauri-env.js"; import type { AppLogRecord, ValidationLine, ValidationMode } from "@parking/shared"; const CSRF_COOKIE = "parking_csrf"; @@ -17,6 +30,18 @@ function readCookie(name: string): string | null { return m ? decodeURIComponent(m[1]!) : null; } +/** Desktop-only in-memory CSRF stash — see file header. Never persisted (a + * fresh app launch always logs in again, or bootstraps via /api/auth/me + * which re-supplies it). */ +let desktopCsrfToken: string | null = null; + +/** Update the desktop CSRF stash. Called wherever a SessionUser is received + * (login, fetchMe). No-op / cheap in the browser (the value just goes + * unused there — reads still come from document.cookie). */ +function setSessionUser(user: SessionUser): void { + if (user.csrfToken) desktopCsrfToken = user.csrfToken; +} + /** fetch wrapper: sends cookies, adds CSRF header on mutations, parses errors. */ export async function apiFetch(path: string, init: RequestInit = {}): Promise { const method = (init.method ?? "GET").toUpperCase(); @@ -25,7 +50,7 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise headers.set("content-type", "application/json"); } if (method !== "GET" && method !== "HEAD") { - const csrf = readCookie(CSRF_COOKIE); + const csrf = inTauri() ? desktopCsrfToken : readCookie(CSRF_COOKIE); if (csrf) headers.set(CSRF_HEADER, csrf); } const res = await platformFetch(apiUrl(path), { ...init, headers, credentials: "include" }); @@ -82,6 +107,10 @@ export interface SessionUser { fullName: string | null; /** Optional contact email (profile metadata); null if unset. */ email: string | null; + /** Desktop-only: the CSRF token also echoed via the (JS-unreadable, on + * desktop) parking_csrf cookie — see the file header. Absent/unused in the + * browser build, which reads the cookie directly instead. */ + csrfToken?: string; } /** Does this session grant the permission? Central authz check for the SPA. */ @@ -89,15 +118,19 @@ export function can(user: SessionUser | null, perm: Permission): boolean { return !!user && user.permissions.includes(perm); } -export function login(username: string, password: string): Promise { - return apiFetch("/api/auth/login", { +export async function login(username: string, password: string): Promise { + const user = await apiFetch("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }), }); + setSessionUser(user); + return user; } -export function logout(): Promise<{ ok: boolean }> { - return apiFetch("/api/auth/logout", { method: "POST" }); +export async function logout(): Promise<{ ok: boolean }> { + const res = await apiFetch<{ ok: boolean }>("/api/auth/logout", { method: "POST" }); + desktopCsrfToken = null; + return res; } /** Persist the current user's UI language preference (restored on next login). */ @@ -146,7 +179,9 @@ export function changeMyPassword( /** Returns the current user, or null if not authenticated. */ export async function fetchMe(): Promise { try { - return await apiFetch("/api/auth/me"); + const user = await apiFetch("/api/auth/me"); + setSessionUser(user); + return user; } catch (e) { if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null; throw e; diff --git a/apps/web/src/lib/backend-config.ts b/apps/web/src/lib/backend-config.ts new file mode 100644 index 0000000..ece5174 --- /dev/null +++ b/apps/web/src/lib/backend-config.ts @@ -0,0 +1,88 @@ +// Desktop-only: the operator-configured backend origin (host:port of the +// Fastify server this install talks to), persisted across restarts. +// +// The desktop shell is a generic .deb/.AppImage — it is NOT built for one +// specific booth, so the backend address can't be baked in at build time +// (that was the old VITE_API_BASE approach; a rebuild was needed to point the +// same installer at a different appliance). Instead the operator enters it +// once in the ConnectScreen (shown before login whenever nothing usable is +// stored yet) and it's saved to a JSON file in the OS config dir via +// tauri-plugin-store, read back on every launch before any API call. +// +// Browser build: this module is never reached (inTauri() gates every call +// site — see origin.ts), so there is no browser equivalent or fallback here. + +import type { Store } from "@tauri-apps/plugin-store"; + +const STORE_FILE = "backend-config.json"; +const KEY = "backendUrl"; + +let storeHandle: Store | null = null; +async function getStore(): Promise { + if (!storeHandle) { + const { load } = await import("@tauri-apps/plugin-store"); + storeHandle = await load(STORE_FILE, { autoSave: true }); + } + return storeHandle; +} + +/** The saved backend origin (no trailing slash), or null if never configured. + * Desktop only — throws if called from a browser build. */ +export async function loadBackendUrl(): Promise { + const store = await getStore(); + const v = await store.get(KEY); + return typeof v === "string" && v.length > 0 ? v.replace(/\/$/, "") : null; +} + +/** Persist a new backend origin (validated + reachable — call testBackendUrl + * first). Takes effect immediately for future platformFetch/wsUrl calls. */ +export async function saveBackendUrl(url: string): Promise { + const store = await getStore(); + await store.set(KEY, url.replace(/\/$/, "")); + await store.save(); +} + +/** Clear the saved backend (forces the ConnectScreen back up next launch). */ +export async function clearBackendUrl(): Promise { + const store = await getStore(); + await store.delete(KEY); + await store.save(); +} + +export interface BackendCheck { + ok: boolean; + /** "unreachable" (network/DNS/refused) | "bad_response" (reachable, not our API). */ + reason?: "unreachable" | "bad_response"; + detail?: string; +} + +/** Probe a candidate origin by hitting /api/version. That route is behind + * requirePermission("site:read") (session cookie + site:read — see + * apps/server/src/routes/site.ts), so a pre-login probe can never get a 2xx; + * we're not checking "is this reachable and mine to use", only "is something + * that speaks our Fastify auth protocol listening here" — a 401 (missing/bad + * JWT) or 403 (valid session, wrong permission) from THIS specific route is + * as strong a signal of that as a 200 would be, and both are expected outcomes + * pre-login. Uses the same tauri-plugin-http path platformFetch does (raw + * fetch from the webview can't reach an arbitrary LAN host — mixed content, + * see origin.ts). */ +export async function testBackendUrl(url: string): Promise { + const origin = url.replace(/\/$/, ""); + try { + const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http"); + const res = await tauriFetch(`${origin}/api/version`, { + method: "GET", + signal: AbortSignal.timeout(5000), + }); + if (!res.ok && res.status !== 401 && res.status !== 403) { + return { ok: false, reason: "bad_response", detail: `HTTP ${res.status}` }; + } + return { ok: true }; + } catch (err) { + return { + ok: false, + reason: "unreachable", + detail: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/apps/web/src/lib/desktop-updater.ts b/apps/web/src/lib/desktop-updater.ts index 2860365..2c93e0e 100644 --- a/apps/web/src/lib/desktop-updater.ts +++ b/apps/web/src/lib/desktop-updater.ts @@ -20,11 +20,7 @@ // LogsViewer.tsx without needing a terminal or devtools at all. import { logClient } from "./logger.js"; - -/** True when running inside the Tauri webview (not a normal browser). */ -function inTauri(): boolean { - return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; -} +import { inTauri } from "./tauri-env.js"; export interface UpdatePrompt { /** Newer version string offered by the server. */ diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 8f935c9..5e47505 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -42,6 +42,20 @@ export const en: Catalog = { signIn: "Sign in", signingIn: "Signing in…", }, + connect: { + title: "Connect to server", + hint: "Enter the address of the parking system server for this booth.", + serverAddress: "Server address", + test: "Test", + testing: "Testing…", + save: "Save & continue", + saving: "Saving…", + testOk: "Reachable — this looks like a Parking System server.", + testUnreachable: "Could not reach this address.", + testBadResponse: "Reachable, but this doesn't look like a Parking System server.", + changeServer: "Change server", + changeServerConfirm: "This signs you out and asks for a new server address on next launch. Continue?", + }, update: { available: "Update available", prompt: "Version {{version}} is available. Install now and restart?", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 350855d..654d013 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -45,6 +45,20 @@ export const sq = { signIn: "Hyr", signingIn: "Duke hyrë…", }, + connect: { + title: "Lidhu me serverin", + hint: "Vendos adresën e serverit të sistemit të parkimit për këtë kabinë.", + serverAddress: "Adresa e serverit", + test: "Testo", + testing: "Duke testuar…", + save: "Ruaj & vazhdo", + saving: "Duke ruajtur…", + testOk: "I arritshëm — duket si server i Sistemit të Parkimit.", + testUnreachable: "Nuk u arrit kjo adresë.", + testBadResponse: "I arritshëm, por nuk duket si server i Sistemit të Parkimit.", + changeServer: "Ndrysho serverin", + changeServerConfirm: "Kjo do t'ju dalë nga sesioni dhe do kërkojë adresë të re serveri në hapjen tjetër. Vazhdo?", + }, update: { available: "Përditësim i disponueshëm", prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis?", diff --git a/apps/web/src/lib/origin.ts b/apps/web/src/lib/origin.ts index 4fda7df..682dfee 100644 --- a/apps/web/src/lib/origin.ts +++ b/apps/web/src/lib/origin.ts @@ -3,16 +3,19 @@ // In a browser (dev via the Vite proxy, or prod where Fastify serves the built // SPA) this is EMPTY — requests stay relative (`/api/...`) and same-origin, so // nothing changes. The Tauri desktop shell (apps/desktop) serves the bundled -// SPA from `tauri://localhost`, which has no backend and no proxy; there we set -// VITE_API_BASE to the appliance's Fastify origin (e.g. http://127.0.0.1:3000) -// at build time so /api and the live WS feed resolve to the real server. +// SPA from `tauri://localhost`, which has no backend and no proxy; there the +// operator enters the appliance's Fastify origin (e.g. http://192.168.1.50:3000) +// once in the ConnectScreen and it's persisted via tauri-plugin-store (see +// backend-config.ts) — a RUNTIME value, not a build-time one, since the same +// installer is used across every booth and the backend can move (new box, new +// IP) without a rebuild. main.tsx calls initApiBase() before the app mounts. // // Keep this the SINGLE source for the backend origin — api.ts and the live-feed // WebSocket both read it, so the web app and the desktop shell stay identical -// except for this one build-time value. +// except for this one runtime value. // // platformFetch(): WebKitGTK treats tauri://localhost as a SECURE origin, so a -// plain http://127.0.0.1:3000 fetch() from inside it is blocked as mixed +// plain http://192.168.1.50:3000 fetch() from inside it is blocked as mixed // content (a WebKit limitation — CSP's connect-src does NOT override this; // found 2026-09-03 as "Load failed" on every desktop request). Inside Tauri we // dynamically import @tauri-apps/plugin-http's fetch, which routes the request @@ -20,8 +23,29 @@ // the check entirely. Browser build never imports the plugin (dynamic import, // same pattern as desktop-updater.ts). -/** Backend HTTP origin, no trailing slash. Empty string = same-origin/relative. */ -export const API_BASE: string = (import.meta.env.VITE_API_BASE ?? "").replace(/\/$/, ""); +import { inTauri } from "./tauri-env.js"; + +/** Backend HTTP origin, no trailing slash. Empty string = same-origin/relative + * (browser) or not-yet-configured (desktop, before the ConnectScreen runs). */ +export let API_BASE: string = ""; + +/** Desktop only: load the persisted backend URL (if any) before the app + * mounts, so the very first fetchMe() call already has the right origin. + * No-op in the browser. Returns the loaded value (null = not configured yet, + * meaning main.tsx should show the ConnectScreen instead of the normal app). */ +export async function initApiBase(): Promise { + if (!inTauri()) return null; + const { loadBackendUrl } = await import("./backend-config.js"); + const saved = await loadBackendUrl(); + if (saved) API_BASE = saved; + return saved; +} + +/** Desktop only: change the backend origin at runtime (after the operator + * saves a new one in Settings) without requiring a full app restart. */ +export function setApiBase(url: string): void { + API_BASE = url.replace(/\/$/, ""); +} /** Resolve an API path to a full URL (or a relative path when API_BASE is empty). */ export function apiUrl(path: string): string { @@ -38,10 +62,7 @@ export function wsUrl(path: string): string { return `${proto}//${window.location.host}${path}`; } -/** True when running inside the Tauri webview (not a normal browser). */ -export function inTauri(): boolean { - return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; -} +export { inTauri }; /** * fetch(), but routed through @tauri-apps/plugin-http inside the desktop diff --git a/apps/web/src/lib/platform-ws.ts b/apps/web/src/lib/platform-ws.ts index fb2ee1b..aad8a2f 100644 --- a/apps/web/src/lib/platform-ws.ts +++ b/apps/web/src/lib/platform-ws.ts @@ -17,6 +17,8 @@ // Browser build: plain pass-through to the real WebSocket (this file's // createPlatformSocket is only called from inside inTauri() callers). +import { inTauri } from "./tauri-env.js"; + export interface PlatformSocket { onopen: (() => void) | null; onmessage: ((ev: { data: string }) => void) | null; @@ -97,11 +99,6 @@ class TauriSocketAdapter implements PlatformSocket { } } -/** True when running inside the Tauri webview (not a normal browser). */ -function inTauri(): boolean { - return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; -} - /** Open a live-feed socket, routed through the Tauri WebSocket plugin inside the * desktop shell (mixed-content workaround), or the native WebSocket in a browser. */ export function createPlatformSocket(url: string): PlatformSocket { diff --git a/apps/web/src/lib/tauri-env.ts b/apps/web/src/lib/tauri-env.ts new file mode 100644 index 0000000..ff57b32 --- /dev/null +++ b/apps/web/src/lib/tauri-env.ts @@ -0,0 +1,6 @@ +/** True when running inside the Tauri webview (not a normal browser). Single + * source for this check — origin.ts, platform-ws.ts, desktop-updater.ts, and + * backend-config.ts all gate their Tauri-only code paths on it. */ +export function inTauri(): boolean { + return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 12ab48d..8836b01 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -130,6 +130,55 @@ function DesktopVersionBadge() { return app v{version}; } +/** Desktop-only "change which server this install talks to" control. No-op / + * renders nothing in a browser (the concept doesn't apply — same-origin). + * Simplest correct action: clear the saved backend URL and reload, which + * drops the app back to ConnectScreen (see App.tsx) to re-enter it — this + * mirrors clearing the session (logout → back to Login), not an inline + * editor, since repointing the app is a rare, deliberate admin action. */ +function DesktopServerButton() { + const { t } = useTranslation(); + const [confirming, setConfirming] = useState(false); + const [busy, setBusy] = useState(false); + if (!inTauri()) return null; + return ( + <> + + {confirming && ( + setConfirming(false)} title={t("connect.changeServer")} width="max-w-sm"> +
+

{t("connect.changeServerConfirm")}

+
+ + +
+
+
+ )} + + ); +} + /** Setup layout — the config hub. Renders a permission-gated tab bar and the active * tab's screen via . Each tab is a child route (its own URL + guard), so * deep links and the back button work and a denied tab redirects to the booth. */ @@ -150,6 +199,7 @@ function SetupLayout() { {show("backup:read") && } {show("site:read") && } + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8697c72..745e270 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: '@tauri-apps/plugin-process': specifier: ^2.3.1 version: 2.3.1 + '@tauri-apps/plugin-store': + specifier: ^2.4.0 + version: 2.4.4 '@tauri-apps/plugin-updater': specifier: ^2.10.1 version: 2.10.1 @@ -1592,6 +1595,9 @@ packages: '@tauri-apps/plugin-process@2.3.1': resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} + '@tauri-apps/plugin-store@2.4.4': + resolution: {integrity: sha512-oxSMaj/QpVfJcBMYX5aOQV94fWvga0MwQMfD6TLlbK2dh+ShPWAzefd8HWXhvOKjPRJdGVAkW7ZGO76JzzjaDA==} + '@tauri-apps/plugin-updater@2.10.1': resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==} @@ -4112,6 +4118,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.1 + '@tauri-apps/plugin-store@2.4.4': + dependencies: + '@tauri-apps/api': 2.11.1 + '@tauri-apps/plugin-updater@2.10.1': dependencies: '@tauri-apps/api': 2.11.1 diff --git a/wiki/decisions/desktop-shell-tauri.md b/wiki/decisions/desktop-shell-tauri.md index 630883f..97f0016 100644 --- a/wiki/decisions/desktop-shell-tauri.md +++ b/wiki/decisions/desktop-shell-tauri.md @@ -2,7 +2,7 @@ type: decision tags: [parking, decisions, desktop, frontend] sources: [] -updated: 2026-09-03 +updated: 2026-09-04 status: settled --- @@ -302,3 +302,70 @@ The desktop bundle now runs in CI under **two distinct workflows** — keep the above (download traffic visible, then nothing). Fixed by nesting `downloadAndInstall()` in its own try/catch that logs and rethrows — offline/no-update still no-ops silently (outer catch), but a failure *after* the operator accepted now logs to the console instead of vanishing. + +### Runtime-configurable backend origin — no more one-install-per-booth builds (2026-09-04) + +**Problem:** `VITE_API_BASE` was a **build-time** Vite env var (`tauri.conf.json`'s +`beforeBuildCommand`), hardcoded to `http://127.0.0.1:3000`. The desktop shell is a single +generic `.deb`/`.AppImage` distributed via [[fleet-deployment-komodo|mca/public_releases]] — it is +**not** built per-booth — so a build-time backend address meant the installer could only ever talk +to a server on the same machine, and pointing an install at any other host (a remote appliance, a +different port) needed a full rebuild. **Fix:** the backend origin is now an **operator-entered, +runtime-persisted** value. + +- **`ConnectScreen.tsx`** — shown by `App.tsx` BEFORE `fetchMe()`/`Login` whenever running inside + Tauri (`inTauri()`) and no backend URL is saved yet (first launch, or after "Change server"). + Operator types a host, hits **Test** (`backend-config.ts`'s `testBackendUrl`, an unauthenticated- + from-the-client's-perspective `GET /api/version` probe — see the CSRF gotcha below for why that + route isn't actually public), then **Save & continue**. +- **`tauri-plugin-store`** persists the value (`backend-config.json` in the OS config dir, + `autoSave: true`) — survives restarts, is NOT `localStorage` (deliberately; matches the existing + server-persisted-preference pattern elsewhere in this app, and a real file is easier to inspect/ + back up on an appliance). `origin.ts`'s `API_BASE` changed from a `const` to a `let`, set once via + `initApiBase()` (called by `App.tsx` before mount) and again via `setApiBase()` after the + ConnectScreen saves — no restart required to start using it. +- **CSP had to loosen, deliberately, to a narrower real boundary.** `connect-src` was + `'self' http://127.0.0.1:3000 ... ws://127.0.0.1:3000 ...`; an operator-chosen arbitrary LAN host + can't be named at build time, so it's now **`'self'` only** — meaning a raw `fetch()`/`WebSocket` + from the webview is blocked to EVERY origin, not just disallowed ones. This is intentional, not a + regression: all backend traffic already went through `tauri-plugin-http`/`tauri-plugin-websocket` + anyway (the WebKit mixed-content fix above), and those plugins run on the Rust side, **outside** + `connect-src`'s jurisdiction entirely. The real access boundary moved to + `capabilities/default.json`'s `http:default` scope, which is now wildcarded + (`http://*`, `https://*`, `http://*:*`, `https://*:*` — all four forms needed, a known Tauri + scope-matching quirk drops bare `http://*` matches for a `host:port` URL otherwise). `websocket: + default` already had no scope restriction. Net effect: **the app can now reach any host the + operator types in, and nothing else** — same shape of guarantee as before, just operator-directed + instead of build-directed. +- **"Change server"** — `router.tsx`'s `DesktopServerButton`, in the Setup nav bar next to + `DesktopVersionBadge` (both `inTauri()`-gated, invisible in the browser). Confirm-modal (reuses + the shared `Modal`, not a bespoke dialog) → `clearBackendUrl()` → reload, which drops back to + ConnectScreen. Deliberately not an inline editor: repointing a booth's app is a rare, deliberate + admin action, not a frequent setting — same reasoning as why logout is a plain action button with + no separate "are you sure" for THAT (this one gets a confirm because it also blows away the + session, unlike a normal logout-then-relogin against the same server). +- **Gotcha (found via research before shipping, not in the field — worth recording anyway): the + CSRF double-submit cookie is invisible to `document.cookie` on desktop.** `tauri-plugin-http`'s + `fetch()` doesn't run through the webview — it's dispatched to Tauri's Rust side and executed by + `reqwest`, which keeps its **own** cookie jar, entirely separate from WebKitGTK's. `Set-Cookie` on + a `tauri-plugin-http` response is stored in that reqwest jar and IS correctly re-sent by + reqwest on later requests (so plain session auth — GETs — silently worked) — but it is **never** + synced into the webview's own cookie store, so `document.cookie` on the `tauri://localhost` page + can never see it. This is an open, unresolved upstream Tauri bug + ([tauri-apps/tauri#13045](https://github.com/tauri-apps/tauri/issues/13045), + [#11518](https://github.com/tauri-apps/tauri/issues/11518)) — not something fixable on our side by + changing how/when we read the cookie. Since `api.ts`'s `apiFetch` reads the readable `parking_csrf` + cookie via `document.cookie` to echo it in `X-CSRF-Token` (double-submit — see + [[local-jwt-auth]]), this meant **every mutating request from the desktop app was silently sending + no CSRF header at all**, pre-dating this runtime-URL change (it was equally true against the old + hardcoded `127.0.0.1:3000`) — caught now because widening the backend to "any host" was the + occasion to actually trace the desktop auth path end-to-end. **Fix, without touching + `assertCsrf()`'s verification logic at all:** the server's `sessionView()` (`routes/auth.ts`, + shared by `login` and `me`) now optionally echoes the CSRF token value in the JSON response body + (`csrfToken`) — the SAME value already set as the cookie, just a second channel to learn it. The + desktop client (`api.ts`) stashes that value in an in-memory-only variable (`desktopCsrfToken`, + never persisted — a fresh launch always re-learns it via login or `/api/auth/me`) and echoes THAT + instead of reading `document.cookie` when `inTauri()`. The actual cookie is still what + `assertCsrf()` checks server-side (and reqwest still sends it correctly, per above) — this only + fixes how the desktop *client* learns what value to put in the header, so browser behavior and + server verification are both completely unchanged. diff --git a/wiki/log.md b/wiki/log.md index 22ec5f5..9b3e1fe 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2817,3 +2817,27 @@ version ("it's offering v0.1.4, so I must be on v0.1.3"). Added DesktopVersionBa existing server-side VersionBadge in router.tsx, using @tauri-apps/api's getVersion() (the real running app version, synced to the git tag at build time by release.yml). No-ops in a browser. Full detail on [[desktop-shell-tauri]]. + +## [2026-09-04] feat | Desktop backend origin is now runtime-configurable (was build-time) + +The desktop shell is one generic .deb/.AppImage distributed via mca/public_releases — not built +per-booth — but VITE_API_BASE was a build-time env var hardcoded to http://127.0.0.1:3000, so the +same installer could only ever talk to a server on its own machine. Added ConnectScreen (shown +before Login in Tauri when no backend is saved), backed by tauri-plugin-store persisting the +operator-entered URL across restarts; origin.ts's API_BASE became a runtime-settable `let`. CSP's +connect-src tightened to 'self' only (all backend traffic already went through +tauri-plugin-http/websocket, which run Rust-side and are outside connect-src's reach anyway); the +real boundary moved to capabilities/default.json's http:default scope, wildcarded to any host so +the operator-chosen address is actually reachable. Added a "Change server" control (Setup nav, +desktop-only) that clears the saved URL and reloads back to ConnectScreen. + +While tracing the desktop auth path for this, found a pre-existing (not newly introduced) bug: +tauri-plugin-http's fetch() runs through Rust's reqwest, which keeps its own cookie jar separate +from the webview — document.cookie on tauri://localhost never sees the parking_csrf cookie the +server sets (open upstream bug, tauri-apps/tauri#13045/#11518), so the desktop app has likely been +silently sending no CSRF header on every mutation since the shell was first built, regardless of +which host it targeted. Fixed by having sessionView() (routes/auth.ts) also echo the same csrf +value in the login/me JSON body; the desktop client stashes it in memory and echoes that instead of +reading document.cookie. assertCsrf() itself is untouched — the cookie is still what's verified, +and reqwest was already sending it correctly; this only fixes how the desktop client *learns* the +value. Full detail (including the exact CSP/capability tradeoffs) on [[desktop-shell-tauri]]. From 70e1e9939f1883832ed96bcff19a3235d091efcb Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Fri, 4 Sep 2026 11:27:17 +0200 Subject: [PATCH 4/8] chore(resources): bump stage TAG to 5c6a21e Promotes park-buzi + park-2 to the runtime-configurable desktop backend address (ConnectScreen) and the desktop CSRF fix. build-images.yml confirmed green for this sha before bumping. --- komodo/resources.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/komodo/resources.toml b/komodo/resources.toml index be633f3..d55da95 100644 --- a/komodo/resources.toml +++ b/komodo/resources.toml @@ -49,7 +49,7 @@ REGISTRY=git.infra.msai.al/mca/parking_solution # Staging booth: pinned immutable stage-. After each promotion (merge dev → stage, CI builds # :stage-), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag # exists as the pointer; we deploy the sha, not the mover. -TAG=stage-7d67934 +TAG=stage-5c6a21e COOKIE_SECURE=0 VISION_ENABLED=1 # Desktop app WS handshake: Origin is tauri://localhost (set explicitly by @@ -82,7 +82,7 @@ REGISTRY=git.infra.msai.al/mca/parking_solution # Staging booth: pinned immutable stage-. After each promotion (merge dev → stage, CI builds # :stage-), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag # exists as the pointer; we deploy the sha, not the mover. -TAG=stage-7d67934 +TAG=stage-5c6a21e COOKIE_SECURE=0 VISION_ENABLED=1 # Desktop app WS handshake: Origin is tauri://localhost (set explicitly by From 8fa66c9911e456a0fd69f8219e1a314f5924db90 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Fri, 4 Sep 2026 12:09:30 +0200 Subject: [PATCH 5/8] fix(desktop): WS ticket auth for the live feed; desktop logs never reached the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.1.4 Origin fix cleared only the first of two gates in /api/ws's preHandler. The second, req.jwtVerify(), reads the HttpOnly cookie — which tauri-plugin-websocket (a bare tungstenite client, no cookie jar) can never send. Every desktop handshake 401'd and use-live-feed reconnected every 10s (confirmed in the park-2 server log). - routes/ws.ts: POST /api/ws/ticket (cookie + CSRF auth) mints a 30s, single-use, in-memory ticket; the WS preHandler accepts it via an x-ws-ticket header after the Origin check, then the same report:read role check. Browser cookie path unchanged; JWT stays out of JS. - platform-ws.ts: fetch a ticket before connect, send it with the Origin header; connect failures now go through logClient (rate-limited). - logger.ts: flush read the CSRF token from document.cookie, null on desktop, so every desktop POST /api/logs 403'd and was dropped silently — no desktop client log had ever reached app_logs. Stash moved to a dependency-free lib/desktop-csrf.ts shared by api.ts and logger.ts. - backend-config.ts: ConnectScreen probe uses the unauthenticated /health (now also returns app: "parking-system") instead of accepting any 401. - README: local-AppImage release gate — tauri dev runs at http://localhost:5173, not tauri://localhost, so none of these origin-dependent bugs reproduce there. - wiki: new section + log entry; four citation corrections. Requires the server image with this commit deployed before the new desktop build connects (the ticket endpoint must exist). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU --- apps/desktop/README.md | 21 +++++++ apps/server/src/routes/routes.test.ts | 2 +- apps/server/src/routes/ws.ts | 83 +++++++++++++++++++++++++-- apps/server/src/server.ts | 5 +- apps/web/src/api.ts | 36 ++++++------ apps/web/src/lib/backend-config.ts | 25 ++++---- apps/web/src/lib/desktop-csrf.ts | 25 ++++++++ apps/web/src/lib/logger.ts | 8 ++- apps/web/src/lib/platform-ws.ts | 42 +++++++++++--- wiki/decisions/desktop-shell-tauri.md | 69 +++++++++++++++++++--- wiki/log.md | 19 ++++++ 11 files changed, 279 insertions(+), 56 deletions(-) create mode 100644 apps/web/src/lib/desktop-csrf.ts diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 315b19c..d3db707 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -44,6 +44,27 @@ see that workflow's header and `wiki/decisions/desktop-shell-tauri.md`). The upd signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never committed. +## Release gate — run the REAL bundle locally before tagging + +`tauri dev` loads the SPA from `http://localhost:5173`, a plain http origin. The shipped bundle +loads it from `tauri://localhost`, a *secure* custom-scheme origin — and every desktop-only bug +found in the field on 2026-09-03/04 (relative-URL DOMException, mixed content, missing WS +`Origin`, the reqwest-vs-webview cookie split, the WS handshake that can't carry the cookie) +depends on that difference. **Dev mode cannot reproduce any of them**, so "works in `tauri dev`" +carries no information about a release. Before pushing a `vX.Y.Z` tag: + +1. `pnpm --filter @parking/server dev` (local backend; `.env` must have `COOKIE_SECURE=0` and + `tauri://localhost` in `WS_ALLOWED_ORIGINS`). +2. `pnpm --filter @parking/desktop bundle` and run the produced AppImage from + `src-tauri/target/release/bundle/appimage/` (WSLg is enough). +3. On the ConnectScreen enter `127.0.0.1:3000`, **Test** must say reachable, then **Save**. +4. Log in. The booth header must show **LIVE** (not "JASHTË LINJË") within a few seconds. +5. Perform one mutation (e.g. change your UI language) — it must succeed (proves CSRF). +6. Open Setup → Logs and confirm a `frontend`-sourced row from this desktop session exists + (proves the desktop log channel; historically it was silently 403'd). + +Only then tag. If a release still fails in the field, the gap is in this list — fix the list. + ## Not here (deliberately) Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for diff --git a/apps/server/src/routes/routes.test.ts b/apps/server/src/routes/routes.test.ts index 0793f3d..72807f6 100644 --- a/apps/server/src/routes/routes.test.ts +++ b/apps/server/src/routes/routes.test.ts @@ -30,7 +30,7 @@ describe("health + login", () => { it("GET /health is open", async () => { const res = await app.inject({ method: "GET", url: "/health" }); expect(res.statusCode).toBe(200); - expect(res.json()).toEqual({ status: "ok" }); + expect(res.json()).toEqual({ status: "ok", app: "parking-system" }); }); it("login with bad credentials is rejected", async () => { diff --git a/apps/server/src/routes/ws.ts b/apps/server/src/routes/ws.ts index 44f7a9d..e143de8 100644 --- a/apps/server/src/routes/ws.ts +++ b/apps/server/src/routes/ws.ts @@ -1,7 +1,8 @@ +import { randomBytes } from "node:crypto"; import type { FastifyInstance } from "fastify"; import type { Db } from "@parking/db"; import type { LedgerEvent } from "@parking/shared"; -import { roleHasPermissions } from "../auth.js"; +import { requireAuth, roleHasPermissions } from "../auth.js"; import { deviceEvents, type LaneStatusEvent, @@ -31,11 +32,57 @@ import { getOccupancy } from "../occupancy.js"; // an Origin allowlist: the handshake's Origin must be same-origin (or an explicitly // allowed booth UI origin). Non-browser clients (no Origin) are rejected too. // See auth.ts, event-log.ts (emitLedger), capacity-occupancy.md. +// +// Desktop shell (Tauri) exception — the WS TICKET. The desktop app's HTTP goes +// through tauri-plugin-http (reqwest, its own cookie jar) and its WebSocket +// through tauri-plugin-websocket (bare tungstenite, NO cookie jar at all), so +// the JWT cookie set at login can never ride on the WS handshake — jwtVerify() +// would 401 every connect (found 2026-09-04: the desktop live feed reconnected +// every 10s forever). The JWT is HttpOnly and must stay out of JS, so instead +// the desktop client POSTs /api/ws/ticket (normal cookie + CSRF auth) to get a +// single-use, 30-second random ticket bound to its user, and presents it in an +// `x-ws-ticket` header on the handshake. A browser page cannot set custom +// headers on a WebSocket, so this path is unreachable from a browser and adds +// no CSWSH surface; the Origin allowlist still applies to both paths. /** Permission required to watch the live feed (a read-only stream of ledger + * device status). Any role granted `report:read` may watch. */ const WATCH_PERMISSION = "report:read" as const; +/** Handshake header carrying a desktop WS ticket (see file header). */ +const WS_TICKET_HEADER = "x-ws-ticket"; +/** A ticket is only good for the connect that immediately follows its issue. */ +const WS_TICKET_TTL_MS = 30_000; + +interface WsTicket { + sub: string; + roleId: string; + expiresAt: number; +} + +/** Outstanding tickets. Tiny (one per desktop connect attempt), in-memory only — + * a server restart invalidates them, which is fine: the client just asks for + * another on its next reconnect. */ +const tickets = new Map(); + +function issueWsTicket(sub: string, roleId: string): string { + const now = Date.now(); + for (const [key, t] of tickets) { + if (t.expiresAt <= now) tickets.delete(key); + } + const ticket = randomBytes(32).toString("hex"); + tickets.set(ticket, { sub, roleId, expiresAt: now + WS_TICKET_TTL_MS }); + return ticket; +} + +/** Single-use: the ticket is removed whether or not it turns out to be valid. */ +function consumeWsTicket(ticket: string): WsTicket | null { + const t = tickets.get(ticket); + if (!t) return null; + tickets.delete(ticket); + return t.expiresAt > Date.now() ? t : null; +} + /** * Is the handshake's Origin trusted? Same-origin (Origin host === Host header) is * always allowed; additional origins can be allowlisted via WS_ALLOWED_ORIGINS @@ -80,19 +127,43 @@ export async function wsRoutes( laneStatus: LaneStatus, lanePresence: LanePresence, ): Promise { + // Desktop-only: mint a WS ticket for the signed-in session (see file header). + // Ordinary cookie + CSRF auth — the desktop client CAN do that over HTTP (via + // tauri-plugin-http), it just can't carry the cookie onto the WebSocket. + app.post("/api/ws/ticket", { preHandler: requireAuth }, async (req) => ({ + ticket: issueWsTicket(req.user.sub, req.user.roleId), + expiresInMs: WS_TICKET_TTL_MS, + })); + app.get( "/api/ws", { websocket: true, - // Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN JWT + - // role. Reject a cross/absent origin before touching the token, so a hijack - // attempt never reaches an authenticated socket. jwtVerify reads the cookie. + // Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN + // session (JWT cookie, or a desktop WS ticket) THEN role. Reject a + // cross/absent origin before touching either credential, so a hijack + // attempt never reaches an authenticated socket. preHandler: async (req) => { if (!isAllowedOrigin(req.headers.origin, req.headers.host)) { throw Object.assign(new Error("forbidden origin"), { statusCode: 403 }); } - await req.jwtVerify(); - if (!req.user || !roleHasPermissions(req.user.roleId, [WATCH_PERMISSION])) { + const rawTicket = req.headers[WS_TICKET_HEADER]; + const ticket = Array.isArray(rawTicket) ? rawTicket[0] : rawTicket; + let roleId: string; + if (ticket !== undefined) { + const t = consumeWsTicket(ticket); + if (!t) { + throw Object.assign(new Error("invalid or expired ws ticket"), { statusCode: 401 }); + } + roleId = t.roleId; + } else { + await req.jwtVerify(); // reads the HttpOnly cookie (browser path) + if (!req.user) { + throw Object.assign(new Error("forbidden"), { statusCode: 403 }); + } + roleId = req.user.roleId; + } + if (!roleHasPermissions(roleId, [WATCH_PERMISSION])) { throw Object.assign(new Error("forbidden"), { statusCode: 403 }); } }, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e540937..b0043f9 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -106,7 +106,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise ({ status: "ok" })); + // Unauthenticated liveness probe. `app` lets a client (the desktop ConnectScreen + // test — apps/web/src/lib/backend-config.ts) tell THIS server apart from any + // other service that happens to answer on the address the operator typed. + app.get("/health", async () => ({ status: "ok", app: "parking-system" })); // Local username/password login → JWT in an HttpOnly cookie + CSRF cookie. await authRoutes(app, db); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 9df39d2..ab0bb5f 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -5,18 +5,14 @@ // back in the X-CSRF-Token header (double-submit). See // wiki/entities/local-jwt-auth.md. // -// Desktop shell exception: tauri-plugin-http's fetch() runs through Rust's -// reqwest, which keeps its OWN cookie jar separate from the webview — -// document.cookie on the tauri://localhost page never sees a cookie set on a -// plugin-routed response (open upstream bug, tauri-apps/tauri#13045). The -// cookie itself IS still sent back to the server by reqwest on later -// requests (only the client-side *read* is broken), so the server also +// Desktop shell exception: document.cookie can't see the CSRF cookie there +// (reqwest's separate jar — see lib/desktop-csrf.ts), so the server also // echoes the token in the login/me response BODY (sessionView's csrfToken — -// see routes/auth.ts) purely as a second channel for the desktop client to -// learn the value; desktopCsrfToken below stashes it in memory and -// setSessionUser() (called wherever a SessionUser is received) keeps it -// current. The browser path is untouched — it still reads document.cookie. +// see routes/auth.ts) and setSessionUser() (called wherever a SessionUser is +// received) stashes it via setDesktopCsrfToken(). The browser path is +// untouched — it still reads document.cookie. +import { getDesktopCsrfToken, setDesktopCsrfToken } from "./lib/desktop-csrf.js"; import { logFailedRequest } from "./lib/logger.js"; import { apiUrl, platformFetch } from "./lib/origin.js"; import { inTauri } from "./lib/tauri-env.js"; @@ -30,16 +26,11 @@ function readCookie(name: string): string | null { return m ? decodeURIComponent(m[1]!) : null; } -/** Desktop-only in-memory CSRF stash — see file header. Never persisted (a - * fresh app launch always logs in again, or bootstraps via /api/auth/me - * which re-supplies it). */ -let desktopCsrfToken: string | null = null; - /** Update the desktop CSRF stash. Called wherever a SessionUser is received * (login, fetchMe). No-op / cheap in the browser (the value just goes * unused there — reads still come from document.cookie). */ function setSessionUser(user: SessionUser): void { - if (user.csrfToken) desktopCsrfToken = user.csrfToken; + if (user.csrfToken) setDesktopCsrfToken(user.csrfToken); } /** fetch wrapper: sends cookies, adds CSRF header on mutations, parses errors. */ @@ -50,7 +41,7 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise headers.set("content-type", "application/json"); } if (method !== "GET" && method !== "HEAD") { - const csrf = inTauri() ? desktopCsrfToken : readCookie(CSRF_COOKIE); + const csrf = inTauri() ? getDesktopCsrfToken() : readCookie(CSRF_COOKIE); if (csrf) headers.set(CSRF_HEADER, csrf); } const res = await platformFetch(apiUrl(path), { ...init, headers, credentials: "include" }); @@ -129,10 +120,19 @@ export async function login(username: string, password: string): Promise { const res = await apiFetch<{ ok: boolean }>("/api/auth/logout", { method: "POST" }); - desktopCsrfToken = null; + setDesktopCsrfToken(null); return res; } +/** Desktop only: mint a single-use, short-lived ticket that authenticates the + * live-feed WebSocket handshake in place of the session cookie — the native WS + * plugin has no cookie jar, so the cookie can never ride along (see + * routes/ws.ts and lib/platform-ws.ts). Normal cookie + CSRF auth on the way in. */ +export async function fetchWsTicket(): Promise { + const { ticket } = await apiFetch<{ ticket: string }>("/api/ws/ticket", { method: "POST" }); + return ticket; +} + /** Persist the current user's UI language preference (restored on next login). */ export function setLanguagePref(language: Lang): Promise<{ language: Lang }> { return apiFetch("/api/auth/language", { method: "PUT", body: JSON.stringify({ language }) }); diff --git a/apps/web/src/lib/backend-config.ts b/apps/web/src/lib/backend-config.ts index ece5174..1ef8ab7 100644 --- a/apps/web/src/lib/backend-config.ts +++ b/apps/web/src/lib/backend-config.ts @@ -56,27 +56,28 @@ export interface BackendCheck { detail?: string; } -/** Probe a candidate origin by hitting /api/version. That route is behind - * requirePermission("site:read") (session cookie + site:read — see - * apps/server/src/routes/site.ts), so a pre-login probe can never get a 2xx; - * we're not checking "is this reachable and mine to use", only "is something - * that speaks our Fastify auth protocol listening here" — a 401 (missing/bad - * JWT) or 403 (valid session, wrong permission) from THIS specific route is - * as strong a signal of that as a 200 would be, and both are expected outcomes - * pre-login. Uses the same tauri-plugin-http path platformFetch does (raw - * fetch from the webview can't reach an arbitrary LAN host — mixed content, - * see origin.ts). */ +/** Probe a candidate origin via GET /health — the server's one unauthenticated + * route (server.ts), which answers `{status:"ok", app:"parking-system"}`. We + * require BOTH a 2xx and that `app` value: the previous probe hit an + * auth-guarded route and accepted 401/403 as "ours", which any password- + * protected service on the LAN would also have passed. Uses the same + * tauri-plugin-http path platformFetch does (raw fetch from the webview can't + * reach an arbitrary LAN host — mixed content, see origin.ts). */ export async function testBackendUrl(url: string): Promise { const origin = url.replace(/\/$/, ""); try { const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http"); - const res = await tauriFetch(`${origin}/api/version`, { + const res = await tauriFetch(`${origin}/health`, { method: "GET", signal: AbortSignal.timeout(5000), }); - if (!res.ok && res.status !== 401 && res.status !== 403) { + if (!res.ok) { return { ok: false, reason: "bad_response", detail: `HTTP ${res.status}` }; } + const body = (await res.json().catch(() => null)) as { app?: unknown } | null; + if (body?.app !== "parking-system") { + return { ok: false, reason: "bad_response", detail: "unexpected /health body" }; + } return { ok: true }; } catch (err) { return { diff --git a/apps/web/src/lib/desktop-csrf.ts b/apps/web/src/lib/desktop-csrf.ts new file mode 100644 index 0000000..b75a31d --- /dev/null +++ b/apps/web/src/lib/desktop-csrf.ts @@ -0,0 +1,25 @@ +// Desktop-only in-memory CSRF token stash. +// +// tauri-plugin-http's fetch() runs through Rust's reqwest, which keeps its OWN +// cookie jar separate from the webview — document.cookie on tauri://localhost +// never sees the parking_csrf cookie the server sets (open upstream bug, +// tauri-apps/tauri#13045). The cookie IS still sent to the server by reqwest; +// only the client-side READ is broken. So the server echoes the same value in +// the login / me response body (sessionView's csrfToken, routes/auth.ts) and +// the desktop client keeps it here, echoing THIS in X-CSRF-Token instead of +// reading document.cookie. +// +// One module, no imports, so BOTH echo sites can share it without a cycle: +// api.ts (sets it, uses it for apiFetch mutations) and logger.ts (uses it for +// the /api/logs flush — which api.ts imports, so it can't import api.ts back). +// Never persisted: a fresh launch re-learns it via login or /api/auth/me. + +let token: string | null = null; + +export function setDesktopCsrfToken(value: string | null): void { + token = value; +} + +export function getDesktopCsrfToken(): string | null { + return token; +} diff --git a/apps/web/src/lib/logger.ts b/apps/web/src/lib/logger.ts index b577ee1..0ee65ac 100644 --- a/apps/web/src/lib/logger.ts +++ b/apps/web/src/lib/logger.ts @@ -14,7 +14,9 @@ // high-signal sources (failed requests, uncaught errors) are always captured. import { LOG_LEVEL_ORDER, type ClientLogInput, type LogLevel } from "@parking/shared"; +import { getDesktopCsrfToken } from "./desktop-csrf.js"; import { apiUrl, platformFetch } from "./origin.js"; +import { inTauri } from "./tauri-env.js"; const ENDPOINT = "/api/logs"; const FLUSH_MS = 4000; @@ -75,7 +77,11 @@ async function flush(): Promise { flushing = true; try { const headers: Record = { "content-type": "application/json" }; - const csrf = readCookie(CSRF_COOKIE); + // /api/logs is behind requireAuth → assertCsrf on POST. On desktop the + // cookie is unreadable (see desktop-csrf.ts) — without this branch every + // desktop flush 403'd and was dropped here, silently, by design (found + // 2026-09-04: no desktop client log had EVER reached app_logs). + const csrf = inTauri() ? getDesktopCsrfToken() : readCookie(CSRF_COOKIE); if (csrf) headers[CSRF_HEADER] = csrf; await platformFetch(apiUrl(ENDPOINT), { method: "POST", diff --git a/apps/web/src/lib/platform-ws.ts b/apps/web/src/lib/platform-ws.ts index aad8a2f..ef5de04 100644 --- a/apps/web/src/lib/platform-ws.ts +++ b/apps/web/src/lib/platform-ws.ts @@ -17,8 +17,16 @@ // Browser build: plain pass-through to the real WebSocket (this file's // createPlatformSocket is only called from inside inTauri() callers). +import { fetchWsTicket } from "../api.js"; +import { logClient } from "./logger.js"; import { inTauri } from "./tauri-env.js"; +/** Rate-limit the "connect failed" log: use-live-feed reconnects every ≤10s + * forever, and each attempt is a fresh adapter, so without this an outage + * would write six near-identical app_logs rows a minute. */ +const CONNECT_FAIL_LOG_INTERVAL_MS = 60_000; +let lastConnectFailLogAt = 0; + export interface PlatformSocket { onopen: (() => void) | null; onmessage: ((ev: { data: string }) => void) | null; @@ -64,13 +72,21 @@ class TauriSocketAdapter implements PlatformSocket { try { const { default: TauriWebSocket } = await import("@tauri-apps/plugin-websocket"); if (this.#closed) return; // close() called before connect resolved - // Runs on Tauri's native (Rust) side, NOT inside the webview page — there - // is no page context to auto-attach an Origin header the way a real - // browser WebSocket would. The server's anti-CSWSH check (routes/ws.ts) - // rejects any handshake with a missing/mismatched Origin, so it must be - // set explicitly here to match what WS_ALLOWED_ORIGINS expects - // (tauri://localhost — see apps/server/.env.example). - const conn = await TauriWebSocket.connect(url, { headers: { Origin: "tauri://localhost" } }); + // The native WS plugin is a bare tungstenite client: no page context AND + // no cookie jar. Two consequences, both handled via explicit headers: + // - Origin: nothing auto-attaches `Origin: tauri://localhost` the way a + // browser WebSocket would, and routes/ws.ts's anti-CSWSH check rejects a + // missing/mismatched Origin — so set it to match WS_ALLOWED_ORIGINS. + // - Session: the HttpOnly JWT cookie lives in tauri-plugin-http's reqwest + // jar and can't ride on this handshake, so jwtVerify() would 401 every + // connect (the 2026-09-04 "reconnects every 10s forever" bug). Instead, + // mint a single-use ticket over normal HTTP auth and present it in the + // x-ws-ticket header (see routes/ws.ts). + const ticket = await fetchWsTicket(); + if (this.#closed) return; + const conn = await TauriWebSocket.connect(url, { + headers: { Origin: "tauri://localhost", "x-ws-ticket": ticket }, + }); if (this.#closed) { void conn.disconnect(); return; @@ -87,7 +103,17 @@ class TauriSocketAdapter implements PlatformSocket { }); this.onopen?.(); } catch (err) { - console.error("Tauri WebSocket connect failed:", url, err); + // logClient, not console.error: console output only reaches app_logs at + // debug/trace level, which is how the ticket-less 401 stayed invisible + // for a full day. A closed-before-connect race isn't a failure. + if (!this.#closed && Date.now() - lastConnectFailLogAt > CONNECT_FAIL_LOG_INTERVAL_MS) { + lastConnectFailLogAt = Date.now(); + logClient({ + level: "error", + message: `desktop live-feed connect failed: ${err instanceof Error ? err.message : String(err)}`, + context: { kind: "desktop_ws_connect_failed", url }, + }); + } this.onerror?.(); this.onclose?.(); } diff --git a/wiki/decisions/desktop-shell-tauri.md b/wiki/decisions/desktop-shell-tauri.md index 97f0016..3cd320b 100644 --- a/wiki/decisions/desktop-shell-tauri.md +++ b/wiki/decisions/desktop-shell-tauri.md @@ -144,8 +144,11 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd `VITE_API_BASE` correctly set (below), login still failed with WebKit's generic `"Load failed"`. Root cause is a separate, deeper issue: WebKitGTK treats `tauri://localhost` as a **secure origin**, so a plain `http://127.0.0.1:3000` `fetch()` — or a `ws://127.0.0.1:3000` WebSocket — - from inside it is blocked as **mixed content**, a long-standing WebKit limitation - ([bugs.webkit.org #171934](https://bugs.webkit.org/show_bug.cgi?id=171934)). `connect-src` in the + from inside it is blocked as **mixed content**. (Nearest upstream ticket: + [bugs.webkit.org #171934](https://bugs.webkit.org/show_bug.cgi?id=171934) — note that one is + specifically about *loopback* addresses from https pages; a LAN IP such as `192.168.1.50:3000` + would stay mixed content even if it were fixed, so the plugin route below is the right + architecture for a remote booth regardless, not a stopgap.) `connect-src` in the CSP does **not** override this — it's a different browser security layer entirely, so the request never even reaches the network layer to be diagnosable via server logs. **Fix:** two Tauri plugins route the SPA's traffic through Tauri's native (Rust) side instead of the webview's own @@ -167,7 +170,9 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd - **Gotcha (found immediately after shipping the above): the native WS plugin sends no `Origin` header.** `tauri-plugin-websocket`'s `connect()` runs on Tauri's Rust side, not inside the webview page — there's no page context to auto-attach `Origin: tauri://localhost` the way a real - browser `WebSocket` would. The server's anti-CSWSH check (`routes/ws.ts`, `isAllowedOrigin`) + browser `WebSocket` would. (The **HTTP** plugin, by contrast, *does* attach that Origin itself — + `tauri-plugin-http/src/commands.rs`, "ensure we have an Origin header set" — so only the WS + path needs the explicit header.) The server's anti-CSWSH check (`routes/ws.ts`, `isAllowedOrigin`) treats a missing Origin as untrusted and 403s the handshake before touching auth — the live feed showed **"JASHTË LINJË"** (offline) in the desktop app while the browser showed **"LIVE"**, same server, same moment. **Fix (two parts, both needed):** `platform-ws.ts`'s `connect()` call now @@ -332,8 +337,9 @@ runtime-persisted** value. anyway (the WebKit mixed-content fix above), and those plugins run on the Rust side, **outside** `connect-src`'s jurisdiction entirely. The real access boundary moved to `capabilities/default.json`'s `http:default` scope, which is now wildcarded - (`http://*`, `https://*`, `http://*:*`, `https://*:*` — all four forms needed, a known Tauri - scope-matching quirk drops bare `http://*` matches for a `host:port` URL otherwise). `websocket: + (`http://*`, `https://*`, `http://*:*`, `https://*:*` — all four forms needed: the scope is a + URLPattern, and a pattern with no port matches only the scheme's *default* port, so `http://*` + covers `:80` (Caddy) while `http://*:*` is what covers `:3000`). `websocket: default` already had no scope restriction. Net effect: **the app can now reach any host the operator types in, and nothing else** — same shape of guarantee as before, just operator-directed instead of build-directed. @@ -351,10 +357,12 @@ runtime-persisted** value. a `tauri-plugin-http` response is stored in that reqwest jar and IS correctly re-sent by reqwest on later requests (so plain session auth — GETs — silently worked) — but it is **never** synced into the webview's own cookie store, so `document.cookie` on the `tauri://localhost` page - can never see it. This is an open, unresolved upstream Tauri bug - ([tauri-apps/tauri#13045](https://github.com/tauri-apps/tauri/issues/13045), - [#11518](https://github.com/tauri-apps/tauri/issues/11518)) — not something fixable on our side by - changing how/when we read the cookie. Since `api.ts`'s `apiFetch` reads the readable `parking_csrf` + can never see it. Upstream: [tauri-apps/tauri#13045](https://github.com/tauri-apps/tauri/issues/13045) + (open — asks for exactly this jar→webview sync) and + [#11518](https://github.com/tauri-apps/tauri/issues/11518) (closed, without adding a sync) — not + something fixable on our side by changing how/when we read the cookie. The reqwest jar itself + IS persisted (`.cookies` in the app cache dir), so a desktop session survives an app restart + just like the browser's 30-day cookie does. Since `api.ts`'s `apiFetch` reads the readable `parking_csrf` cookie via `document.cookie` to echo it in `X-CSRF-Token` (double-submit — see [[local-jwt-auth]]), this meant **every mutating request from the desktop app was silently sending no CSRF header at all**, pre-dating this runtime-URL change (it was equally true against the old @@ -369,3 +377,46 @@ runtime-persisted** value. `assertCsrf()` checks server-side (and reqwest still sends it correctly, per above) — this only fixes how the desktop *client* learns what value to put in the header, so browser behavior and server verification are both completely unchanged. + +### Live feed needs a WS *ticket*, not the cookie — and desktop logs never reached the server (2026-09-04, v0.1.6) + +A retrospective of the 2026-09-03/04 run found that v0.1.4's Origin fix cleared only the **first** +of two gates in `routes/ws.ts`'s preHandler, and that the diagnostic channel everyone was staring +at was itself broken on desktop. Booth evidence: `docker logs park-2-server-1 | grep /api/ws` showed +a fresh handshake every 10 s (use-live-feed's capped backoff), i.e. every connect rejected. + +- **Gate two: `req.jwtVerify()` reads the HttpOnly `parking_token` cookie — which the WebSocket + plugin cannot send.** `tauri-plugin-websocket` is a bare tokio-tungstenite client with **no + cookie jar at all** (its source has no cookie handling); the cookie lives in + `tauri-plugin-http`'s reqwest jar and is HttpOnly besides, so JS can't copy it across either. + Origin OK + no cookie → 401 → reconnect forever. **Fix: a single-use WS ticket.** The desktop + client `POST`s `/api/ws/ticket` over normal HTTP auth (cookie + CSRF, which it CAN do) and gets + a 32-byte random ticket bound to its user, valid 30 s, single-use, in-memory only; it presents + it in an `x-ws-ticket` header on the handshake (`platform-ws.ts`), and the preHandler accepts + ticket-or-cookie *after* the Origin check, then does the same `report:read` role check for both. + A browser page can't set custom WebSocket headers, so the ticket path is unreachable from a + browser and adds no CSWSH surface. **Rejected:** echoing the JWT in the login body and sending + it as `Authorization: Bearer` (fastify-jwt would accept it) — that puts the session token in JS, + which HttpOnly exists to prevent; the ticket keeps it out. Verified locally with an 11-case + handshake script: ticket/no-cookie → 101 + hello; reused/bogus/absent → 401; ticket + bad Origin + → 403; cookie path unchanged. +- **Desktop client logs had never reached `app_logs`.** `logger.ts`'s flush read the CSRF token + from `document.cookie` (null on desktop — the same jar split as above), so every + `POST /api/logs` from the desktop 403'd under `requireAuth`→`assertCsrf`, and the flush drops + failures by design (loop safety). Consequences: the 2026-09-03 "route update-failure logging + through logClient" fix wrote to a dead channel, and the v0.1.5 CSRF fix patched `api.ts` but not + `logger.ts`. **Fix:** the stash moved to a dependency-free `lib/desktop-csrf.ts` (so `logger.ts` + can read it without importing `api.ts`, which imports `logger.ts`), and the flush uses it when + `inTauri()`. `platform-ws.ts`'s connect failure now goes through `logClient` too (rate-limited + to one row/min — reconnects are every ≤10 s), instead of `console.error`, which only forwards at + debug/trace. +- **ConnectScreen probe now hits `/health`.** The v0.1.5 probe hit an auth-guarded route and + treated 401/403 as "ours" — any password-protected service on the LAN would have passed it, and + the comment claiming no unauthenticated route existed was wrong (`/health` has been there all + along). `/health` now also returns `app: "parking-system"`, and the probe requires both a 2xx + and that value. +- **Why every one of these was found in the field:** `tauri dev` loads `http://localhost:5173`, + not `tauri://localhost`, so the relative-URL error, mixed content, the missing Origin, and the + cookie-jar split *cannot* reproduce in dev mode. The pre-tag gate is now: build the bundle + locally, run the AppImage against a local server, log in, confirm **LIVE**, do one mutation, + and confirm a desktop-sourced row appears in the Logs viewer (`apps/desktop/README.md`). diff --git a/wiki/log.md b/wiki/log.md index 9b3e1fe..ca6fd27 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2841,3 +2841,22 @@ value in the login/me JSON body; the desktop client stashes it in memory and ech reading document.cookie. assertCsrf() itself is untouched — the cookie is still what's verified, and reqwest was already sending it correctly; this only fixes how the desktop client *learns* the value. Full detail (including the exact CSP/capability tradeoffs) on [[desktop-shell-tauri]]. + +## [2026-09-04] fix | Desktop live feed: WS handshake can't carry the cookie → single-use ticket; desktop logs never reached app_logs + +Retrospective of the 2026-09-03/04 desktop run (six releases in 26 h) found the v0.1.4 Origin fix +cleared only gate one of two in routes/ws.ts: gate two is req.jwtVerify() reading the HttpOnly +cookie, and tauri-plugin-websocket has no cookie jar at all — so every desktop handshake 401'd and +use-live-feed reconnected every 10 s (confirmed in the park-2 server log). Fixed with a 30-second, +single-use, in-memory WS ticket minted by POST /api/ws/ticket over normal cookie+CSRF auth and +presented in an x-ws-ticket header; Origin check still runs first, browser path unchanged, JWT +stays out of JS. Second finding: logger.ts read the CSRF cookie via document.cookie, null on +desktop, so every desktop POST /api/logs 403'd and was dropped silently — no desktop client log had +EVER reached app_logs, which is why "no logs whatsoever" kept happening and why yesterday's +logClient fix couldn't help. Stash moved to lib/desktop-csrf.ts, shared by api.ts and logger.ts; +WS connect failures now go through logClient (rate-limited). Third: the ConnectScreen probe now +uses the unauthenticated /health (extended with app: "parking-system") instead of accepting any +401. Also corrected four wiki citations (WebKit 171934 scope, tauri#11518 is closed, the HTTP +plugin does set Origin itself, the http-scope "quirk" is URLPattern default-port semantics) and +added a local-AppImage pre-tag gate to the desktop README, since tauri dev cannot reproduce any +of these origin-dependent bugs. Full detail on [[desktop-shell-tauri]]. From 52862db8add7044fe07e84cb1e74a5fd2e9acde9 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Fri, 4 Sep 2026 12:16:16 +0200 Subject: [PATCH 6/8] chore(resources): bump stage TAG to 8fa66c9 Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU --- komodo/resources.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/komodo/resources.toml b/komodo/resources.toml index d55da95..c2d0e3b 100644 --- a/komodo/resources.toml +++ b/komodo/resources.toml @@ -49,7 +49,7 @@ REGISTRY=git.infra.msai.al/mca/parking_solution # Staging booth: pinned immutable stage-. After each promotion (merge dev → stage, CI builds # :stage-), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag # exists as the pointer; we deploy the sha, not the mover. -TAG=stage-5c6a21e +TAG=stage-8fa66c9 COOKIE_SECURE=0 VISION_ENABLED=1 # Desktop app WS handshake: Origin is tauri://localhost (set explicitly by @@ -82,7 +82,7 @@ REGISTRY=git.infra.msai.al/mca/parking_solution # Staging booth: pinned immutable stage-. After each promotion (merge dev → stage, CI builds # :stage-), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag # exists as the pointer; we deploy the sha, not the mover. -TAG=stage-5c6a21e +TAG=stage-8fa66c9 COOKIE_SECURE=0 VISION_ENABLED=1 # Desktop app WS handshake: Origin is tauri://localhost (set explicitly by From 54e691a4c9986063eff268397d4b7bef538d38c3 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Fri, 4 Sep 2026 15:28:08 +0200 Subject: [PATCH 7/8] =?UTF-8?q?fix(release):=20latest.json=20entry=20per?= =?UTF-8?q?=20installer=20type=20=E2=80=94=20.deb=20booths=20could=20never?= =?UTF-8?q?=20self-update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tauri-plugin-updater resolves the download target as {os}-{arch}-{installer} first (linux-x86_64-deb — the bundler stamps the installer type into the binary, verified with `strings` on a local .deb) and only then bare linux-x86_64. Our manifest carried only the bare key, pointing at the AppImage. A .deb install therefore downloaded the AppImage, verified its signature, then failed install_deb()'s is_deb check with InvalidUpdaterFormat — after the download, before any relaunch. This, not version drift or swallowed errors, is why v0.1.0→v0.1.6 never self-updated. latest.json now carries linux-x86_64-deb, linux-x86_64-rpm (when built) and linux-x86_64 (AppImage), each with its own .sig. A .deb update ends in a polkit password prompt (pkexec dpkg -i) — the intended admin gate on a root-installed package. README + wiki updated; wiki also records the v0.1.6 LIVE field verification. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU --- .gitea/workflows/release.yml | 68 ++++++++++++++++++++------- apps/desktop/README.md | 8 ++++ wiki/decisions/desktop-shell-tauri.md | 37 ++++++++++++++- wiki/log.md | 13 +++++ 4 files changed, 108 insertions(+), 18 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 0a35150..4583de8 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -129,8 +129,23 @@ jobs: # The Tauri updater fetches a manifest describing the newest version, its # notes, and per-target {signature, url}. The URL points at the MIRROR # repo (mca/public_releases) — that's the unauthenticated endpoint field - # appliances actually reach; see the workflow header for why. Adjust the - # platform keys you actually ship. + # appliances actually reach; see the workflow header for why. + # + # ONE ENTRY PER INSTALLER TYPE — this is what made every in-app update + # v0.1.0→v0.1.6 fail. tauri-plugin-updater looks up + # `{os}-{arch}-{installer}` FIRST (linux-x86_64-deb / -rpm / -appimage, + # from the running app's detected bundle type) and only then the bare + # `linux-x86_64`. The booths run the .deb, and the manifest used to + # carry ONLY `linux-x86_64` → the AppImage. So a .deb install found the + # "update", downloaded the AppImage, verified its signature fine, then + # handed the bytes to install_deb(), which checks they're a .deb + # (infer::archive::is_deb) and bails with InvalidUpdaterFormat — after + # the download, before any relaunch, with the error swallowed client- + # side until v0.1.6. Now each installer gets its own signed asset; the + # bare key stays for an AppImage install. .deb/.rpm updates run + # `pkexec dpkg -i` / `rpm -U`, so the operator sees a polkit password + # prompt — intended: updating a root-installed package IS an admin + # action on this box (see wiki/decisions/desktop-shell-tauri.md). env: SERVER_URL: ${{ github.server_url }} MIRROR_REPO: mca/public_releases @@ -138,22 +153,41 @@ jobs: run: | set -e VERSION="${TAG#v}" - APPIMAGE=$(cd dist && ls *.AppImage | head -1) - SIG=$(cat "dist/${APPIMAGE}.sig") - ASSET_URL="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest/${APPIMAGE}" - cat > dist/latest.json < /tmp/latest.js <<'JS' + const fs = require("fs"); + const [version, tag, base] = process.argv.slice(2); + const files = fs.readdirSync("dist"); + const pick = (ext) => files.find((f) => f.endsWith(ext)); + const entry = (f) => ({ + signature: fs.readFileSync(`dist/${f}.sig`, "utf8").trim(), + url: `${base}/${f}`, + }); + const deb = pick(".deb"), rpm = pick(".rpm"), appimage = pick(".AppImage"); + if (!deb || !appimage) { + console.error(`missing bundle in dist/: deb=${deb} appimage=${appimage}`); + process.exit(1); } - JSON + const platforms = { + "linux-x86_64-deb": entry(deb), + ...(rpm ? { "linux-x86_64-rpm": entry(rpm) } : {}), + "linux-x86_64": entry(appimage), + }; + fs.writeFileSync( + "dist/latest.json", + JSON.stringify( + { + version, + notes: `Parking System ${tag}`, + pub_date: new Date().toISOString().replace(/\.\d+Z$/, "Z"), + platforms, + }, + null, + 2, + ) + "\n", + ); + JS + node /tmp/latest.js "${VERSION}" "${TAG}" "${ASSET_BASE}" echo "latest.json:"; cat dist/latest.json - name: Create release + upload assets (Gitea API) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index d3db707..afefcce 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -44,6 +44,14 @@ see that workflow's header and `wiki/decisions/desktop-shell-tauri.md`). The upd signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never committed. +**The manifest carries one entry per installer type** (`linux-x86_64-deb`, `linux-x86_64-rpm`, +and bare `linux-x86_64` for AppImage). The updater picks the entry matching how the running app +was installed — a `.deb` install will only ever accept a signed `.deb`. Booths run the `.deb`, +so an in-app update ends in a **polkit password prompt** (`pkexec dpkg -i`): that is expected, +and it is the right gate — the package lives in `/usr/bin`, root-owned, and the operator is not +supposed to be able to replace it silently. Cancel the prompt and the app keeps running the old +version; the failure is logged to the server's Logs viewer. + ## Release gate — run the REAL bundle locally before tagging `tauri dev` loads the SPA from `http://localhost:5173`, a plain http origin. The shipped bundle diff --git a/wiki/decisions/desktop-shell-tauri.md b/wiki/decisions/desktop-shell-tauri.md index 3cd320b..bbdc816 100644 --- a/wiki/decisions/desktop-shell-tauri.md +++ b/wiki/decisions/desktop-shell-tauri.md @@ -399,7 +399,8 @@ a fresh handshake every 10 s (use-live-feed's capped backoff), i.e. every connec it as `Authorization: Bearer` (fastify-jwt would accept it) — that puts the session token in JS, which HttpOnly exists to prevent; the ticket keeps it out. Verified locally with an 11-case handshake script: ticket/no-cookie → 101 + hello; reused/bogus/absent → 401; ticket + bad Origin - → 403; cookie path unchanged. + → 403; cookie path unchanged. **Field-verified 2026-09-04:** v0.1.6 on the park-2 booth against + image `stage-8fa66c9` shows **LIVE** — the first desktop build to do so. - **Desktop client logs had never reached `app_logs`.** `logger.ts`'s flush read the CSRF token from `document.cookie` (null on desktop — the same jar split as above), so every `POST /api/logs` from the desktop 403'd under `requireAuth`→`assertCsrf`, and the flush drops @@ -420,3 +421,37 @@ a fresh handshake every 10 s (use-live-feed's capped backoff), i.e. every connec cookie-jar split *cannot* reproduce in dev mode. The pre-tag gate is now: build the bundle locally, run the AppImage against a local server, log in, confirm **LIVE**, do one mutation, and confirm a desktop-sourced row appears in the Logs viewer (`apps/desktop/README.md`). + +### In-app update never worked: the manifest only described the AppImage, the booths run the .deb (2026-09-04) + +Every self-update attempt from v0.1.0 through v0.1.6 ended the same way — prompt, download +traffic, then nothing, and the same prompt again next launch. The version-sync (v0.1.2) and +error-logging fixes were real but not the cause. **Root cause:** `tauri-plugin-updater` resolves +the download target as `{os}-{arch}-{installer}` **first** (`linux-x86_64-deb` here — the +bundler stamps `__TAURI_BUNDLE_TYPE_VAR_DEB` into the `.deb`'s binary, verified with `strings` +on a local build), then falls back to bare `{os}-{arch}`. `release.yml`'s `latest.json` carried +**only** `linux-x86_64`, pointing at the **AppImage**. So a `.deb` install found the update, +downloaded the AppImage, verified its signature (which was correct — for the AppImage), then +handed the bytes to `install_deb()`, whose first line checks `infer::archive::is_deb(bytes)` and +returns `InvalidUpdaterFormat`. Before v0.1.6 that error never reached the server (the desktop +log channel was itself broken — see the previous section), so it looked like a silent no-op. +Sources: `tauri-plugin-updater-2.10.1/src/updater.rs` (`get_urls`, `install_inner`, +`install_deb`), `tauri-utils/src/platform.rs` (`bundle_type`). + +- **Fix:** `latest.json` now carries one signed entry per installer — `linux-x86_64-deb`, + `linux-x86_64-rpm` (when built), and bare `linux-x86_64` for the AppImage — assembled by a + small Node script in the workflow (the `.sig` files for `.deb`/`.rpm` were already being + produced and uploaded, just never referenced). +- **What a booth update now looks like:** prompt → download → **polkit password dialog** + (`pkexec dpkg -i`) → relaunch into the new version. The prompt is deliberate, not a wart: the + package is root-owned in `/usr/bin`, and under the [[threat-model]] the operator must not be + able to replace the app silently; whoever brings the box online for an update is the admin. + Cancelling the dialog leaves the old version running and logs + `desktop_update_install_failed` to `app_logs`. +- **Rejected:** switching booths to the AppImage so updates need no privilege. It would work + (the updater rewrites the AppImage in place), but the binary would then be operator-writable, + it needs FUSE on the appliance image, and launcher/autostart integration becomes manual — + three regressions to avoid one password prompt. +- **Judgment note for the retrospective:** three fixes were shipped against this symptom + without reading the updater's install path once. The whole chain is ~60 lines of vendored + Rust in `~/.cargo/registry`; it names the exact failure (`InvalidUpdaterFormat`). diff --git a/wiki/log.md b/wiki/log.md index ca6fd27..b5e3bde 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2860,3 +2860,16 @@ uses the unauthenticated /health (extended with app: "parking-system") instead o plugin does set Origin itself, the http-scope "quirk" is URLPattern default-port semantics) and added a local-AppImage pre-tag gate to the desktop README, since tauri dev cannot reproduce any of these origin-dependent bugs. Full detail on [[desktop-shell-tauri]]. + +## [2026-09-04] fix | Desktop in-app update never worked: latest.json described only the AppImage, booths run the .deb + +tauri-plugin-updater looks up `{os}-{arch}-{installer}` first (linux-x86_64-deb — the bundler +stamps the installer type into the binary; verified with strings on a local .deb) and only then +bare linux-x86_64. release.yml's latest.json carried only the bare key → the AppImage, so every +.deb install downloaded the AppImage, passed signature verification, then failed install_deb()'s +is_deb check with InvalidUpdaterFormat — invisible until v0.1.6 fixed the desktop log channel. +This, not version drift or swallowed errors, is why v0.1.0→…→v0.1.6 never self-updated. +latest.json now has one signed entry per installer (deb, rpm, AppImage); a .deb update ends in a +polkit password prompt (pkexec dpkg -i), which is the intended admin gate on a root-installed +package. README + [[desktop-shell-tauri]] updated. First real test: tag v0.1.7 and accept the +prompt on the v0.1.6 booth. From 9c05f86c8684f6f97885fc91b1f10ff4455147b9 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Fri, 4 Sep 2026 18:11:41 +0200 Subject: [PATCH 8/8] =?UTF-8?q?docs(desktop):=20updates=20are=20admin-only?= =?UTF-8?q?=20=E2=80=94=20keep=20the=20polkit=20prompt;=20AppImage=20rejec?= =?UTF-8?q?ted=20on=20field=20evidence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision (user, 2026-09-04) after the first successful self-update (v0.1.6 → v0.1.7): a .deb update runs pkexec dpkg -i and asks for an admin password the operator does not have — that prompt is the intended gate. The AppImage was tried as the no-root path and aborts on the 26.04 booth (bundled 24.04 glib/WebKitGTK vs host gvfs/Mesa: EGL_BAD_PARAMETER), and it discards the distro-maintained WebKitGTK the platform decision rests on. Passwordless polkit for dpkg is root for the operator — rejected. - update.prompt (en + sq) now says the install needs the administrator password. - desktop-shell-tauri.md: decision, evidence, rejected alternatives, and the deferred fleet-grade option (root systemd timer in the .deb, minisign- verified, notify-only in-app). - standing-decisions.md: ship the .deb; runtime backend; updates admin-only. - appliance-provisioning.md: drop the stale "hardcoded to localhost" note. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU --- apps/web/src/lib/i18n/en.ts | 2 +- apps/web/src/lib/i18n/sq.ts | 2 +- wiki/decisions/appliance-provisioning.md | 8 ++++--- wiki/decisions/desktop-shell-tauri.md | 29 ++++++++++++++++++++++++ wiki/decisions/standing-decisions.md | 5 +++- wiki/log.md | 12 ++++++++++ 6 files changed, 52 insertions(+), 6 deletions(-) diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 5e47505..71ece3c 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -58,7 +58,7 @@ export const en: Catalog = { }, update: { available: "Update available", - prompt: "Version {{version}} is available. Install now and restart?", + prompt: "Version {{version}} is available. Install now and restart? (Installing requires the administrator password.)", }, nav: { booth: "Booth", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 654d013..05685b1 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -61,7 +61,7 @@ export const sq = { }, update: { available: "Përditësim i disponueshëm", - prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis?", + prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis? (Instalimi kërkon fjalëkalimin e administratorit.)", }, nav: { booth: "Kabina", diff --git a/wiki/decisions/appliance-provisioning.md b/wiki/decisions/appliance-provisioning.md index 46b5e7c..d213879 100644 --- a/wiki/decisions/appliance-provisioning.md +++ b/wiki/decisions/appliance-provisioning.md @@ -462,9 +462,11 @@ before vision finishes loading). Reach the UI at **`http:///`** (Cad **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. The **Tauri desktop app** is hardcoded to -`localhost:3000` (CSP + endpoints) and can't reach a remote booth without code changes — a browser -works; the desktop app is a separate workstream. +`hosts`/DNS ON-SITE, never an image rebuild. The **Tauri desktop app** (install the `.deb` from +`mca/public_releases`, NOT the AppImage — see [[desktop-shell-tauri]]) asks for the server address +on first launch (`127.0.0.1:3000` on the booth itself, or any `:3000` / `` via Caddy); +nothing is baked in since v0.1.5. In-app updates need the **admin** password (polkit) — by +decision, updates are an admin action, so plan to be at the box when bringing it online for one. ## Quick-reference: the gotchas, in order they bit us diff --git a/wiki/decisions/desktop-shell-tauri.md b/wiki/decisions/desktop-shell-tauri.md index bbdc816..4494873 100644 --- a/wiki/decisions/desktop-shell-tauri.md +++ b/wiki/decisions/desktop-shell-tauri.md @@ -455,3 +455,32 @@ Sources: `tauri-plugin-updater-2.10.1/src/updater.rs` (`get_urls`, `install_inne - **Judgment note for the retrospective:** three fixes were shipped against this symptom without reading the updater's install path once. The whole chain is ~60 lines of vendored Rust in `~/.cargo/registry`; it names the exact failure (`InvalidUpdaterFormat`). + +### Decision: desktop updates are an admin-only action — the polkit prompt stays (2026-09-04) + +Settled with the user after the first successful self-update (v0.1.6 → v0.1.7 on the park-2 +booth, `pkexec dpkg -i`, polkit dialog, relaunch, badge shows 0.1.7). The prompt asks for an +**admin** password the operator does not have — and that is now the intended gate, not a defect. + +- **AppImage was tried and rejected on evidence, not theory.** The v0.1.6 AppImage fails to + start on the Ubuntu 26.04 booth: `libgvfscommon.so: undefined symbol: + g_variant_builder_init_static` (the host's newer gvfs modules loading into the *bundled* older + glib) followed by `Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...` (the + bundled WebKitGTK vs. the host's Mesa). Tauri's AppImage freezes the CI runner's (24.04) + GTK/WebKitGTK/glib into the bundle, which throws away the one property this platform decision + rests on — the **distro-maintained, Canonical-patched WebKitGTK** — and replaces it with a + host-mismatch hazard at every OS update. `WEBKIT_DISABLE_DMABUF_RENDERER=1` / + `WEBKIT_DISABLE_COMPOSITING_MODE=1` may paper over the EGL abort; they don't fix the shape. + **The `.deb` is the right artifact; only its install step needs root.** +- **Passwordless polkit/sudoers for `dpkg -i` rejected:** any rule that lets the operator + account pass that prompt silently lets them run `pkexec dpkg -i ` — root — which the + [[threat-model]] forbids outright. +- **Deferred, not rejected — the fleet-grade answer:** a root systemd timer shipped inside the + `.deb` (via Tauri's deb `files` + postinstall) that fetches `latest.json` from + `public_releases`, verifies the `.deb` with `minisign` against the same embedded pubkey, and + `dpkg -i`s it when the box is online; the in-app updater then only *notifies*. No prompt, no + privileged code in the shell, standard appliance practice. Revisit when more than one booth + needs keeping current, or when someone other than the admin has to bring a box online. +- **Operator-facing consequence:** the in-app prompt now says the install needs the + administrator password (i18n `update.prompt`, en + sq). An operator who accepts and can't + authenticate simply stays on the current version; nothing breaks, and the failure is logged. diff --git a/wiki/decisions/standing-decisions.md b/wiki/decisions/standing-decisions.md index ac7b641..dc1a97c 100644 --- a/wiki/decisions/standing-decisions.md +++ b/wiki/decisions/standing-decisions.md @@ -24,7 +24,10 @@ The decisions treated as settled in the design notes. (See [[parking-system-arch (chosen over Electron, 2026-06-21) — small footprint, no bundled Chromium to patch, and a deny-by-default native surface that fits [[threat-model|the booth-operator threat model]]. The shell stays **thin**: all privileged logic remains in [[fastify]]. One open dependency — the - appliance's WebKitGTK version (see [[open-questions]] #11). + appliance's WebKitGTK version (see [[open-questions]] #11). Ships as a **`.deb`** (the AppImage + bundles a runner's WebKitGTK and fails on the 26.04 booth — 2026-09-04); its backend address is + **operator-entered at runtime**, not baked in; and **in-app updates are an admin-only action** + behind the polkit password prompt (user, 2026-09-04) — never make that prompt passwordless. - **Integrity:** append-only, hash-chained, **software-signed** event log ([[append-only-event-chain]]) — hardware-backed signing (a non-extractable key in the **[[tpm|TPM]]** or a **USB HSM**; the [[atecc608]] is [[open-questions|upcoming, not present]]) is diff --git a/wiki/log.md b/wiki/log.md index b5e3bde..3ae5fa3 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2873,3 +2873,15 @@ latest.json now has one signed entry per installer (deb, rpm, AppImage); a .deb polkit password prompt (pkexec dpkg -i), which is the intended admin gate on a root-installed package. README + [[desktop-shell-tauri]] updated. First real test: tag v0.1.7 and accept the prompt on the v0.1.6 booth. + +## [2026-09-04] decision | Desktop updates are admin-only: keep the polkit prompt; AppImage rejected on field evidence + +First successful desktop self-update (v0.1.6 → v0.1.7, pkexec dpkg -i + polkit dialog) raised +the question of the admin password the operator lacks. Tried the AppImage as the no-root path: +it fails to start on the Ubuntu 26.04 booth (bundled 24.04 glib/WebKitGTK vs host gvfs/Mesa — +EGL_BAD_PARAMETER abort), and structurally it abandons the distro-maintained WebKitGTK the +platform decision depends on. Passwordless polkit for dpkg is root-for-the-operator, rejected. +Decision (user, 2026-09-04): the .deb stays, updates are an admin action behind the prompt; the +in-app prompt now says so (en + sq). A root systemd updater timer shipped in the .deb (minisign- +verified, notify-only in-app) is recorded as the deferred fleet-grade option on +[[desktop-shell-tauri]].