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
This commit is contained in:
2026-09-04 12:09:30 +02:00
parent 70e1e9939f
commit 8fa66c9911
11 changed files with 279 additions and 56 deletions
+60 -9
View File
@@ -144,8 +144,11 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
`VITE_API_BASE` correctly set (below), login still failed with WebKit's generic `"Load failed"`.
Root cause is a separate, deeper issue: WebKitGTK treats `tauri://localhost` as a **secure
origin**, so a plain `http://127.0.0.1:3000` `fetch()` — or a `ws://127.0.0.1:3000` WebSocket —
from inside it is blocked as **mixed content**, a long-standing WebKit limitation
([bugs.webkit.org #171934](https://bugs.webkit.org/show_bug.cgi?id=171934)). `connect-src` in the
from inside it is blocked as **mixed content**. (Nearest upstream ticket:
[bugs.webkit.org #171934](https://bugs.webkit.org/show_bug.cgi?id=171934) — note that one is
specifically about *loopback* addresses from https pages; a LAN IP such as `192.168.1.50:3000`
would stay mixed content even if it were fixed, so the plugin route below is the right
architecture for a remote booth regardless, not a stopgap.) `connect-src` in the
CSP does **not** override this — it's a different browser security layer entirely, so the request
never even reaches the network layer to be diagnosable via server logs. **Fix:** two Tauri plugins
route the SPA's traffic through Tauri's native (Rust) side instead of the webview's own
@@ -167,7 +170,9 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
- **Gotcha (found immediately after shipping the above): the native WS plugin sends no `Origin`
header.** `tauri-plugin-websocket`'s `connect()` runs on Tauri's Rust side, not inside the
webview page — there's no page context to auto-attach `Origin: tauri://localhost` the way a real
browser `WebSocket` would. The server's anti-CSWSH check (`routes/ws.ts`, `isAllowedOrigin`)
browser `WebSocket` would. (The **HTTP** plugin, by contrast, *does* attach that Origin itself —
`tauri-plugin-http/src/commands.rs`, "ensure we have an Origin header set" — so only the WS
path needs the explicit header.) The server's anti-CSWSH check (`routes/ws.ts`, `isAllowedOrigin`)
treats a missing Origin as untrusted and 403s the handshake before touching auth — the live feed
showed **"JASHTË LINJË"** (offline) in the desktop app while the browser showed **"LIVE"**, same
server, same moment. **Fix (two parts, both needed):** `platform-ws.ts`'s `connect()` call now
@@ -332,8 +337,9 @@ runtime-persisted** value.
anyway (the WebKit mixed-content fix above), and those plugins run on the Rust side, **outside**
`connect-src`'s jurisdiction entirely. The real access boundary moved to
`capabilities/default.json`'s `http:default` scope, which is now wildcarded
(`http://*`, `https://*`, `http://*:*`, `https://*:*` — all four forms needed, a known Tauri
scope-matching quirk drops bare `http://*` matches for a `host:port` URL otherwise). `websocket:
(`http://*`, `https://*`, `http://*:*`, `https://*:*` — all four forms needed: the scope is a
URLPattern, and a pattern with no port matches only the scheme's *default* port, so `http://*`
covers `:80` (Caddy) while `http://*:*` is what covers `:3000`). `websocket:
default` already had no scope restriction. Net effect: **the app can now reach any host the
operator types in, and nothing else** — same shape of guarantee as before, just operator-directed
instead of build-directed.
@@ -351,10 +357,12 @@ runtime-persisted** value.
a `tauri-plugin-http` response is stored in that reqwest jar and IS correctly re-sent by
reqwest on later requests (so plain session auth — GETs — silently worked) — but it is **never**
synced into the webview's own cookie store, so `document.cookie` on the `tauri://localhost` page
can never see it. This is an open, unresolved upstream Tauri bug
([tauri-apps/tauri#13045](https://github.com/tauri-apps/tauri/issues/13045),
[#11518](https://github.com/tauri-apps/tauri/issues/11518)) — not something fixable on our side by
changing how/when we read the cookie. Since `api.ts`'s `apiFetch` reads the readable `parking_csrf`
can never see it. Upstream: [tauri-apps/tauri#13045](https://github.com/tauri-apps/tauri/issues/13045)
(open — asks for exactly this jar→webview sync) and
[#11518](https://github.com/tauri-apps/tauri/issues/11518) (closed, without adding a sync) — not
something fixable on our side by changing how/when we read the cookie. The reqwest jar itself
IS persisted (`.cookies` in the app cache dir), so a desktop session survives an app restart
just like the browser's 30-day cookie does. Since `api.ts`'s `apiFetch` reads the readable `parking_csrf`
cookie via `document.cookie` to echo it in `X-CSRF-Token` (double-submit — see
[[local-jwt-auth]]), this meant **every mutating request from the desktop app was silently sending
no CSRF header at all**, pre-dating this runtime-URL change (it was equally true against the old
@@ -369,3 +377,46 @@ runtime-persisted** value.
`assertCsrf()` checks server-side (and reqwest still sends it correctly, per above) — this only
fixes how the desktop *client* learns what value to put in the header, so browser behavior and
server verification are both completely unchanged.
### Live feed needs a WS *ticket*, not the cookie — and desktop logs never reached the server (2026-09-04, v0.1.6)
A retrospective of the 2026-09-03/04 run found that v0.1.4's Origin fix cleared only the **first**
of two gates in `routes/ws.ts`'s preHandler, and that the diagnostic channel everyone was staring
at was itself broken on desktop. Booth evidence: `docker logs park-2-server-1 | grep /api/ws` showed
a fresh handshake every 10 s (use-live-feed's capped backoff), i.e. every connect rejected.
- **Gate two: `req.jwtVerify()` reads the HttpOnly `parking_token` cookie — which the WebSocket
plugin cannot send.** `tauri-plugin-websocket` is a bare tokio-tungstenite client with **no
cookie jar at all** (its source has no cookie handling); the cookie lives in
`tauri-plugin-http`'s reqwest jar and is HttpOnly besides, so JS can't copy it across either.
Origin OK + no cookie → 401 → reconnect forever. **Fix: a single-use WS ticket.** The desktop
client `POST`s `/api/ws/ticket` over normal HTTP auth (cookie + CSRF, which it CAN do) and gets
a 32-byte random ticket bound to its user, valid 30 s, single-use, in-memory only; it presents
it in an `x-ws-ticket` header on the handshake (`platform-ws.ts`), and the preHandler accepts
ticket-or-cookie *after* the Origin check, then does the same `report:read` role check for both.
A browser page can't set custom WebSocket headers, so the ticket path is unreachable from a
browser and adds no CSWSH surface. **Rejected:** echoing the JWT in the login body and sending
it as `Authorization: Bearer` (fastify-jwt would accept it) — that puts the session token in JS,
which HttpOnly exists to prevent; the ticket keeps it out. Verified locally with an 11-case
handshake script: ticket/no-cookie → 101 + hello; reused/bogus/absent → 401; ticket + bad Origin
→ 403; cookie path unchanged.
- **Desktop client logs had never reached `app_logs`.** `logger.ts`'s flush read the CSRF token
from `document.cookie` (null on desktop — the same jar split as above), so every
`POST /api/logs` from the desktop 403'd under `requireAuth`→`assertCsrf`, and the flush drops
failures by design (loop safety). Consequences: the 2026-09-03 "route update-failure logging
through logClient" fix wrote to a dead channel, and the v0.1.5 CSRF fix patched `api.ts` but not
`logger.ts`. **Fix:** the stash moved to a dependency-free `lib/desktop-csrf.ts` (so `logger.ts`
can read it without importing `api.ts`, which imports `logger.ts`), and the flush uses it when
`inTauri()`. `platform-ws.ts`'s connect failure now goes through `logClient` too (rate-limited
to one row/min — reconnects are every ≤10 s), instead of `console.error`, which only forwards at
debug/trace.
- **ConnectScreen probe now hits `/health`.** The v0.1.5 probe hit an auth-guarded route and
treated 401/403 as "ours" — any password-protected service on the LAN would have passed it, and
the comment claiming no unauthenticated route existed was wrong (`/health` has been there all
along). `/health` now also returns `app: "parking-system"`, and the probe requires both a 2xx
and that value.
- **Why every one of these was found in the field:** `tauri dev` loads `http://localhost:5173`,
not `tauri://localhost`, so the relative-URL error, mixed content, the missing Origin, and the
cookie-jar split *cannot* reproduce in dev mode. The pre-tag gate is now: build the bundle
locally, run the AppImage against a local server, log in, confirm **LIVE**, do one mutation,
and confirm a desktop-sourced row appears in the Logs viewer (`apps/desktop/README.md`).