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
+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
+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);
+18 -18
View File
@@ -5,18 +5,14 @@
// 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: tauri-plugin-http's fetch() runs through Rust's // Desktop shell exception: document.cookie can't see the CSRF cookie there
// reqwest, which keeps its OWN cookie jar separate from the webview — // (reqwest's separate jar — see lib/desktop-csrf.ts), so the server also
// 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 later
// requests (only the client-side *read* is broken), so the server also
// echoes the token in the login/me response BODY (sessionView's csrfToken — // echoes the token in the login/me response BODY (sessionView's csrfToken —
// see routes/auth.ts) purely as a second channel for the desktop client to // see routes/auth.ts) and setSessionUser() (called wherever a SessionUser is
// learn the value; desktopCsrfToken below stashes it in memory and // received) stashes it via setDesktopCsrfToken(). The browser path is
// setSessionUser() (called wherever a SessionUser is received) keeps it // untouched — it still reads document.cookie.
// current. 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 { inTauri } from "./lib/tauri-env.js";
@@ -30,16 +26,11 @@ function readCookie(name: string): string | null {
return m ? decodeURIComponent(m[1]!) : null; return m ? decodeURIComponent(m[1]!) : null;
} }
/** Desktop-only in-memory CSRF stash — see file header. Never persisted (a
* fresh app launch always logs in again, or bootstraps via /api/auth/me
* which re-supplies it). */
let desktopCsrfToken: string | null = null;
/** Update the desktop CSRF stash. Called wherever a SessionUser is received /** Update the desktop CSRF stash. Called wherever a SessionUser is received
* (login, fetchMe). No-op / cheap in the browser (the value just goes * (login, fetchMe). No-op / cheap in the browser (the value just goes
* unused there — reads still come from document.cookie). */ * unused there — reads still come from document.cookie). */
function setSessionUser(user: SessionUser): void { function setSessionUser(user: SessionUser): void {
if (user.csrfToken) desktopCsrfToken = user.csrfToken; 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. */
@@ -50,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 = inTauri() ? desktopCsrfToken : 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" });
@@ -129,10 +120,19 @@ export async function login(username: string, password: string): Promise<Session
export async function logout(): Promise<{ ok: boolean }> { export async function logout(): Promise<{ ok: boolean }> {
const res = await apiFetch<{ ok: boolean }>("/api/auth/logout", { method: "POST" }); const res = await apiFetch<{ ok: boolean }>("/api/auth/logout", { method: "POST" });
desktopCsrfToken = null; setDesktopCsrfToken(null);
return res; 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). */
export function setLanguagePref(language: Lang): Promise<{ language: Lang }> { export function setLanguagePref(language: Lang): Promise<{ language: Lang }> {
return apiFetch("/api/auth/language", { method: "PUT", body: JSON.stringify({ language }) }); return apiFetch("/api/auth/language", { method: "PUT", body: JSON.stringify({ language }) });
+13 -12
View File
@@ -56,27 +56,28 @@ export interface BackendCheck {
detail?: string; detail?: string;
} }
/** Probe a candidate origin by hitting /api/version. That route is behind /** Probe a candidate origin via GET /health — the server's one unauthenticated
* requirePermission("site:read") (session cookie + site:read — see * route (server.ts), which answers `{status:"ok", app:"parking-system"}`. We
* apps/server/src/routes/site.ts), so a pre-login probe can never get a 2xx; * require BOTH a 2xx and that `app` value: the previous probe hit an
* we're not checking "is this reachable and mine to use", only "is something * auth-guarded route and accepted 401/403 as "ours", which any password-
* that speaks our Fastify auth protocol listening here" — a 401 (missing/bad * protected service on the LAN would also have passed. Uses the same
* JWT) or 403 (valid session, wrong permission) from THIS specific route is * tauri-plugin-http path platformFetch does (raw fetch from the webview can't
* as strong a signal of that as a 200 would be, and both are expected outcomes * reach an arbitrary LAN host — mixed content, see origin.ts). */
* 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). */
export async function testBackendUrl(url: string): Promise<BackendCheck> { export async function testBackendUrl(url: string): Promise<BackendCheck> {
const origin = url.replace(/\/$/, ""); const origin = url.replace(/\/$/, "");
try { try {
const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http"); const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
const res = await tauriFetch(`${origin}/api/version`, { const res = await tauriFetch(`${origin}/health`, {
method: "GET", method: "GET",
signal: AbortSignal.timeout(5000), 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}` }; 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 }; return { ok: true };
} catch (err) { } catch (err) {
return { return {
+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;
}
+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",
+34 -8
View File
@@ -17,8 +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"; 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;
@@ -64,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;
@@ -87,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?.();
} }
+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"`. `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
@@ -332,8 +337,9 @@ runtime-persisted** value.
anyway (the WebKit mixed-content fix above), and those plugins run on the Rust side, **outside** 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 `connect-src`'s jurisdiction entirely. The real access boundary moved to
`capabilities/default.json`'s `http:default` scope, which is now wildcarded `capabilities/default.json`'s `http:default` scope, which is now wildcarded
(`http://*`, `https://*`, `http://*:*`, `https://*:*` — all four forms needed, a known Tauri (`http://*`, `https://*`, `http://*:*`, `https://*:*` — all four forms needed: the scope is a
scope-matching quirk drops bare `http://*` matches for a `host:port` URL otherwise). `websocket: 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 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 operator types in, and nothing else** — same shape of guarantee as before, just operator-directed
instead of build-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 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** 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 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 can never see it. Upstream: [tauri-apps/tauri#13045](https://github.com/tauri-apps/tauri/issues/13045)
([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)) — not something fixable on our side by [#11518](https://github.com/tauri-apps/tauri/issues/11518) (closed, without adding a sync) — not
changing how/when we read the cookie. Since `api.ts`'s `apiFetch` reads the readable `parking_csrf` 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 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 [[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 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 `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 fixes how the desktop *client* learns what value to put in the header, so browser behavior and
server verification are both completely unchanged. 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`).
+19
View File
@@ -2841,3 +2841,22 @@ value in the login/me JSON body; the desktop client stashes it in memory and ech
reading document.cookie. assertCsrf() itself is untouched — the cookie is still what's verified, 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 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]]. 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]].