fix(desktop): WS ticket auth for the live feed; desktop logs never reached the server
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user