7 Commits

Author SHA1 Message Date
julian 5c6a21e2c3 feat(desktop): runtime-configurable backend server address
Build & push images / images (push) Successful in 3m19s
Release desktop / bundle (push) Successful in 4m57s
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.
2026-09-04 10:32:03 +02:00
julian 969bf2b191 chore(resources): bump stage TAG to 7d67934
Build & push images / images (push) Successful in 2m49s
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.
2026-09-03 18:22:07 +02:00
julian 7d67934a10 Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m48s
2026-09-03 17:29:31 +02:00
julian 56904422af feat(desktop): show the installed app's own version in the UI
Build desktop / desktop (push) Successful in 4m47s
Build & push images / images (push) Successful in 2m54s
CI / check (push) Successful in 42s
Nothing displayed which desktop build was actually installed — debugging
a stuck update meant inferring the current version backwards from the
update prompt's target version. Added DesktopVersionBadge (next to the
existing server-side VersionBadge) using @tauri-apps/api's getVersion(),
the real running app version baked in from tauri.conf.json. No-ops in a
browser. Exported inTauri() from origin.ts instead of redefining it again.
2026-09-03 16:31:48 +02:00
julian 8bcdea9e4a Merge remote-tracking branch 'origin/dev' into stage
Build & push images / images (push) Successful in 2m48s
2026-09-03 16:24:44 +02:00
julian 7804285dec fix(desktop): route update-failure logging through logClient, not console
Build desktop / desktop (push) Successful in 4m44s
Build & push images / images (push) Successful in 2m50s
CI / check (push) Successful in 43s
console.error/console.warn only forward to the server when the client log
level is debug/trace (default: info) — the earlier error-logging fix never
actually surfaced anything, and a real update failure produced zero logs
anywhere. desktop-updater.ts now calls logClient() directly, unconditionally,
plus download-progress events. Also documents the resource-sync-park-systems
branch misconfig (pointed at dev, Stacks are stage-tier) found while chasing
this — full writeup on fleet-deployment-komodo.md.
2026-09-03 16:23:02 +02:00
julian 4a7029cea6 chore(resources): bump stage TAG to 7317042
Build & push images / images (push) Successful in 2m49s
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.
2026-09-03 16:04:57 +02:00
23 changed files with 704 additions and 55 deletions
+29
View File
@@ -2305,6 +2305,7 @@ dependencies = [
"tauri-build", "tauri-build",
"tauri-plugin-http", "tauri-plugin-http",
"tauri-plugin-process", "tauri-plugin-process",
"tauri-plugin-store",
"tauri-plugin-updater", "tauri-plugin-updater",
"tauri-plugin-websocket", "tauri-plugin-websocket",
] ]
@@ -3793,6 +3794,22 @@ dependencies = [
"tauri-plugin", "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]] [[package]]
name = "tauri-plugin-updater" name = "tauri-plugin-updater"
version = "2.10.1" version = "2.10.1"
@@ -4301,9 +4318,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"pin-project-lite", "pin-project-lite",
"tracing-attributes",
"tracing-core", "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]] [[package]]
name = "tracing-core" name = "tracing-core"
version = "0.1.36" version = "0.1.36"
+4
View File
@@ -33,6 +33,10 @@ tauri-plugin-http = "2"
# (ws://127.0.0.1:3000 from the secure tauri://localhost origin) — HTTP and WS # (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. # are separate browser checks, so this needs its own plugin.
tauri-plugin-websocket = "2" 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] [features]
# Used by `tauri dev`/CLI for hot-reload of the Rust side. # Used by `tauri dev`/CLI for hot-reload of the Rust side.
@@ -8,11 +8,15 @@
"updater:default", "updater:default",
"process:default", "process:default",
"websocket:default", "websocket:default",
"store:default",
{ {
"identifier": "http: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": [ "allow": [
{ "url": "http://127.0.0.1:3000" }, { "url": "http://*" },
{ "url": "http://localhost:3000" } { "url": "https://*" },
{ "url": "http://*:*" },
{ "url": "https://*:*" }
] ]
} }
] ]
+10 -6
View File
@@ -3,9 +3,10 @@
// Intentionally minimal: build the default Tauri app and run it. The window // 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. // 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 // 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 // the backend over HTTP to a Fastify server (address operator-configured at
// keeps the shell a thin presentation wrapper with a deny-by-default native // runtime, not baked in — see apps/web/src/lib/backend-config.ts), NOT through
// surface (see wiki/decisions/desktop-shell-tauri.md). // 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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
@@ -16,13 +17,16 @@ pub fn run() {
// endpoint + signing pubkey live in tauri.conf.json. // endpoint + signing pubkey live in tauri.conf.json.
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
// Routes the SPA's fetch()/WS calls to the local Fastify server through // Routes the SPA's fetch()/WS calls to the operator-configured Fastify
// Tauri's native HTTP client — see the Cargo.toml comment on why the // server through Tauri's native HTTP client — see the Cargo.toml
// webview's own fetch() can't reach http://127.0.0.1:3000 directly. // comment on why the webview's own fetch() can't reach it directly.
.plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_http::init())
// Live-feed WebSocket — same mixed-content reason as the HTTP plugin // Live-feed WebSocket — same mixed-content reason as the HTTP plugin
// above, but WS needs its own plugin (separate browser check). // above, but WS needs its own plugin (separate browser check).
.plugin(tauri_plugin_websocket::init()) .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!()) .run(tauri::generate_context!())
.expect("error while running the Parking System desktop shell"); .expect("error while running the Parking System desktop shell");
} }
+2 -2
View File
@@ -7,7 +7,7 @@
"devUrl": "http://localhost:5173", "devUrl": "http://localhost:5173",
"frontendDist": "../../web/dist", "frontendDist": "../../web/dist",
"beforeDevCommand": "pnpm --filter @parking/web dev", "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": { "app": {
"windows": [ "windows": [
@@ -24,7 +24,7 @@
} }
], ],
"security": { "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": { "bundle": {
+21 -3
View File
@@ -65,7 +65,21 @@ function cleanProfileField(v: string | null | undefined): string | null | undefi
/** The session shape the SPA bootstraps from: identity + role + its permission /** 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 * 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( function sessionView(
db: Db, db: Db,
user: { user: {
@@ -78,6 +92,7 @@ function sessionView(
fullName?: string | null; fullName?: string | null;
email?: string | null; email?: string | null;
}, },
csrf?: string,
) { ) {
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get(); const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
const permissions = [...permissionsFor(user.roleId)]; const permissions = [...permissionsFor(user.roleId)];
@@ -92,6 +107,7 @@ function sessionView(
fontScale: user.fontScale, fontScale: user.fontScale,
fullName: user.fullName ?? null, fullName: user.fullName ?? null,
email: user.email ?? null, email: user.email ?? null,
...(csrf ? { csrfToken: csrf } : {}),
}; };
} }
@@ -126,7 +142,7 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
setAuthCookies(reply, token, csrf); setAuthCookies(reply, token, csrf);
// `language` is NOT in the JWT (identity/role only) — it's a mutable preference // `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. // 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) => { app.post("/api/auth/logout", async (_req, reply) => {
@@ -146,7 +162,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
clearAuthCookies(reply); clearAuthCookies(reply);
return reply.code(401).send({ error: "session no longer valid" }); 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);
}, },
); );
+2
View File
@@ -18,8 +18,10 @@
"@radix-ui/react-tabs": "^1.1.15", "@radix-ui/react-tabs": "^1.1.15",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.16", "@tanstack/react-router": "^1.170.16",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-http": "^2.5.2", "@tauri-apps/plugin-http": "^2.5.2",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-store": "^2.4.0",
"@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-websocket": "^2.3.0", "@tauri-apps/plugin-websocket": "^2.3.0",
"i18next": "^26.3.1", "i18next": "^26.3.1",
+30
View File
@@ -3,25 +3,42 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router"; import { RouterProvider } from "@tanstack/react-router";
import { fetchMe, type SessionUser } from "./api.js"; import { fetchMe, type SessionUser } from "./api.js";
import { Login } from "./Login.js"; import { Login } from "./Login.js";
import { ConnectScreen } from "./ConnectScreen.js";
import { queryClient } from "./lib/query.js"; import { queryClient } from "./lib/query.js";
import { setLanguage } from "./lib/i18n/index.js"; import { setLanguage } from "./lib/i18n/index.js";
import { applyTheme, applyFontScale } from "./lib/theme.js"; import { applyTheme, applyFontScale } from "./lib/theme.js";
import { router } from "./router.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 // 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 // off to TanStack Router inside the QueryClient provider. The router renders the
// terminal chrome + screens; auth gating stays here (Login until signed in), and // 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. // 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. // 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() { export function App() {
const [user, setUser] = useState<SessionUser | null>(null); const [user, setUser] = useState<SessionUser | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [needsConnect, setNeedsConnect] = useState(false);
useEffect(() => { useEffect(() => {
initApiBase().then((saved) => {
if (inTauri() && !saved) {
setNeedsConnect(true);
setLoading(false);
return;
}
fetchMe() fetchMe()
.then(setUser) .then(setUser)
.finally(() => setLoading(false)); .finally(() => setLoading(false));
});
}, []); }, []);
// Apply the signed-in user's preferred language + theme + font scale whenever they // Apply the signed-in user's preferred language + theme + font scale whenever they
@@ -41,6 +58,19 @@ export function App() {
if (loading) { if (loading) {
return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>; return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>;
} }
if (needsConnect) {
return (
<ConnectScreen
onConnected={() => {
setNeedsConnect(false);
setLoading(true);
fetchMe()
.then(setUser)
.finally(() => setLoading(false));
}}
/>
);
}
if (!user) { if (!user) {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
+116
View File
@@ -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<string | undefined>(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 (
<main className="flex min-h-screen items-center justify-center bg-term-bg px-4">
<form onSubmit={handleTest} className="card w-full max-w-sm p-6">
<h1 className="mb-1 text-h5 font-semibold uppercase tracking-widest text-term-amber">
{t("connect.title")}
</h1>
<p className="mb-5 text-[0.75rem] text-term-muted">{t("connect.hint")}</p>
<div className="field mb-3">
<label className="label">{t("connect.serverAddress")}</label>
<input
className="input"
value={host}
onChange={(e) => {
setHost(e.target.value);
setResult(null);
}}
placeholder="192.168.1.50:3000"
autoFocus
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
</div>
{result === "ok" && (
<p className="mb-3 text-[0.75rem] text-term-green">{t("connect.testOk")}</p>
)}
{result === "unreachable" && (
<p className="mb-3 text-[0.75rem] text-term-red">
{t("connect.testUnreachable")}
{detail ? ` (${detail})` : ""}
</p>
)}
{result === "bad_response" && (
<p className="mb-3 text-[0.75rem] text-term-red">{t("connect.testBadResponse")}</p>
)}
<div className="flex gap-2">
<button type="submit" className="btn flex-1" disabled={!canSubmit}>
{testing ? t("connect.testing") : t("connect.test")}
</button>
<button
type="button"
className="btn btn-primary flex-1"
disabled={!canSubmit || result !== "ok"}
onClick={handleSave}
>
{saving ? t("connect.saving") : t("connect.save")}
</button>
</div>
</form>
</main>
);
}
+44 -9
View File
@@ -1,12 +1,25 @@
// Thin API client for the operator/admin UI. // Thin API client for the operator/admin UI.
// //
// Auth is cookie-based: the JWT lives in an HttpOnly cookie the browser sends // Auth is cookie-based: the JWT lives in an HttpOnly cookie sent automatically
// automatically (credentials: 'include'). For mutations we echo the readable // (credentials: 'include'). For mutations we echo the readable CSRF cookie
// CSRF cookie back in the X-CSRF-Token header (double-submit). See // back in the X-CSRF-Token header (double-submit). See
// wiki/entities/local-jwt-auth.md. // 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 { logFailedRequest } from "./lib/logger.js";
import { apiUrl, platformFetch } from "./lib/origin.js"; import { apiUrl, platformFetch } from "./lib/origin.js";
import { inTauri } from "./lib/tauri-env.js";
import type { AppLogRecord, ValidationLine, ValidationMode } from "@parking/shared"; import type { AppLogRecord, ValidationLine, ValidationMode } from "@parking/shared";
const CSRF_COOKIE = "parking_csrf"; const CSRF_COOKIE = "parking_csrf";
@@ -17,6 +30,18 @@ function readCookie(name: string): string | null {
return m ? decodeURIComponent(m[1]!) : 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. */ /** fetch wrapper: sends cookies, adds CSRF header on mutations, parses errors. */
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> { export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const method = (init.method ?? "GET").toUpperCase(); const method = (init.method ?? "GET").toUpperCase();
@@ -25,7 +50,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
headers.set("content-type", "application/json"); headers.set("content-type", "application/json");
} }
if (method !== "GET" && method !== "HEAD") { if (method !== "GET" && method !== "HEAD") {
const csrf = readCookie(CSRF_COOKIE); const csrf = inTauri() ? desktopCsrfToken : readCookie(CSRF_COOKIE);
if (csrf) headers.set(CSRF_HEADER, csrf); if (csrf) headers.set(CSRF_HEADER, csrf);
} }
const res = await platformFetch(apiUrl(path), { ...init, headers, credentials: "include" }); const res = await platformFetch(apiUrl(path), { ...init, headers, credentials: "include" });
@@ -82,6 +107,10 @@ export interface SessionUser {
fullName: string | null; fullName: string | null;
/** Optional contact email (profile metadata); null if unset. */ /** Optional contact email (profile metadata); null if unset. */
email: string | null; 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. */ /** 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); return !!user && user.permissions.includes(perm);
} }
export function login(username: string, password: string): Promise<SessionUser> { export async function login(username: string, password: string): Promise<SessionUser> {
return apiFetch<SessionUser>("/api/auth/login", { const user = await apiFetch<SessionUser>("/api/auth/login", {
method: "POST", method: "POST",
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
}); });
setSessionUser(user);
return user;
} }
export function logout(): Promise<{ ok: boolean }> { export async function logout(): Promise<{ ok: boolean }> {
return apiFetch("/api/auth/logout", { method: "POST" }); 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). */ /** 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. */ /** Returns the current user, or null if not authenticated. */
export async function fetchMe(): Promise<SessionUser | null> { export async function fetchMe(): Promise<SessionUser | null> {
try { try {
return await apiFetch<SessionUser>("/api/auth/me"); const user = await apiFetch<SessionUser>("/api/auth/me");
setSessionUser(user);
return user;
} catch (e) { } catch (e) {
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null; if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null;
throw e; throw e;
+88
View File
@@ -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<Store> {
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<string | null> {
const store = await getStore();
const v = await store.get<string>(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<void> {
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<void> {
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<BackendCheck> {
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),
};
}
}
+32 -8
View File
@@ -9,11 +9,18 @@
// and never tries to resolve the Tauri APIs. Offline-first: a failed check (no // and never tries to resolve the Tauri APIs. Offline-first: a failed check (no
// network — the appliance is usually offline) is swallowed; updates only happen // network — the appliance is usually offline) is swallowed; updates only happen
// when someone has brought the box online (e.g. a phone hotspot) on purpose. // when someone has brought the box online (e.g. a phone hotspot) on purpose.
//
// A release build's console.error is invisible with no way to attach devtools
// in the field (kiosk mode blocks the context menu; this WebKitGTK build's
// remote inspector doesn't answer standard discovery endpoints either — both
// confirmed dead ends 2026-09-03). logClient() ships straight to the
// server-side app_logs store regardless of the client's console-forward log
// level (that gate is meant for noisy console chatter, not this), so a real
// post-accept install failure is visible via wiki/concepts/app-logs.md /
// LogsViewer.tsx without needing a terminal or devtools at all.
/** True when running inside the Tauri webview (not a normal browser). */ import { logClient } from "./logger.js";
function inTauri(): boolean { import { inTauri } from "./tauri-env.js";
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
export interface UpdatePrompt { export interface UpdatePrompt {
/** Newer version string offered by the server. */ /** Newer version string offered by the server. */
@@ -42,13 +49,24 @@ export async function checkForDesktopUpdate(
// Download + install the signed update (signature verified against the // Download + install the signed update (signature verified against the
// pubkey in tauri.conf.json), then relaunch into the new version. // pubkey in tauri.conf.json), then relaunch into the new version.
try { try {
await update.downloadAndInstall(); await update.downloadAndInstall((progress) => {
logClient({
level: "info",
message: `desktop update download progress: ${progress.event}`,
context: { kind: "desktop_update_progress", version: update.version, event: progress.event },
});
});
} catch (err) { } catch (err) {
// A real update WAS found and accepted — this is a genuine install // A real update WAS found and accepted — this is a genuine install
// failure (bad signature, corrupted download, disk/permission issue), // failure (bad signature, corrupted download, disk/permission issue),
// not "offline". Surface it instead of silently reverting to the old // not "offline". Surface it instead of silently reverting to the old
// version with no explanation. // version with no explanation.
console.error("desktop update download/install failed:", err); logClient({
level: "error",
message: `desktop update download/install failed: ${err instanceof Error ? err.message : String(err)}`,
stack: err instanceof Error ? err.stack : undefined,
context: { kind: "desktop_update_install_failed", version: update.version },
});
throw err; throw err;
} }
const { relaunch } = await import("@tauri-apps/plugin-process"); const { relaunch } = await import("@tauri-apps/plugin-process");
@@ -56,7 +74,13 @@ export async function checkForDesktopUpdate(
} catch (err) { } catch (err) {
// Offline / endpoint unreachable / no update server yet → ignore. The app // Offline / endpoint unreachable / no update server yet → ignore. The app
// keeps running on the current version; checking again next launch. Still // keeps running on the current version; checking again next launch. Still
// log it so a real install failure (rethrown above) isn't invisible. // log it (info, not error — this path is expected/normal far more often
console.warn("desktop update check/apply skipped:", err); // than it's a real problem) so a real install failure (rethrown above,
// logged as error) isn't lost among routine offline checks.
logClient({
level: "info",
message: `desktop update check/apply skipped: ${err instanceof Error ? err.message : String(err)}`,
context: { kind: "desktop_update_skipped" },
});
} }
} }
+14
View File
@@ -42,6 +42,20 @@ export const en: Catalog = {
signIn: "Sign in", signIn: "Sign in",
signingIn: "Signing 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: { update: {
available: "Update available", available: "Update available",
prompt: "Version {{version}} is available. Install now and restart?", prompt: "Version {{version}} is available. Install now and restart?",
+14
View File
@@ -45,6 +45,20 @@ export const sq = {
signIn: "Hyr", signIn: "Hyr",
signingIn: "Duke 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: { update: {
available: "Përditësim i disponueshëm", 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?",
+32 -11
View File
@@ -3,16 +3,19 @@
// In a browser (dev via the Vite proxy, or prod where Fastify serves the built // 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 // SPA) this is EMPTY — requests stay relative (`/api/...`) and same-origin, so
// nothing changes. The Tauri desktop shell (apps/desktop) serves the bundled // 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 // SPA from `tauri://localhost`, which has no backend and no proxy; there the
// VITE_API_BASE to the appliance's Fastify origin (e.g. http://127.0.0.1:3000) // operator enters the appliance's Fastify origin (e.g. http://192.168.1.50:3000)
// at build time so /api and the live WS feed resolve to the real server. // 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 // 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 // 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 // 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; // 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 // 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 // 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, // the check entirely. Browser build never imports the plugin (dynamic import,
// same pattern as desktop-updater.ts). // same pattern as desktop-updater.ts).
/** Backend HTTP origin, no trailing slash. Empty string = same-origin/relative. */ import { inTauri } from "./tauri-env.js";
export const API_BASE: string = (import.meta.env.VITE_API_BASE ?? "").replace(/\/$/, "");
/** 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<string | null> {
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). */ /** Resolve an API path to a full URL (or a relative path when API_BASE is empty). */
export function apiUrl(path: string): string { export function apiUrl(path: string): string {
@@ -38,10 +62,7 @@ export function wsUrl(path: string): string {
return `${proto}//${window.location.host}${path}`; return `${proto}//${window.location.host}${path}`;
} }
/** True when running inside the Tauri webview (not a normal browser). */ export { inTauri };
function inTauri(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
/** /**
* fetch(), but routed through @tauri-apps/plugin-http inside the desktop * fetch(), but routed through @tauri-apps/plugin-http inside the desktop
+2 -5
View File
@@ -17,6 +17,8 @@
// Browser build: plain pass-through to the real WebSocket (this file's // Browser build: plain pass-through to the real WebSocket (this file's
// createPlatformSocket is only called from inside inTauri() callers). // createPlatformSocket is only called from inside inTauri() callers).
import { inTauri } from "./tauri-env.js";
export interface PlatformSocket { export interface PlatformSocket {
onopen: (() => void) | null; onopen: (() => void) | null;
onmessage: ((ev: { data: string }) => 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 /** 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. */ * desktop shell (mixed-content workaround), or the native WebSocket in a browser. */
export function createPlatformSocket(url: string): PlatformSocket { export function createPlatformSocket(url: string): PlatformSocket {
+6
View File
@@ -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;
}
+76 -1
View File
@@ -6,7 +6,7 @@ import {
Outlet, Outlet,
redirect, redirect,
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import { lazy, Suspense, useState } from "react"; import { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { Lang, Permission, SessionUser, Theme } from "./api.js"; import type { Lang, Permission, SessionUser, Theme } from "./api.js";
@@ -30,6 +30,7 @@ import { Spinner } from "./ui/Spinner.js";
import { setLanguage } from "./lib/i18n/index.js"; import { setLanguage } from "./lib/i18n/index.js";
import { applyTheme, applyFontScale } from "./lib/theme.js"; import { applyTheme, applyFontScale } from "./lib/theme.js";
import { useLiveFeed } from "./lib/use-live-feed.js"; import { useLiveFeed } from "./lib/use-live-feed.js";
import { inTauri } from "./lib/origin.js";
import { useShift } from "./lib/use-shift.js"; import { useShift } from "./lib/use-shift.js";
import { DeviceFooter } from "./ui/DeviceFooter.js"; import { DeviceFooter } from "./ui/DeviceFooter.js";
import { StatusDot } from "./ui/StatusDot.js"; import { StatusDot } from "./ui/StatusDot.js";
@@ -106,6 +107,78 @@ function VersionBadge() {
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">{version}</span>; return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">{version}</span>;
} }
/** The installed Tauri app's own "vX.Y.Z" (from tauri.conf.json, synced to the git tag by
* release.yml — see wiki/decisions/desktop-shell-tauri.md) — the client's version, distinct
* from VersionBadge's SERVER build. No-op / renders nothing in a browser (there's no Tauri
* API to call). Was invisible before this: an operator had no way to tell which desktop
* build was actually installed short of reading the update-available prompt. */
function DesktopVersionBadge() {
const [version, setVersion] = useState<string | null>(null);
useEffect(() => {
if (!inTauri()) return;
let cancelled = false;
void import("@tauri-apps/api/app").then(({ getVersion }) =>
getVersion().then((v) => {
if (!cancelled) setVersion(v);
}),
);
return () => {
cancelled = true;
};
}, []);
if (!version) return null;
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">app v{version}</span>;
}
/** 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 (
<>
<button
type="button"
className="btn btn-ghost btn-sm ml-2"
onClick={() => setConfirming(true)}
>
{t("connect.changeServer")}
</button>
{confirming && (
<Modal open onClose={() => setConfirming(false)} title={t("connect.changeServer")} width="max-w-sm">
<div className="text-[0.8125rem]">
<p className="text-term-muted">{t("connect.changeServerConfirm")}</p>
<div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={() => setConfirming(false)} disabled={busy}>
{t("subs.cancel")}
</button>
<button
type="button"
className="btn btn-sm btn-danger"
disabled={busy}
onClick={async () => {
setBusy(true);
const { clearBackendUrl } = await import("./lib/backend-config.js");
await clearBackendUrl();
window.location.reload();
}}
>
{busy ? <Spinner /> : t("connect.changeServer")}
</button>
</div>
</div>
</Modal>
)}
</>
);
}
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active /** Setup layout — the config hub. Renders a permission-gated tab bar and the active
* tab's screen via <Outlet>. Each tab is a child route (its own URL + guard), so * tab's screen via <Outlet>. 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. */ * deep links and the back button work and a denied tab redirects to the booth. */
@@ -125,6 +198,8 @@ function SetupLayout() {
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />} {show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />} {show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
{show("site:read") && <VersionBadge />} {show("site:read") && <VersionBadge />}
<DesktopVersionBadge />
<DesktopServerButton />
</nav> </nav>
<Outlet /> <Outlet />
</div> </div>
+2 -2
View File
@@ -49,7 +49,7 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds # Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag # :stage-<sha>), 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. # exists as the pointer; we deploy the sha, not the mover.
TAG=stage-28bd838 TAG=stage-7d67934
COOKIE_SECURE=0 COOKIE_SECURE=0
VISION_ENABLED=1 VISION_ENABLED=1
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by # 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-<sha>. After each promotion (merge dev → stage, CI builds # Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag # :stage-<sha>), 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. # exists as the pointer; we deploy the sha, not the mover.
TAG=stage-28bd838 TAG=stage-7d67934
COOKIE_SECURE=0 COOKIE_SECURE=0
VISION_ENABLED=1 VISION_ENABLED=1
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by # Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
+13
View File
@@ -108,12 +108,18 @@ importers:
'@tanstack/react-router': '@tanstack/react-router':
specifier: ^1.170.16 specifier: ^1.170.16
version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@tauri-apps/api':
specifier: ^2.11.1
version: 2.11.1
'@tauri-apps/plugin-http': '@tauri-apps/plugin-http':
specifier: ^2.5.2 specifier: ^2.5.2
version: 2.6.0 version: 2.6.0
'@tauri-apps/plugin-process': '@tauri-apps/plugin-process':
specifier: ^2.3.1 specifier: ^2.3.1
version: 2.3.1 version: 2.3.1
'@tauri-apps/plugin-store':
specifier: ^2.4.0
version: 2.4.4
'@tauri-apps/plugin-updater': '@tauri-apps/plugin-updater':
specifier: ^2.10.1 specifier: ^2.10.1
version: 2.10.1 version: 2.10.1
@@ -1589,6 +1595,9 @@ packages:
'@tauri-apps/plugin-process@2.3.1': '@tauri-apps/plugin-process@2.3.1':
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} 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': '@tauri-apps/plugin-updater@2.10.1':
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==} resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
@@ -4109,6 +4118,10 @@ snapshots:
dependencies: dependencies:
'@tauri-apps/api': 2.11.1 '@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': '@tauri-apps/plugin-updater@2.10.1':
dependencies: dependencies:
'@tauri-apps/api': 2.11.1 '@tauri-apps/api': 2.11.1
+80 -2
View File
@@ -2,7 +2,7 @@
type: decision type: decision
tags: [parking, decisions, desktop, frontend] tags: [parking, decisions, desktop, frontend]
sources: [] sources: []
updated: 2026-09-03 updated: 2026-09-04
status: settled status: settled
--- ---
@@ -176,7 +176,18 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
`.env.example` documenting `tauri://localhost,http://tauri.localhost` as required) — even a `.env.example` documenting `tauri://localhost,http://tauri.localhost` as required) — even a
correct Origin header is useless if the server's allowlist doesn't include it. Both fixed correct Origin header is useless if the server's allowlist doesn't include it. Both fixed
together; a `resources.toml` change still needs a Komodo sync + Stack redeploy to take effect on together; a `resources.toml` change still needs a Komodo sync + Stack redeploy to take effect on
a live booth, it isn't automatic from a git push alone. a live booth, it isn't automatic from a git push alone — and see [[fleet-deployment-komodo]] for
a real ResourceSync-branch gotcha this exact fix ran into.
- **No way to see the installed app's own version (found + fixed 2026-09-03).** `VersionBadge` in
`router.tsx` shows the *server's* `<branch>-<sha>` (from `/api/version`, gated `site:read`) — but
nothing showed the *desktop client's* own version. An operator debugging a stuck update had no way
to confirm which build was actually installed short of reading the update-available prompt's
target version and inferring backwards. Fixed with `DesktopVersionBadge`, next to `VersionBadge`:
calls `@tauri-apps/api/app`'s `getVersion()` (the real running app's version, baked in from
`tauri.conf.json` — synced to the git tag by `release.yml`, see the version-drift gotcha above),
no-ops/renders nothing in a browser (`inTauri()` guard, now exported from `origin.ts` instead of
redefined a 4th time). `@tauri-apps/api` added as an explicit dependency (was only ever transitive
via the plugins).
- **`VITE_API_BASE` — desktop vs. browser (regression found + fixed 2026-09-03):** - **`VITE_API_BASE` — desktop vs. browser (regression found + fixed 2026-09-03):**
`apps/web/.env.production` (committed, shared by both builds) sets `VITE_API_BASE=` (empty) — this `apps/web/.env.production` (committed, shared by both builds) sets `VITE_API_BASE=` (empty) — this
is correct for the **browser/booth** build (Fastify same-origin, stays relative) since commit is correct for the **browser/booth** build (Fastify same-origin, stays relative) since commit
@@ -291,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 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), 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. 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.
+30 -1
View File
@@ -2,7 +2,7 @@
type: decision type: decision
tags: [parking, deployment, fleet, komodo, netbird, offline-first, threat-model] tags: [parking, deployment, fleet, komodo, netbird, offline-first, threat-model]
sources: [] sources: []
updated: 2026-07-07 updated: 2026-09-03
status: settled status: settled
--- ---
@@ -192,3 +192,32 @@ FILE is read from, each stack's `branch` picks its compose files, `TAG` picks th
secrets even in the lab (blast radius). The lab box earned its keep immediately: it caught the secrets even in the lab (blast radius). The lab box earned its keep immediately: it caught the
USB close-cancel truncation, the printer/controller wizard gate, and the Periphery v2.2.0 USB close-cancel truncation, the printer/controller wizard gate, and the Periphery v2.2.0
root_directory default before any of them reached a real booth ([[appliance-provisioning]] §7a). root_directory default before any of them reached a real booth ([[appliance-provisioning]] §7a).
## ResourceSync branch drift — the exact gotcha this page already warned about (2026-09-03)
This page's own §"park-lab" note (2026-07-07) already spelled it out: *"the ResourceSync's own
branch only governs where the FILE is read from"* — independent of any `[[stack]]`'s own `branch`
field. It bit anyway. `resource-sync-park-systems` in Komodo Core was pointed at **`dev`**, while
`park-buzi` and `park-2` are `stage`-tier Stacks (`branch = "stage"`, pinned `TAG=stage-<sha>`, per
the promotion-tiers model above). `resources.toml` had been byte-identical on `dev` and `stage`
since park-buzi's Stack was first written, so this had **zero observable effect for months** — until
a desktop-app debugging session (see [[desktop-shell-tauri]]) landed 9 real commits on `dev`
(including a `WS_ALLOWED_ORIGINS` fix) that were never merged to `stage`, creating the first genuine
divergence between the two branches.
**Symptom:** merged `dev` → `stage`, pushed, bumped `TAG` in `resources.toml` on `stage`, committed,
pushed — then destroyed + recreated the `park-2` Stack in Komodo Core and it STILL came back running
the old image. Every sync was silently re-reading `resources.toml` from `dev` (which still had the
stale `TAG`), overwriting the correct value just committed on `stage`. No error, no warning — the
sync just quietly did what it was configured to do, from the wrong branch.
**Fix:** pointed `resource-sync-park-systems` at `stage` in Komodo Core's UI (Sync config → branch
field), then re-synced + redeployed `park-2` — confirmed via `/api/version` (previously 404,
proving a stale image; correctly 401-auth-gated after the fix, proving the new image + route exist).
**Standing lesson, now written twice:** a `[[stack]]`'s promotion tier (which branch its own
`branch`/`TAG` fields track) and the ResourceSync resource's own git branch are **two independently
configured settings in Komodo Core — nothing enforces they agree**, and a mismatch is invisible
until the two branches' `resources.toml` actually diverge. **Check this FIRST** whenever a
redeploy doesn't pick up an expected `resources.toml` change, before assuming the change itself,
the CI build, or the deploy step is broken.
+48
View File
@@ -2793,3 +2793,51 @@ get the real allowlist. Needs a Komodo sync + redeploy to reach a live booth, no
Also confirmed the "update downloads then nothing happens" report was an older pre-fix build (v0.1.2) Also confirmed the "update downloads then nothing happens" report was an older pre-fix build (v0.1.2)
self-updating — expected, not a new bug; v0.1.3 carries the error-logging fix from the mixed-content self-updating — expected, not a new bug; v0.1.3 carries the error-logging fix from the mixed-content
commit and should surface a real error going forward. Full detail on [[desktop-shell-tauri]]. commit and should surface a real error going forward. Full detail on [[desktop-shell-tauri]].
## [2026-09-03] fix | Update failures were invisible: console-forward gate blocked the error logging
The desktop-updater.ts error logging added earlier this session used console.error/console.warn,
but logger.ts only forwards console output to the server when the client log level is debug/trace
(default: info) — so the "fix" never actually surfaced anything, and a real v0.1.3→v0.1.4 update
failure showed zero logs anywhere, sending debugging in circles (a WebKit remote-inspector attempt
via WEBKIT_INSPECTOR_SERVER also dead-ended — this build doesn't answer standard discovery
endpoints). Fixed by calling logClient() directly in desktop-updater.ts, unconditionally, bypassing
the console-forward gate entirely — a genuine post-accept install failure now always reaches
app_logs regardless of client log level. Also added download-progress logging. Separately: found
and fixed a real, pre-existing Komodo ResourceSync misconfig (resource-sync-park-systems pointed at
`dev`, not `stage`, silently reading resources.toml from the wrong branch for months with zero
effect until dev/stage first diverged today) — full writeup on [[fleet-deployment-komodo]], which
had already warned about exactly this gotcha back in 2026-07-07 and it happened anyway.
## [2026-09-03] feat | Desktop app version now visible in the UI (was invisible)
There was no way to see which desktop build was actually installed anywhere in the app — an
operator debugging a stuck update had to infer it backwards from the update prompt's target
version ("it's offering v0.1.4, so I must be on v0.1.3"). Added DesktopVersionBadge next to the
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]].