Compare commits
8 Commits
4a7029cea6
...
v0.1.6
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fa66c9911 | |||
| 70e1e9939f | |||
| 5c6a21e2c3 | |||
| 969bf2b191 | |||
| 7d67934a10 | |||
| 56904422af | |||
| 8bcdea9e4a | |||
| 7804285dec |
@@ -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
|
||||||
|
|||||||
Generated
+29
@@ -2305,6 +2305,7 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-http",
|
"tauri-plugin-http",
|
||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
|
"tauri-plugin-store",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"tauri-plugin-websocket",
|
"tauri-plugin-websocket",
|
||||||
]
|
]
|
||||||
@@ -3793,6 +3794,22 @@ dependencies = [
|
|||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-store"
|
||||||
|
version = "2.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6708afbe549f176b712066e71648ba8fafba20789453718260c7ca356733cb0c"
|
||||||
|
dependencies = [
|
||||||
|
"dunce",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-updater"
|
name = "tauri-plugin-updater"
|
||||||
version = "2.10.1"
|
version = "2.10.1"
|
||||||
@@ -4301,9 +4318,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-attributes"
|
||||||
|
version = "0.1.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.118",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-core"
|
name = "tracing-core"
|
||||||
version = "0.1.36"
|
version = "0.1.36"
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ tauri-plugin-http = "2"
|
|||||||
# (ws://127.0.0.1:3000 from the secure tauri://localhost origin) — HTTP and WS
|
# (ws://127.0.0.1:3000 from the secure tauri://localhost origin) — HTTP and WS
|
||||||
# are separate browser checks, so this needs its own plugin.
|
# are separate browser checks, so this needs its own plugin.
|
||||||
tauri-plugin-websocket = "2"
|
tauri-plugin-websocket = "2"
|
||||||
|
# Persists the operator-configured backend URL (host:port of the Fastify
|
||||||
|
# server this install talks to) across restarts. Read before any API call —
|
||||||
|
# see apps/web/src/lib/backend-config.ts.
|
||||||
|
tauri-plugin-store = "2"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Used by `tauri dev`/CLI for hot-reload of the Rust side.
|
# Used by `tauri dev`/CLI for hot-reload of the Rust side.
|
||||||
|
|||||||
@@ -8,11 +8,15 @@
|
|||||||
"updater:default",
|
"updater:default",
|
||||||
"process:default",
|
"process:default",
|
||||||
"websocket:default",
|
"websocket:default",
|
||||||
|
"store:default",
|
||||||
{
|
{
|
||||||
"identifier": "http:default",
|
"identifier": "http:default",
|
||||||
|
"//": "Backend address is operator-configured at runtime (backend-config.ts) so the exact host:port can't be allow-listed at build time. Wildcarded to any host — the CSP forces ALL backend traffic through this plugin (see tauri.conf.json), so this scope is the real boundary; a compromised/malicious page still can't reach anything the operator hasn't pointed the app at, since the app only ever calls the one configured origin. All 4 forms needed: a known Tauri scope-matching quirk drops http://*:PORT unless both bare and :* variants are listed.",
|
||||||
"allow": [
|
"allow": [
|
||||||
{ "url": "http://127.0.0.1:3000" },
|
{ "url": "http://*" },
|
||||||
{ "url": "http://localhost:3000" }
|
{ "url": "https://*" },
|
||||||
|
{ "url": "http://*:*" },
|
||||||
|
{ "url": "https://*:*" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
// Intentionally minimal: build the default Tauri app and run it. The window
|
// Intentionally minimal: build the default Tauri app and run it. The window
|
||||||
// config (kiosk, fullscreen, which URL/assets to load) lives in tauri.conf.json.
|
// config (kiosk, fullscreen, which URL/assets to load) lives in tauri.conf.json.
|
||||||
// No custom commands are registered — the renderer (the @parking/web SPA) reaches
|
// No custom commands are registered — the renderer (the @parking/web SPA) reaches
|
||||||
// the backend over HTTP to the local Fastify server, NOT through Tauri IPC. This
|
// the backend over HTTP to a Fastify server (address operator-configured at
|
||||||
// keeps the shell a thin presentation wrapper with a deny-by-default native
|
// runtime, not baked in — see apps/web/src/lib/backend-config.ts), NOT through
|
||||||
// surface (see wiki/decisions/desktop-shell-tauri.md).
|
// Tauri IPC. This keeps the shell a thin presentation wrapper with a
|
||||||
|
// deny-by-default native surface (see wiki/decisions/desktop-shell-tauri.md).
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@@ -16,13 +17,16 @@ pub fn run() {
|
|||||||
// endpoint + signing pubkey live in tauri.conf.json.
|
// endpoint + signing pubkey live in tauri.conf.json.
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
// Routes the SPA's fetch()/WS calls to the local Fastify server through
|
// Routes the SPA's fetch()/WS calls to the operator-configured Fastify
|
||||||
// Tauri's native HTTP client — see the Cargo.toml comment on why the
|
// server through Tauri's native HTTP client — see the Cargo.toml
|
||||||
// webview's own fetch() can't reach http://127.0.0.1:3000 directly.
|
// comment on why the webview's own fetch() can't reach it directly.
|
||||||
.plugin(tauri_plugin_http::init())
|
.plugin(tauri_plugin_http::init())
|
||||||
// Live-feed WebSocket — same mixed-content reason as the HTTP plugin
|
// Live-feed WebSocket — same mixed-content reason as the HTTP plugin
|
||||||
// above, but WS needs its own plugin (separate browser check).
|
// above, but WS needs its own plugin (separate browser check).
|
||||||
.plugin(tauri_plugin_websocket::init())
|
.plugin(tauri_plugin_websocket::init())
|
||||||
|
// Persists the operator-configured backend URL across restarts (JSON
|
||||||
|
// file in the app's config dir) — see backend-config.ts.
|
||||||
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running the Parking System desktop shell");
|
.expect("error while running the Parking System desktop shell");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"devUrl": "http://localhost:5173",
|
"devUrl": "http://localhost:5173",
|
||||||
"frontendDist": "../../web/dist",
|
"frontendDist": "../../web/dist",
|
||||||
"beforeDevCommand": "pnpm --filter @parking/web dev",
|
"beforeDevCommand": "pnpm --filter @parking/web dev",
|
||||||
"beforeBuildCommand": "VITE_API_BASE=http://127.0.0.1:3000 pnpm --filter @parking/web build"
|
"beforeBuildCommand": "pnpm --filter @parking/web build"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:3000 http://localhost:3000 ws://127.0.0.1:3000 ws://localhost:3000"
|
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@@ -65,7 +65,21 @@ function cleanProfileField(v: string | null | undefined): string | null | undefi
|
|||||||
|
|
||||||
/** The session shape the SPA bootstraps from: identity + role + its permission
|
/** The session shape the SPA bootstraps from: identity + role + its permission
|
||||||
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
|
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
|
||||||
* permissions are the source of truth. */
|
* permissions are the source of truth.
|
||||||
|
*
|
||||||
|
* `csrf`, when passed, echoes the SAME value already sent as the readable
|
||||||
|
* parking_csrf cookie — not a new secret, just a second channel to learn it.
|
||||||
|
* The desktop shell needs this: tauri-plugin-http's fetch() runs through
|
||||||
|
* Rust's reqwest, which keeps its own cookie jar separate from the webview,
|
||||||
|
* so 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
|
||||||
|
* subsequent requests — only the *client-side read* is broken — so
|
||||||
|
* api.ts's desktop path stashes this body value in memory instead of
|
||||||
|
* reading document.cookie, and echoes it in X-CSRF-Token exactly as the
|
||||||
|
* browser path echoes the cookie. See lib/api.ts and assertCsrf() in
|
||||||
|
* ../auth.ts (unchanged — this never touches verification, only how the
|
||||||
|
* desktop client learns what to send). */
|
||||||
function sessionView(
|
function sessionView(
|
||||||
db: Db,
|
db: Db,
|
||||||
user: {
|
user: {
|
||||||
@@ -78,6 +92,7 @@ function sessionView(
|
|||||||
fullName?: string | null;
|
fullName?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
},
|
},
|
||||||
|
csrf?: string,
|
||||||
) {
|
) {
|
||||||
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
||||||
const permissions = [...permissionsFor(user.roleId)];
|
const permissions = [...permissionsFor(user.roleId)];
|
||||||
@@ -92,6 +107,7 @@ function sessionView(
|
|||||||
fontScale: user.fontScale,
|
fontScale: user.fontScale,
|
||||||
fullName: user.fullName ?? null,
|
fullName: user.fullName ?? null,
|
||||||
email: user.email ?? null,
|
email: user.email ?? null,
|
||||||
|
...(csrf ? { csrfToken: csrf } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +142,7 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
setAuthCookies(reply, token, csrf);
|
setAuthCookies(reply, token, csrf);
|
||||||
// `language` is NOT in the JWT (identity/role only) — it's a mutable preference
|
// `language` is NOT in the JWT (identity/role only) — it's a mutable preference
|
||||||
// read from the DB, so changing it needs no token refresh.
|
// read from the DB, so changing it needs no token refresh.
|
||||||
return sessionView(db, user);
|
return sessionView(db, user, csrf);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/auth/logout", async (_req, reply) => {
|
app.post("/api/auth/logout", async (_req, reply) => {
|
||||||
@@ -146,7 +162,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
clearAuthCookies(reply);
|
clearAuthCookies(reply);
|
||||||
return reply.code(401).send({ error: "session no longer valid" });
|
return reply.code(401).send({ error: "session no longer valid" });
|
||||||
}
|
}
|
||||||
return sessionView(db, row);
|
// req.user.csrf is the value bound into the JWT at login (see assertCsrf in
|
||||||
|
// ../auth.ts) — same value as the cookie, re-surfaced for the desktop path.
|
||||||
|
return sessionView(db, row, req.user.csrf);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +18,10 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.15",
|
"@radix-ui/react-tabs": "^1.1.15",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@tanstack/react-router": "^1.170.16",
|
"@tanstack/react-router": "^1.170.16",
|
||||||
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"@tauri-apps/plugin-http": "^2.5.2",
|
"@tauri-apps/plugin-http": "^2.5.2",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
|
"@tauri-apps/plugin-store": "^2.4.0",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
"@tauri-apps/plugin-websocket": "^2.3.0",
|
"@tauri-apps/plugin-websocket": "^2.3.0",
|
||||||
"i18next": "^26.3.1",
|
"i18next": "^26.3.1",
|
||||||
|
|||||||
@@ -3,25 +3,42 @@ import { QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { RouterProvider } from "@tanstack/react-router";
|
import { RouterProvider } from "@tanstack/react-router";
|
||||||
import { fetchMe, type SessionUser } from "./api.js";
|
import { fetchMe, type SessionUser } from "./api.js";
|
||||||
import { Login } from "./Login.js";
|
import { Login } from "./Login.js";
|
||||||
|
import { ConnectScreen } from "./ConnectScreen.js";
|
||||||
import { queryClient } from "./lib/query.js";
|
import { queryClient } from "./lib/query.js";
|
||||||
import { setLanguage } from "./lib/i18n/index.js";
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||||||
import { router } from "./router.js";
|
import { router } from "./router.js";
|
||||||
|
import { initApiBase, inTauri } from "./lib/origin.js";
|
||||||
|
|
||||||
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
||||||
// off to TanStack Router inside the QueryClient provider. The router renders the
|
// off to TanStack Router inside the QueryClient provider. The router renders the
|
||||||
// terminal chrome + screens; auth gating stays here (Login until signed in), and
|
// terminal chrome + screens; auth gating stays here (Login until signed in), and
|
||||||
// the signed-in user flows into the router context for role-based route guards.
|
// the signed-in user flows into the router context for role-based route guards.
|
||||||
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
|
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
|
||||||
|
//
|
||||||
|
// Desktop shell only: BEFORE any of that, the backend origin itself must be
|
||||||
|
// known — the same installer is used at every booth (see lib/origin.ts /
|
||||||
|
// backend-config.ts), so on first launch (or after the operator clears it)
|
||||||
|
// there is no server to call fetchMe() against yet. ConnectScreen gates that;
|
||||||
|
// a browser build always has a same-origin backend, so `needsConnect` is
|
||||||
|
// always false there and this is skipped entirely.
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [user, setUser] = useState<SessionUser | null>(null);
|
const [user, setUser] = useState<SessionUser | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [needsConnect, setNeedsConnect] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
initApiBase().then((saved) => {
|
||||||
|
if (inTauri() && !saved) {
|
||||||
|
setNeedsConnect(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
fetchMe()
|
fetchMe()
|
||||||
.then(setUser)
|
.then(setUser)
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Apply the signed-in user's preferred language + theme + font scale whenever they
|
// Apply the signed-in user's preferred language + theme + font scale whenever they
|
||||||
@@ -41,6 +58,19 @@ export function App() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>;
|
return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>;
|
||||||
}
|
}
|
||||||
|
if (needsConnect) {
|
||||||
|
return (
|
||||||
|
<ConnectScreen
|
||||||
|
onConnected={() => {
|
||||||
|
setNeedsConnect(false);
|
||||||
|
setLoading(true);
|
||||||
|
fetchMe()
|
||||||
|
.then(setUser)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { setApiBase } from "./lib/origin.js";
|
||||||
|
|
||||||
|
// Desktop-only gate shown BEFORE Login whenever no backend has been
|
||||||
|
// configured yet (first launch of a generic .deb/.AppImage install, or after
|
||||||
|
// the operator clears it from Settings). Same installer works at any booth —
|
||||||
|
// see backend-config.ts for why this can't be a build-time value.
|
||||||
|
//
|
||||||
|
// backend-config.ts is imported dynamically (not at module top-level) purely
|
||||||
|
// to keep bundling consistent with origin.ts/router.tsx's other Tauri-only
|
||||||
|
// imports — this component itself only ever renders inside Tauri anyway, so
|
||||||
|
// it's not a functional requirement, just avoids an INEFFECTIVE_DYNAMIC_IMPORT
|
||||||
|
// warning from Vite (a static import here would defeat those other dynamic
|
||||||
|
// imports' chunk-splitting intent).
|
||||||
|
|
||||||
|
function normalizeHost(raw: string): string {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return trimmed;
|
||||||
|
return /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConnectScreen({ onConnected }: { onConnected: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [host, setHost] = useState("");
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [result, setResult] = useState<"ok" | "unreachable" | "bad_response" | null>(null);
|
||||||
|
const [detail, setDetail] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const url = normalizeHost(host);
|
||||||
|
const canSubmit = url.length > 0 && !testing && !saving;
|
||||||
|
|
||||||
|
async function handleTest(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
setTesting(true);
|
||||||
|
setResult(null);
|
||||||
|
setDetail(undefined);
|
||||||
|
try {
|
||||||
|
const { testBackendUrl } = await import("./lib/backend-config.js");
|
||||||
|
const check = await testBackendUrl(url);
|
||||||
|
setResult(check.ok ? "ok" : (check.reason ?? "unreachable"));
|
||||||
|
setDetail(check.detail);
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const { saveBackendUrl } = await import("./lib/backend-config.js");
|
||||||
|
await saveBackendUrl(url);
|
||||||
|
setApiBase(url);
|
||||||
|
onConnected();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-term-bg px-4">
|
||||||
|
<form onSubmit={handleTest} className="card w-full max-w-sm p-6">
|
||||||
|
<h1 className="mb-1 text-h5 font-semibold uppercase tracking-widest text-term-amber">
|
||||||
|
{t("connect.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="mb-5 text-[0.75rem] text-term-muted">{t("connect.hint")}</p>
|
||||||
|
|
||||||
|
<div className="field mb-3">
|
||||||
|
<label className="label">{t("connect.serverAddress")}</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={host}
|
||||||
|
onChange={(e) => {
|
||||||
|
setHost(e.target.value);
|
||||||
|
setResult(null);
|
||||||
|
}}
|
||||||
|
placeholder="192.168.1.50:3000"
|
||||||
|
autoFocus
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result === "ok" && (
|
||||||
|
<p className="mb-3 text-[0.75rem] text-term-green">{t("connect.testOk")}</p>
|
||||||
|
)}
|
||||||
|
{result === "unreachable" && (
|
||||||
|
<p className="mb-3 text-[0.75rem] text-term-red">
|
||||||
|
{t("connect.testUnreachable")}
|
||||||
|
{detail ? ` (${detail})` : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{result === "bad_response" && (
|
||||||
|
<p className="mb-3 text-[0.75rem] text-term-red">{t("connect.testBadResponse")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" className="btn flex-1" disabled={!canSubmit}>
|
||||||
|
{testing ? t("connect.testing") : t("connect.test")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary flex-1"
|
||||||
|
disabled={!canSubmit || result !== "ok"}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
{saving ? t("connect.saving") : t("connect.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+44
-9
@@ -1,12 +1,21 @@
|
|||||||
// Thin API client for the operator/admin UI.
|
// Thin API client for the operator/admin UI.
|
||||||
//
|
//
|
||||||
// Auth is cookie-based: the JWT lives in an HttpOnly cookie the browser sends
|
// Auth is cookie-based: the JWT lives in an HttpOnly cookie sent automatically
|
||||||
// automatically (credentials: 'include'). For mutations we echo the readable
|
// (credentials: 'include'). For mutations we echo the readable CSRF cookie
|
||||||
// CSRF cookie 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: document.cookie can't see the CSRF cookie there
|
||||||
|
// (reqwest's separate jar — see lib/desktop-csrf.ts), so the server also
|
||||||
|
// echoes the token in the login/me response BODY (sessionView's csrfToken —
|
||||||
|
// see routes/auth.ts) and setSessionUser() (called wherever a SessionUser is
|
||||||
|
// received) stashes it via setDesktopCsrfToken(). 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 type { AppLogRecord, ValidationLine, ValidationMode } from "@parking/shared";
|
import type { AppLogRecord, ValidationLine, ValidationMode } from "@parking/shared";
|
||||||
|
|
||||||
const CSRF_COOKIE = "parking_csrf";
|
const CSRF_COOKIE = "parking_csrf";
|
||||||
@@ -17,6 +26,13 @@ function readCookie(name: string): string | null {
|
|||||||
return m ? decodeURIComponent(m[1]!) : null;
|
return m ? decodeURIComponent(m[1]!) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Update the desktop CSRF stash. Called wherever a SessionUser is received
|
||||||
|
* (login, fetchMe). No-op / cheap in the browser (the value just goes
|
||||||
|
* unused there — reads still come from document.cookie). */
|
||||||
|
function setSessionUser(user: SessionUser): void {
|
||||||
|
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. */
|
||||||
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
const method = (init.method ?? "GET").toUpperCase();
|
const method = (init.method ?? "GET").toUpperCase();
|
||||||
@@ -25,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 = 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" });
|
||||||
@@ -82,6 +98,10 @@ export interface SessionUser {
|
|||||||
fullName: string | null;
|
fullName: string | null;
|
||||||
/** Optional contact email (profile metadata); null if unset. */
|
/** Optional contact email (profile metadata); null if unset. */
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
/** Desktop-only: the CSRF token also echoed via the (JS-unreadable, on
|
||||||
|
* desktop) parking_csrf cookie — see the file header. Absent/unused in the
|
||||||
|
* browser build, which reads the cookie directly instead. */
|
||||||
|
csrfToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Does this session grant the permission? Central authz check for the SPA. */
|
/** Does this session grant the permission? Central authz check for the SPA. */
|
||||||
@@ -89,15 +109,28 @@ export function can(user: SessionUser | null, perm: Permission): boolean {
|
|||||||
return !!user && user.permissions.includes(perm);
|
return !!user && user.permissions.includes(perm);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function login(username: string, password: string): Promise<SessionUser> {
|
export async function login(username: string, password: string): Promise<SessionUser> {
|
||||||
return apiFetch<SessionUser>("/api/auth/login", {
|
const user = await apiFetch<SessionUser>("/api/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
|
setSessionUser(user);
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logout(): Promise<{ ok: boolean }> {
|
export async function logout(): Promise<{ ok: boolean }> {
|
||||||
return apiFetch("/api/auth/logout", { method: "POST" });
|
const res = await apiFetch<{ ok: boolean }>("/api/auth/logout", { method: "POST" });
|
||||||
|
setDesktopCsrfToken(null);
|
||||||
|
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). */
|
||||||
@@ -146,7 +179,9 @@ export function changeMyPassword(
|
|||||||
/** Returns the current user, or null if not authenticated. */
|
/** Returns the current user, or null if not authenticated. */
|
||||||
export async function fetchMe(): Promise<SessionUser | null> {
|
export async function fetchMe(): Promise<SessionUser | null> {
|
||||||
try {
|
try {
|
||||||
return await apiFetch<SessionUser>("/api/auth/me");
|
const user = await apiFetch<SessionUser>("/api/auth/me");
|
||||||
|
setSessionUser(user);
|
||||||
|
return user;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null;
|
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null;
|
||||||
throw e;
|
throw e;
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Desktop-only: the operator-configured backend origin (host:port of the
|
||||||
|
// Fastify server this install talks to), persisted across restarts.
|
||||||
|
//
|
||||||
|
// The desktop shell is a generic .deb/.AppImage — it is NOT built for one
|
||||||
|
// specific booth, so the backend address can't be baked in at build time
|
||||||
|
// (that was the old VITE_API_BASE approach; a rebuild was needed to point the
|
||||||
|
// same installer at a different appliance). Instead the operator enters it
|
||||||
|
// once in the ConnectScreen (shown before login whenever nothing usable is
|
||||||
|
// stored yet) and it's saved to a JSON file in the OS config dir via
|
||||||
|
// tauri-plugin-store, read back on every launch before any API call.
|
||||||
|
//
|
||||||
|
// Browser build: this module is never reached (inTauri() gates every call
|
||||||
|
// site — see origin.ts), so there is no browser equivalent or fallback here.
|
||||||
|
|
||||||
|
import type { Store } from "@tauri-apps/plugin-store";
|
||||||
|
|
||||||
|
const STORE_FILE = "backend-config.json";
|
||||||
|
const KEY = "backendUrl";
|
||||||
|
|
||||||
|
let storeHandle: Store | null = null;
|
||||||
|
async function getStore(): Promise<Store> {
|
||||||
|
if (!storeHandle) {
|
||||||
|
const { load } = await import("@tauri-apps/plugin-store");
|
||||||
|
storeHandle = await load(STORE_FILE, { autoSave: true });
|
||||||
|
}
|
||||||
|
return storeHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The saved backend origin (no trailing slash), or null if never configured.
|
||||||
|
* Desktop only — throws if called from a browser build. */
|
||||||
|
export async function loadBackendUrl(): Promise<string | null> {
|
||||||
|
const store = await getStore();
|
||||||
|
const v = await store.get<string>(KEY);
|
||||||
|
return typeof v === "string" && v.length > 0 ? v.replace(/\/$/, "") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist a new backend origin (validated + reachable — call testBackendUrl
|
||||||
|
* first). Takes effect immediately for future platformFetch/wsUrl calls. */
|
||||||
|
export async function saveBackendUrl(url: string): Promise<void> {
|
||||||
|
const store = await getStore();
|
||||||
|
await store.set(KEY, url.replace(/\/$/, ""));
|
||||||
|
await store.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the saved backend (forces the ConnectScreen back up next launch). */
|
||||||
|
export async function clearBackendUrl(): Promise<void> {
|
||||||
|
const store = await getStore();
|
||||||
|
await store.delete(KEY);
|
||||||
|
await store.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendCheck {
|
||||||
|
ok: boolean;
|
||||||
|
/** "unreachable" (network/DNS/refused) | "bad_response" (reachable, not our API). */
|
||||||
|
reason?: "unreachable" | "bad_response";
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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}/health`, {
|
||||||
|
method: "GET",
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
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 {
|
||||||
|
ok: false,
|
||||||
|
reason: "unreachable",
|
||||||
|
detail: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -9,11 +9,18 @@
|
|||||||
// and never tries to resolve the Tauri APIs. Offline-first: a failed check (no
|
// and never tries to resolve the Tauri APIs. Offline-first: a failed check (no
|
||||||
// network — the appliance is usually offline) is swallowed; updates only happen
|
// network — the appliance is usually offline) is swallowed; updates only happen
|
||||||
// when someone has brought the box online (e.g. a phone hotspot) on purpose.
|
// when someone has brought the box online (e.g. a phone hotspot) on purpose.
|
||||||
|
//
|
||||||
|
// A release build's console.error is invisible with no way to attach devtools
|
||||||
|
// in the field (kiosk mode blocks the context menu; this WebKitGTK build's
|
||||||
|
// remote inspector doesn't answer standard discovery endpoints either — both
|
||||||
|
// confirmed dead ends 2026-09-03). logClient() ships straight to the
|
||||||
|
// server-side app_logs store regardless of the client's console-forward log
|
||||||
|
// level (that gate is meant for noisy console chatter, not this), so a real
|
||||||
|
// post-accept install failure is visible via wiki/concepts/app-logs.md /
|
||||||
|
// LogsViewer.tsx without needing a terminal or devtools at all.
|
||||||
|
|
||||||
/** True when running inside the Tauri webview (not a normal browser). */
|
import { logClient } from "./logger.js";
|
||||||
function inTauri(): boolean {
|
import { inTauri } from "./tauri-env.js";
|
||||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdatePrompt {
|
export interface UpdatePrompt {
|
||||||
/** Newer version string offered by the server. */
|
/** Newer version string offered by the server. */
|
||||||
@@ -42,13 +49,24 @@ export async function checkForDesktopUpdate(
|
|||||||
// Download + install the signed update (signature verified against the
|
// Download + install the signed update (signature verified against the
|
||||||
// pubkey in tauri.conf.json), then relaunch into the new version.
|
// pubkey in tauri.conf.json), then relaunch into the new version.
|
||||||
try {
|
try {
|
||||||
await update.downloadAndInstall();
|
await update.downloadAndInstall((progress) => {
|
||||||
|
logClient({
|
||||||
|
level: "info",
|
||||||
|
message: `desktop update download progress: ${progress.event}`,
|
||||||
|
context: { kind: "desktop_update_progress", version: update.version, event: progress.event },
|
||||||
|
});
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A real update WAS found and accepted — this is a genuine install
|
// A real update WAS found and accepted — this is a genuine install
|
||||||
// failure (bad signature, corrupted download, disk/permission issue),
|
// failure (bad signature, corrupted download, disk/permission issue),
|
||||||
// not "offline". Surface it instead of silently reverting to the old
|
// not "offline". Surface it instead of silently reverting to the old
|
||||||
// version with no explanation.
|
// version with no explanation.
|
||||||
console.error("desktop update download/install failed:", err);
|
logClient({
|
||||||
|
level: "error",
|
||||||
|
message: `desktop update download/install failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
stack: err instanceof Error ? err.stack : undefined,
|
||||||
|
context: { kind: "desktop_update_install_failed", version: update.version },
|
||||||
|
});
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const { relaunch } = await import("@tauri-apps/plugin-process");
|
const { relaunch } = await import("@tauri-apps/plugin-process");
|
||||||
@@ -56,7 +74,13 @@ export async function checkForDesktopUpdate(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Offline / endpoint unreachable / no update server yet → ignore. The app
|
// Offline / endpoint unreachable / no update server yet → ignore. The app
|
||||||
// keeps running on the current version; checking again next launch. Still
|
// keeps running on the current version; checking again next launch. Still
|
||||||
// log it so a real install failure (rethrown above) isn't invisible.
|
// log it (info, not error — this path is expected/normal far more often
|
||||||
console.warn("desktop update check/apply skipped:", err);
|
// than it's a real problem) so a real install failure (rethrown above,
|
||||||
|
// logged as error) isn't lost among routine offline checks.
|
||||||
|
logClient({
|
||||||
|
level: "info",
|
||||||
|
message: `desktop update check/apply skipped: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
context: { kind: "desktop_update_skipped" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ export const en: Catalog = {
|
|||||||
signIn: "Sign in",
|
signIn: "Sign in",
|
||||||
signingIn: "Signing in…",
|
signingIn: "Signing in…",
|
||||||
},
|
},
|
||||||
|
connect: {
|
||||||
|
title: "Connect to server",
|
||||||
|
hint: "Enter the address of the parking system server for this booth.",
|
||||||
|
serverAddress: "Server address",
|
||||||
|
test: "Test",
|
||||||
|
testing: "Testing…",
|
||||||
|
save: "Save & continue",
|
||||||
|
saving: "Saving…",
|
||||||
|
testOk: "Reachable — this looks like a Parking System server.",
|
||||||
|
testUnreachable: "Could not reach this address.",
|
||||||
|
testBadResponse: "Reachable, but this doesn't look like a Parking System server.",
|
||||||
|
changeServer: "Change server",
|
||||||
|
changeServerConfirm: "This signs you out and asks for a new server address on next launch. Continue?",
|
||||||
|
},
|
||||||
update: {
|
update: {
|
||||||
available: "Update available",
|
available: "Update available",
|
||||||
prompt: "Version {{version}} is available. Install now and restart?",
|
prompt: "Version {{version}} is available. Install now and restart?",
|
||||||
|
|||||||
@@ -45,6 +45,20 @@ export const sq = {
|
|||||||
signIn: "Hyr",
|
signIn: "Hyr",
|
||||||
signingIn: "Duke hyrë…",
|
signingIn: "Duke hyrë…",
|
||||||
},
|
},
|
||||||
|
connect: {
|
||||||
|
title: "Lidhu me serverin",
|
||||||
|
hint: "Vendos adresën e serverit të sistemit të parkimit për këtë kabinë.",
|
||||||
|
serverAddress: "Adresa e serverit",
|
||||||
|
test: "Testo",
|
||||||
|
testing: "Duke testuar…",
|
||||||
|
save: "Ruaj & vazhdo",
|
||||||
|
saving: "Duke ruajtur…",
|
||||||
|
testOk: "I arritshëm — duket si server i Sistemit të Parkimit.",
|
||||||
|
testUnreachable: "Nuk u arrit kjo adresë.",
|
||||||
|
testBadResponse: "I arritshëm, por nuk duket si server i Sistemit të Parkimit.",
|
||||||
|
changeServer: "Ndrysho serverin",
|
||||||
|
changeServerConfirm: "Kjo do t'ju dalë nga sesioni dhe do kërkojë adresë të re serveri në hapjen tjetër. Vazhdo?",
|
||||||
|
},
|
||||||
update: {
|
update: {
|
||||||
available: "Përditësim i disponueshëm",
|
available: "Përditësim i disponueshëm",
|
||||||
prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis?",
|
prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis?",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+32
-11
@@ -3,16 +3,19 @@
|
|||||||
// In a browser (dev via the Vite proxy, or prod where Fastify serves the built
|
// In a browser (dev via the Vite proxy, or prod where Fastify serves the built
|
||||||
// SPA) this is EMPTY — requests stay relative (`/api/...`) and same-origin, so
|
// SPA) this is EMPTY — requests stay relative (`/api/...`) and same-origin, so
|
||||||
// nothing changes. The Tauri desktop shell (apps/desktop) serves the bundled
|
// nothing changes. The Tauri desktop shell (apps/desktop) serves the bundled
|
||||||
// SPA from `tauri://localhost`, which has no backend and no proxy; there we set
|
// SPA from `tauri://localhost`, which has no backend and no proxy; there the
|
||||||
// VITE_API_BASE to the appliance's Fastify origin (e.g. http://127.0.0.1:3000)
|
// operator enters the appliance's Fastify origin (e.g. http://192.168.1.50:3000)
|
||||||
// at build time so /api and the live WS feed resolve to the real server.
|
// once in the ConnectScreen and it's persisted via tauri-plugin-store (see
|
||||||
|
// backend-config.ts) — a RUNTIME value, not a build-time one, since the same
|
||||||
|
// installer is used across every booth and the backend can move (new box, new
|
||||||
|
// IP) without a rebuild. main.tsx calls initApiBase() before the app mounts.
|
||||||
//
|
//
|
||||||
// Keep this the SINGLE source for the backend origin — api.ts and the live-feed
|
// Keep this the SINGLE source for the backend origin — api.ts and the live-feed
|
||||||
// WebSocket both read it, so the web app and the desktop shell stay identical
|
// WebSocket both read it, so the web app and the desktop shell stay identical
|
||||||
// except for this one build-time value.
|
// except for this one runtime value.
|
||||||
//
|
//
|
||||||
// platformFetch(): WebKitGTK treats tauri://localhost as a SECURE origin, so a
|
// platformFetch(): WebKitGTK treats tauri://localhost as a SECURE origin, so a
|
||||||
// plain http://127.0.0.1:3000 fetch() from inside it is blocked as mixed
|
// plain http://192.168.1.50:3000 fetch() from inside it is blocked as mixed
|
||||||
// content (a WebKit limitation — CSP's connect-src does NOT override this;
|
// content (a WebKit limitation — CSP's connect-src does NOT override this;
|
||||||
// found 2026-09-03 as "Load failed" on every desktop request). Inside Tauri we
|
// found 2026-09-03 as "Load failed" on every desktop request). Inside Tauri we
|
||||||
// dynamically import @tauri-apps/plugin-http's fetch, which routes the request
|
// dynamically import @tauri-apps/plugin-http's fetch, which routes the request
|
||||||
@@ -20,8 +23,29 @@
|
|||||||
// the check entirely. Browser build never imports the plugin (dynamic import,
|
// the check entirely. Browser build never imports the plugin (dynamic import,
|
||||||
// same pattern as desktop-updater.ts).
|
// same pattern as desktop-updater.ts).
|
||||||
|
|
||||||
/** Backend HTTP origin, no trailing slash. Empty string = same-origin/relative. */
|
import { inTauri } from "./tauri-env.js";
|
||||||
export const API_BASE: string = (import.meta.env.VITE_API_BASE ?? "").replace(/\/$/, "");
|
|
||||||
|
/** Backend HTTP origin, no trailing slash. Empty string = same-origin/relative
|
||||||
|
* (browser) or not-yet-configured (desktop, before the ConnectScreen runs). */
|
||||||
|
export let API_BASE: string = "";
|
||||||
|
|
||||||
|
/** Desktop only: load the persisted backend URL (if any) before the app
|
||||||
|
* mounts, so the very first fetchMe() call already has the right origin.
|
||||||
|
* No-op in the browser. Returns the loaded value (null = not configured yet,
|
||||||
|
* meaning main.tsx should show the ConnectScreen instead of the normal app). */
|
||||||
|
export async function initApiBase(): Promise<string | null> {
|
||||||
|
if (!inTauri()) return null;
|
||||||
|
const { loadBackendUrl } = await import("./backend-config.js");
|
||||||
|
const saved = await loadBackendUrl();
|
||||||
|
if (saved) API_BASE = saved;
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Desktop only: change the backend origin at runtime (after the operator
|
||||||
|
* saves a new one in Settings) without requiring a full app restart. */
|
||||||
|
export function setApiBase(url: string): void {
|
||||||
|
API_BASE = url.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve an API path to a full URL (or a relative path when API_BASE is empty). */
|
/** Resolve an API path to a full URL (or a relative path when API_BASE is empty). */
|
||||||
export function apiUrl(path: string): string {
|
export function apiUrl(path: string): string {
|
||||||
@@ -38,10 +62,7 @@ export function wsUrl(path: string): string {
|
|||||||
return `${proto}//${window.location.host}${path}`;
|
return `${proto}//${window.location.host}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when running inside the Tauri webview (not a normal browser). */
|
export { inTauri };
|
||||||
function inTauri(): boolean {
|
|
||||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* fetch(), but routed through @tauri-apps/plugin-http inside the desktop
|
* fetch(), but routed through @tauri-apps/plugin-http inside the desktop
|
||||||
|
|||||||
@@ -17,6 +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";
|
||||||
|
|
||||||
|
/** 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;
|
||||||
@@ -62,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;
|
||||||
@@ -85,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?.();
|
||||||
}
|
}
|
||||||
@@ -97,11 +125,6 @@ class TauriSocketAdapter implements PlatformSocket {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when running inside the Tauri webview (not a normal browser). */
|
|
||||||
function inTauri(): boolean {
|
|
||||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open a live-feed socket, routed through the Tauri WebSocket plugin inside the
|
/** Open a live-feed socket, routed through the Tauri WebSocket plugin inside the
|
||||||
* desktop shell (mixed-content workaround), or the native WebSocket in a browser. */
|
* desktop shell (mixed-content workaround), or the native WebSocket in a browser. */
|
||||||
export function createPlatformSocket(url: string): PlatformSocket {
|
export function createPlatformSocket(url: string): PlatformSocket {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** True when running inside the Tauri webview (not a normal browser). Single
|
||||||
|
* source for this check — origin.ts, platform-ws.ts, desktop-updater.ts, and
|
||||||
|
* backend-config.ts all gate their Tauri-only code paths on it. */
|
||||||
|
export function inTauri(): boolean {
|
||||||
|
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||||
|
}
|
||||||
+76
-1
@@ -6,7 +6,7 @@ import {
|
|||||||
Outlet,
|
Outlet,
|
||||||
redirect,
|
redirect,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { lazy, Suspense, useState } from "react";
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
||||||
@@ -30,6 +30,7 @@ import { Spinner } from "./ui/Spinner.js";
|
|||||||
import { setLanguage } from "./lib/i18n/index.js";
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||||
|
import { inTauri } from "./lib/origin.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { useShift } from "./lib/use-shift.js";
|
||||||
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
||||||
import { StatusDot } from "./ui/StatusDot.js";
|
import { StatusDot } from "./ui/StatusDot.js";
|
||||||
@@ -106,6 +107,78 @@ function VersionBadge() {
|
|||||||
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">{version}</span>;
|
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">{version}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The installed Tauri app's own "vX.Y.Z" (from tauri.conf.json, synced to the git tag by
|
||||||
|
* release.yml — see wiki/decisions/desktop-shell-tauri.md) — the client's version, distinct
|
||||||
|
* from VersionBadge's SERVER build. No-op / renders nothing in a browser (there's no Tauri
|
||||||
|
* API to call). Was invisible before this: an operator had no way to tell which desktop
|
||||||
|
* build was actually installed short of reading the update-available prompt. */
|
||||||
|
function DesktopVersionBadge() {
|
||||||
|
const [version, setVersion] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inTauri()) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void import("@tauri-apps/api/app").then(({ getVersion }) =>
|
||||||
|
getVersion().then((v) => {
|
||||||
|
if (!cancelled) setVersion(v);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
if (!version) return null;
|
||||||
|
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">app v{version}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Desktop-only "change which server this install talks to" control. No-op /
|
||||||
|
* renders nothing in a browser (the concept doesn't apply — same-origin).
|
||||||
|
* Simplest correct action: clear the saved backend URL and reload, which
|
||||||
|
* drops the app back to ConnectScreen (see App.tsx) to re-enter it — this
|
||||||
|
* mirrors clearing the session (logout → back to Login), not an inline
|
||||||
|
* editor, since repointing the app is a rare, deliberate admin action. */
|
||||||
|
function DesktopServerButton() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
if (!inTauri()) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm ml-2"
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
>
|
||||||
|
{t("connect.changeServer")}
|
||||||
|
</button>
|
||||||
|
{confirming && (
|
||||||
|
<Modal open onClose={() => setConfirming(false)} title={t("connect.changeServer")} width="max-w-sm">
|
||||||
|
<div className="text-[0.8125rem]">
|
||||||
|
<p className="text-term-muted">{t("connect.changeServerConfirm")}</p>
|
||||||
|
<div className="mt-3 flex justify-end gap-2">
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => setConfirming(false)} disabled={busy}>
|
||||||
|
{t("subs.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-danger"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(true);
|
||||||
|
const { clearBackendUrl } = await import("./lib/backend-config.js");
|
||||||
|
await clearBackendUrl();
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? <Spinner /> : t("connect.changeServer")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
|
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
|
||||||
* tab's screen via <Outlet>. Each tab is a child route (its own URL + guard), so
|
* tab's screen via <Outlet>. Each tab is a child route (its own URL + guard), so
|
||||||
* deep links and the back button work and a denied tab redirects to the booth. */
|
* deep links and the back button work and a denied tab redirects to the booth. */
|
||||||
@@ -125,6 +198,8 @@ function SetupLayout() {
|
|||||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||||
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
||||||
{show("site:read") && <VersionBadge />}
|
{show("site:read") && <VersionBadge />}
|
||||||
|
<DesktopVersionBadge />
|
||||||
|
<DesktopServerButton />
|
||||||
</nav>
|
</nav>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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-7317042
|
TAG=stage-5c6a21e
|
||||||
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-7317042
|
TAG=stage-5c6a21e
|
||||||
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
|
||||||
|
|||||||
Generated
+13
@@ -108,12 +108,18 @@ importers:
|
|||||||
'@tanstack/react-router':
|
'@tanstack/react-router':
|
||||||
specifier: ^1.170.16
|
specifier: ^1.170.16
|
||||||
version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
'@tauri-apps/api':
|
||||||
|
specifier: ^2.11.1
|
||||||
|
version: 2.11.1
|
||||||
'@tauri-apps/plugin-http':
|
'@tauri-apps/plugin-http':
|
||||||
specifier: ^2.5.2
|
specifier: ^2.5.2
|
||||||
version: 2.6.0
|
version: 2.6.0
|
||||||
'@tauri-apps/plugin-process':
|
'@tauri-apps/plugin-process':
|
||||||
specifier: ^2.3.1
|
specifier: ^2.3.1
|
||||||
version: 2.3.1
|
version: 2.3.1
|
||||||
|
'@tauri-apps/plugin-store':
|
||||||
|
specifier: ^2.4.0
|
||||||
|
version: 2.4.4
|
||||||
'@tauri-apps/plugin-updater':
|
'@tauri-apps/plugin-updater':
|
||||||
specifier: ^2.10.1
|
specifier: ^2.10.1
|
||||||
version: 2.10.1
|
version: 2.10.1
|
||||||
@@ -1589,6 +1595,9 @@ packages:
|
|||||||
'@tauri-apps/plugin-process@2.3.1':
|
'@tauri-apps/plugin-process@2.3.1':
|
||||||
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
|
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-store@2.4.4':
|
||||||
|
resolution: {integrity: sha512-oxSMaj/QpVfJcBMYX5aOQV94fWvga0MwQMfD6TLlbK2dh+ShPWAzefd8HWXhvOKjPRJdGVAkW7ZGO76JzzjaDA==}
|
||||||
|
|
||||||
'@tauri-apps/plugin-updater@2.10.1':
|
'@tauri-apps/plugin-updater@2.10.1':
|
||||||
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
|
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
|
||||||
|
|
||||||
@@ -4109,6 +4118,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.11.1
|
'@tauri-apps/api': 2.11.1
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-store@2.4.4':
|
||||||
|
dependencies:
|
||||||
|
'@tauri-apps/api': 2.11.1
|
||||||
|
|
||||||
'@tauri-apps/plugin-updater@2.10.1':
|
'@tauri-apps/plugin-updater@2.10.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.11.1
|
'@tauri-apps/api': 2.11.1
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: decision
|
type: decision
|
||||||
tags: [parking, decisions, desktop, frontend]
|
tags: [parking, decisions, desktop, frontend]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-09-03
|
updated: 2026-09-04
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -176,7 +181,18 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
|
|||||||
`.env.example` documenting `tauri://localhost,http://tauri.localhost` as required) — even a
|
`.env.example` documenting `tauri://localhost,http://tauri.localhost` as required) — even a
|
||||||
correct Origin header is useless if the server's allowlist doesn't include it. Both fixed
|
correct Origin header is useless if the server's allowlist doesn't include it. Both fixed
|
||||||
together; a `resources.toml` change still needs a Komodo sync + Stack redeploy to take effect on
|
together; a `resources.toml` change still needs a Komodo sync + Stack redeploy to take effect on
|
||||||
a live booth, it isn't automatic from a git push alone.
|
a live booth, it isn't automatic from a git push alone — and see [[fleet-deployment-komodo]] for
|
||||||
|
a real ResourceSync-branch gotcha this exact fix ran into.
|
||||||
|
- **No way to see the installed app's own version (found + fixed 2026-09-03).** `VersionBadge` in
|
||||||
|
`router.tsx` shows the *server's* `<branch>-<sha>` (from `/api/version`, gated `site:read`) — but
|
||||||
|
nothing showed the *desktop client's* own version. An operator debugging a stuck update had no way
|
||||||
|
to confirm which build was actually installed short of reading the update-available prompt's
|
||||||
|
target version and inferring backwards. Fixed with `DesktopVersionBadge`, next to `VersionBadge`:
|
||||||
|
calls `@tauri-apps/api/app`'s `getVersion()` (the real running app's version, baked in from
|
||||||
|
`tauri.conf.json` — synced to the git tag by `release.yml`, see the version-drift gotcha above),
|
||||||
|
no-ops/renders nothing in a browser (`inTauri()` guard, now exported from `origin.ts` instead of
|
||||||
|
redefined a 4th time). `@tauri-apps/api` added as an explicit dependency (was only ever transitive
|
||||||
|
via the plugins).
|
||||||
- **`VITE_API_BASE` — desktop vs. browser (regression found + fixed 2026-09-03):**
|
- **`VITE_API_BASE` — desktop vs. browser (regression found + fixed 2026-09-03):**
|
||||||
`apps/web/.env.production` (committed, shared by both builds) sets `VITE_API_BASE=` (empty) — this
|
`apps/web/.env.production` (committed, shared by both builds) sets `VITE_API_BASE=` (empty) — this
|
||||||
is correct for the **browser/booth** build (Fastify same-origin, stays relative) since commit
|
is correct for the **browser/booth** build (Fastify same-origin, stays relative) since commit
|
||||||
@@ -291,3 +307,116 @@ The desktop bundle now runs in CI under **two distinct workflows** — keep the
|
|||||||
above (download traffic visible, then nothing). Fixed by nesting `downloadAndInstall()` in its
|
above (download traffic visible, then nothing). Fixed by nesting `downloadAndInstall()` in its
|
||||||
own try/catch that logs and rethrows — offline/no-update still no-ops silently (outer catch),
|
own try/catch that logs and rethrows — offline/no-update still no-ops silently (outer catch),
|
||||||
but a failure *after* the operator accepted now logs to the console instead of vanishing.
|
but a failure *after* the operator accepted now logs to the console instead of vanishing.
|
||||||
|
|
||||||
|
### Runtime-configurable backend origin — no more one-install-per-booth builds (2026-09-04)
|
||||||
|
|
||||||
|
**Problem:** `VITE_API_BASE` was a **build-time** Vite env var (`tauri.conf.json`'s
|
||||||
|
`beforeBuildCommand`), hardcoded to `http://127.0.0.1:3000`. The desktop shell is a single
|
||||||
|
generic `.deb`/`.AppImage` distributed via [[fleet-deployment-komodo|mca/public_releases]] — it is
|
||||||
|
**not** built per-booth — so a build-time backend address meant the installer could only ever talk
|
||||||
|
to a server on the same machine, and pointing an install at any other host (a remote appliance, a
|
||||||
|
different port) needed a full rebuild. **Fix:** the backend origin is now an **operator-entered,
|
||||||
|
runtime-persisted** value.
|
||||||
|
|
||||||
|
- **`ConnectScreen.tsx`** — shown by `App.tsx` BEFORE `fetchMe()`/`Login` whenever running inside
|
||||||
|
Tauri (`inTauri()`) and no backend URL is saved yet (first launch, or after "Change server").
|
||||||
|
Operator types a host, hits **Test** (`backend-config.ts`'s `testBackendUrl`, an unauthenticated-
|
||||||
|
from-the-client's-perspective `GET /api/version` probe — see the CSRF gotcha below for why that
|
||||||
|
route isn't actually public), then **Save & continue**.
|
||||||
|
- **`tauri-plugin-store`** persists the value (`backend-config.json` in the OS config dir,
|
||||||
|
`autoSave: true`) — survives restarts, is NOT `localStorage` (deliberately; matches the existing
|
||||||
|
server-persisted-preference pattern elsewhere in this app, and a real file is easier to inspect/
|
||||||
|
back up on an appliance). `origin.ts`'s `API_BASE` changed from a `const` to a `let`, set once via
|
||||||
|
`initApiBase()` (called by `App.tsx` before mount) and again via `setApiBase()` after the
|
||||||
|
ConnectScreen saves — no restart required to start using it.
|
||||||
|
- **CSP had to loosen, deliberately, to a narrower real boundary.** `connect-src` was
|
||||||
|
`'self' http://127.0.0.1:3000 ... ws://127.0.0.1:3000 ...`; an operator-chosen arbitrary LAN host
|
||||||
|
can't be named at build time, so it's now **`'self'` only** — meaning a raw `fetch()`/`WebSocket`
|
||||||
|
from the webview is blocked to EVERY origin, not just disallowed ones. This is intentional, not a
|
||||||
|
regression: all backend traffic already went through `tauri-plugin-http`/`tauri-plugin-websocket`
|
||||||
|
anyway (the WebKit mixed-content fix above), and those plugins run on the Rust side, **outside**
|
||||||
|
`connect-src`'s jurisdiction entirely. The real access boundary moved to
|
||||||
|
`capabilities/default.json`'s `http:default` scope, which is now wildcarded
|
||||||
|
(`http://*`, `https://*`, `http://*:*`, `https://*:*` — all four forms needed: the scope is a
|
||||||
|
URLPattern, and a pattern with no port matches only the scheme's *default* port, so `http://*`
|
||||||
|
covers `:80` (Caddy) while `http://*:*` is what covers `:3000`). `websocket:
|
||||||
|
default` already had no scope restriction. Net effect: **the app can now reach any host the
|
||||||
|
operator types in, and nothing else** — same shape of guarantee as before, just operator-directed
|
||||||
|
instead of build-directed.
|
||||||
|
- **"Change server"** — `router.tsx`'s `DesktopServerButton`, in the Setup nav bar next to
|
||||||
|
`DesktopVersionBadge` (both `inTauri()`-gated, invisible in the browser). Confirm-modal (reuses
|
||||||
|
the shared `Modal`, not a bespoke dialog) → `clearBackendUrl()` → reload, which drops back to
|
||||||
|
ConnectScreen. Deliberately not an inline editor: repointing a booth's app is a rare, deliberate
|
||||||
|
admin action, not a frequent setting — same reasoning as why logout is a plain action button with
|
||||||
|
no separate "are you sure" for THAT (this one gets a confirm because it also blows away the
|
||||||
|
session, unlike a normal logout-then-relogin against the same server).
|
||||||
|
- **Gotcha (found via research before shipping, not in the field — worth recording anyway): the
|
||||||
|
CSRF double-submit cookie is invisible to `document.cookie` on desktop.** `tauri-plugin-http`'s
|
||||||
|
`fetch()` doesn't run through the webview — it's dispatched to Tauri's Rust side and executed by
|
||||||
|
`reqwest`, which keeps its **own** cookie jar, entirely separate from WebKitGTK's. `Set-Cookie` on
|
||||||
|
a `tauri-plugin-http` response is stored in that reqwest jar and IS correctly re-sent by
|
||||||
|
reqwest on later requests (so plain session auth — GETs — silently worked) — but it is **never**
|
||||||
|
synced into the webview's own cookie store, so `document.cookie` on the `tauri://localhost` page
|
||||||
|
can never see it. Upstream: [tauri-apps/tauri#13045](https://github.com/tauri-apps/tauri/issues/13045)
|
||||||
|
(open — asks for exactly this jar→webview sync) and
|
||||||
|
[#11518](https://github.com/tauri-apps/tauri/issues/11518) (closed, without adding a sync) — not
|
||||||
|
something fixable on our side by changing how/when we read the cookie. The reqwest jar itself
|
||||||
|
IS persisted (`.cookies` in the app cache dir), so a desktop session survives an app restart
|
||||||
|
just like the browser's 30-day cookie does. Since `api.ts`'s `apiFetch` reads the readable `parking_csrf`
|
||||||
|
cookie via `document.cookie` to echo it in `X-CSRF-Token` (double-submit — see
|
||||||
|
[[local-jwt-auth]]), this meant **every mutating request from the desktop app was silently sending
|
||||||
|
no CSRF header at all**, pre-dating this runtime-URL change (it was equally true against the old
|
||||||
|
hardcoded `127.0.0.1:3000`) — caught now because widening the backend to "any host" was the
|
||||||
|
occasion to actually trace the desktop auth path end-to-end. **Fix, without touching
|
||||||
|
`assertCsrf()`'s verification logic at all:** the server's `sessionView()` (`routes/auth.ts`,
|
||||||
|
shared by `login` and `me`) now optionally echoes the CSRF token value in the JSON response body
|
||||||
|
(`csrfToken`) — the SAME value already set as the cookie, just a second channel to learn it. The
|
||||||
|
desktop client (`api.ts`) stashes that value in an in-memory-only variable (`desktopCsrfToken`,
|
||||||
|
never persisted — a fresh launch always re-learns it via login or `/api/auth/me`) and echoes THAT
|
||||||
|
instead of reading `document.cookie` when `inTauri()`. The actual cookie is still what
|
||||||
|
`assertCsrf()` checks server-side (and reqwest still sends it correctly, per above) — this only
|
||||||
|
fixes how the desktop *client* learns what value to put in the header, so browser behavior and
|
||||||
|
server verification are both completely unchanged.
|
||||||
|
|
||||||
|
### Live feed needs a WS *ticket*, not the cookie — and desktop logs never reached the server (2026-09-04, v0.1.6)
|
||||||
|
|
||||||
|
A retrospective of the 2026-09-03/04 run found that v0.1.4's Origin fix cleared only the **first**
|
||||||
|
of two gates in `routes/ws.ts`'s preHandler, and that the diagnostic channel everyone was staring
|
||||||
|
at was itself broken on desktop. Booth evidence: `docker logs park-2-server-1 | grep /api/ws` showed
|
||||||
|
a fresh handshake every 10 s (use-live-feed's capped backoff), i.e. every connect rejected.
|
||||||
|
|
||||||
|
- **Gate two: `req.jwtVerify()` reads the HttpOnly `parking_token` cookie — which the WebSocket
|
||||||
|
plugin cannot send.** `tauri-plugin-websocket` is a bare tokio-tungstenite client with **no
|
||||||
|
cookie jar at all** (its source has no cookie handling); the cookie lives in
|
||||||
|
`tauri-plugin-http`'s reqwest jar and is HttpOnly besides, so JS can't copy it across either.
|
||||||
|
Origin OK + no cookie → 401 → reconnect forever. **Fix: a single-use WS ticket.** The desktop
|
||||||
|
client `POST`s `/api/ws/ticket` over normal HTTP auth (cookie + CSRF, which it CAN do) and gets
|
||||||
|
a 32-byte random ticket bound to its user, valid 30 s, single-use, in-memory only; it presents
|
||||||
|
it in an `x-ws-ticket` header on the handshake (`platform-ws.ts`), and the preHandler accepts
|
||||||
|
ticket-or-cookie *after* the Origin check, then does the same `report:read` role check for both.
|
||||||
|
A browser page can't set custom WebSocket headers, so the ticket path is unreachable from a
|
||||||
|
browser and adds no CSWSH surface. **Rejected:** echoing the JWT in the login body and sending
|
||||||
|
it as `Authorization: Bearer` (fastify-jwt would accept it) — that puts the session token in JS,
|
||||||
|
which HttpOnly exists to prevent; the ticket keeps it out. Verified locally with an 11-case
|
||||||
|
handshake script: ticket/no-cookie → 101 + hello; reused/bogus/absent → 401; ticket + bad Origin
|
||||||
|
→ 403; cookie path unchanged.
|
||||||
|
- **Desktop client logs had never reached `app_logs`.** `logger.ts`'s flush read the CSRF token
|
||||||
|
from `document.cookie` (null on desktop — the same jar split as above), so every
|
||||||
|
`POST /api/logs` from the desktop 403'd under `requireAuth`→`assertCsrf`, and the flush drops
|
||||||
|
failures by design (loop safety). Consequences: the 2026-09-03 "route update-failure logging
|
||||||
|
through logClient" fix wrote to a dead channel, and the v0.1.5 CSRF fix patched `api.ts` but not
|
||||||
|
`logger.ts`. **Fix:** the stash moved to a dependency-free `lib/desktop-csrf.ts` (so `logger.ts`
|
||||||
|
can read it without importing `api.ts`, which imports `logger.ts`), and the flush uses it when
|
||||||
|
`inTauri()`. `platform-ws.ts`'s connect failure now goes through `logClient` too (rate-limited
|
||||||
|
to one row/min — reconnects are every ≤10 s), instead of `console.error`, which only forwards at
|
||||||
|
debug/trace.
|
||||||
|
- **ConnectScreen probe now hits `/health`.** The v0.1.5 probe hit an auth-guarded route and
|
||||||
|
treated 401/403 as "ours" — any password-protected service on the LAN would have passed it, and
|
||||||
|
the comment claiming no unauthenticated route existed was wrong (`/health` has been there all
|
||||||
|
along). `/health` now also returns `app: "parking-system"`, and the probe requires both a 2xx
|
||||||
|
and that value.
|
||||||
|
- **Why every one of these was found in the field:** `tauri dev` loads `http://localhost:5173`,
|
||||||
|
not `tauri://localhost`, so the relative-URL error, mixed content, the missing Origin, and the
|
||||||
|
cookie-jar split *cannot* reproduce in dev mode. The pre-tag gate is now: build the bundle
|
||||||
|
locally, run the AppImage against a local server, log in, confirm **LIVE**, do one mutation,
|
||||||
|
and confirm a desktop-sourced row appears in the Logs viewer (`apps/desktop/README.md`).
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: decision
|
type: decision
|
||||||
tags: [parking, deployment, fleet, komodo, netbird, offline-first, threat-model]
|
tags: [parking, deployment, fleet, komodo, netbird, offline-first, threat-model]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-07-07
|
updated: 2026-09-03
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -192,3 +192,32 @@ FILE is read from, each stack's `branch` picks its compose files, `TAG` picks th
|
|||||||
secrets even in the lab (blast radius). The lab box earned its keep immediately: it caught the
|
secrets even in the lab (blast radius). The lab box earned its keep immediately: it caught the
|
||||||
USB close-cancel truncation, the printer/controller wizard gate, and the Periphery v2.2.0
|
USB close-cancel truncation, the printer/controller wizard gate, and the Periphery v2.2.0
|
||||||
root_directory default before any of them reached a real booth ([[appliance-provisioning]] §7a).
|
root_directory default before any of them reached a real booth ([[appliance-provisioning]] §7a).
|
||||||
|
|
||||||
|
## ResourceSync branch drift — the exact gotcha this page already warned about (2026-09-03)
|
||||||
|
|
||||||
|
This page's own §"park-lab" note (2026-07-07) already spelled it out: *"the ResourceSync's own
|
||||||
|
branch only governs where the FILE is read from"* — independent of any `[[stack]]`'s own `branch`
|
||||||
|
field. It bit anyway. `resource-sync-park-systems` in Komodo Core was pointed at **`dev`**, while
|
||||||
|
`park-buzi` and `park-2` are `stage`-tier Stacks (`branch = "stage"`, pinned `TAG=stage-<sha>`, per
|
||||||
|
the promotion-tiers model above). `resources.toml` had been byte-identical on `dev` and `stage`
|
||||||
|
since park-buzi's Stack was first written, so this had **zero observable effect for months** — until
|
||||||
|
a desktop-app debugging session (see [[desktop-shell-tauri]]) landed 9 real commits on `dev`
|
||||||
|
(including a `WS_ALLOWED_ORIGINS` fix) that were never merged to `stage`, creating the first genuine
|
||||||
|
divergence between the two branches.
|
||||||
|
|
||||||
|
**Symptom:** merged `dev` → `stage`, pushed, bumped `TAG` in `resources.toml` on `stage`, committed,
|
||||||
|
pushed — then destroyed + recreated the `park-2` Stack in Komodo Core and it STILL came back running
|
||||||
|
the old image. Every sync was silently re-reading `resources.toml` from `dev` (which still had the
|
||||||
|
stale `TAG`), overwriting the correct value just committed on `stage`. No error, no warning — the
|
||||||
|
sync just quietly did what it was configured to do, from the wrong branch.
|
||||||
|
|
||||||
|
**Fix:** pointed `resource-sync-park-systems` at `stage` in Komodo Core's UI (Sync config → branch
|
||||||
|
field), then re-synced + redeployed `park-2` — confirmed via `/api/version` (previously 404,
|
||||||
|
proving a stale image; correctly 401-auth-gated after the fix, proving the new image + route exist).
|
||||||
|
|
||||||
|
**Standing lesson, now written twice:** a `[[stack]]`'s promotion tier (which branch its own
|
||||||
|
`branch`/`TAG` fields track) and the ResourceSync resource's own git branch are **two independently
|
||||||
|
configured settings in Komodo Core — nothing enforces they agree**, and a mismatch is invisible
|
||||||
|
until the two branches' `resources.toml` actually diverge. **Check this FIRST** whenever a
|
||||||
|
redeploy doesn't pick up an expected `resources.toml` change, before assuming the change itself,
|
||||||
|
the CI build, or the deploy step is broken.
|
||||||
|
|||||||
+67
@@ -2793,3 +2793,70 @@ get the real allowlist. Needs a Komodo sync + redeploy to reach a live booth, no
|
|||||||
Also confirmed the "update downloads then nothing happens" report was an older pre-fix build (v0.1.2)
|
Also confirmed the "update downloads then nothing happens" report was an older pre-fix build (v0.1.2)
|
||||||
self-updating — expected, not a new bug; v0.1.3 carries the error-logging fix from the mixed-content
|
self-updating — expected, not a new bug; v0.1.3 carries the error-logging fix from the mixed-content
|
||||||
commit and should surface a real error going forward. Full detail on [[desktop-shell-tauri]].
|
commit and should surface a real error going forward. Full detail on [[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-03] fix | Update failures were invisible: console-forward gate blocked the error logging
|
||||||
|
|
||||||
|
The desktop-updater.ts error logging added earlier this session used console.error/console.warn,
|
||||||
|
but logger.ts only forwards console output to the server when the client log level is debug/trace
|
||||||
|
(default: info) — so the "fix" never actually surfaced anything, and a real v0.1.3→v0.1.4 update
|
||||||
|
failure showed zero logs anywhere, sending debugging in circles (a WebKit remote-inspector attempt
|
||||||
|
via WEBKIT_INSPECTOR_SERVER also dead-ended — this build doesn't answer standard discovery
|
||||||
|
endpoints). Fixed by calling logClient() directly in desktop-updater.ts, unconditionally, bypassing
|
||||||
|
the console-forward gate entirely — a genuine post-accept install failure now always reaches
|
||||||
|
app_logs regardless of client log level. Also added download-progress logging. Separately: found
|
||||||
|
and fixed a real, pre-existing Komodo ResourceSync misconfig (resource-sync-park-systems pointed at
|
||||||
|
`dev`, not `stage`, silently reading resources.toml from the wrong branch for months with zero
|
||||||
|
effect until dev/stage first diverged today) — full writeup on [[fleet-deployment-komodo]], which
|
||||||
|
had already warned about exactly this gotcha back in 2026-07-07 and it happened anyway.
|
||||||
|
|
||||||
|
## [2026-09-03] feat | Desktop app version now visible in the UI (was invisible)
|
||||||
|
|
||||||
|
There was no way to see which desktop build was actually installed anywhere in the app — an
|
||||||
|
operator debugging a stuck update had to infer it backwards from the update prompt's target
|
||||||
|
version ("it's offering v0.1.4, so I must be on v0.1.3"). Added DesktopVersionBadge next to the
|
||||||
|
existing server-side VersionBadge in router.tsx, using @tauri-apps/api's getVersion() (the real
|
||||||
|
running app version, synced to the git tag at build time by release.yml). No-ops in a browser.
|
||||||
|
Full detail on [[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-04] feat | Desktop backend origin is now runtime-configurable (was build-time)
|
||||||
|
|
||||||
|
The desktop shell is one generic .deb/.AppImage distributed via mca/public_releases — not built
|
||||||
|
per-booth — but VITE_API_BASE was a build-time env var hardcoded to http://127.0.0.1:3000, so the
|
||||||
|
same installer could only ever talk to a server on its own machine. Added ConnectScreen (shown
|
||||||
|
before Login in Tauri when no backend is saved), backed by tauri-plugin-store persisting the
|
||||||
|
operator-entered URL across restarts; origin.ts's API_BASE became a runtime-settable `let`. CSP's
|
||||||
|
connect-src tightened to 'self' only (all backend traffic already went through
|
||||||
|
tauri-plugin-http/websocket, which run Rust-side and are outside connect-src's reach anyway); the
|
||||||
|
real boundary moved to capabilities/default.json's http:default scope, wildcarded to any host so
|
||||||
|
the operator-chosen address is actually reachable. Added a "Change server" control (Setup nav,
|
||||||
|
desktop-only) that clears the saved URL and reloads back to ConnectScreen.
|
||||||
|
|
||||||
|
While tracing the desktop auth path for this, found a pre-existing (not newly introduced) bug:
|
||||||
|
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/#11518), so the desktop app has likely been
|
||||||
|
silently sending no CSRF header on every mutation since the shell was first built, regardless of
|
||||||
|
which host it targeted. Fixed by having sessionView() (routes/auth.ts) also echo the same csrf
|
||||||
|
value in the login/me JSON body; the desktop client stashes it in memory and echoes that instead of
|
||||||
|
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
|
||||||
|
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]].
|
||||||
|
|||||||
Reference in New Issue
Block a user