Compare commits
4 Commits
v0.1.5
...
54e691a4c9
| Author | SHA1 | Date | |
|---|---|---|---|
| 54e691a4c9 | |||
| 52862db8ad | |||
| 8fa66c9911 | |||
| 70e1e9939f |
@@ -129,8 +129,23 @@ jobs:
|
|||||||
# The Tauri updater fetches a manifest describing the newest version, its
|
# The Tauri updater fetches a manifest describing the newest version, its
|
||||||
# notes, and per-target {signature, url}. The URL points at the MIRROR
|
# notes, and per-target {signature, url}. The URL points at the MIRROR
|
||||||
# repo (mca/public_releases) — that's the unauthenticated endpoint field
|
# repo (mca/public_releases) — that's the unauthenticated endpoint field
|
||||||
# appliances actually reach; see the workflow header for why. Adjust the
|
# appliances actually reach; see the workflow header for why.
|
||||||
# platform keys you actually ship.
|
#
|
||||||
|
# ONE ENTRY PER INSTALLER TYPE — this is what made every in-app update
|
||||||
|
# v0.1.0→v0.1.6 fail. tauri-plugin-updater looks up
|
||||||
|
# `{os}-{arch}-{installer}` FIRST (linux-x86_64-deb / -rpm / -appimage,
|
||||||
|
# from the running app's detected bundle type) and only then the bare
|
||||||
|
# `linux-x86_64`. The booths run the .deb, and the manifest used to
|
||||||
|
# carry ONLY `linux-x86_64` → the AppImage. So a .deb install found the
|
||||||
|
# "update", downloaded the AppImage, verified its signature fine, then
|
||||||
|
# handed the bytes to install_deb(), which checks they're a .deb
|
||||||
|
# (infer::archive::is_deb) and bails with InvalidUpdaterFormat — after
|
||||||
|
# the download, before any relaunch, with the error swallowed client-
|
||||||
|
# side until v0.1.6. Now each installer gets its own signed asset; the
|
||||||
|
# bare key stays for an AppImage install. .deb/.rpm updates run
|
||||||
|
# `pkexec dpkg -i` / `rpm -U`, so the operator sees a polkit password
|
||||||
|
# prompt — intended: updating a root-installed package IS an admin
|
||||||
|
# action on this box (see wiki/decisions/desktop-shell-tauri.md).
|
||||||
env:
|
env:
|
||||||
SERVER_URL: ${{ github.server_url }}
|
SERVER_URL: ${{ github.server_url }}
|
||||||
MIRROR_REPO: mca/public_releases
|
MIRROR_REPO: mca/public_releases
|
||||||
@@ -138,22 +153,41 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
VERSION="${TAG#v}"
|
VERSION="${TAG#v}"
|
||||||
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
|
ASSET_BASE="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest"
|
||||||
SIG=$(cat "dist/${APPIMAGE}.sig")
|
cat > /tmp/latest.js <<'JS'
|
||||||
ASSET_URL="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest/${APPIMAGE}"
|
const fs = require("fs");
|
||||||
cat > dist/latest.json <<JSON
|
const [version, tag, base] = process.argv.slice(2);
|
||||||
|
const files = fs.readdirSync("dist");
|
||||||
|
const pick = (ext) => files.find((f) => f.endsWith(ext));
|
||||||
|
const entry = (f) => ({
|
||||||
|
signature: fs.readFileSync(`dist/${f}.sig`, "utf8").trim(),
|
||||||
|
url: `${base}/${f}`,
|
||||||
|
});
|
||||||
|
const deb = pick(".deb"), rpm = pick(".rpm"), appimage = pick(".AppImage");
|
||||||
|
if (!deb || !appimage) {
|
||||||
|
console.error(`missing bundle in dist/: deb=${deb} appimage=${appimage}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const platforms = {
|
||||||
|
"linux-x86_64-deb": entry(deb),
|
||||||
|
...(rpm ? { "linux-x86_64-rpm": entry(rpm) } : {}),
|
||||||
|
"linux-x86_64": entry(appimage),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
|
"dist/latest.json",
|
||||||
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
"version": "${VERSION}",
|
version,
|
||||||
"notes": "Parking System ${TAG}",
|
notes: `Parking System ${tag}`,
|
||||||
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
pub_date: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
|
||||||
"platforms": {
|
platforms,
|
||||||
"linux-x86_64": {
|
},
|
||||||
"signature": "${SIG}",
|
null,
|
||||||
"url": "${ASSET_URL}"
|
2,
|
||||||
}
|
) + "\n",
|
||||||
}
|
);
|
||||||
}
|
JS
|
||||||
JSON
|
node /tmp/latest.js "${VERSION}" "${TAG}" "${ASSET_BASE}"
|
||||||
echo "latest.json:"; cat dist/latest.json
|
echo "latest.json:"; cat dist/latest.json
|
||||||
|
|
||||||
- name: Create release + upload assets (Gitea API)
|
- name: Create release + upload assets (Gitea API)
|
||||||
|
|||||||
@@ -44,6 +44,35 @@ 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.
|
||||||
|
|
||||||
|
**The manifest carries one entry per installer type** (`linux-x86_64-deb`, `linux-x86_64-rpm`,
|
||||||
|
and bare `linux-x86_64` for AppImage). The updater picks the entry matching how the running app
|
||||||
|
was installed — a `.deb` install will only ever accept a signed `.deb`. Booths run the `.deb`,
|
||||||
|
so an in-app update ends in a **polkit password prompt** (`pkexec dpkg -i`): that is expected,
|
||||||
|
and it is the right gate — the package lives in `/usr/bin`, root-owned, and the operator is not
|
||||||
|
supposed to be able to replace it silently. Cancel the prompt and the app keeps running the old
|
||||||
|
version; the failure is logged to the server's Logs viewer.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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 }) });
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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?.();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
|
|||||||
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||||
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||||
# exists as the pointer; we deploy the sha, not the mover.
|
# exists as the pointer; we deploy the sha, not the mover.
|
||||||
TAG=stage-7d67934
|
TAG=stage-8fa66c9
|
||||||
COOKIE_SECURE=0
|
COOKIE_SECURE=0
|
||||||
VISION_ENABLED=1
|
VISION_ENABLED=1
|
||||||
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
|
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
|
||||||
@@ -82,7 +82,7 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
|
|||||||
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||||
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||||
# exists as the pointer; we deploy the sha, not the mover.
|
# exists as the pointer; we deploy the sha, not the mover.
|
||||||
TAG=stage-7d67934
|
TAG=stage-8fa66c9
|
||||||
COOKIE_SECURE=0
|
COOKIE_SECURE=0
|
||||||
VISION_ENABLED=1
|
VISION_ENABLED=1
|
||||||
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
|
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
|
||||||
|
|||||||
@@ -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,81 @@ 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. **Field-verified 2026-09-04:** v0.1.6 on the park-2 booth against
|
||||||
|
image `stage-8fa66c9` shows **LIVE** — the first desktop build to do so.
|
||||||
|
- **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`).
|
||||||
|
|
||||||
|
### In-app update never worked: the manifest only described the AppImage, the booths run the .deb (2026-09-04)
|
||||||
|
|
||||||
|
Every self-update attempt from v0.1.0 through v0.1.6 ended the same way — prompt, download
|
||||||
|
traffic, then nothing, and the same prompt again next launch. The version-sync (v0.1.2) and
|
||||||
|
error-logging fixes were real but not the cause. **Root cause:** `tauri-plugin-updater` resolves
|
||||||
|
the download target as `{os}-{arch}-{installer}` **first** (`linux-x86_64-deb` here — the
|
||||||
|
bundler stamps `__TAURI_BUNDLE_TYPE_VAR_DEB` into the `.deb`'s binary, verified with `strings`
|
||||||
|
on a local build), then falls back to bare `{os}-{arch}`. `release.yml`'s `latest.json` carried
|
||||||
|
**only** `linux-x86_64`, pointing at the **AppImage**. So a `.deb` install found the update,
|
||||||
|
downloaded the AppImage, verified its signature (which was correct — for the AppImage), then
|
||||||
|
handed the bytes to `install_deb()`, whose first line checks `infer::archive::is_deb(bytes)` and
|
||||||
|
returns `InvalidUpdaterFormat`. Before v0.1.6 that error never reached the server (the desktop
|
||||||
|
log channel was itself broken — see the previous section), so it looked like a silent no-op.
|
||||||
|
Sources: `tauri-plugin-updater-2.10.1/src/updater.rs` (`get_urls`, `install_inner`,
|
||||||
|
`install_deb`), `tauri-utils/src/platform.rs` (`bundle_type`).
|
||||||
|
|
||||||
|
- **Fix:** `latest.json` now carries one signed entry per installer — `linux-x86_64-deb`,
|
||||||
|
`linux-x86_64-rpm` (when built), and bare `linux-x86_64` for the AppImage — assembled by a
|
||||||
|
small Node script in the workflow (the `.sig` files for `.deb`/`.rpm` were already being
|
||||||
|
produced and uploaded, just never referenced).
|
||||||
|
- **What a booth update now looks like:** prompt → download → **polkit password dialog**
|
||||||
|
(`pkexec dpkg -i`) → relaunch into the new version. The prompt is deliberate, not a wart: the
|
||||||
|
package is root-owned in `/usr/bin`, and under the [[threat-model]] the operator must not be
|
||||||
|
able to replace the app silently; whoever brings the box online for an update is the admin.
|
||||||
|
Cancelling the dialog leaves the old version running and logs
|
||||||
|
`desktop_update_install_failed` to `app_logs`.
|
||||||
|
- **Rejected:** switching booths to the AppImage so updates need no privilege. It would work
|
||||||
|
(the updater rewrites the AppImage in place), but the binary would then be operator-writable,
|
||||||
|
it needs FUSE on the appliance image, and launcher/autostart integration becomes manual —
|
||||||
|
three regressions to avoid one password prompt.
|
||||||
|
- **Judgment note for the retrospective:** three fixes were shipped against this symptom
|
||||||
|
without reading the updater's install path once. The whole chain is ~60 lines of vendored
|
||||||
|
Rust in `~/.cargo/registry`; it names the exact failure (`InvalidUpdaterFormat`).
|
||||||
|
|||||||
+32
@@ -2841,3 +2841,35 @@ 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]].
|
||||||
|
|
||||||
|
## [2026-09-04] fix | Desktop in-app update never worked: latest.json described only the AppImage, booths run the .deb
|
||||||
|
|
||||||
|
tauri-plugin-updater looks up `{os}-{arch}-{installer}` first (linux-x86_64-deb — the bundler
|
||||||
|
stamps the installer type into the binary; verified with strings on a local .deb) and only then
|
||||||
|
bare linux-x86_64. release.yml's latest.json carried only the bare key → the AppImage, so every
|
||||||
|
.deb install downloaded the AppImage, passed signature verification, then failed install_deb()'s
|
||||||
|
is_deb check with InvalidUpdaterFormat — invisible until v0.1.6 fixed the desktop log channel.
|
||||||
|
This, not version drift or swallowed errors, is why v0.1.0→…→v0.1.6 never self-updated.
|
||||||
|
latest.json now has one signed entry per installer (deb, rpm, AppImage); a .deb update ends in a
|
||||||
|
polkit password prompt (pkexec dpkg -i), which is the intended admin gate on a root-installed
|
||||||
|
package. README + [[desktop-shell-tauri]] updated. First real test: tag v0.1.7 and accept the
|
||||||
|
prompt on the v0.1.6 booth.
|
||||||
|
|||||||
Reference in New Issue
Block a user