feat(desktop): runtime-configurable backend server address
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user