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
+13 -12
View File
@@ -56,27 +56,28 @@ export interface BackendCheck {
detail?: string;
}
/** Probe a candidate origin by hitting /api/version. That route is behind
* requirePermission("site:read") (session cookie + site:read — see
* apps/server/src/routes/site.ts), so a pre-login probe can never get a 2xx;
* we're not checking "is this reachable and mine to use", only "is something
* that speaks our Fastify auth protocol listening here" — a 401 (missing/bad
* JWT) or 403 (valid session, wrong permission) from THIS specific route is
* as strong a signal of that as a 200 would be, and both are expected outcomes
* pre-login. Uses the same tauri-plugin-http path platformFetch does (raw
* fetch from the webview can't reach an arbitrary LAN host — mixed content,
* see origin.ts). */
/** 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}/api/version`, {
const res = await tauriFetch(`${origin}/health`, {
method: "GET",
signal: AbortSignal.timeout(5000),
});
if (!res.ok && res.status !== 401 && res.status !== 403) {
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 {