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.
This commit is contained in:
2026-09-04 10:32:03 +02:00
parent 969bf2b191
commit 5c6a21e2c3
21 changed files with 575 additions and 47 deletions
+68 -1
View File
@@ -2,7 +2,7 @@
type: decision
tags: [parking, decisions, desktop, frontend]
sources: []
updated: 2026-09-03
updated: 2026-09-04
status: settled
---
@@ -302,3 +302,70 @@ The desktop bundle now runs in CI under **two distinct workflows** — keep the
above (download traffic visible, then nothing). Fixed by nesting `downloadAndInstall()` in its
own try/catch that logs and rethrows — offline/no-update still no-ops silently (outer catch),
but a failure *after* the operator accepted now logs to the console instead of vanishing.
### Runtime-configurable backend origin — no more one-install-per-booth builds (2026-09-04)
**Problem:** `VITE_API_BASE` was a **build-time** Vite env var (`tauri.conf.json`'s
`beforeBuildCommand`), hardcoded to `http://127.0.0.1:3000`. The desktop shell is a single
generic `.deb`/`.AppImage` distributed via [[fleet-deployment-komodo|mca/public_releases]] — it is
**not** built per-booth — so a build-time backend address meant the installer could only ever talk
to a server on the same machine, and pointing an install at any other host (a remote appliance, a
different port) needed a full rebuild. **Fix:** the backend origin is now an **operator-entered,
runtime-persisted** value.
- **`ConnectScreen.tsx`** — shown by `App.tsx` BEFORE `fetchMe()`/`Login` whenever running inside
Tauri (`inTauri()`) and no backend URL is saved yet (first launch, or after "Change server").
Operator types a host, hits **Test** (`backend-config.ts`'s `testBackendUrl`, an unauthenticated-
from-the-client's-perspective `GET /api/version` probe — see the CSRF gotcha below for why that
route isn't actually public), then **Save & continue**.
- **`tauri-plugin-store`** persists the value (`backend-config.json` in the OS config dir,
`autoSave: true`) — survives restarts, is NOT `localStorage` (deliberately; matches the existing
server-persisted-preference pattern elsewhere in this app, and a real file is easier to inspect/
back up on an appliance). `origin.ts`'s `API_BASE` changed from a `const` to a `let`, set once via
`initApiBase()` (called by `App.tsx` before mount) and again via `setApiBase()` after the
ConnectScreen saves — no restart required to start using it.
- **CSP had to loosen, deliberately, to a narrower real boundary.** `connect-src` was
`'self' http://127.0.0.1:3000 ... ws://127.0.0.1:3000 ...`; an operator-chosen arbitrary LAN host
can't be named at build time, so it's now **`'self'` only** — meaning a raw `fetch()`/`WebSocket`
from the webview is blocked to EVERY origin, not just disallowed ones. This is intentional, not a
regression: all backend traffic already went through `tauri-plugin-http`/`tauri-plugin-websocket`
anyway (the WebKit mixed-content fix above), and those plugins run on the Rust side, **outside**
`connect-src`'s jurisdiction entirely. The real access boundary moved to
`capabilities/default.json`'s `http:default` scope, which is now wildcarded
(`http://*`, `https://*`, `http://*:*`, `https://*:*` — all four forms needed, a known Tauri
scope-matching quirk drops bare `http://*` matches for a `host:port` URL otherwise). `websocket:
default` already had no scope restriction. Net effect: **the app can now reach any host the
operator types in, and nothing else** — same shape of guarantee as before, just operator-directed
instead of build-directed.
- **"Change server"** — `router.tsx`'s `DesktopServerButton`, in the Setup nav bar next to
`DesktopVersionBadge` (both `inTauri()`-gated, invisible in the browser). Confirm-modal (reuses
the shared `Modal`, not a bespoke dialog) → `clearBackendUrl()` → reload, which drops back to
ConnectScreen. Deliberately not an inline editor: repointing a booth's app is a rare, deliberate
admin action, not a frequent setting — same reasoning as why logout is a plain action button with
no separate "are you sure" for THAT (this one gets a confirm because it also blows away the
session, unlike a normal logout-then-relogin against the same server).
- **Gotcha (found via research before shipping, not in the field — worth recording anyway): the
CSRF double-submit cookie is invisible to `document.cookie` on desktop.** `tauri-plugin-http`'s
`fetch()` doesn't run through the webview — it's dispatched to Tauri's Rust side and executed by
`reqwest`, which keeps its **own** cookie jar, entirely separate from WebKitGTK's. `Set-Cookie` on
a `tauri-plugin-http` response is stored in that reqwest jar and IS correctly re-sent by
reqwest on later requests (so plain session auth — GETs — silently worked) — but it is **never**
synced into the webview's own cookie store, so `document.cookie` on the `tauri://localhost` page
can never see it. This is an open, unresolved upstream Tauri bug
([tauri-apps/tauri#13045](https://github.com/tauri-apps/tauri/issues/13045),
[#11518](https://github.com/tauri-apps/tauri/issues/11518)) — not something fixable on our side by
changing how/when we read the cookie. Since `api.ts`'s `apiFetch` reads the readable `parking_csrf`
cookie via `document.cookie` to echo it in `X-CSRF-Token` (double-submit — see
[[local-jwt-auth]]), this meant **every mutating request from the desktop app was silently sending
no CSRF header at all**, pre-dating this runtime-URL change (it was equally true against the old
hardcoded `127.0.0.1:3000`) — caught now because widening the backend to "any host" was the
occasion to actually trace the desktop auth path end-to-end. **Fix, without touching
`assertCsrf()`'s verification logic at all:** the server's `sessionView()` (`routes/auth.ts`,
shared by `login` and `me`) now optionally echoes the CSRF token value in the JSON response body
(`csrfToken`) — the SAME value already set as the cookie, just a second channel to learn it. The
desktop client (`api.ts`) stashes that value in an in-memory-only variable (`desktopCsrfToken`,
never persisted — a fresh launch always re-learns it via login or `/api/auth/me`) and echoes THAT
instead of reading `document.cookie` when `inTauri()`. The actual cookie is still what
`assertCsrf()` checks server-side (and reqwest still sends it correctly, per above) — this only
fixes how the desktop *client* learns what value to put in the header, so browser behavior and
server verification are both completely unchanged.