7 Commits

Author SHA1 Message Date
julian 8fa66c9911 fix(desktop): WS ticket auth for the live feed; desktop logs never reached the server
Build & push images / images (push) Successful in 2m51s
Release desktop / bundle (push) Successful in 41m19s
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
2026-09-04 12:09:30 +02:00
julian 70e1e9939f chore(resources): bump stage TAG to 5c6a21e
Build & push images / images (push) Successful in 2m50s
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.
2026-09-04 11:27:17 +02:00
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 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 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
28 changed files with 820 additions and 69 deletions
+21
View File
@@ -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 signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never
committed. 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) ## Not here (deliberately)
Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for
+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);
}, },
); );
+1 -1
View File
@@ -30,7 +30,7 @@ describe("health + login", () => {
it("GET /health is open", async () => { it("GET /health is open", async () => {
const res = await app.inject({ method: "GET", url: "/health" }); const res = await app.inject({ method: "GET", url: "/health" });
expect(res.statusCode).toBe(200); 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 () => { it("login with bad credentials is rejected", async () => {
+77 -6
View File
@@ -1,7 +1,8 @@
import { randomBytes } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db"; import type { Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared"; import type { LedgerEvent } from "@parking/shared";
import { roleHasPermissions } from "../auth.js"; import { requireAuth, roleHasPermissions } from "../auth.js";
import { import {
deviceEvents, deviceEvents,
type LaneStatusEvent, 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 // 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. // allowed booth UI origin). Non-browser clients (no Origin) are rejected too.
// See auth.ts, event-log.ts (emitLedger), capacity-occupancy.md. // 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 + /** Permission required to watch the live feed (a read-only stream of ledger +
* device status). Any role granted `report:read` may watch. */ * device status). Any role granted `report:read` may watch. */
const WATCH_PERMISSION = "report:read" as const; 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<string, WsTicket>();
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 * Is the handshake's Origin trusted? Same-origin (Origin host === Host header) is
* always allowed; additional origins can be allowlisted via WS_ALLOWED_ORIGINS * always allowed; additional origins can be allowlisted via WS_ALLOWED_ORIGINS
@@ -80,19 +127,43 @@ export async function wsRoutes(
laneStatus: LaneStatus, laneStatus: LaneStatus,
lanePresence: LanePresence, lanePresence: LanePresence,
): Promise<void> { ): Promise<void> {
// 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( app.get(
"/api/ws", "/api/ws",
{ {
websocket: true, websocket: true,
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN JWT + // Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN
// role. Reject a cross/absent origin before touching the token, so a hijack // session (JWT cookie, or a desktop WS ticket) THEN role. Reject a
// attempt never reaches an authenticated socket. jwtVerify reads the cookie. // cross/absent origin before touching either credential, so a hijack
// attempt never reaches an authenticated socket.
preHandler: async (req) => { preHandler: async (req) => {
if (!isAllowedOrigin(req.headers.origin, req.headers.host)) { if (!isAllowedOrigin(req.headers.origin, req.headers.host)) {
throw Object.assign(new Error("forbidden origin"), { statusCode: 403 }); throw Object.assign(new Error("forbidden origin"), { statusCode: 403 });
} }
await req.jwtVerify(); const rawTicket = req.headers[WS_TICKET_HEADER];
if (!req.user || !roleHasPermissions(req.user.roleId, [WATCH_PERMISSION])) { 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 }); throw Object.assign(new Error("forbidden"), { statusCode: 403 });
} }
}, },
+4 -1
View File
@@ -106,7 +106,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
cookie: { cookieName: TOKEN_COOKIE, signed: false }, cookie: { cookieName: TOKEN_COOKIE, signed: false },
}); });
app.get("/health", async () => ({ 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. // Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
await authRoutes(app, db); await authRoutes(app, db);
+1
View File
@@ -21,6 +21,7 @@
"@tauri-apps/api": "^2.11.1", "@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",
+33 -3
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(() => {
fetchMe() initApiBase().then((saved) => {
.then(setUser) if (inTauri() && !saved) {
.finally(() => setLoading(false)); setNeedsConnect(true);
setLoading(false);
return;
}
fetchMe()
.then(setUser)
.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,21 @@
// 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: 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) 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 { 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 +26,13 @@ function readCookie(name: string): string | null {
return m ? decodeURIComponent(m[1]!) : null; return m ? decodeURIComponent(m[1]!) : 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) setDesktopCsrfToken(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 +41,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() ? getDesktopCsrfToken() : 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 +98,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 +109,28 @@ 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" });
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<string> {
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). */ /** 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;
+89
View File
@@ -0,0 +1,89 @@
// 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 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<BackendCheck> {
const origin = url.replace(/\/$/, "");
try {
const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
const res = await tauriFetch(`${origin}/health`, {
method: "GET",
signal: AbortSignal.timeout(5000),
});
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 {
ok: false,
reason: "unreachable",
detail: err instanceof Error ? err.message : String(err),
};
}
}
+25
View File
@@ -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;
}
+1 -5
View File
@@ -20,11 +20,7 @@
// LogsViewer.tsx without needing a terminal or devtools at all. // LogsViewer.tsx without needing a terminal or devtools at all.
import { logClient } from "./logger.js"; import { logClient } from "./logger.js";
import { inTauri } from "./tauri-env.js";
/** True when running inside the Tauri webview (not a normal browser). */
function inTauri(): boolean {
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. */
+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?",
+7 -1
View File
@@ -14,7 +14,9 @@
// high-signal sources (failed requests, uncaught errors) are always captured. // high-signal sources (failed requests, uncaught errors) are always captured.
import { LOG_LEVEL_ORDER, type ClientLogInput, type LogLevel } from "@parking/shared"; import { LOG_LEVEL_ORDER, type ClientLogInput, type LogLevel } from "@parking/shared";
import { getDesktopCsrfToken } from "./desktop-csrf.js";
import { apiUrl, platformFetch } from "./origin.js"; import { apiUrl, platformFetch } from "./origin.js";
import { inTauri } from "./tauri-env.js";
const ENDPOINT = "/api/logs"; const ENDPOINT = "/api/logs";
const FLUSH_MS = 4000; const FLUSH_MS = 4000;
@@ -75,7 +77,11 @@ async function flush(): Promise<void> {
flushing = true; flushing = true;
try { try {
const headers: Record<string, string> = { "content-type": "application/json" }; const headers: Record<string, string> = { "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; if (csrf) headers[CSRF_HEADER] = csrf;
await platformFetch(apiUrl(ENDPOINT), { await platformFetch(apiUrl(ENDPOINT), {
method: "POST", method: "POST",
+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 };
export 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
+36 -13
View File
@@ -17,6 +17,16 @@
// 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 { 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 { export interface PlatformSocket {
onopen: (() => void) | null; onopen: (() => void) | null;
onmessage: ((ev: { data: string }) => void) | null; onmessage: ((ev: { data: string }) => void) | null;
@@ -62,13 +72,21 @@ class TauriSocketAdapter implements PlatformSocket {
try { try {
const { default: TauriWebSocket } = await import("@tauri-apps/plugin-websocket"); const { default: TauriWebSocket } = await import("@tauri-apps/plugin-websocket");
if (this.#closed) return; // close() called before connect resolved if (this.#closed) return; // close() called before connect resolved
// Runs on Tauri's native (Rust) side, NOT inside the webview page — there // The native WS plugin is a bare tungstenite client: no page context AND
// is no page context to auto-attach an Origin header the way a real // no cookie jar. Two consequences, both handled via explicit headers:
// browser WebSocket would. The server's anti-CSWSH check (routes/ws.ts) // - Origin: nothing auto-attaches `Origin: tauri://localhost` the way a
// rejects any handshake with a missing/mismatched Origin, so it must be // browser WebSocket would, and routes/ws.ts's anti-CSWSH check rejects a
// set explicitly here to match what WS_ALLOWED_ORIGINS expects // missing/mismatched Origin — so set it to match WS_ALLOWED_ORIGINS.
// (tauri://localhost — see apps/server/.env.example). // - Session: the HttpOnly JWT cookie lives in tauri-plugin-http's reqwest
const conn = await TauriWebSocket.connect(url, { headers: { Origin: "tauri://localhost" } }); // 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) { if (this.#closed) {
void conn.disconnect(); void conn.disconnect();
return; return;
@@ -85,7 +103,17 @@ class TauriSocketAdapter implements PlatformSocket {
}); });
this.onopen?.(); this.onopen?.();
} catch (err) { } 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.onerror?.();
this.onclose?.(); this.onclose?.();
} }
@@ -97,11 +125,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;
}
+50
View File
@@ -130,6 +130,55 @@ function DesktopVersionBadge() {
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">app v{version}</span>; 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. */
@@ -150,6 +199,7 @@ function SetupLayout() {
{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 /> <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-5c6a21e
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-5c6a21e
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
+10
View File
@@ -117,6 +117,9 @@ importers:
'@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
@@ -1592,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==}
@@ -4112,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
+122 -4
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
--- ---
@@ -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"`. `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 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 — 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 from inside it is blocked as **mixed content**. (Nearest upstream ticket:
([bugs.webkit.org #171934](https://bugs.webkit.org/show_bug.cgi?id=171934)). `connect-src` in the [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 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 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 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` - **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 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 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 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 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 server, same moment. **Fix (two parts, both needed):** `platform-ws.ts`'s `connect()` call now
@@ -302,3 +307,116 @@ 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: 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.
- **"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. 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
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.
### 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`).
+43
View File
@@ -2817,3 +2817,46 @@ 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 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. 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]]. 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]].
## [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]].