31 Commits

Author SHA1 Message Date
julian 54e691a4c9 fix(release): latest.json entry per installer type — .deb booths could never self-update
Release desktop / bundle (push) Successful in 5m26s
tauri-plugin-updater resolves the download target as {os}-{arch}-{installer}
first (linux-x86_64-deb — the bundler stamps the installer type into the
binary, verified with `strings` on a local .deb) and only then bare
linux-x86_64. Our manifest carried only the bare key, pointing at the
AppImage. A .deb install therefore downloaded the AppImage, verified its
signature, then failed install_deb()'s is_deb check with
InvalidUpdaterFormat — after the download, before any relaunch. This, not
version drift or swallowed errors, is why v0.1.0→v0.1.6 never self-updated.

latest.json now carries linux-x86_64-deb, linux-x86_64-rpm (when built) and
linux-x86_64 (AppImage), each with its own .sig. A .deb update ends in a
polkit password prompt (pkexec dpkg -i) — the intended admin gate on a
root-installed package. README + wiki updated; wiki also records the v0.1.6
LIVE field verification.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-04 15:28:08 +02:00
julian 52862db8ad chore(resources): bump stage TAG to 8fa66c9
Build & push images / images (push) Successful in 2m48s
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-04 12:16:16 +02:00
julian 8fa66c9911 fix(desktop): WS ticket auth for the live feed; desktop logs never reached the server
Build & push images / images (push) Successful in 2m51s
Release desktop / bundle (push) Successful in 41m19s
The v0.1.4 Origin fix cleared only the first of two gates in /api/ws's
preHandler. The second, req.jwtVerify(), reads the HttpOnly cookie — which
tauri-plugin-websocket (a bare tungstenite client, no cookie jar) can never
send. Every desktop handshake 401'd and use-live-feed reconnected every 10s
(confirmed in the park-2 server log).

- routes/ws.ts: POST /api/ws/ticket (cookie + CSRF auth) mints a 30s,
  single-use, in-memory ticket; the WS preHandler accepts it via an
  x-ws-ticket header after the Origin check, then the same report:read
  role check. Browser cookie path unchanged; JWT stays out of JS.
- platform-ws.ts: fetch a ticket before connect, send it with the Origin
  header; connect failures now go through logClient (rate-limited).
- logger.ts: flush read the CSRF token from document.cookie, null on
  desktop, so every desktop POST /api/logs 403'd and was dropped silently —
  no desktop client log had ever reached app_logs. Stash moved to a
  dependency-free lib/desktop-csrf.ts shared by api.ts and logger.ts.
- backend-config.ts: ConnectScreen probe uses the unauthenticated /health
  (now also returns app: "parking-system") instead of accepting any 401.
- README: local-AppImage release gate — tauri dev runs at
  http://localhost:5173, not tauri://localhost, so none of these
  origin-dependent bugs reproduce there.
- wiki: new section + log entry; four citation corrections.

Requires the server image with this commit deployed before the new desktop
build connects (the ticket endpoint must exist).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-04 12:09:30 +02:00
julian 70e1e9939f chore(resources): bump stage TAG to 5c6a21e
Build & push images / images (push) Successful in 2m50s
Promotes park-buzi + park-2 to the runtime-configurable desktop backend
address (ConnectScreen) and the desktop CSRF fix. build-images.yml
confirmed green for this sha before bumping.
2026-09-04 11:27:17 +02:00
julian 5c6a21e2c3 feat(desktop): runtime-configurable backend server address
Build & push images / images (push) Successful in 3m19s
Release desktop / bundle (push) Successful in 4m57s
The desktop shell is one generic .deb/.AppImage distributed via
mca/public_releases, not built per-booth, but the backend origin was baked
in at build time (VITE_API_BASE, hardcoded to http://127.0.0.1:3000) — the
same installer could never point at a different appliance without a
rebuild.

Adds ConnectScreen (shown before Login in Tauri when no backend is saved),
backed by tauri-plugin-store persisting the operator-entered URL across
restarts. CSP's connect-src tightens to 'self' only — all backend traffic
already routes through tauri-plugin-http/websocket, which run Rust-side
and are outside connect-src's reach anyway — and the real access boundary
moves to capabilities/default.json's http:default scope, wildcarded so an
operator-chosen host is actually reachable. Adds a "Change server" control
in Setup (desktop-only) to repoint an already-configured install.

While tracing the desktop auth path for 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 tauri://localhost never sees the
parking_csrf cookie the server sets (open upstream bug,
tauri-apps/tauri#13045/#11518). This means the desktop app has likely been
silently sending no CSRF header on every mutation since the shell was
first built — pre-existing, independent of this change. Fixed by having
sessionView() (routes/auth.ts) also echo the 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.

Verified end-to-end against a real LAN-bound dev server: login returns a
csrfToken matching the cookie, a mutation using the body-sourced token in
X-CSRF-Token succeeds (200), and the same mutation without it still
correctly 403s.
2026-09-04 10:32:03 +02:00
julian 969bf2b191 chore(resources): bump stage TAG to 7d67934
Build & push images / images (push) Successful in 2m49s
Promotes park-buzi + park-2 to the WS_ALLOWED_ORIGINS fix and the desktop
version badge. build-images.yml confirmed green for this sha before bumping.
2026-09-03 18:22:07 +02:00
julian 7d67934a10 Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m48s
2026-09-03 17:29:31 +02:00
julian 56904422af feat(desktop): show the installed app's own version in the UI
Build desktop / desktop (push) Successful in 4m47s
Build & push images / images (push) Successful in 2m54s
CI / check (push) Successful in 42s
Nothing displayed which desktop build was actually installed — debugging
a stuck update meant inferring the current version backwards from the
update prompt's target version. Added DesktopVersionBadge (next to the
existing server-side VersionBadge) using @tauri-apps/api's getVersion(),
the real running app version baked in from tauri.conf.json. No-ops in a
browser. Exported inTauri() from origin.ts instead of redefining it again.
2026-09-03 16:31:48 +02:00
julian 8bcdea9e4a Merge remote-tracking branch 'origin/dev' into stage
Build & push images / images (push) Successful in 2m48s
2026-09-03 16:24:44 +02:00
julian 7804285dec fix(desktop): route update-failure logging through logClient, not console
Build desktop / desktop (push) Successful in 4m44s
Build & push images / images (push) Successful in 2m50s
CI / check (push) Successful in 43s
console.error/console.warn only forward to the server when the client log
level is debug/trace (default: info) — the earlier error-logging fix never
actually surfaced anything, and a real update failure produced zero logs
anywhere. desktop-updater.ts now calls logClient() directly, unconditionally,
plus download-progress events. Also documents the resource-sync-park-systems
branch misconfig (pointed at dev, Stacks are stage-tier) found while chasing
this — full writeup on fleet-deployment-komodo.md.
2026-09-03 16:23:02 +02:00
julian 4a7029cea6 chore(resources): bump stage TAG to 7317042
Build & push images / images (push) Successful in 2m49s
Promotes park-buzi + park-2 to the just-merged desktop-app fixes (login,
mixed-content routing, WS origin) and the WS_ALLOWED_ORIGINS fix — none of
this was on stage before. Wait for build-images.yml to confirm the image
actually exists before syncing/deploying in Komodo.
2026-09-03 16:04:57 +02:00
julian 7317042e8d fix(desktop): WS live feed offline — native plugin sends no Origin header
Build desktop / desktop (push) Successful in 4m42s
CI / check (push) Successful in 43s
Release desktop / bundle (push) Successful in 4m43s
Build & push images / images (push) Successful in 2m46s
Login worked after the mixed-content fix, but the live feed 403'd silently:
tauri-plugin-websocket's connect() runs on Tauri's Rust side, not inside the
webview page, so it never auto-attaches Origin the way a browser WebSocket
would — routes/ws.ts's anti-CSWSH check rejects a missing Origin before
auth. platform-ws.ts now sets Origin: tauri://localhost explicitly.

Also fixes a second, independent gap the above alone wouldn't have caught:
komodo/resources.toml's booth Stacks had WS_ALLOWED_ORIGINS= empty in
production despite .env.example documenting it as required for desktop.
Needs a Komodo sync + redeploy to reach a live booth.
2026-09-03 15:35:04 +02:00
julian 439b11d16d fix(desktop): route fetch + WebSocket through native Tauri plugins (mixed-content)
Build desktop / desktop (push) Successful in 4m33s
Build & push images / images (push) Successful in 2m50s
CI / check (push) Successful in 43s
Release desktop / bundle (push) Successful in 5m13s
Fixing VITE_API_BASE got login to build a correct absolute URL, but it still
failed with WebKit's generic "Load failed" — WebKitGTK treats tauri://localhost
as a secure origin, so http://127.0.0.1:3000 (and ws://) from inside it is
blocked as mixed content, a WebKit limitation CSP's connect-src can't override.

Added tauri-plugin-http (genuine fetch() drop-in, wired via a new
platformFetch() in origin.ts, used by api.ts + logger.ts) and
tauri-plugin-websocket (not a drop-in — adapted behind a native-WebSocket-
shaped interface in the new platform-ws.ts so use-live-feed.ts needed no
changes). Both route through Tauri's Rust side instead of the webview's own
fetch/WebSocket. Capabilities scoped to 127.0.0.1:3000/localhost:3000, matching
the existing CSP allowlist.
2026-09-03 14:57:45 +02:00
julian 276b048fa9 fix(desktop): sync tauri.conf.json version to the release tag, stop swallowing install failures
Build desktop / desktop (push) Successful in 4m13s
Build & push images / images (push) Successful in 2m48s
CI / check (push) Successful in 42s
Release desktop / bundle (push) Successful in 4m37s
v0.1.1 was tagged but tauri.conf.json's own "version" field (what Tauri
bakes into the bundle filename/internal version) stayed at 0.1.0 — the
signed binary didn't match what latest.json claimed to describe, so every
update download failed signature verification. desktop-updater.ts's single
catch{} swallowed that identically to "offline", so it looked like nothing
happened at all. release.yml now syncs tauri.conf.json's version from the
git tag before building; the updater now logs a real post-accept failure
instead of silently reverting.
2026-09-03 12:24:04 +02:00
julian faa3265e49 fix(desktop): restore VITE_API_BASE for the desktop build
Build desktop / desktop (push) Successful in 4m17s
CI / check (push) Successful in 42s
Release desktop / bundle (push) Successful in 4m47s
apps/web/.env.production's VITE_API_BASE went empty in 96fd97e to fix the
booth/browser same-origin case, but the desktop build shares that file and
was never given its own override — login broke with WebKitGTK's "The
string did not match the expected pattern." (a relative fetch() URL with
no base, from tauri://localhost). beforeBuildCommand now sets
VITE_API_BASE=http://127.0.0.1:3000 inline for the desktop build only;
verified both builds independently produce the right output.
2026-09-03 12:01:56 +02:00
julian 21bfdce27a fix(release): surface the actual Gitea API error on mirror failure
Release desktop / bundle (push) Successful in 4m24s
The mirror step's release id came back empty on the last real run but
nothing failed loudly — every curl response was swallowed (|| true, or
piped straight to /dev/null), so we had no idea why. Capture HTTP status +
response body on every call and exit 1 with the actual error instead of
silently uploading to a malformed //assets URL with no release id.
2026-09-03 10:50:36 +02:00
julian d3288e29eb fix(release): don't let a grep-not-found kill the script under set -e
CI / check (push) Successful in 42s
Release desktop / bundle (push) Successful in 4m26s
Every REL_ID lookup piped grep -o '"id":...' straight into head/cut with no
guard. Under set -e + pipefail, a Gitea API response with no id (e.g.
"tag already exists" on a retry, or an empty existing-assets list on the
first desktop-latest publish) makes grep exit 1, which aborts the whole
step immediately — before the intended fallback lookup ever runs. Hit on
retrying v0.1.0 after the previous filename fix: the release already
existed from the earlier failed run, and the script died with no output at
all instead of finding it by tag. Guarded every such pipeline with || true.
2026-09-03 10:33:03 +02:00
julian baf7a4a99d fix(release): strip spaces from bundle filenames before upload
CI / check (push) Successful in 44s
Release desktop / bundle (push) Failing after 3m57s
productName "Parking System" produces installer filenames with a literal
space (e.g. "Parking System_0.1.0_amd64.deb"). curl rejected the resulting
asset-upload URL outright on the first real v0.1.0 release ("Malformed
input to a URL function"), before the job ever reached the new
public_releases mirror step. Sanitized on copy into dist/.
2026-09-03 10:26:17 +02:00
julian 885b410e48 chore(desktop): bump version to 0.1.0 for first tagged release
Build desktop / desktop (push) Successful in 4m21s
CI / check (push) Successful in 44s
Release desktop / bundle (push) Failing after 4m1s
Still at the scaffold default 0.0.0 with no v* tag ever cut. Bumping so a
v0.1.0 tag can exercise release.yml (and the new public_releases mirror
step) for the first time.
2026-09-03 10:11:20 +02:00
julian a1f3103a76 fix(desktop): mirror signed releases to public repo for the updater
Build desktop / desktop (push) Successful in 4m46s
CI / check (push) Successful in 43s
The updater endpoint pointed at mca/parking_solution's own Gitea "latest
release" redirect, but that repo is private and field appliances have no
Gitea credentials — every update check was silently failing. release.yml
now mirrors signed installers to mca/public_releases (public, installers
only) under a fixed desktop-latest tag; tauri.conf.json points there.
Rejected embedding a read token in the app instead, given the booth-operator
threat model.

Also: make the appliance-provisioning root_directory gotcha impossible to
skim past (boxed callout + explicit next-step pointers), after it caused a
second missed step on the park-2 install.
2026-09-03 09:56:49 +02:00
julian 0fd66b261a feat(resources): add park-2 booth Stack (stage tier)
Build & push images / images (push) Successful in 2m59s
CI / check (push) Successful in 46s
New real booth, same tier as park-buzi: tracks stage, pinned stage-<sha>,
own park_2_* secret refs. Periphery had the known /etc/komodo root_directory
permission bug on --user install (wiki gotcha #9); fixed and confirmed OK
in Core before adding this Stack.
2026-09-02 18:29:46 +02:00
julian dfc5a07c10 Retire the park-lab stack from art-docker-station
Build & push images / images (push) Successful in 2m56s
CI / check (push) Successful in 44s
That host is becoming a Traefik edge, and parking's prod override brings its own
Caddy on `network_mode: host` holding :80 — the two cannot share the port. The
lab tier moves to a dedicated bench PC rather than contorting either side.

This also names what has been holding :80 on that box: the edge stack deployed
there on 2026-09-01 failed with "address already in use" and the owner was
recorded as unidentified. It was almost certainly this Caddy.

REMOVING THIS BLOCK DOES NOT STOP ANYTHING. The containers keep running and keep
the port. Destroy park-lab from Komodo Core BEFORE syncing this removal:
DestroyStack names a stack and Core resolves where from its own synced copy of
the definitions, so a sync that drops the block first takes the teardown handle
with it. If that has already happened, remove the containers by hand on the host
— there is no compose project context on a Komodo-managed box.

Three Core secrets are now unreferenced: art_docker_station_jwt_secret,
art_docker_station_event_signing_key, art_docker_station_backup_key. Lab keys
with no real ledger behind them, so they are safe to delete once the stack is
gone.

Claude-Session: https://claude.ai/code/session_01SARfPK19vLBstMWBxubezN
2026-09-01 11:33:22 +02:00
julian 5aabd7a791 fix(.gitignore): add questions.txt to ignore list
CI / check (push) Successful in 43s
2026-08-31 12:23:32 +02:00
julian 0e9b9f5d82 fix(resources): drop stale park-lab-old Stack; docs(wiki): Periphery connect_as and upgrade gotchas
Build & push images / images (push) Successful in 3m17s
CI / check (push) Successful in 46s
park-lab-old referenced a server removed from Komodo, breaking the resource
sync. Also documents two Periphery incidents from this session: a Core-UI
rename doesn't touch the agent's own connect_as, and upgrading Periphery is
a config-preserving re-run of the installer.
2026-08-31 12:17:00 +02:00
julian 642c5f4f70 feat(setup): show running build version in the Setup tab bar
Build desktop / desktop (push) Successful in 4m21s
Build & push images / images (push) Successful in 3m6s
CI / check (push) Successful in 42s
CI already computes <branch>-<short-sha> for image tags but never
surfaced it anywhere reachable from the app, so there was no way to
tell what's actually deployed on a booth without cross-referencing
komodo/resources.toml's TAG by hand.

Thread it through: CI passes BUILD_VERSION as a Docker build-arg,
the Dockerfile captures it as a runtime env var, GET /api/version
(gated by the existing site:read permission) exposes it, and the
Setup page's tab bar shows it right-aligned, muted, absent entirely
on a local/dev build with no CI-supplied value.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-08-30 19:01:15 +02:00
julian cb9f4d4979 fix(resources): rename stacks for clarity and consistency
Build & push images / images (push) Successful in 2m50s
CI / check (push) Successful in 43s
2026-08-30 18:46:55 +02:00
julian ea8fe22969 docs(wiki): USB printer cover-open field bug writeup; add art-docker-station lab box
Build desktop / desktop (push) Successful in 5m14s
Build & push images / images (push) Successful in 3m1s
CI / check (push) Successful in 43s
Printer investigation (park-buzi): cover-open on the USB thermal
printer wedges its status offline/faulty, surviving a full reboot,
recoverable only via `docker restart server`. Traced sendRawUsb/
probeUsb end-to-end — no persistent handle in the app layer, so the
leading theory is the container's /dev/usb directory bind-mount
retaining a stale view across the printer's physical re-enumeration.
Not yet confirmed on hardware; documented with repro/confirmation
commands and ranked candidate fixes.

Also registers a new lab bench box, "art-docker-station", as a Komodo
Stack (dev tier, same shape as park-lab, its own isolated secret refs).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-08-30 18:11:34 +02:00
julian 2910672b5a fix(backup): persist last-success/error status; wall-clock-based schedule
BackupService tracked last-success/last-error as plain in-process fields
and scheduled the daily backup via setInterval measured from process
start — so any server restart (deploy/crash/OOM/reboot, routine under
`restart: always`) silently reset the admin UI to "last successful
backup: Never" and drifted the actual cadence, independent of whether
backups were writing correctly to disk (they were — a real field
incident at park-buzi showed 7 valid rotating backups on disk with the
status stuck on "Never").

Persist last-success/error to new site_config columns (migration 0025)
and add BackupService.isDue(), computed from the persisted timestamp
instead of process uptime; server.ts now polls every 15 min and lets
isDue() gate the actual run. No API/UI contract change.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-08-30 18:11:23 +02:00
julian 3a176c5cc8 docs(wiki): DS-2CD1047G3H-LIU main-stream ISAPI snapshot is a firmware bug — camera line to be replaced
CI / check (push) Successful in 1m1s
Build & push images / images (push) Successful in 3m20s
Full live investigation of the persistent 503 "deviceBusy" on main-stream ISAPI
snapshots (10.0.10.13): ruled out config (byte-identical to a working sibling
model), ruled out firmware age (reproduced on both the original V5.8.11 and
current V5.11.0 builds, ~15 months apart), and ruled out real resource
contention (a full channel-ID sweep shows every ID fails identically except
the one hardcoded working value, including nonexistent channels) — pointing
at a broken/incomplete ISAPI snapshot handler that mislabels itself as
"busy," not a real encoder ceiling.

RTSP main-stream frame-grab was confirmed as a working route around it, but
given the bug and the sub-stream's real-world plate-read accuracy problems,
the owner decided to replace the DS-2CD1047G3H-LIU units rather than carry
an ffmpeg/RTSP dependency to work around vendor firmware. Ingested the
vendor datasheet as a source page along the way.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-08-23 14:50:22 +02:00
julian 19dff97c74 fix(web): permission-degrade the app shell for merchant-only users
Build desktop / desktop (push) Successful in 4m51s
Build & push images / images (push) Successful in 3m8s
CI / check (push) Successful in 52s
A user whose role has only validation:create (the bar/lavazh validator) made
the shell misbehave: useLiveFeed() connected /api/ws unconditionally, the
server's report:read guard 403'd the upgrade, and the capped-backoff
reconnect hammered it forever — a 403 in the server log every few seconds.
Gate the socket on report:read (mirrors routes/ws.ts WATCH_PERMISSION) and
render StatusDot / ShiftButton / DeviceFooter only with their backing
permissions (report:read / shift:read / device:read), so a merchant's shell
is just the nav + their /validate screen, with zero doomed requests.

Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
2026-07-13 20:12:43 +02:00
julian 0ed43239c3 bump(resources): update TAG to stage-28bd838 for deployment consistency
Build & push images / images (push) Successful in 3m1s
CI / check (push) Successful in 48s
2026-07-13 20:11:27 +02:00
48 changed files with 2993 additions and 203 deletions
+2
View File
@@ -92,6 +92,8 @@ jobs:
context: .
file: apps/server/Dockerfile
push: true
build-args: |
BUILD_VERSION=${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
tags: |
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
+183 -26
View File
@@ -1,12 +1,24 @@
name: Release desktop
# Build the signed Tauri desktop installers on a version tag and publish them as
# a Gitea Release. The Tauri auto-updater (apps/web/src/lib/desktop-updater.ts)
# fetches these; latest.json + each installer + its .sig are what it needs.
# a Gitea Release — TWICE: once on this (private, source) repo for our own
# records/history, and once mirrored to mca/public_releases, which is what the
# Tauri auto-updater (apps/web/src/lib/desktop-updater.ts) actually points at.
#
# WHY a separate public repo: the updater runs on offline-first field appliances
# with no Gitea credentials, so its endpoint + installer downloads must be
# reachable unauthenticated. Mirroring compiled installers to a public
# releases-only repo avoids embedding any read token in the shipped app (which
# would leak the moment a booth PC is compromised — this box's threat model
# names the operator/booth as the primary adversary, see CLAUDE.md). Source
# stays private; only signed installers become public, same as most desktop
# software. mca/public_releases is shared across apps in the org, not
# parking-specific — namespace release tags/asset names accordingly if another
# app starts publishing there too.
#
# Trigger: push a tag like v0.1.0. The job builds .deb/.rpm/.AppImage, signs them
# with the updater key (Gitea secrets), assembles latest.json, and uploads
# everything to the Release for that tag.
# with the updater key (Gitea secrets), assembles latest.json pointing at the
# MIRROR repo's asset URLs, uploads to both repos, and mirrors the same assets.
on:
push:
@@ -63,6 +75,27 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Sync tauri.conf.json version to the git tag
# tauri.conf.json's own "version" field is what Tauri bakes into the
# bundle filename, the app's internal version, AND the updater's
# "current vs. new" comparison — it is NOT derived from the git tag
# automatically. Hit in v0.1.1: the tag was bumped but this file
# wasn't, so the signed binary + its .sig were still built (and
# named) as 0.1.0 while latest.json (built from TAG below) claimed
# 0.1.1 — the updater found the "update", downloaded a file whose
# signature didn't match what the manifest claimed to sign, and
# silently failed (a separate bug in desktop-updater.ts's error
# handling made this invisible — also fixed). Patch it here so the
# checked-in value is only ever a placeholder for local dev builds;
# a real release's version is always driven by the tag.
run: |
set -e
VERSION="${TAG#v}"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" apps/desktop/src-tauri/tauri.conf.json
grep '"version"' apps/desktop/src-tauri/tauri.conf.json
env:
TAG: ${{ github.ref_name }}
- name: Build + sign desktop bundle
env:
# Updater signing key (Gitea repo/org secrets). Without these the
@@ -73,43 +106,88 @@ jobs:
- name: Collect artifacts
id: collect
# Gather the installers + their .sig into a flat dist/ for upload.
# Gather the installers + their .sig into a flat dist/ for upload, spaces
# stripped from filenames. productName is "Parking System" (a space), so
# Tauri's bundle output is e.g. "Parking System_0.1.0_amd64.deb" — an
# unescaped space in a filename breaks the later curl asset-upload URL
# ("URL rejected: Malformed input to a URL function", hit on the very
# first v0.1.0 release) AND would land in latest.json's asset url, which
# the updater's plain HTTP GET can't handle either. Rename on copy.
run: |
set -e
BUNDLE=apps/desktop/src-tauri/target/release/bundle
mkdir -p dist
find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \
-o -name '*.AppImage.sig' -o -name '*.deb.sig' -o -name '*.rpm.sig' \) \
-exec cp {} dist/ \;
-print0 | while IFS= read -r -d '' f; do
name=$(basename "$f" | tr ' ' '-')
cp "$f" "dist/${name}"
done
echo "Artifacts:"; ls -la dist/
- name: Assemble latest.json
# The Tauri updater fetches a manifest describing the newest version, its
# notes, and per-target {signature, url}. We point the AppImage target at
# this release's asset URL. Adjust the platform keys you actually ship.
# notes, and per-target {signature, url}. The URL points at the MIRROR
# repo (mca/public_releases) — that's the unauthenticated endpoint field
# appliances actually reach; see the workflow header for why.
#
# ONE ENTRY PER INSTALLER TYPE — this is what made every in-app update
# v0.1.0→v0.1.6 fail. tauri-plugin-updater looks up
# `{os}-{arch}-{installer}` FIRST (linux-x86_64-deb / -rpm / -appimage,
# from the running app's detected bundle type) and only then the bare
# `linux-x86_64`. The booths run the .deb, and the manifest used to
# carry ONLY `linux-x86_64` → the AppImage. So a .deb install found the
# "update", downloaded the AppImage, verified its signature fine, then
# handed the bytes to install_deb(), which checks they're a .deb
# (infer::archive::is_deb) and bails with InvalidUpdaterFormat — after
# the download, before any relaunch, with the error swallowed client-
# side until v0.1.6. Now each installer gets its own signed asset; the
# bare key stays for an AppImage install. .deb/.rpm updates run
# `pkexec dpkg -i` / `rpm -U`, so the operator sees a polkit password
# prompt — intended: updating a root-installed package IS an admin
# action on this box (see wiki/decisions/desktop-shell-tauri.md).
env:
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
MIRROR_REPO: mca/public_releases
TAG: ${{ github.ref_name }}
run: |
set -e
VERSION="${TAG#v}"
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
SIG=$(cat "dist/${APPIMAGE}.sig")
ASSET_URL="${SERVER_URL}/${REPO}/releases/download/${TAG}/${APPIMAGE}"
cat > dist/latest.json <<JSON
ASSET_BASE="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest"
cat > /tmp/latest.js <<'JS'
const fs = require("fs");
const [version, tag, base] = process.argv.slice(2);
const files = fs.readdirSync("dist");
const pick = (ext) => files.find((f) => f.endsWith(ext));
const entry = (f) => ({
signature: fs.readFileSync(`dist/${f}.sig`, "utf8").trim(),
url: `${base}/${f}`,
});
const deb = pick(".deb"), rpm = pick(".rpm"), appimage = pick(".AppImage");
if (!deb || !appimage) {
console.error(`missing bundle in dist/: deb=${deb} appimage=${appimage}`);
process.exit(1);
}
const platforms = {
"linux-x86_64-deb": entry(deb),
...(rpm ? { "linux-x86_64-rpm": entry(rpm) } : {}),
"linux-x86_64": entry(appimage),
};
fs.writeFileSync(
"dist/latest.json",
JSON.stringify(
{
"version": "${VERSION}",
"notes": "Parking System ${TAG}",
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"platforms": {
"linux-x86_64": {
"signature": "${SIG}",
"url": "${ASSET_URL}"
}
}
}
JSON
version,
notes: `Parking System ${tag}`,
pub_date: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
platforms,
},
null,
2,
) + "\n",
);
JS
node /tmp/latest.js "${VERSION}" "${TAG}" "${ASSET_BASE}"
echo "latest.json:"; cat dist/latest.json
- name: Create release + upload assets (Gitea API)
@@ -129,12 +207,12 @@ jobs:
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
"${API}/repos/${REPO}/releases" || true)
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -z "$REL_ID" ]; then
# Release may already exist for this tag — look it up by tag.
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
"${API}/repos/${REPO}/releases/tags/${TAG}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
fi
echo "release id: ${REL_ID}"
for f in dist/*; do
@@ -147,3 +225,82 @@ jobs:
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
done
echo "done"
- name: Mirror release to mca/public_releases (Gitea API)
# This is the release the updater and any human downloader actually use —
# public_releases has no source, only installers, so it can be public
# without exposing this repo. RELEASES_MIRROR_TOKEN is a write:repository
# token scoped for pushing releases into that repo (Gitea's org secrets,
# not exposed to any deployed client).
#
# Publishes to TWO tags there, since public_releases is shared across
# apps in the org and Gitea's "latest release" redirect resolves by
# newest tag on the WHOLE repo (would break the moment another app
# publishes something newer):
# - desktop-<TAG> versioned, permanent — audit trail / rollback.
# - desktop-latest moving — assets deleted + re-uploaded each release.
# This is the fixed URL tauri.conf.json's updater endpoint points at
# (a stable name every appliance can always resolve, regardless of
# what else gets released in this repo meanwhile).
env:
TOKEN: ${{ secrets.RELEASES_MIRROR_TOKEN }}
API: ${{ github.api_url }}
MIRROR_REPO: mca/public_releases
TAG: ${{ github.ref_name }}
run: |
set -e
create_or_get_release() {
local mirror_tag="$1" prerelease="$2"
REL=$(curl -sS -w '\n%{http_code}' -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${mirror_tag}\",\"name\":\"Parking System ${TAG}\",\"draft\":false,\"prerelease\":${prerelease}}" \
"${API}/repos/${MIRROR_REPO}/releases" || true)
echo "create response (${mirror_tag}): ${REL}"
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -z "$REL_ID" ]; then
LOOKUP=$(curl -sS -w '\n%{http_code}' -H "Authorization: token ${TOKEN}" \
"${API}/repos/${MIRROR_REPO}/releases/tags/${mirror_tag}")
echo "tag lookup response (${mirror_tag}): ${LOOKUP}"
REL_ID=$(printf '%s' "$LOOKUP" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
fi
if [ -z "$REL_ID" ]; then
echo "::error::could not create or find release for tag ${mirror_tag} on ${MIRROR_REPO} — see responses above"
exit 1
fi
}
upload_assets() {
local rel_id="$1"
for f in dist/*; do
name=$(basename "$f")
echo "mirroring ${name} -> release ${rel_id}"
HTTP_CODE=$(curl -sS -o /tmp/upload_resp.json -w '%{http_code}' -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${f}" \
"${API}/repos/${MIRROR_REPO}/releases/${rel_id}/assets?name=${name}")
if [ "$HTTP_CODE" -ge 300 ]; then
echo "::error::upload of ${name} failed (HTTP ${HTTP_CODE}): $(cat /tmp/upload_resp.json)"
exit 1
fi
done
}
# 1. Versioned, permanent.
create_or_get_release "desktop-${TAG}" false
echo "versioned mirror release id: ${REL_ID}"
upload_assets "${REL_ID}"
# 2. Moving desktop-latest — delete existing assets first (re-upload
# with the same name 409s otherwise), then re-upload.
create_or_get_release "desktop-latest" false
LATEST_REL_ID="${REL_ID}"
echo "latest mirror release id: ${LATEST_REL_ID}"
EXISTING=$(curl -sS -H "Authorization: token ${TOKEN}" \
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets")
printf '%s' "$EXISTING" | grep -o '"id":[0-9]*' | cut -d: -f2 | while read -r asset_id; do
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets/${asset_id}" >/dev/null
done || true
upload_assets "${LATEST_REL_ID}"
echo "done"
+1
View File
@@ -27,3 +27,4 @@ dist/
# Graphify knowledge-graph output (dev tool; generated, not committed)
graphify-out/
parking.sqlite*.bak-*
questions.txt
+40 -3
View File
@@ -35,8 +35,45 @@ pnpm --filter @parking/desktop bundle # build the SPA + bundle the desktop app
Requires the Rust toolchain and (on Linux) WebKitGTK 4.1 + libsoup-3 dev libraries. Under WSL2 the
window needs a display (WSLg or an X server).
## Auto-update
Signed updates are built and published by `.gitea/workflows/release.yml` on a `vX.Y.Z` tag, mirrored
to the public `mca/public_releases` repo (this repo is private; the updater runs on offline-first
field appliances with no Gitea credentials, so its endpoint must be reachable unauthenticated —
see that workflow's header and `wiki/decisions/desktop-shell-tauri.md`). The updater config and
signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never
committed.
**The manifest carries one entry per installer type** (`linux-x86_64-deb`, `linux-x86_64-rpm`,
and bare `linux-x86_64` for AppImage). The updater picks the entry matching how the running app
was installed — a `.deb` install will only ever accept a signed `.deb`. Booths run the `.deb`,
so an in-app update ends in a **polkit password prompt** (`pkexec dpkg -i`): that is expected,
and it is the right gate — the package lives in `/usr/bin`, root-owned, and the operator is not
supposed to be able to replace it silently. Cancel the prompt and the app keeps running the old
version; the failure is logged to the server's Logs viewer.
## Release gate — run the REAL bundle locally before tagging
`tauri dev` loads the SPA from `http://localhost:5173`, a plain http origin. The shipped bundle
loads it from `tauri://localhost`, a *secure* custom-scheme origin — and every desktop-only bug
found in the field on 2026-09-03/04 (relative-URL DOMException, mixed content, missing WS
`Origin`, the reqwest-vs-webview cookie split, the WS handshake that can't carry the cookie)
depends on that difference. **Dev mode cannot reproduce any of them**, so "works in `tauri dev`"
carries no information about a release. Before pushing a `vX.Y.Z` tag:
1. `pnpm --filter @parking/server dev` (local backend; `.env` must have `COOKIE_SECURE=0` and
`tauri://localhost` in `WS_ALLOWED_ORIGINS`).
2. `pnpm --filter @parking/desktop bundle` and run the produced AppImage from
`src-tauri/target/release/bundle/appimage/` (WSLg is enough).
3. On the ConnectScreen enter `127.0.0.1:3000`, **Test** must say reachable, then **Save**.
4. Log in. The booth header must show **LIVE** (not "JASHTË LINJË") within a few seconds.
5. Perform one mutation (e.g. change your UI language) — it must succeed (proves CSRF).
6. Open Setup → Logs and confirm a `frontend`-sourced row from this desktop session exists
(proves the desktop log channel; historically it was silently 403'd).
Only then tag. If a release still fails in the field, the gap is in this list — fix the list.
## Not here (deliberately)
Kiosk lockdown (fullscreen/no-decorations), auto-update, code signing, and launching Fastify from
the shell are out of scope for the scaffold — on the appliance Fastify runs as its own service and
this shell connects to it.
Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for
the scaffold — on the appliance Fastify runs as its own service and this shell connects to it.
+578 -8
View File
@@ -318,6 +318,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -346,10 +363,39 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -373,7 +419,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.0",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"libc",
@@ -386,7 +432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.0",
"core-foundation",
"core-foundation 0.10.1",
"libc",
]
@@ -399,6 +445,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -506,6 +561,18 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "dbus"
version = "0.9.11"
@@ -635,6 +702,15 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "dom_query"
version = "0.27.0"
@@ -721,6 +797,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1034,8 +1119,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -1057,8 +1144,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]]
@@ -1209,6 +1299,25 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "h2"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1298,6 +1407,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1321,6 +1431,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots 1.0.9",
]
[[package]]
@@ -1341,9 +1452,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -1744,6 +1857,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -1759,6 +1878,12 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "markup5ever"
version = "0.38.0"
@@ -2178,8 +2303,11 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-http",
"tauri-plugin-process",
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-websocket",
]
[[package]]
@@ -2330,6 +2458,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@@ -2398,6 +2535,22 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]]
name = "quick-xml"
version = "0.39.4"
@@ -2407,6 +2560,62 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -2428,6 +2637,61 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -2503,6 +2767,49 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"cookie",
"cookie_store",
"encoding_rs",
"futures-core",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 1.0.9",
]
[[package]]
name = "reqwest"
version = "0.13.4"
@@ -2616,6 +2923,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
@@ -2625,7 +2933,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
@@ -2663,6 +2971,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@@ -2745,7 +3059,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.0",
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -2885,6 +3199,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.21.0"
@@ -2948,6 +3274,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -2955,7 +3292,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -3137,6 +3474,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
@@ -3157,6 +3505,27 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -3178,7 +3547,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.13.0",
"block2",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dbus",
@@ -3268,7 +3637,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.4",
"serde",
"serde_json",
"serde_repr",
@@ -3367,6 +3736,54 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-http"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7241a0c762649be8fba7dd4cc84684d0e409f26b335a978ef4dd5fe78da74ce6"
dependencies = [
"bytes",
"cookie_store",
"data-url",
"http",
"regex",
"reqwest 0.12.28",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"tokio",
"url",
"urlpattern",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
@@ -3377,6 +3794,22 @@ dependencies = [
"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]]
name = "tauri-plugin-updater"
version = "2.10.1"
@@ -3393,7 +3826,7 @@ dependencies = [
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"reqwest 0.13.4",
"rustls",
"semver",
"serde",
@@ -3410,6 +3843,26 @@ dependencies = [
"zip",
]
[[package]]
name = "tauri-plugin-websocket"
version = "2.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca243c7f0bf935cd81123e07f82188ccb919b19fbfc74518b947eedc4619bbb"
dependencies = [
"futures-util",
"http",
"log",
"rand 0.9.5",
"rustls",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite",
]
[[package]]
name = "tauri-runtime"
version = "2.11.3"
@@ -3639,9 +4092,21 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -3652,6 +4117,22 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite",
"webpki-roots 0.26.11",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -3837,9 +4318,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"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]]
name = "tracing-core"
version = "0.1.36"
@@ -3877,6 +4370,24 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.5",
"rustls",
"rustls-pki-types",
"sha1",
"thiserror 2.0.18",
]
[[package]]
name = "typeid"
version = "1.0.3"
@@ -4141,6 +4652,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.5"
@@ -4206,6 +4727,24 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -4391,6 +4930,17 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -4820,6 +5370,26 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
+15
View File
@@ -22,6 +22,21 @@ serde_json = "1"
# Auto-update: prompt the operator, download a signed update, relaunch.
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
# HTTP client for the SPA's API/WS calls to the local Fastify server. The window
# runs at tauri://localhost, which WebKitGTK treats as a secure origin — a plain
# http://127.0.0.1:3000 fetch() from inside it is blocked as mixed content (a
# long-standing WebKit limitation, not fixable via CSP). Routing through this
# plugin sends the request via Tauri's Rust side instead of the webview's own
# fetch, sidestepping the browser mixed-content check entirely.
tauri-plugin-http = "2"
# Same mixed-content problem as above, but for the live-feed WebSocket
# (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.
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]
# Used by `tauri dev`/CLI for hot-reload of the Rust side.
@@ -6,6 +6,18 @@
"permissions": [
"core:default",
"updater:default",
"process:default"
"process:default",
"websocket:default",
"store: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": [
{ "url": "http://*" },
{ "url": "https://*" },
{ "url": "http://*:*" },
{ "url": "https://*:*" }
]
}
]
}
+14 -3
View File
@@ -3,9 +3,10 @@
// 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.
// 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
// keeps the shell a thin presentation wrapper with a deny-by-default native
// surface (see wiki/decisions/desktop-shell-tauri.md).
// the backend over HTTP to a Fastify server (address operator-configured at
// runtime, not baked in — see apps/web/src/lib/backend-config.ts), NOT through
// 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)]
pub fn run() {
@@ -16,6 +17,16 @@ pub fn run() {
// endpoint + signing pubkey live in tauri.conf.json.
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
// Routes the SPA's fetch()/WS calls to the operator-configured Fastify
// server through Tauri's native HTTP client — see the Cargo.toml
// comment on why the webview's own fetch() can't reach it directly.
.plugin(tauri_plugin_http::init())
// Live-feed WebSocket — same mixed-content reason as the HTTP plugin
// above, but WS needs its own plugin (separate browser check).
.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!())
.expect("error while running the Parking System desktop shell");
}
+4 -4
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Parking System",
"version": "0.0.0",
"version": "0.1.0",
"identifier": "com.parking.desktop",
"build": {
"devUrl": "http://localhost:5173",
@@ -24,7 +24,7 @@
}
],
"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": {
@@ -41,9 +41,9 @@
},
"plugins": {
"updater": {
"//": "Stable 'latest release' path on Gitea — redirects to the newest tag's latest.json (published by .gitea/workflows/release.yml). The updater GETs this, gets the manifest (platforms.linux-x86_64.{signature,url}), and compares versions. The release is reachable to the appliance only when it's brought online (phone hotspot); offline-first means a failed check is a no-op.",
"//": "Points at mca/public_releases, NOT this (private, source) repo — the updater runs on offline-first field appliances with no Gitea credentials, so the endpoint must be reachable unauthenticated. That repo is public and holds only compiled installers (no source), mirrored here by .gitea/workflows/release.yml. NOT the 'latest release' redirect: public_releases is shared across apps in the org, so 'latest' there could be someone else's release. This URL names our own most-recent tag directly (desktop-vX.Y.Z, bumped by the release workflow each publish) so a newer unrelated app release never shadows ours. The updater GETs this, gets the manifest (platforms.linux-x86_64.{signature,url}), and compares versions. The release is reachable to the appliance only when it's brought online (phone hotspot); offline-first means a failed check is a no-op.",
"endpoints": [
"https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json"
"https://git.infra.msai.al/mca/public_releases/releases/download/desktop-latest/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
}
+5
View File
@@ -47,6 +47,11 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
# ---- runtime: slim, non-root ----
FROM node:22-alpine AS runtime
WORKDIR /app
# Set by CI to "<branch>-<short-sha>" (e.g. "stage-28bd838"), matching the same string used
# as the Komodo Stack's TAG (komodo/resources.toml) — so the version shown in the app is the
# same string an admin would look up there. Empty/absent on a local `docker build` (dev only).
ARG BUILD_VERSION=""
ENV BUILD_VERSION=$BUILD_VERSION
ENV NODE_ENV=production
RUN apk add --no-cache libstdc++ # better-sqlite3 native runtime
RUN addgroup -S app && adduser -S -G app app
+139
View File
@@ -0,0 +1,139 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eq, siteConfig } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { BackupService } from "./backup-service.js";
// BackupService previously tracked last-success/last-error as plain in-process fields, so a
// server restart (a fresh BackupService instance, exactly as happens on every deploy/crash/OOM
// reboot under `restart: always`) silently reset the admin UI to "last successful backup:
// Never" — even with valid, correctly-rotating backups already on disk (2026-08-30 field
// incident, park-buzi). These tests exercise the fix: status is read from site_config, so a new
// BackupService instance pointed at the same DB sees the prior instance's last-run outcome, and
// the schedule is wall-clock-based (isDue()) rather than time-since-process-start.
// See wiki/concepts/backup-recovery.md.
const KEY = "a-test-backup-key-that-is-long-enough";
let workDir: string;
let target: string;
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), "pk-backup-service-test-"));
target = join(workDir, "target");
process.env.BACKUP_KEY = KEY;
});
afterEach(() => {
rmSync(workDir, { recursive: true, force: true });
delete process.env.BACKUP_KEY;
});
function setTargetDir(db: ReturnType<typeof createTestDb>["db"], dir: string): void {
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (existing) {
db.update(siteConfig).set({ backupTargetDir: dir }).where(eq(siteConfig.id, 1)).run();
} else {
db.insert(siteConfig).values({ id: 1, backupTargetDir: dir }).run();
}
}
describe("BackupService — persisted status survives a restart", () => {
it("a fresh instance sees the previous instance's last success", async () => {
const t = createTestDb();
setTargetDir(t.db, target);
const first = new BackupService(t.db);
expect(first.status().lastSuccessAt).toBeNull();
const result = await first.run("manual");
// Simulate a process restart: a brand-new BackupService over the SAME db handle (in
// production this would be a fresh process re-opening the same sqlite file).
const second = new BackupService(t.db);
const status = second.status();
expect(status.lastSuccessAt).not.toBeNull();
expect(status.lastResult).toEqual({ path: result.path, bytes: result.bytes, prunedFiles: result.prunedFiles });
expect(status.lastError).toBeNull();
t.close();
});
it("a fresh instance sees the previous instance's last error, and it clears on next success", async () => {
const t = createTestDb();
// Target dir set, but as a FILE (not a directory) — runBackup's mkdir(recursive) will
// throw, giving us a real, deterministic failure without needing to mock anything.
const badTarget = join(workDir, "not-a-dir");
writeFileSync(badTarget, "x");
setTargetDir(t.db, badTarget);
const first = new BackupService(t.db);
await expect(first.run("manual")).rejects.toThrow();
const second = new BackupService(t.db);
const status = second.status();
expect(status.lastError).not.toBeNull();
expect(status.lastErrorAt).not.toBeNull();
expect(status.lastSuccessAt).toBeNull();
// Now point at a real directory and succeed — the persisted error must clear.
setTargetDir(t.db, target);
await second.run("manual");
const third = new BackupService(t.db);
const finalStatus = third.status();
expect(finalStatus.lastSuccessAt).not.toBeNull();
expect(finalStatus.lastError).toBeNull();
expect(finalStatus.lastErrorAt).toBeNull();
t.close();
});
});
describe("BackupService — isDue() is wall-clock-based, not process-uptime-based", () => {
it("is due immediately when no success has ever been recorded", () => {
const t = createTestDb();
const svc = new BackupService(t.db);
expect(svc.isDue()).toBe(true);
t.close();
});
it("is NOT due right after a fresh instance is constructed, if a recent success is persisted", async () => {
const t = createTestDb();
setTargetDir(t.db, target);
const first = new BackupService(t.db);
await first.run("manual");
// The whole point of the fix: a brand-new instance (simulating a restart moments after a
// real backup completed) must NOT think a backup is due just because ITS OWN uptime is ~0.
const second = new BackupService(t.db);
expect(second.isDue()).toBe(false);
t.close();
});
it("is due once the persisted last-success timestamp is old enough", async () => {
const t = createTestDb();
setTargetDir(t.db, target);
const svc = new BackupService(t.db);
await svc.run("manual");
const almostADayLater = new Date(Date.now() + 23 * 60 * 60 * 1000);
expect(svc.isDue(almostADayLater)).toBe(false);
const overADayLater = new Date(Date.now() + 24 * 60 * 60 * 1000 + 1000);
expect(svc.isDue(overADayLater)).toBe(true);
t.close();
});
it("runScheduled() is a no-op when not yet due, even if configured", async () => {
const t = createTestDb();
setTargetDir(t.db, target);
const svc = new BackupService(t.db);
await svc.run("manual");
const afterFirst = svc.status().lastSuccessAt;
await svc.runScheduled(); // not due yet — must not run again
expect(svc.status().lastSuccessAt).toBe(afterFirst);
t.close();
});
});
+71 -17
View File
@@ -11,6 +11,12 @@ import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult, type BackupRete
// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
// wiki/concepts/backup-recovery.md.
//
// Last-success/last-error are PERSISTED to site_config (backup_last_*), not just held in
// memory — an earlier version tracked these as plain in-process fields only, so every server
// restart (deploy, crash, OOM, host reboot — all routine under `restart: always`) silently
// reset the admin UI to "last successful backup: Never", even with valid, correctly-rotating
// backups already on disk (2026-08-30 field incident, park-buzi). See wiki/concepts/backup-recovery.md.
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
export function backupKeyFromEnv(): string {
@@ -65,16 +71,33 @@ export class BackupService {
readonly #logger?: FastifyBaseLogger;
#running = false;
#lastSuccessAt: string | null = null;
#lastResult: BackupResult | null = null;
#lastErrorAt: string | null = null;
#lastError: string | null = null;
constructor(db: Db, logger?: FastifyBaseLogger) {
this.#db = db;
this.#logger = logger;
}
/** Fresh read of the persisted row (single source of truth — no in-memory cache to go stale
* or reset on restart). */
#row(): { backupLastSuccessAt: string | null; backupLastResultJson: string | null; backupLastErrorAt: string | null; backupLastError: string | null } | undefined {
return this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
}
#persist(patch: {
backupLastSuccessAt?: string | null;
backupLastResultJson?: string | null;
backupLastErrorAt?: string | null;
backupLastError?: string | null;
}): void {
const updatedAt = new Date().toISOString();
const existing = this.#row();
if (existing) {
this.#db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
} else {
this.#db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
}
}
/** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */
targetDir(): string | null {
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
@@ -104,6 +127,15 @@ export class BackupService {
status(): BackupStatus {
const r = this.retention();
const row = this.#row();
let lastResult: BackupStatus["lastResult"] = null;
if (row?.backupLastResultJson) {
try {
lastResult = JSON.parse(row.backupLastResultJson) as BackupStatus["lastResult"];
} catch {
lastResult = null; // corrupt/foreign value in the column — don't let it crash status()
}
}
return {
configured: this.configured,
targetDir: this.targetDir(),
@@ -111,12 +143,10 @@ export class BackupService {
keepDailyDays: r.keepDailyDays,
keyPresent: this.keyPresent,
running: this.#running,
lastSuccessAt: this.#lastSuccessAt,
lastResult: this.#lastResult
? { path: this.#lastResult.path, bytes: this.#lastResult.bytes, prunedFiles: this.#lastResult.prunedFiles }
: null,
lastErrorAt: this.#lastErrorAt,
lastError: this.#lastError,
lastSuccessAt: row?.backupLastSuccessAt ?? null,
lastResult,
lastErrorAt: row?.backupLastErrorAt ?? null,
lastError: row?.backupLastError ?? null,
};
}
@@ -139,14 +169,17 @@ export class BackupService {
try {
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
this.#lastResult = res;
this.#lastSuccessAt = new Date().toISOString();
this.#lastError = null;
this.#persist({
backupLastSuccessAt: new Date().toISOString(),
backupLastResultJson: JSON.stringify({ path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles }),
backupLastErrorAt: null,
backupLastError: null,
});
return res;
} catch (err) {
this.#lastError = (err as Error).message;
this.#lastErrorAt = new Date().toISOString();
this.#logger?.error(`backup: failed (${trigger}): ${this.#lastError}`);
const message = (err as Error).message;
this.#persist({ backupLastErrorAt: new Date().toISOString(), backupLastError: message });
this.#logger?.error(`backup: failed (${trigger}): ${message}`);
throw err;
} finally {
this.#running = false;
@@ -156,13 +189,34 @@ export class BackupService {
return this.#inflight;
}
/** Scheduled-run wrapper: never throws (a timer must not crash the process). */
/**
* Scheduled-run wrapper: never throws (a timer must not crash the process). Safe to call on
* a short, frequent poll (see server.ts) — it's a no-op unless `isDue()` says a full interval
* has actually elapsed since the last recorded success, so frequent polling doesn't cause
* frequent backups.
*/
async runScheduled(): Promise<void> {
if (!this.configured) return; // silent no-op when backups aren't set up
if (!this.isDue()) return;
try {
await this.run("scheduled");
} catch {
/* recorded in last-error; already logged */
}
}
/**
* Wall-clock check: has enough time elapsed since the last successful backup for a new one
* to be due? Deliberately based on the PERSISTED last-success instant, not "time since this
* process started" — a `setInterval(..., 24h)` measured from process start silently drifts
* (or skips a whole day) across every restart, since the countdown restarts from zero each
* time regardless of when the last real backup happened. See wiki/concepts/backup-recovery.md.
*/
isDue(now: Date = new Date(), intervalMs = 24 * 60 * 60 * 1000): boolean {
const lastSuccessAt = this.#row()?.backupLastSuccessAt;
if (!lastSuccessAt) return true; // never recorded a success → due immediately once configured
const last = new Date(lastSuccessAt).getTime();
if (Number.isNaN(last)) return true;
return now.getTime() - last >= intervalMs;
}
}
+21 -3
View File
@@ -65,7 +65,21 @@ function cleanProfileField(v: string | null | undefined): string | null | undefi
/** 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
* 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(
db: Db,
user: {
@@ -78,6 +92,7 @@ function sessionView(
fullName?: string | null;
email?: string | null;
},
csrf?: string,
) {
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
const permissions = [...permissionsFor(user.roleId)];
@@ -92,6 +107,7 @@ function sessionView(
fontScale: user.fontScale,
fullName: user.fullName ?? 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);
// `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.
return sessionView(db, user);
return sessionView(db, user, csrf);
});
app.post("/api/auth/logout", async (_req, reply) => {
@@ -146,7 +162,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
clearAuthCookies(reply);
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);
},
);
+32 -1
View File
@@ -30,7 +30,7 @@ describe("health + login", () => {
it("GET /health is open", async () => {
const res = await app.inject({ method: "GET", url: "/health" });
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 () => {
@@ -56,6 +56,37 @@ describe("auth guard — no token", () => {
});
});
describe("GET /api/version", () => {
it("without a session is 401", async () => {
const res = await app.inject({ method: "GET", url: "/api/version" });
expect(res.statusCode).toBe(401);
});
it("a site:read user gets the BUILD_VERSION env var, null when unset", async () => {
const { username, password } = await seedUser(db, {
username: "viewer2", roleId: "viewer2", permissions: ["site:read"],
});
const { cookie } = await login(app, username, password);
const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ buildVersion: null }); // no BUILD_VERSION set in the test env
});
it("reflects a real BUILD_VERSION when the env var is set", async () => {
process.env.BUILD_VERSION = "stage-abc1234";
try {
const { username, password } = await seedUser(db, {
username: "viewer3", roleId: "viewer3", permissions: ["site:read"],
});
const { cookie } = await login(app, username, password);
const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } });
expect(res.json()).toEqual({ buildVersion: "stage-abc1234" });
} finally {
delete process.env.BUILD_VERSION;
}
});
});
describe("RBAC permission gate", () => {
it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => {
const { username, password } = await seedUser(db, {
+9
View File
@@ -78,6 +78,15 @@ export async function siteRoutes(app: FastifyInstance, db: Db, eventLog?: EventL
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
// Running build version ("<branch>-<short-sha>", matching the Komodo Stack's TAG in
// komodo/resources.toml) — baked in at image build time (apps/server/Dockerfile
// BUILD_VERSION ARG), read here from the running process env. null on a local/dev
// build with no CI-supplied value. Purely informational (Setup nav display); not
// site config, so it isn't stored in site_config.
app.get("/api/version", { preHandler: readGuard }, async () => ({
buildVersion: process.env.BUILD_VERSION?.trim() || null,
}));
// Read site config (capacity + park metadata).
app.get("/api/site-config", { preHandler: readGuard }, async () => {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
+77 -6
View File
@@ -1,7 +1,8 @@
import { randomBytes } from "node:crypto";
import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared";
import { roleHasPermissions } from "../auth.js";
import { requireAuth, roleHasPermissions } from "../auth.js";
import {
deviceEvents,
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
// allowed booth UI origin). Non-browser clients (no Origin) are rejected too.
// 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 +
* device status). Any role granted `report:read` may watch. */
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
* always allowed; additional origins can be allowlisted via WS_ALLOWED_ORIGINS
@@ -80,19 +127,43 @@ export async function wsRoutes(
laneStatus: LaneStatus,
lanePresence: LanePresence,
): 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(
"/api/ws",
{
websocket: true,
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN JWT +
// role. Reject a cross/absent origin before touching the token, so a hijack
// attempt never reaches an authenticated socket. jwtVerify reads the cookie.
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN
// session (JWT cookie, or a desktop WS ticket) THEN role. Reject a
// cross/absent origin before touching either credential, so a hijack
// attempt never reaches an authenticated socket.
preHandler: async (req) => {
if (!isAllowedOrigin(req.headers.origin, req.headers.host)) {
throw Object.assign(new Error("forbidden origin"), { statusCode: 403 });
}
await req.jwtVerify();
if (!req.user || !roleHasPermissions(req.user.roleId, [WATCH_PERMISSION])) {
const rawTicket = req.headers[WS_TICKET_HEADER];
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 });
}
},
+18 -7
View File
@@ -106,7 +106,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
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.
await authRoutes(app, db);
@@ -336,12 +339,20 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
void runSnapPrune(); // once at startup
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
// Scheduled encrypted backup — daily, unref'd. A no-op (silent) until BACKUP_TARGET_DIR +
// BACKUP_KEY are configured; tolerates an unreachable/unmounted target by recording the
// error and trying again next run. NOT run once at startup (a just-booted appliance after a
// power cut shouldn't immediately write to a possibly-not-yet-mounted disk; the daily cadence
// and the manual button cover it). See wiki/concepts/backup-recovery.md.
const backupTimer = setInterval(() => void backupService.runScheduled(), 24 * 60 * 60 * 1000);
// Scheduled encrypted backup — checked every 15 min, unref'd; `runScheduled()` itself is a
// no-op unless a full 24h has actually elapsed since the last PERSISTED success (isDue(), in
// backup-service.ts), so this frequent poll does not cause frequent backups. Deliberately
// NOT a `setInterval(..., 24h)` measured from process start: that design silently reset its
// own countdown on every restart (deploy/crash/OOM/reboot, all routine under `restart:
// always`), which could push a day's backup out arbitrarily far AND — before last-success was
// persisted — made the admin UI show "Never" despite valid backups already on disk
// (2026-08-30 field incident, park-buzi). A short poll against a persisted, wall-clock
// timestamp is immune to both restart timing and to any single restart cadence. A no-op
// (silent) until BACKUP_TARGET_DIR + BACKUP_KEY are configured; tolerates an
// unreachable/unmounted target by recording the error and trying again next check. NOT run
// once at startup (a just-booted appliance after a power cut shouldn't immediately write to a
// possibly-not-yet-mounted disk). See wiki/concepts/backup-recovery.md.
const backupTimer = setInterval(() => void backupService.runScheduled(), 15 * 60 * 1000);
backupTimer.unref();
app.addHook("onClose", async () => clearInterval(backupTimer));
if (backupService.configured) {
+4
View File
@@ -18,8 +18,12 @@
"@radix-ui/react-tabs": "^1.1.15",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.16",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-http": "^2.5.2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-store": "^2.4.0",
"@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-websocket": "^2.3.0",
"i18next": "^26.3.1",
"react": "19.2.7",
"react-dom": "19.2.7",
+30
View File
@@ -3,25 +3,42 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import { fetchMe, type SessionUser } from "./api.js";
import { Login } from "./Login.js";
import { ConnectScreen } from "./ConnectScreen.js";
import { queryClient } from "./lib/query.js";
import { setLanguage } from "./lib/i18n/index.js";
import { applyTheme, applyFontScale } from "./lib/theme.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
// off to TanStack Router inside the QueryClient provider. The router renders the
// 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.
// 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() {
const [user, setUser] = useState<SessionUser | null>(null);
const [loading, setLoading] = useState(true);
const [needsConnect, setNeedsConnect] = useState(false);
useEffect(() => {
initApiBase().then((saved) => {
if (inTauri() && !saved) {
setNeedsConnect(true);
setLoading(false);
return;
}
fetchMe()
.then(setUser)
.finally(() => setLoading(false));
});
}, []);
// Apply the signed-in user's preferred language + theme + font scale whenever they
@@ -41,6 +58,19 @@ export function App() {
if (loading) {
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) {
return (
<QueryClientProvider client={queryClient}>
+116
View File
@@ -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>
);
}
+55 -11
View File
@@ -1,12 +1,21 @@
// Thin API client for the operator/admin UI.
//
// Auth is cookie-based: the JWT lives in an HttpOnly cookie the browser sends
// automatically (credentials: 'include'). For mutations we echo the readable
// CSRF cookie back in the X-CSRF-Token header (double-submit). See
// Auth is cookie-based: the JWT lives in an HttpOnly cookie sent automatically
// (credentials: 'include'). For mutations we echo the readable CSRF cookie
// back in the X-CSRF-Token header (double-submit). See
// 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 { apiUrl } 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";
const CSRF_COOKIE = "parking_csrf";
@@ -17,6 +26,13 @@ function readCookie(name: string): string | 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. */
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const method = (init.method ?? "GET").toUpperCase();
@@ -25,10 +41,10 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
headers.set("content-type", "application/json");
}
if (method !== "GET" && method !== "HEAD") {
const csrf = readCookie(CSRF_COOKIE);
const csrf = inTauri() ? getDesktopCsrfToken() : readCookie(CSRF_COOKIE);
if (csrf) headers.set(CSRF_HEADER, csrf);
}
const res = await fetch(apiUrl(path), { ...init, headers, credentials: "include" });
const res = await platformFetch(apiUrl(path), { ...init, headers, credentials: "include" });
if (!res.ok) {
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[]; [k: string]: unknown };
const error = msg.error ?? `${path}: ${res.status}`;
@@ -82,6 +98,10 @@ export interface SessionUser {
fullName: string | null;
/** Optional contact email (profile metadata); null if unset. */
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. */
@@ -89,15 +109,28 @@ export function can(user: SessionUser | null, perm: Permission): boolean {
return !!user && user.permissions.includes(perm);
}
export function login(username: string, password: string): Promise<SessionUser> {
return apiFetch<SessionUser>("/api/auth/login", {
export async function login(username: string, password: string): Promise<SessionUser> {
const user = await apiFetch<SessionUser>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
setSessionUser(user);
return user;
}
export function logout(): Promise<{ ok: boolean }> {
return apiFetch("/api/auth/logout", { method: "POST" });
export async function logout(): Promise<{ ok: boolean }> {
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). */
@@ -146,7 +179,9 @@ export function changeMyPassword(
/** Returns the current user, or null if not authenticated. */
export async function fetchMe(): Promise<SessionUser | null> {
try {
return await apiFetch<SessionUser>("/api/auth/me");
const user = await apiFetch<SessionUser>("/api/auth/me");
setSessionUser(user);
return user;
} catch (e) {
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null;
throw e;
@@ -253,6 +288,15 @@ export async function fetchBackupStatus(): Promise<BackupStatus> {
return apiFetch("/api/backup/status");
}
export interface VersionInfo {
/** "<branch>-<short-sha>" baked in at image build time; null on a local/dev build. */
buildVersion: string | null;
}
export async function fetchVersion(): Promise<VersionInfo> {
return apiFetch("/api/version");
}
export interface BackupConfigPatch {
/** "" clears the target. Omit a field to leave it unchanged; null resets retention to default. */
targetDir?: string | null;
+89
View File
@@ -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),
};
}
}
+25
View File
@@ -0,0 +1,25 @@
// Desktop-only in-memory CSRF token stash.
//
// tauri-plugin-http's fetch() runs through Rust's reqwest, which keeps its OWN
// cookie jar separate from the webview — document.cookie on tauri://localhost
// never sees the parking_csrf cookie the server sets (open upstream bug,
// tauri-apps/tauri#13045). The cookie IS still sent to the server by reqwest;
// only the client-side READ is broken. So the server echoes the same value in
// the login / me response body (sessionView's csrfToken, routes/auth.ts) and
// the desktop client keeps it here, echoing THIS in X-CSRF-Token instead of
// reading document.cookie.
//
// One module, no imports, so BOTH echo sites can share it without a cycle:
// api.ts (sets it, uses it for apiFetch mutations) and logger.ts (uses it for
// the /api/logs flush — which api.ts imports, so it can't import api.ts back).
// Never persisted: a fresh launch re-learns it via login or /api/auth/me.
let token: string | null = null;
export function setDesktopCsrfToken(value: string | null): void {
token = value;
}
export function getDesktopCsrfToken(): string | null {
return token;
}
+42 -7
View File
@@ -9,11 +9,18 @@
// 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
// 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). */
function inTauri(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
import { logClient } from "./logger.js";
import { inTauri } from "./tauri-env.js";
export interface UpdatePrompt {
/** Newer version string offered by the server. */
@@ -41,11 +48,39 @@ export async function checkForDesktopUpdate(
// Download + install the signed update (signature verified against the
// pubkey in tauri.conf.json), then relaunch into the new version.
await update.downloadAndInstall();
try {
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) {
// A real update WAS found and accepted — this is a genuine install
// failure (bad signature, corrupted download, disk/permission issue),
// not "offline". Surface it instead of silently reverting to the old
// version with no explanation.
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;
}
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
} catch {
} catch (err) {
// Offline / endpoint unreachable / no update server yet → ignore. The app
// keeps running on the current version; checking again next launch.
// keeps running on the current version; checking again next launch. Still
// log it (info, not error — this path is expected/normal far more often
// 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" },
});
}
}
+14
View File
@@ -42,6 +42,20 @@ export const en: Catalog = {
signIn: "Sign 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: {
available: "Update available",
prompt: "Version {{version}} is available. Install now and restart?",
+14
View File
@@ -45,6 +45,20 @@ export const sq = {
signIn: "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: {
available: "Përditësim i disponueshëm",
prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis?",
+14 -4
View File
@@ -14,6 +14,9 @@
// high-signal sources (failed requests, uncaught errors) are always captured.
import { LOG_LEVEL_ORDER, type ClientLogInput, type LogLevel } from "@parking/shared";
import { getDesktopCsrfToken } from "./desktop-csrf.js";
import { apiUrl, platformFetch } from "./origin.js";
import { inTauri } from "./tauri-env.js";
const ENDPOINT = "/api/logs";
const FLUSH_MS = 4000;
@@ -74,9 +77,13 @@ async function flush(): Promise<void> {
flushing = true;
try {
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;
await fetch(ENDPOINT, {
await platformFetch(apiUrl(ENDPOINT), {
method: "POST",
headers,
credentials: "include",
@@ -90,7 +97,10 @@ async function flush(): Promise<void> {
}
}
/** Best-effort synchronous flush on page hide (sendBeacon survives unload). */
/** Best-effort synchronous flush on page hide (sendBeacon survives unload). Browser
* only — sendBeacon is a native browser API with no Tauri-HTTP-plugin equivalent,
* so this drops silently in the desktop shell (unload is rare there; the regular
* 4s-interval flush above covers the common case). */
function flushBeacon(): void {
if (queue.length === 0) return;
const entries = queue.splice(0, queue.length);
@@ -99,7 +109,7 @@ function flushBeacon(): void {
// sendBeacon can't set the CSRF header; the server accepts the ingest for any
// signed-in session (cookie sent automatically). If CSRF later guards it strictly,
// this path degrades to "lost on unload" — acceptable for diagnostics.
navigator.sendBeacon(ENDPOINT, blob);
navigator.sendBeacon(apiUrl(ENDPOINT), blob);
} catch {
/* ignore */
}
+55 -6
View File
@@ -3,16 +3,49 @@
// 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
// 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
// VITE_API_BASE to the appliance's Fastify origin (e.g. http://127.0.0.1:3000)
// at build time so /api and the live WS feed resolve to the real server.
// SPA from `tauri://localhost`, which has no backend and no proxy; there the
// operator enters the appliance's Fastify origin (e.g. http://192.168.1.50:3000)
// 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
// 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
// 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;
// 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
// through Tauri's native side instead of the webview's own fetch, sidestepping
// the check entirely. Browser build never imports the plugin (dynamic import,
// same pattern as desktop-updater.ts).
/** Backend HTTP origin, no trailing slash. Empty string = same-origin/relative. */
export const API_BASE: string = (import.meta.env.VITE_API_BASE ?? "").replace(/\/$/, "");
import { inTauri } from "./tauri-env.js";
/** 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). */
export function apiUrl(path: string): string {
@@ -28,3 +61,19 @@ export function wsUrl(path: string): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}${path}`;
}
export { inTauri };
/**
* fetch(), but routed through @tauri-apps/plugin-http inside the desktop
* shell (see the file header for why the webview's own fetch can't reach
* the local backend). Same signature as the global fetch; a plain pass-
* through in the browser.
*/
export async function platformFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
if (inTauri()) {
const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
return tauriFetch(input, init);
}
return fetch(input, init);
}
+132
View File
@@ -0,0 +1,132 @@
// Desktop-only WebSocket adapter.
//
// WebKitGTK treats tauri://localhost as a SECURE origin, so a plain
// ws://127.0.0.1:3000 connection from inside it is blocked as mixed content —
// same root cause as the HTTP fetch() issue (see origin.ts's platformFetch),
// but WS is a separate browser check with its own plugin
// (@tauri-apps/plugin-websocket), which routes the connection through Tauri's
// native side instead of the webview's own WebSocket.
//
// That plugin's API is async/listener-based, not the synchronous
// onopen/onmessage/onclose event surface use-live-feed.ts is written against
// (and has already been hardened for — reconnect backoff, StrictMode
// double-invoke, cleanup). Rather than rewrite that hook around a different
// API shape, this adapter presents the same native-WebSocket-like interface
// use-live-feed.ts already expects, so that hook needs no changes at all.
//
// Browser build: plain pass-through to the real WebSocket (this file's
// 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 {
onopen: (() => void) | null;
onmessage: ((ev: { data: string }) => void) | null;
onclose: (() => void) | null;
onerror: (() => void) | null;
close(): void;
}
class NativeSocketAdapter implements PlatformSocket {
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
#sock: WebSocket;
constructor(url: string) {
this.#sock = new WebSocket(url);
this.#sock.onopen = () => this.onopen?.();
this.#sock.onmessage = (ev) => this.onmessage?.({ data: ev.data as string });
this.#sock.onclose = () => this.onclose?.();
this.#sock.onerror = () => this.onerror?.();
}
close(): void {
this.#sock.close();
}
}
class TauriSocketAdapter implements PlatformSocket {
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
#closed = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
#conn: any = null;
constructor(url: string) {
void this.#connect(url);
}
async #connect(url: string): Promise<void> {
try {
const { default: TauriWebSocket } = await import("@tauri-apps/plugin-websocket");
if (this.#closed) return; // close() called before connect resolved
// The native WS plugin is a bare tungstenite client: no page context AND
// no cookie jar. Two consequences, both handled via explicit headers:
// - Origin: nothing auto-attaches `Origin: tauri://localhost` the way a
// browser WebSocket would, and routes/ws.ts's anti-CSWSH check rejects a
// missing/mismatched Origin — so set it to match WS_ALLOWED_ORIGINS.
// - Session: the HttpOnly JWT cookie lives in tauri-plugin-http's reqwest
// 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) {
void conn.disconnect();
return;
}
this.#conn = conn;
conn.addListener((msg: { type: string; data: unknown }) => {
if (msg.type === "Text") {
this.onmessage?.({ data: msg.data as string });
} else if (msg.type === "Close") {
this.onclose?.();
}
// Binary/Ping/Pong: the server protocol here is text-JSON only (see
// routes/ws.ts) — nothing else is expected.
});
this.onopen?.();
} catch (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.onclose?.();
}
}
close(): void {
this.#closed = true;
void this.#conn?.disconnect();
}
}
/** 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. */
export function createPlatformSocket(url: string): PlatformSocket {
return inTauri() ? new TauriSocketAdapter(url) : new NativeSocketAdapter(url);
}
+6
View File
@@ -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;
}
+18 -5
View File
@@ -4,6 +4,7 @@ import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
import { qk } from "./query.js";
import { useLiveStore, type LaneStatus, type LanePresence } from "./live-store.js";
import { wsUrl } from "./origin.js";
import { createPlatformSocket, type PlatformSocket } from "./platform-ws.js";
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
// (a) live-store updates for the ticker/occupancy and (b) Query cache invalidations
@@ -23,23 +24,34 @@ type WsMessage =
| { kind: "plate-recognized"; plate: { identity: string; plate: string; direction: "entry" | "exit" } };
export function useLiveFeed(): void {
/**
* @param enabled Gate on the WATCHER permission (`report:read` — mirrors the server's
* WS guard in routes/ws.ts). A user whose role lacks it (e.g. a merchant validator
* with only `validation:create`) must not attempt the socket at all: the server
* 403s the upgrade and the capped-backoff reconnect would otherwise hammer it
* forever, filling the server log with a 403 every few seconds.
*/
export function useLiveFeed(enabled: boolean = true): void {
const qc = useQueryClient();
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } =
useLiveStore();
// Hold the socket + reconnect timer across renders; guard against StrictMode
// double-invoke and unmount.
const sockRef = useRef<WebSocket | null>(null);
const sockRef = useRef<PlatformSocket | null>(null);
const retryRef = useRef(0);
const closedRef = useRef(false);
useEffect(() => {
if (!enabled) {
setStatus("closed");
return;
}
closedRef.current = false;
const connect = () => {
if (closedRef.current) return;
setStatus(retryRef.current === 0 ? "connecting" : "connecting");
const sock = new WebSocket(wsUrl("/api/ws"));
const sock = createPlatformSocket(wsUrl("/api/ws"));
sockRef.current = sock;
sock.onopen = () => {
@@ -117,7 +129,8 @@ export function useLiveFeed(): void {
sockRef.current?.close();
sockRef.current = null;
};
// qc / store setters are stable; run once on mount.
// qc / store setters are stable; re-run only if the permission gate flips
// (login as a different role without a full reload).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [enabled]);
}
+103 -7
View File
@@ -6,7 +6,7 @@ import {
Outlet,
redirect,
} from "@tanstack/react-router";
import { lazy, Suspense, useState } from "react";
import { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
@@ -14,6 +14,7 @@ import {
can,
closeShift,
fetchShiftReport,
fetchVersion,
logout,
openShift,
setLanguagePref,
@@ -29,6 +30,7 @@ import { Spinner } from "./ui/Spinner.js";
import { setLanguage } from "./lib/i18n/index.js";
import { applyTheme, applyFontScale } from "./lib/theme.js";
import { useLiveFeed } from "./lib/use-live-feed.js";
import { inTauri } from "./lib/origin.js";
import { useShift } from "./lib/use-shift.js";
import { DeviceFooter } from "./ui/DeviceFooter.js";
import { StatusDot } from "./ui/StatusDot.js";
@@ -94,6 +96,89 @@ function SetupTab({ to, label, exact = false }: { to: string; label: string; exa
);
}
/** The running deploy's "<branch>-<short-sha>" (matches the Komodo Stack's TAG in
* komodo/resources.toml), gated the same as the "Park" tab (site:read) since it's the
* same kind of read-only app metadata. Renders nothing if the value isn't known (e.g. a
* local/dev build with no CI-supplied BUILD_VERSION) rather than showing an empty badge. */
function VersionBadge() {
const q = useQuery({ queryKey: ["version"], queryFn: fetchVersion, staleTime: Infinity });
const version = q.data?.buildVersion;
if (!version) return null;
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
* 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. */
@@ -112,6 +197,9 @@ function SetupLayout() {
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
{show("site:read") && <VersionBadge />}
<DesktopVersionBadge />
<DesktopServerButton />
</nav>
<Outlet />
</div>
@@ -448,11 +536,17 @@ function ConfirmFigure({ label, value, bold, sub }: { label: string; value: stri
function RootLayout() {
const { user, setUser } = rootRoute.useRouteContext();
const { t } = useTranslation();
// One app-wide WebSocket for the live feed (booth + any live widget).
useLiveFeed();
// Nav is gated by PERMISSION, not role — a tab shows iff the user's role grants
// the permission its screen needs (the route guards enforce the same server-side).
const show = (perm: Permission) => can(user, perm);
// One app-wide WebSocket for the live feed (booth + any live widget) — but ONLY
// for roles the server would accept (routes/ws.ts gates on report:read). A
// merchant validator must not even attempt it: the 403'd upgrade would reconnect
// on backoff forever and spam the server log. Same rule for the widgets that feed
// off it (StatusDot) or make their own gated calls (ShiftButton → shift:read,
// DeviceFooter → device:read).
const canWatch = show("report:read");
useLiveFeed(canWatch);
return (
<div className="flex h-screen flex-col bg-term-bg text-term-text">
@@ -489,11 +583,11 @@ function RootLayout() {
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
</nav>
<div className="ml-auto flex items-center gap-3">
{user && <ShiftButton />}
{user && show("shift:read") && <ShiftButton />}
{user && <LanguageToggle user={user} setUser={setUser} />}
{user && <ThemeToggle user={user} setUser={setUser} />}
{user && <FontScaleToggle user={user} setUser={setUser} />}
<StatusDot />
{canWatch && <StatusDot />}
{user && (
<Link
to="/profile"
@@ -518,8 +612,10 @@ function RootLayout() {
<main className="min-h-0 flex-1 overflow-auto p-3">
<Outlet />
</main>
{/* Fixed device-status footer — relays, readers, cameras, printers. */}
{user && <DeviceFooter />}
{/* Fixed device-status footer — relays, readers, cameras, printers. Its REST
seed needs device:read (and its live updates ride the report:read WS), so
it's hidden for roles without device visibility (e.g. merchant validators). */}
{user && show("device:read") && <DeviceFooter />}
</div>
);
}
+38 -38
View File
@@ -30,42 +30,6 @@
# new [[stack]] block per site (unique name, its own per-booth secret refs).
##############################################################################
##############################################################################
# park-lab — the LAB bench box (hardware/dev testing, no real traffic). Chases
# the dev tier: compose files from `dev`, MOVING image tag `dev` (labs may
# float; real booths pin). Secrets are its own park_lab_* refs — per-box blast
# radius, never shared with a real booth even in the lab.
##############################################################################
[[stack]]
name = "park-lab"
[stack.config]
server = "park-lab"
git_provider = "git.infra.msai.al"
git_account = "komodo"
repo = "mca/parking_solution"
branch = "dev"
file_paths = [
"docker-compose.yml",
"docker-compose.prod.yml"
]
registry_provider = "git.infra.msai.al"
registry_account = "komodo"
environment = """
REGISTRY=git.infra.msai.al/mca/parking_solution
# Lab tier: the MOVING dev tag — redeploy pulls the latest dev build. Pin to a
# dev-<sha> only when reproducing a specific state.
TAG=dev
COOKIE_SECURE=0
VISION_ENABLED=1
WS_ALLOWED_ORIGINS=
JWT_SECRET=[[park_lab_jwt_secret]]
EVENT_SIGNING_KEY=[[park_lab_event_signing_key]]
BACKUP_KEY=[[park_lab_backup_key]]
"""
##############################################################################
[[stack]]
name = "park-buzi"
[stack.config]
@@ -85,11 +49,47 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
# 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
# exists as the pointer; we deploy the sha, not the mover.
TAG=stage-22544ec
TAG=stage-8fa66c9
COOKIE_SECURE=0
VISION_ENABLED=1
WS_ALLOWED_ORIGINS=
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
# platform-ws.ts, since the native WS plugin has no page context to auto-attach
# one). Linux may also send http://tauri.localhost. See routes/ws.ts anti-CSWSH check.
WS_ALLOWED_ORIGINS=tauri://localhost,http://tauri.localhost
JWT_SECRET=[[park_buzi_jwt_secret]]
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
BACKUP_KEY=[[park_buzi_backup_key]]
"""
##############################################################################
[[stack]]
name = "park-2"
[stack.config]
server = "park-2"
git_provider = "git.infra.msai.al"
git_account = "komodo"
repo = "mca/parking_solution"
branch = "stage"
file_paths = [
"docker-compose.yml",
"docker-compose.prod.yml"
]
registry_provider = "git.infra.msai.al"
registry_account = "komodo"
environment = """
REGISTRY=git.infra.msai.al/mca/parking_solution
# 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
# exists as the pointer; we deploy the sha, not the mover.
TAG=stage-8fa66c9
COOKIE_SECURE=0
VISION_ENABLED=1
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
# platform-ws.ts, since the native WS plugin has no page context to auto-attach
# one). Linux may also send http://tauri.localhost. See routes/ws.ts anti-CSWSH check.
WS_ALLOWED_ORIGINS=tauri://localhost,http://tauri.localhost
JWT_SECRET=[[park_2_jwt_secret]]
EVENT_SIGNING_KEY=[[park_2_event_signing_key]]
BACKUP_KEY=[[park_2_backup_key]]
"""
@@ -0,0 +1,11 @@
-- Last-success/last-error for the encrypted DB backup were previously tracked only as
-- in-process fields on BackupService (never written to the DB) — so every server restart
-- (deploy/crash/OOM/host reboot, all routine under `restart: always`) silently reset the admin
-- UI's "last successful backup" to "Never", even with valid, correctly-rotating backups already
-- on disk (2026-08-30 field incident, park-buzi). Four additive, nullable columns; null = no
-- run recorded yet (or, for the error pair, no failure since the last success). See
-- wiki/concepts/backup-recovery.md.
ALTER TABLE `site_config` ADD `backup_last_success_at` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `backup_last_result_json` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `backup_last_error_at` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `backup_last_error` text;
+7
View File
@@ -176,6 +176,13 @@
"when": 1783948800000,
"tag": "0024_validation_programs",
"breakpoints": true
},
{
"idx": 25,
"version": "6",
"when": 1788078414270,
"tag": "0025_backup_last_status",
"breakpoints": true
}
]
}
+14
View File
@@ -288,6 +288,20 @@ export const siteConfig = sqliteTable("site_config", {
backupKeepLast: integer("backup_keep_last"),
/** Beyond keepLast, keep one backup per day for this many days. null ⇒ code default (30). */
backupKeepDailyDays: integer("backup_keep_daily_days"),
/** ISO timestamp of the last backup that actually completed successfully. Persisted here
* (not just in-process memory) so the admin UI's "last successful backup" survives a
* server restart — before this column existed, a restart silently reset that status to
* "Never" even with valid backups already on disk. null = no successful run recorded yet.
* See wiki/concepts/backup-recovery.md. */
backupLastSuccessAt: text("backup_last_success_at"),
/** JSON-encoded { path, bytes, prunedFiles } of the last successful run, for the same
* restart-durability reason as backupLastSuccessAt. null = none recorded yet. */
backupLastResultJson: text("backup_last_result_json"),
/** ISO timestamp of the last FAILED scheduled/manual backup attempt, persisted for the same
* reason. null = no failure recorded (or none since the last success). */
backupLastErrorAt: text("backup_last_error_at"),
/** Error message of the last failed attempt. Cleared (set null) on the next success. */
backupLastError: text("backup_last_error"),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
+33
View File
@@ -108,12 +108,24 @@ importers:
'@tanstack/react-router':
specifier: ^1.170.16
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':
specifier: ^2.5.2
version: 2.6.0
'@tauri-apps/plugin-process':
specifier: ^2.3.1
version: 2.3.1
'@tauri-apps/plugin-store':
specifier: ^2.4.0
version: 2.4.4
'@tauri-apps/plugin-updater':
specifier: ^2.10.1
version: 2.10.1
'@tauri-apps/plugin-websocket':
specifier: ^2.3.0
version: 2.4.3
i18next:
specifier: ^26.3.1
version: 26.3.1(typescript@6.0.3)
@@ -1577,12 +1589,21 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-http@2.6.0':
resolution: {integrity: sha512-QYXwbGb4hQ9/8Riv/ejU/kPFFnbBIrBcWwV1LIXv2xBKfoj8lkWfGkd9pkCSsBI/pljPtz+IPqfrE3t3bVl3mg==}
'@tauri-apps/plugin-process@2.3.1':
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':
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
'@tauri-apps/plugin-websocket@2.4.3':
resolution: {integrity: sha512-c85ykljg6AzY6Zw4KpYsEBaLirjPIs6m8xxC6hZcdwAchckaBU368US+oSsa5B43PjSLukjwD5vOOqzYYnswWA==}
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@@ -4089,14 +4110,26 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.11.3
'@tauri-apps/cli-win32-x64-msvc': 2.11.3
'@tauri-apps/plugin-http@2.6.0':
dependencies:
'@tauri-apps/api': 2.11.1
'@tauri-apps/plugin-process@2.3.1':
dependencies:
'@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':
dependencies:
'@tauri-apps/api': 2.11.1
'@tauri-apps/plugin-websocket@2.4.3':
dependencies:
'@tauri-apps/api': 2.11.1
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.7
+61 -5
View File
@@ -2,7 +2,7 @@
type: concept
tags: [parking, durability, backup, recovery, security, crypto]
sources: []
updated: 2026-06-29
updated: 2026-08-30
---
# Backup & Disaster Recovery
@@ -207,12 +207,68 @@ timer + the manual route**. What landed:
**SMB/NFS already work** — they're just a mounted path the admin enters as the target. **Deferred to
follow-up slices:** an **SFTP** target and a **restore runbook / CLI**.
## Field bug — "last successful backup: Never" despite valid, rotating backups on disk (found + fixed 2026-08-30)
**Symptom (park-buzi):** the admin noticed the backup directory held 7 real, correctly-sized,
correctly-rotating encrypted backups (`parking-backup-*.sqlite.enc`, retention working exactly as
designed) — yet the Backup screen's "Kopja e fundit e suksesshme" (last successful backup) showed
**"Asnjëherë" (Never)**. Separately, the most recent file was 2 days old rather than ~1.
**Root cause — two independent, disconnected code paths, both traced to `setInterval`-since-
process-start:**
1. **Status was never persisted.** `BackupService` tracked `lastSuccessAt`/`lastResult`/
`lastErrorAt`/`lastError` as **plain in-process private fields** — set only inside `run()`,
read only by `status()` on the *same running instance*. Nothing wrote them to `site_config` or
anywhere else durable. The actual backup-writing engine (`backup.ts`: consistent copy → encrypt
→ `pruneOldBackups`) is a completely separate code path that only touches the filesystem and
has no notion of this status object. So "7 valid files on disk" and "status says Never" were
never contradictory — they were two unrelated signals, and **any** server restart (deploy,
crash, OOM, host reboot — all routine under `restart: always` in `docker-compose.prod.yml`)
silently reset the in-memory fields to `null` regardless of what had actually happened on disk.
2. **The schedule was measured from process start, not from the last real backup.** The daily
timer was `setInterval(() => backupService.runScheduled(), 24h)` — a fixed 24h period counted
from whenever the *process* last started, not from wall-clock time or from when a backup last
actually succeeded. The exact same restart that wiped the in-memory status also reset this
countdown, which is why the cadence can silently drift or skip past a day with no error ever
surfacing anywhere.
Both symptoms are one cause: **the server process restarted after the Aug 28 backup, and nothing
about this design was built to survive that.**
### Fix (2026-08-30)
- **`packages/db/src/schema.ts`** / migration `0025_backup_last_status.sql` — four new nullable
`site_config` columns: `backup_last_success_at`, `backup_last_result_json`,
`backup_last_error_at`, `backup_last_error`. Same table, same upsert pattern as
`backup_target_dir`/`backup_keep_last`/`backup_keep_daily_days` (migrations 0016/0017).
- **`backup-service.ts`** — `run()` now writes success/error outcomes to these columns (via a
`#persist` upsert helper) instead of private fields; `status()` reads them fresh from the DB on
every call. A brand-new `BackupService` instance (i.e. a fresh process) now sees exactly what
the previous instance last recorded — no more restart amnesia.
- **New `isDue(now, intervalMs = 24h)`** method: due iff `now - backupLastSuccessAt >= 24h` (or
immediately due if no success was ever recorded), computed from the **persisted** timestamp —
never from process uptime.
- **`server.ts`** — the daily `setInterval` was replaced with a **15-minute poll** calling
`runScheduled()`, which now itself no-ops unless `isDue()` is true. This makes the actual backup
cadence immune to restart timing entirely: however often the process happens to restart, the
next backup fires within 15 minutes of 24h having genuinely elapsed since the last real success
— not 24h after whatever moment the process most recently came back up.
- Covered by a new `backup-service.test.ts`: a fresh `BackupService` over the same DB handle
(simulating a restart) sees the prior instance's last success/error and its cleared-on-success
behavior; `isDue()` is exercised directly against injected timestamps rather than real sleeps.
No change to the `BackupStatus` shape returned by `GET /api/backup/status` or to
`BackupSettings.tsx` — this was purely a durability fix underneath the same contract.
## Status
Design settled 2026-06-29; **engine + admin-configured local/mounted target + admin UI BUILT
2026-06-29** (SFTP + restore tooling pending). The target directory is **admin-chosen in the UI**
(`site_config`, migration 0016), not an env var — the on-site admin picks where backups land; only
`BACKUP_KEY` stays a server secret. Resolves the *design* half of [[open-questions]] #5 and the first
build slices; records the key-custody stance that bears on #6 (signing stays decoupled from the TPM) and
#10 (snapshots bloat backups → future exclude toggle). See [[append-only-event-chain]],
[[disk-os-hardening]], [[tpm]], [[fleet-deployment-komodo]], [[reconciliation]].
`BACKUP_KEY` stays a server secret. **Last-success/last-error status + the scheduling cadence are
now restart-durable (migration 0025, 2026-08-30)** — see field bug above. Resolves the *design*
half of [[open-questions]] #5 and the first build slices; records the key-custody stance that bears
on #6 (signing stays decoupled from the TPM) and #10 (snapshots bloat backups → future exclude
toggle). See [[append-only-event-chain]], [[disk-os-hardening]], [[tpm]], [[fleet-deployment-komodo]],
[[reconciliation]].
+66 -1
View File
@@ -2,7 +2,7 @@
type: concept
tags: [parking, device, printer, transport, usb, escpos, provisioning]
sources: []
updated: 2026-07-06
updated: 2026-08-30
status: settled
---
@@ -140,5 +140,70 @@ hint. The transport option label no longer hardcodes lp0.
> the monitor would mark a perfectly working printer offline/degraded. Over USB the two drivers
> behave identically (reachability floor), so either works post-fix. See [[rongta-printer]].
## Field bug — cover-open re-enumeration wedges the container's `/dev/usb` view; only `docker restart`, not a host reboot, clears it (investigated 2026-08-30, unconfirmed root cause)
**Symptom (park-buzi, unknown/"Generic" USB printer, model not yet identified — see below):** every
time the booth operator opens the printer's paper-roll cover to reload paper, the printer's status
goes `offline`/faulty in the app and **never self-recovers** — not after the cover closes, not after
a full appliance reboot. The only fix found so far is SSH in and `docker restart server`.
**Ruled out at the application layer.** Traced `sendRawUsb`/`probeUsb` in `printer-escpos.ts`: every
print AND every poll tick (`device-monitor.ts` 8s / `printer-monitor.ts` 5s) does a fresh
`open()` → write/probe → `close()` against the configured `devicePath`. **No fd, socket, or driver
instance is held across calls** — `driver.create(config)` is a throwaway object with no persistent
handle. So a naive "stale Node file descriptor" explanation does not fit this codebase; the
app-layer retry-by-fresh-open-every-poll should self-heal within one poll cycle if the kernel's view
of the device node is current.
**Leading hypothesis: the container's bind-mount of `/dev/usb`, not the Node process, holds the
stale state.** Docker Compose wires the printer in as a **directory bind-mount**
(`docker-compose.prod.yml`, `volumes: - /dev/usb:/dev/usb`), chosen deliberately (per its own
comment) so the app survives the printer renumbering to a different `lpN`. But many USB thermal
printers cut power to their own USB interface board when the cover-open microswitch trips (a
hardware safety/power feature, not just a status flag) — the printer drops off the bus and
re-enumerates, potentially as a new device node, when the cover closes. The **host** kernel picks
this up fine; the **container's mount namespace**, once established, is a known Docker/OverlayFS
sharp edge for `/dev` subtree bind-mounts — it can keep resolving the old node until the mount
itself is redone.
- `docker restart server` recreates the container's mount namespace → the `/dev/usb` bind-mount is
redone against current host state → the new node is picked up → fixed.
- A full host reboot restarts the container too (`restart: always`), but as a boot-time race: if the
container starts before the USB subsystem finishes settling, or the printer re-enumerated some
time *before* the reboot and Docker doesn't necessarily redo an already-satisfied bind-mount
target on a policy-driven restart, the container can come back up still bound to the pre-incident
view. This matches the exact reported asymmetry (reboot doesn't fix it; explicit restart does).
**Not yet confirmed on hardware** — this is the leading theory, not a verified root cause. To
confirm at the next occurrence, BEFORE restarting anything:
```bash
# host:
ls -la /dev/usb/ && stat /dev/usb/lp1
# container:
docker exec server ls -la /dev/usb/ && docker exec server stat /dev/usb/lp1
```
A major:minor or inode mismatch between host and container is the smoking gun. Also worth
capturing on the lab RONGTA (different printer, but same cover-open mechanism is plausible):
`watch -n1 lsusb` + `sudo dmesg -w | grep -i -E 'usb|disconnect'` while cycling the cover, to see
whether the Bus/Device number changes.
**Candidate fixes, not yet implemented** (ranked cheapest-to-most-invasive):
1. A host-side watchdog/udev rule that detects re-enumeration of this printer (match vendor:product
ID) and runs `docker restart server` automatically — turns the manual SSH fix into a self-healing
one without touching app code.
2. Same idea but event-driven via a udev rule or systemd path unit watching `/dev/usb`, rather than
polling.
3. Switch the compose device wiring from the directory bind-mount to a specific `--device=` cgroup
passthrough + a udev rule pinning a stable symlink name — reintroduces the renumbering fragility
the directory bind-mount was chosen to avoid, so only worth doing alongside (1)/(2), not instead.
**Open sub-question — printer identity.** The park-buzi unit shows as "Generic (unknown)" in the
app; not yet identified by vendor/product ID. Lab reproduction uses a **RONGTA** unit instead (not
the same hardware), so the lab cannot currently reproduce the park-buzi symptom directly — only
validate the general re-enumeration mechanism. Commands to identify the real park-buzi printer next
time it's reachable via SSH: `lsusb`, `udevadm info -q property -n /dev/usb/lp1`, `udevadm info -a
-n /dev/usb/lp1`. This mirrors the same discovery gap already noted above under "Device discovery"
(sysfs `ieee1284_id` enrichment) — once identified, fold the model into that mechanism's coverage.
Related: [[rongta-printer]], [[printer-status-monitoring]], [[printer-roles-failover]],
[[appliance-provisioning]], [[network-isolation]], [[technology-stack]].
+5 -1
View File
@@ -100,7 +100,11 @@ priced at booth check-in, inside the normal walk-back-grace flow).
both; mode/params/caps/receipt-label/bound-users). `/validate` (`ValidateScreen.tsx`) is the
merchant's whole surface (scan/key → apply → void own unused), mobile-friendly, autofocused
input works with HID scanners; merchant-only users (no `session:read`) land there on login and
the permission-gated nav shows them nothing else. Booth pay modal shows gross → lines → net;
the permission-gated nav shows them nothing else. The app SHELL also degrades by permission
(2026-07-13 follow-up): the live-feed WebSocket connects only with `report:read` (the server's
WS guard — a merchant's socket would 403 and the capped-backoff reconnect would spam the server
log forever), and the StatusDot / ShiftButton / DeviceFooter widgets render only with their
backing permissions (`report:read` / `shift:read` / `device:read`). Booth pay modal shows gross → lines → net;
the zero-net comp settles through the normal pay path (grace starts, voucher/exit unchanged).
Feed label `VALIDIM`/`VALIDATION`. RolesManager picks the new resource up generically.
- **Verified**: 8 route-level integration tests (guards, signed events, money cycle, void locks,
+57 -15
View File
@@ -2,7 +2,7 @@
type: reference
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
sources: []
updated: 2026-07-06
updated: 2026-09-02
status: settled
---
@@ -282,24 +282,38 @@ sudo loginctl enable-linger admin # so the user service starts at boot witho
```
- `--connect-as` is the **Server name in Core** — unique, stable, site-meaningful (the fleet's
primary key). Booth #2 = a different name (e.g. `park-durres`); never reuse one.
primary key). Booth #2 = a different name (e.g. `park-durres`); never reuse one. **Get this
right in the command itself** — it's a plain field in `periphery.config.toml` on the host, so a
typo/placeholder here needs a config edit + agent restart to fix, NOT a rename in Core's UI
(which only relabels Core's record, not the agent's real identity — gotcha #12 below).
- `--core-address` is Core's **reverse-proxy URL** (the URL you load the Core UI at over the mesh),
NOT `:9120` — Core's container port `9120` is exposed-not-published; the agent reaches it through
the proxy. (Gotcha #7 below.)
- Config lands at `~/.config/komodo/periphery.config.toml`. The key field is **`core_address`**
(singular); `root_directory` must be a path `admin` can write. **⚠ VERIFY THIS after install —
Periphery v2.2.0's installer writes `root_directory = "/etc/komodo"` even with `--user`**
(bit the lab box 2026-07-07: panic `Failed to write private key pem to "/etc/komodo/keys/
periphery.key" … Permission denied`, crash-loop until systemd gives up). Fix + restart:
```bash
sed -i 's|^root_directory = .*|root_directory = "'"$HOME"'/.komodo"|' ~/.config/komodo/periphery.config.toml
systemctl --user reset-failed periphery && systemctl --user restart periphery
```
NB `sudo systemctl restart periphery` says *unit not found* — it's a USER unit; always
`systemctl --user …`. The onboarding key survives a pre-connect crash (unused until first dial).
(singular).
Verify: `systemctl --user status periphery` → active; the server **`park-buzi`** appears and goes
**OK/green** in Core → Servers. Then **delete the onboarding key**.
> ⚠ **ALWAYS CHECK THIS — every install so far has hit it (lab box 2026-07-07, booth `park-2`
> 2026-09-02).** `root_directory` must be a path `admin` can write, but **Periphery's installer
> writes `root_directory = "/etc/komodo"` even with `--user`** (still true as of v2.3.3). Result:
> panic `Failed to write private key pem to "/etc/komodo/keys/periphery.key" … Permission denied`,
> crash-loop until systemd gives up (`Start request repeated too quickly`).
>
> **Fix + restart:**
> ```bash
> sed -i 's|^root_directory = .*|root_directory = "'"$HOME"'/.komodo"|' ~/.config/komodo/periphery.config.toml
> systemctl --user reset-failed periphery && systemctl --user restart periphery
> ```
> NB `sudo systemctl restart periphery` says *unit not found* — it's a USER unit; always
> `systemctl --user …`. The onboarding key survives a pre-connect crash (unused until first dial).
>
> **➜ Do not stop here once it's green.** This fix only gets Periphery *running* — the Stack still
> isn't deployed. Immediately continue to **verify below, then §7b**.
**Verify:** `systemctl --user status periphery` → active; the server **`park-buzi`** appears and
goes **OK/green** in Core → Servers. Then **delete the onboarding key**.
**➜ Next step is §7b below — the Stack itself is not deployed yet.** A green Server in Core just
means the agent connected; it runs nothing until you add the Registry/Git accounts and deploy.
### 7b. Deploy the Stack (in Core — by hand once, then code)
@@ -482,8 +496,36 @@ works; the desktop app is a separate workstream.
separate Komodo credentials. A blank registry account on the Stack → anonymous pull →
`no basic auth credentials`. Set the Stack's **Registry Account** (`komodo`).
9. **User-mode Periphery + `/etc/komodo` `root_directory` = `Permission denied`** writing the agent
key. User-mode (runs as `admin`, no root daemon) must keep `root_directory` under `$HOME`.
key. User-mode (runs as `admin`, no root daemon) must keep `root_directory` under `$HOME`. Hit
on every install so far (lab box 2026-07-07, booth `park-2` 2026-09-02, still on v2.3.3) —
**check this first** whenever a fresh Periphery install crash-loops; see the boxed callout in
§7a for the fix. Easy to fix-and-move-on without realizing the Stack still isn't deployed —
§7a's fix only starts the agent, §7b deploys the Stack.
10. The config key is **`core_address`** (singular). And `--core-address` derives `wss://` from
`https://` — if Core were plain-HTTP you'd need `http://` (→ `ws://`).
11. ResourceSync **Execute disabled + file shown clean in Info = empty diff = already in sync**
(success). Execute only enables when the file and Core diverge (e.g. you edit `TAG`).
12. **Renaming a Server in Core's UI does NOT change the agent's actual identity.**
`connect_as` is a plain field persisted in the agent's own
`~/.config/komodo/periphery.config.toml` — Core's UI rename only relabels Core's *record*,
the agent keeps re-announcing under its original `connect_as` on every reconnect. Symptom (hit
2026-08-30, lab box): a server named via a leftover template placeholder in the install
command kept reappearing in Core no matter how many times it was renamed there, while the
intended name sat permanently NOT OK (nothing was ever checking in as that name). **Fix: edit
`connect_as` directly in `periphery.config.toml` on the host, then `systemctl --user restart
periphery`** — no reinstall/re-onboarding needed. Delete the stray old-name Server record in
Core afterward. Lesson: always double-check `--connect-as` is a REAL name (never leave a
template placeholder like `<new-server-name>` in a copy-pasted install command) — Core will
happily create a server with that literal string.
13. **Upgrading an already-installed Periphery is: re-run the same installer, unchanged
`--connect-as`.** No separate update mechanism, no update-only flag. The installer script
explicitly skips rewriting `periphery.config.toml` if one already exists ("Config already
exists, skipping...") — it only stops the service, replaces the binary, and restarts — so a
re-run is **config-preserving** and a fresh/dummy `--onboarding-key` value on that re-run is
simply unused (confirmed against Komodo's own `setup-periphery.py` source, 2026-08-30; no
Periphery-specific breaking changes between v2.2.0 and v2.3.2 per Komodo's release notes).
Verified end-to-end on `art-docker-station` (lab, dry run) then `park-buzi` (live booth,
2026-08-30): same command as §7a step 2, same `--connect-as`, app containers untouched
throughout (Periphery restarting itself never touches the already-running compose stack).
**Always dry-run a version bump on a lab/dev box before a live booth**, even with a clean
release-notes check — this project only had one lab box to test against and used it first.
+270 -13
View File
@@ -2,7 +2,7 @@
type: decision
tags: [parking, decisions, desktop, frontend]
sources: []
updated: 2026-06-21
updated: 2026-09-04
status: settled
---
@@ -140,20 +140,80 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
- **Right-click:** the context menu is blocked in **prod only** (`apps/web/src/lib/kiosk.ts`,
guarded on `import.meta.env.PROD`); dev keeps right-click + devtools. Applies to both the browser
prod build and the desktop build (same SPA).
- **`VITE_API_BASE` wired to the environment:** `apps/web/.env.production` (committed, non-secret,
allow-listed in `.gitignore`) sets `VITE_API_BASE=http://127.0.0.1:3000`, auto-loaded by
`vite build` (which the desktop bundle runs). So the desktop build targets Fastify with no manual
export; the browser-served-by-Fastify build should override to `""`.
- **Mixed content blocks http(s)/ws(s) from the webview — fixed 2026-09-03.** Even with
`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
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**. (Nearest upstream ticket:
[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
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
fetch/WebSocket, which sidesteps the check entirely:
- **`tauri-plugin-http`** — `apps/web/src/lib/origin.ts`'s `platformFetch()` dynamically imports
`@tauri-apps/plugin-http`'s `fetch` (a genuine drop-in for the standard Fetch API) inside Tauri,
plain `fetch` in the browser. `api.ts` and `logger.ts` both call `platformFetch` instead of the
global `fetch` now.
- **`tauri-plugin-websocket`** — NOT a drop-in (async `connect()`/listener-callback API, not
`onopen`/`onmessage`/sync `send`/`close`). `apps/web/src/lib/platform-ws.ts` adapts it behind
the same native-`WebSocket`-shaped interface `use-live-feed.ts` already expects (hardened for
reconnect backoff + StrictMode double-invoke), so that hook needed zero changes.
- Capability grants: `apps/desktop/src-tauri/capabilities/default.json` adds `websocket:default`
and a scoped `http:default` (`allow: [{url: "http://127.0.0.1:3000"}, {url:
"http://localhost:3000"}]`) — deny-by-default, matching the CSP's existing allowlist.
- `logger.ts`'s `flushBeacon()` (page-hide `navigator.sendBeacon`) is a native browser API with no
Tauri equivalent — it still drops silently in the desktop shell on unload. Accepted: the regular
4s-interval flush (now fixed, routes through `platformFetch`) covers the common case.
- **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
webview page — there's no page context to auto-attach `Origin: tauri://localhost` the way a real
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
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
passes `{ headers: { Origin: "tauri://localhost" } }` explicitly; separately, `komodo/
resources.toml`'s booth Stacks had `WS_ALLOWED_ORIGINS=` **empty** in production (despite
`.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
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 — 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):**
`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
`96fd97e` (2026-06-27), but that same change silently broke the **desktop** build, which was never
given its own override. Result: the desktop shell's `apiUrl()` returned a bare relative path
(`/api/auth/login`) to `fetch()` from a page loaded at `tauri://localhost` — WebKitGTK has no base
to resolve a relative URL against from a non-`http(s)` origin, and threw `DOMException: "The
string did not match the expected pattern."` on the first authenticated request (login). Login
worked fine in the browser (same-origin, no absolute URL needed) the whole time, which is what
made this easy to miss. **Fix:** `tauri.conf.json`'s `build.beforeBuildCommand` now sets
`VITE_API_BASE=http://127.0.0.1:3000` inline (`VITE_API_BASE=http://127.0.0.1:3000 pnpm --filter
@parking/web build`) — process env vars override `.env.production` in Vite's load order, so this
overrides the shared file for the desktop build only, without touching it (the browser/booth build
still gets the empty value, unaffected). Verified: rebuilding with the override bakes
`127.0.0.1:3000` into the bundle; rebuilding without it stays clean/relative.
- **Auto-update (prompt-on-update, self-hosted):** `tauri-plugin-updater` + `tauri-plugin-process`.
On launch the SPA checks the endpoint (`apps/web/src/lib/desktop-updater.ts`, no-op in browser /
offline), prompts the operator (i18n `update.prompt`), then `downloadAndInstall()` + `relaunch()`.
Accepts that the appliance may be **offline** day-to-day and brought online (phone hotspot) only
when an update is wanted — consistent with [[offline-first]] (no network dependency in *core*
operation; updates are out-of-band). Endpoint is the **self-hosted Gitea** "latest release"
path — `https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json`
— which redirects to the newest tag's `latest.json` (published by `.gitea/workflows/release.yml`).
The updater GETs it (200 + manifest, or 204 = up-to-date), reads `platforms.linux-x86_64.
{signature,url}`, and downloads the signed installer. **WS origin:** the desktop window's origin
operation; updates are out-of-band). **WS origin:** the desktop window's origin
is `tauri://localhost` (Linux may also send `http://tauri.localhost`), so the backend's
`WS_ALLOWED_ORIGINS` must include both or the live feed won't connect (documented in
`apps/server/.env.example`).
@@ -165,8 +225,27 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
produced `.deb`/`.rpm`/`.AppImage` **plus their `.sig` updater signatures**; full `turbo run build
lint` 14/14 green. *(This is the **updater** signing — distinct from OS-installer signing for
Windows/macOS "unknown publisher", and from the [[atecc608]]/[[tpm]] **event** signing.)*
- **Still deferred:** the actual update-hosting URL, OS-level installer signing
(Windows/macOS publisher trust), and the Windows kiosk-browser fallback path.
- **Update-hosting endpoint (found broken, fixed 2026-09-03):** the endpoint originally pointed at
the **source repo's own** Gitea "latest release" redirect
(`.../mca/parking_solution/releases/latest/download/latest.json`) — but `mca/parking_solution` is
**private**, and the updater runs on offline-first field appliances with **no Gitea credentials**.
Every deployed update check was silently failing (swallowed by a `try/catch` in
`desktop-updater.ts`) — this was never field-verified, and it couldn't have worked as configured.
**Fix:** signed installers are now mirrored to a separate **public**, releases-only repo,
`mca/public_releases` (shared across apps in the org — see [[fleet-deployment-komodo]] sibling
infra), holding **only compiled installers, no source**. `tauri.conf.json`'s endpoint now points
there at a fixed `desktop-latest` tag (NOT that repo's generic "latest release" redirect, since
other apps publishing there would shadow ours — see the `desktop-latest` vs `desktop-<TAG>`
split below). `.gitea/workflows/release.yml` pushes to both repos: the private source repo (own
record) and the public mirror (what the updater and any human downloader actually use).
**Rejected alternative:** embedding a `read:repository` Gitea token in `tauri.conf.json`'s
updater `headers` so it could read the private repo directly — ruled out because that token would
ship inside every installed binary in the field, and this appliance's own threat model names the
**booth operator as the primary adversary** (see root `CLAUDE.md`); a leaked token scoped to the
whole private repo, with no cheap way to rotate it across appliances already in the field, was
judged worse than publishing installers-only.
- **Still deferred:** OS-level installer signing (Windows/macOS publisher trust) and the Windows
kiosk-browser fallback path.
### Desktop in CI — two workflows, two purposes (added 2026-06-24)
@@ -174,7 +253,15 @@ The desktop bundle now runs in CI under **two distinct workflows** — keep the
- **`.gitea/workflows/release.yml`** (tag `v*`) — the **signed, versioned release**: builds
`.deb`/`.rpm`/`.AppImage` **+ their `.sig`** (updater key from secrets), assembles `latest.json`,
and publishes a Gitea Release. This is what the auto-updater consumes. Unchanged.
and publishes a Gitea Release **on `mca/parking_solution` (source, own record) AND mirrors it to
`mca/public_releases`** (public, installers-only — see the update-hosting-endpoint entry above for
why). The mirror step uses a second token, `RELEASES_MIRROR_TOKEN`
(`write:repository`, scoped for pushing into `public_releases` only — a CI-side secret, never
shipped to any client, distinct from the embedded updater *pubkey*). It publishes two tags there:
`desktop-<TAG>` (versioned, permanent, for audit/rollback) and `desktop-latest` (moving — existing
assets deleted then re-uploaded each release, since Gitea has no per-app "latest" concept and this
repo is shared across apps). `latest.json`'s asset URL and `tauri.conf.json`'s updater endpoint
both point at `desktop-latest`. This is what the auto-updater actually consumes.
- **`.gitea/workflows/build-desktop.yml`** (push to `dev`/`main`) — a **per-commit test build**:
compiles `.deb` + `.AppImage` only (`pnpm --filter @parking/desktop bundle --bundles deb,appimage`)
and publishes them to a **rolling per-branch pre-release** (tag `desktop-<branch>`). **Unsigned** —
@@ -198,3 +285,173 @@ The desktop bundle now runs in CI under **two distinct workflows** — keep the
The unsigned CI build therefore overrides it off with
`--config '{"bundle":{"createUpdaterArtifacts":false}}'` (a JSON patch merged over the config),
so no `.sig` is attempted and no key is required. `release.yml` keeps the config default (signs).
- **Gotcha (tag ≠ tauri.conf.json version — found + fixed 2026-09-03, v0.1.1).** The git tag
(`v0.1.1`) and `tauri.conf.json`'s own `"version"` field are two independent values with nothing
syncing them. Tauri bakes `"version"` into the bundle filename, the app's internal version, AND
what the updater compares against — NOT the git tag. Bumping only the tag (as the release
procedure implied) left the file at the prior `0.1.0`: the signed binary was built and named as
`0.1.0`, `latest.json` (built from `TAG`) correctly claimed `0.1.1`, and the updater found an
"update," downloaded it, then failed signature verification against a manifest that didn't
actually describe the file it pointed at. Compounded by a second bug (below) that made this
failure completely invisible to the operator. **Fix:** `release.yml` now has a "Sync
tauri.conf.json version to the git tag" step (`sed`-patches `"version"` from `TAG` right before
`tauri build`) — the checked-in value is now only a placeholder for local dev builds; every real
release derives its version from the tag automatically.
- **Gotcha (silent updater failure — found + fixed 2026-09-03).** `desktop-updater.ts`'s
`checkForDesktopUpdate` wrapped the ENTIRE check-download-install-relaunch sequence in one
`catch {}` that swallowed everything, by design, for the offline/no-server case. But that meant
a REAL failure after the operator already accepted the prompt (bad signature, corrupted
download, disk/permission error) failed exactly the same way as "endpoint unreachable" — no
error, no log, the app just silently reverted to the old version and re-showed the same "update
available" prompt on next launch, forever. This is what actually surfaced the tag-sync bug
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),
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. **Field-verified 2026-09-04:** v0.1.6 on the park-2 booth against
image `stage-8fa66c9` shows **LIVE** — the first desktop build to do so.
- **Desktop client logs had never reached `app_logs`.** `logger.ts`'s flush read the CSRF token
from `document.cookie` (null on desktop — the same jar split as above), so every
`POST /api/logs` from the desktop 403'd under `requireAuth`→`assertCsrf`, and the flush drops
failures by design (loop safety). Consequences: the 2026-09-03 "route update-failure logging
through logClient" fix wrote to a dead channel, and the v0.1.5 CSRF fix patched `api.ts` but not
`logger.ts`. **Fix:** the stash moved to a dependency-free `lib/desktop-csrf.ts` (so `logger.ts`
can read it without importing `api.ts`, which imports `logger.ts`), and the flush uses it when
`inTauri()`. `platform-ws.ts`'s connect failure now goes through `logClient` too (rate-limited
to one row/min — reconnects are every ≤10 s), instead of `console.error`, which only forwards at
debug/trace.
- **ConnectScreen probe now hits `/health`.** The v0.1.5 probe hit an auth-guarded route and
treated 401/403 as "ours" — any password-protected service on the LAN would have passed it, and
the comment claiming no unauthenticated route existed was wrong (`/health` has been there all
along). `/health` now also returns `app: "parking-system"`, and the probe requires both a 2xx
and that value.
- **Why every one of these was found in the field:** `tauri dev` loads `http://localhost:5173`,
not `tauri://localhost`, so the relative-URL error, mixed content, the missing Origin, and the
cookie-jar split *cannot* reproduce in dev mode. The pre-tag gate is now: build the bundle
locally, run the AppImage against a local server, log in, confirm **LIVE**, do one mutation,
and confirm a desktop-sourced row appears in the Logs viewer (`apps/desktop/README.md`).
### In-app update never worked: the manifest only described the AppImage, the booths run the .deb (2026-09-04)
Every self-update attempt from v0.1.0 through v0.1.6 ended the same way — prompt, download
traffic, then nothing, and the same prompt again next launch. The version-sync (v0.1.2) and
error-logging fixes were real but not the cause. **Root cause:** `tauri-plugin-updater` resolves
the download target as `{os}-{arch}-{installer}` **first** (`linux-x86_64-deb` here — the
bundler stamps `__TAURI_BUNDLE_TYPE_VAR_DEB` into the `.deb`'s binary, verified with `strings`
on a local build), then falls back to bare `{os}-{arch}`. `release.yml`'s `latest.json` carried
**only** `linux-x86_64`, pointing at the **AppImage**. So a `.deb` install found the update,
downloaded the AppImage, verified its signature (which was correct — for the AppImage), then
handed the bytes to `install_deb()`, whose first line checks `infer::archive::is_deb(bytes)` and
returns `InvalidUpdaterFormat`. Before v0.1.6 that error never reached the server (the desktop
log channel was itself broken — see the previous section), so it looked like a silent no-op.
Sources: `tauri-plugin-updater-2.10.1/src/updater.rs` (`get_urls`, `install_inner`,
`install_deb`), `tauri-utils/src/platform.rs` (`bundle_type`).
- **Fix:** `latest.json` now carries one signed entry per installer — `linux-x86_64-deb`,
`linux-x86_64-rpm` (when built), and bare `linux-x86_64` for the AppImage — assembled by a
small Node script in the workflow (the `.sig` files for `.deb`/`.rpm` were already being
produced and uploaded, just never referenced).
- **What a booth update now looks like:** prompt → download → **polkit password dialog**
(`pkexec dpkg -i`) → relaunch into the new version. The prompt is deliberate, not a wart: the
package is root-owned in `/usr/bin`, and under the [[threat-model]] the operator must not be
able to replace the app silently; whoever brings the box online for an update is the admin.
Cancelling the dialog leaves the old version running and logs
`desktop_update_install_failed` to `app_logs`.
- **Rejected:** switching booths to the AppImage so updates need no privilege. It would work
(the updater rewrites the AppImage in place), but the binary would then be operator-writable,
it needs FUSE on the appliance image, and launcher/autostart integration becomes manual —
three regressions to avoid one password prompt.
- **Judgment note for the retrospective:** three fixes were shipped against this symptom
without reading the updater's install path once. The whole chain is ~60 lines of vendored
Rust in `~/.cargo/registry`; it names the exact failure (`InvalidUpdaterFormat`).
+30 -1
View File
@@ -2,7 +2,7 @@
type: decision
tags: [parking, deployment, fleet, komodo, netbird, offline-first, threat-model]
sources: []
updated: 2026-07-07
updated: 2026-09-03
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
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).
## 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.
+108 -2
View File
@@ -1,8 +1,8 @@
---
type: entity
tags: [parking, hardware, readers, offline-first]
sources: [parking-system-architecture]
updated: 2026-07-07
sources: [parking-system-architecture, DS-2CD1047G3H-LIU]
updated: 2026-08-23
---
# LPR Camera
@@ -110,6 +110,112 @@ Covered by `packages/devices/src/drivers/camera.test.ts` (retry behaviour + the
selection). `healthCheck()` deliberately reports a live 503 as `degraded` (it surfaces a genuinely
saturated main stream rather than hiding it behind a retry).
### Main-stream ISAPI snapshot 503 is model-specific, not config — and RTSP routes around it (2026-08-23)
Live comparison, same site (park-buzi), same day, both cameras reachable via the site's port
forwards (`park-buzi.msai.al:8081` / `:8082`) and directly on the LAN (`10.0.10.13` = the `:8082`
unit):
| | `:8081` (works) | `:8082` = `10.0.10.13` (503s) |
|---|---|---|
| Model | **DS-2CD1043G2-LIU** | **DS-2CD1047G3H-LIU** |
| Firmware | V5.8.10 | V5.8.11 |
| Channel 101 config | 2560×1440, VBR, 6144 Kbps cap, 20fps | **identical** — 2560×1440, VBR, 6144 Kbps cap, 20fps |
| `SmartCodec` | disabled | disabled |
| `GET /ISAPI/Streaming/channels/101/picture` | **200**, valid JPEG | **503**, `statusCode 2 / deviceBusy` (3/3 retries, instant) |
| `GET /ISAPI/Streaming/channels/102/picture` (sub) | — | **200**, valid JPEG |
Channel-101 config is **byte-identical** between the two units (bitrate, resolution, frame rate,
SmartCodec) — this rules out "misconfigured over some ceiling" definitively; the only things that
differ are model + firmware. Combined with the [[#Source: HIKVISION DS-2CD1047G3H-LIU-F datasheet|
vendor datasheet]] fact that the G3H's **main stream has no MJPEG option** (sub-stream does), the
working theory is that this SKU's snapshot codepath has to transcode a live H.264/H.265 frame into
JPEG on demand for main, and its firmware/encoder can't do that reliably at this resolution —
while sub can serve JPEG more natively. **Treat this as a `DS-2CD1047G3H-LIU`-model limitation
(this firmware line), not a config or ISAPI-usage bug** — matches every earlier finding on this
same unit (`10.0.10.13`) in the sections below, now cross-confirmed against a working sibling model
on the same network with identical settings.
**RTSP main-stream frame-grab works and routes around it entirely**, confirmed live against
`10.0.10.13`:
```bash
ffmpeg -rtsp_transport tcp -y \
-i "rtsp://admin:<pw>@10.0.10.13:554/Streaming/Channels/101" \
-frames:v 1 -update 1 snapshot.jpg
```
Returned a valid 2560×1440 JPEG (94 KB) on the first try — same camera, same main-stream
resolution the ISAPI endpoint 503s on. This makes sense mechanically: RTSP just taps the H.264
stream the encoder is **already producing continuously** for live-view/recording; there's no
on-demand "pause and re-encode as standalone JPEG" step for the firmware to choke on, unlike the
ISAPI snapshot path. Port 554 is open on the LAN (`10.0.10.13`) but **not** forwarded through the
site's public port-forward (`park-buzi.msai.al` only exposes the HTTP/ISAPI ports, consistent with
[[network-isolation|the camera/controller network staying LAN-only]] — RTSP was only reachable
from inside the site network, never tested through the public forward).
**Not yet built**: `packages/devices/src/drivers/camera.ts` is HTTP-Digest/ISAPI only today: no
RTSP client, no `ffmpeg` child-process dependency. Adding an RTSP fallback (or RTSP-first path for
cameras that report persistent `deviceBusy` on ISAPI main) is a real architectural addition — new
process-spawn dependency, RTSP auth handling, transport selection (TCP confirmed working; UDP
untested) — not implemented as of this writing.
**Escalated from "nice to have" to a real requirement (2026-08-23): the sub-stream (768×432) is
too weak for reliable plate reads** — it fails to read plates "from time to time" in practice, so
sub-stream-only is not an acceptable permanent mitigation for this camera; RTSP-for-main is needed
for ANPR accuracy, not just for a higher-res evidence photo.
**Firmware update tested and RULED OUT as the fix (2026-08-23).** Before building the RTSP path,
checked whether this was simply a day-one bug: the camera shipped on `V5.8.11` build 250415 —
confirmed via the official Hikvision release note to be **the very first H13U firmware build that
added support for the DS-2CD1XX7G3H-LIU family at all** ("Newly add 1 series 4MP fixed-focus
cameras: DS-2CD1XX7G3H-LIU"), a plausible day-one-bug candidate. Upgraded live to **`V5.11.0` build
260701** (over a year of firmware progress, incl. an intermediate `V5.8.21_SP1` release explicitly
noting "Fix network and image potential bugs to enhance device stability"). **Result: NO CHANGE.**
Post-upgrade, `channels/101/picture` still returns `HTTP 503 / statusCode 2 / deviceBusy`, 5/5
consecutive attempts, byte-identical error body to pre-upgrade. Sub-stream (`102`) still healthy
(200, ~15KB) — camera is fine post-upgrade, just this one limitation persists.
**Superseded finding, below: "durable hardware/encoder ceiling" was the wrong framing.** At this
point in the investigation it looked like a real capacity limit (reproduced across ~15 months of
firmware). The channel-sweep test below shows that's not what's actually happening.
### The real cause: a broken/incomplete ISAPI snapshot handler, not "busy" (2026-08-23)
`deviceBusy` never meant "busy." Swept every channel/stream ID against the snapshot endpoint in
one sitting, including IDs that don't exist on this camera at all:
| channel | `GET .../channels/<id>/picture` |
|---|---|
| 1 | 503 `deviceBusy` |
| 100 | 503 `deviceBusy` |
| **101** (real main) | **503 `deviceBusy`** |
| **102** (real sub) | **200 OK** |
| 103 | 503 `deviceBusy` |
| 201 (channel 2 doesn't exist — single-channel camera) | 503 `deviceBusy` |
| 999 (garbage) | 503 `deviceBusy` |
**Every ID fails identically except exactly `102`.** A genuinely busy/saturated encoder would not
succeed on one specific value and fail the same way on nonexistent channel IDs — a real resource
contention error would 404 or behave differently on garbage input, not return the identical `Device
Busy` XML body regardless of whether the target exists. This is the signature of a **generic
fallback error path**: the firmware's snapshot handler appears to only be correctly wired for
`102` (the one channel/stream combination Hikvision evidently tested for this SKU) and returns a
stock, misleading `deviceBusy` for every other case — valid main-stream `101` included. It is a
**firmware bug that mislabels itself as resource contention**, not a real capacity ceiling — which
also fits the firmware-upgrade non-result above (a wrong-code-path bug doesn't get fixed by
"more capacity," so no firmware version fixing it would be surprising).
**Decision (2026-08-23): replace this camera line rather than build around it.** RTSP main-stream
capture is proven to work (see above) and could still be built as a `camera.ts` addition, but given
the ISAPI snapshot path is flatly broken for anything but one hardcoded channel value, and the
site's actual need (reliable plate reads — sub-stream alone isn't accurate enough) requires
full-resolution captures, the owner chose to swap out the `DS-2CD1047G3H-LIU` units rather than
carry a `ffmpeg`/RTSP dependency to route around a vendor firmware bug. The working `DS-2CD1043G2-
LIU` (`:8081` in the comparison above) has no such issue — ISAPI main-stream snapshot works
natively — and is the reference model for replacements. RTSP-frame-grab remains documented above
as a viable fallback if a `G3H`-family camera is ever unavoidable.
## Clock sync — the 1970 power-cut reset (built 2026-07-07)
Field observation (park-buzi): after a power cut these cameras come back with their clock at the
+5 -4
View File
@@ -19,6 +19,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
- [[dingtian-dt008]] — Dingtian DT-008 product page: QR/RFID access reader (QR/barcode + ID/IC/NFC; Wiegand/TCP-IP/USB/RS485; HTTP-GET push).
- [[qrcode-sdk]] — QRCode SDK v1.6.5: the reader's HTTP-GET-poll protocol + JSON verdict (beep/output).
- [[parksql2017-legacy-schema]] — predecessor SQL Server schema (Albanian market): legacy tariff/discount/membership/shift/fiscal model; confirms blocks, adds time-windows + categories, lacks postpaid sponsors.
- [[ds-2cd1047g3h-liu]] — HIKVISION DS-2CD1047G3H-LIU-F datasheet: 4MP, main stream has no MJPEG option (sub does) — likely explains the model's persistent ISAPI snapshot 503 on main; confirms ISAPI/RTSP both fully in-spec.
## Entities — technology stack
- [[technology-stack]] — the full stack table; all MIT/Apache/BSD, chosen to avoid lock-in.
@@ -39,7 +40,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
- [[esp32-custom-controller]] — prevention-grade upgrade; device-level auth.
- [[atecc608]] — secure element; non-extractable signing key (host events + controller auth).
- [[wiegand]] — reader standard feeding the controller directly (autonomous permit-holder path).
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source.
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source; DS-2CD1047G3H-LIU main-stream ISAPI snapshot 503 is a firmware bug (only channel 102 ever works, ALL other IDs incl. garbage 503 identically) — firmware update tested, no fix; owner decided to replace the camera line rather than build an RTSP workaround.
- [[dingtian-dt008-reader]] — Dingtian DT-008 QR/RFID reader on hand; host-side serial → `read` bus (the QR-ticket scanner).
- [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued.
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware); spare relays drive aux outputs (`setAux`).
@@ -57,7 +58,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
- [[hardware-signer-options]] — where the ledger signing key should live (TPM interim → USB-HSM target; ATECC608 upcoming, not on-site) so a host-owner can't forge the chain.
- [[reconciliation]] — the real anti-fraud control; what remote sync actually is.
- [[disk-os-hardening]] — the *why* of host hardening: LUKS FDE + TPM-sealed auto-unlock (PCR 7) + Secure Boot + GRUB edit-lock + unprivileged operator + firmware/dbx lockdown; secondary control (reconciliation is the main event). Commands → [[appliance-provisioning]].
- [[backup-recovery]] — admin-driven encrypted full-DB backup (local/SMB/SFTP) + DR; signing key escrowed & decoupled from TPM so the ledger survives total hardware loss; restore is admin-only.
- [[backup-recovery]] — admin-driven encrypted full-DB backup (local/SMB/SFTP) + DR; signing key escrowed & decoupled from TPM so the ledger survives total hardware loss; restore is admin-only; last-success/error status + schedule are now restart-durable (migration 0025, fixed a "shows Never despite valid backups" bug).
## Concepts — device architecture & safety
- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
@@ -68,7 +69,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
- [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth.
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
- [[printer-usb-transport]] — ESC/POS drivers drive TCP (9100) OR local USB (/dev/usb/lp0) behind one render layer; USB = usblp char device, reachability-only status; provisioning open (oq#14).
- [[printer-usb-transport]] — ESC/POS drivers drive TCP (9100) OR local USB (/dev/usb/lp0) behind one render layer; USB = usblp char device, reachability-only status; provisioning open (oq#14); park-buzi cover-open-wedges-USB-status bug (docker restart-only fix) under investigation.
- [[device-status-monitoring]] — unified live status across ALL device categories (healthCheck + printer readStatus) → the booth footer over /api/ws.
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
@@ -133,7 +134,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
- [[vision-service]] — build a host-side ANPR + vehicle-verification service; replaces edge-LPR; scoped AGPL exception.
- [[vision-service-packaging]] — the vision service lives in this monorepo (apps/vision/), separate process, wired into Turbo via a package.json shim; uv-managed Python.
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell.
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell. Auto-updater mirrors signed releases to public `mca/public_releases` (source repo is private — field appliances have no Gitea creds).
- [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot.
- [[fleet-deployment-komodo]] — fleet control plane: Komodo Periphery on each booth, driven by Komodo Core over a NetBird mesh, running the same compose files. Deploys manual + pinned to dev-<sha> (no webhook); secrets Komodo-managed per-booth+unique; booth.sh demoted to break-glass. Threat-model caveats: Periphery is a root agent (mesh-bound only), EVENT_SIGNING_KEY-in-Core is a fraud-root blast radius until ATECC608 signs. komodo/ is infra-as-code.
- [[appliance-provisioning]] — booth-PC provisioning runbook (Dell 7070, i5-8500, discrete Nuvoton TPM): BIOS/Secure-Boot → direct-flash Ubuntu 26.04 USB (not Ventoy) → passphrase-LUKS install → manual PCR-7 TPM seal (workaround for the installer's dbt PCR_UNUSABLE error) → Docker. Verified on hardware 2026-06-23; TPM auto-unlock works.
+254
View File
@@ -2619,3 +2619,257 @@ scanning (blocked on secure-context TLS for LAN phones + weak Code128-via-camera
QR-on-ticket first) and a Tauri v2 Android merchant app (native ML Kit scanning via the official
barcode-scanner plugin; deferred over Android build/distribution overhead + the
configurable-server-URL prerequisite). Full analysis on [[validation-discounts]].
## [2026-08-23] ingest | HIKVISION DS-2CD1047G3H-LIU-F datasheet
Vendor datasheet dropped in `raw/DS-2CD1047G3H-LIU.md`. Key new fact: main stream on this model
supports H.265+/H.265/H.264+/H.264 only — **no MJPEG**; sub-stream adds MJPEG. Likely mechanical
explanation for the persistent ISAPI main-stream snapshot 503 (`deviceBusy`) already logged on this
model in [[lpr-camera]] (2026-06-26/27): the on-demand JPEG snapshot has no native path on main,
so it has to transcode from H.264/H.265 live, which this SKU's firmware apparently can't do
reliably at 2560×1440. Bitrate spec (32Kbps–16Mbps) also confirms the site's main-stream config
(6144–12288Kbps) was never out of range — rules out misconfiguration definitively.
## [2026-08-23] query | Main-stream 503: model-specific or config? RTSP as a workaround?
Live-compared two Hikvision units at park-buzi via their public port forwards
(`park-buzi.msai.al:8081`/`:8082`) plus the `:8082` unit directly on the LAN (`10.0.10.13`).
Channel-101 (main) config is **byte-identical** between a working `DS-2CD1043G2-LIU` (8081, 200 OK)
and the failing `DS-2CD1047G3H-LIU` (8082 / `10.0.10.13`, persistent 503 `deviceBusy`, 3/3 retries
instant) — same resolution/bitrate/framerate/SmartCodec state. Rules out config as the cause;
confirms it's model/firmware-specific (matches the datasheet finding above). Then tested RTSP
(`rtsp://…@10.0.10.13:554/Streaming/Channels/101` via ffmpeg, TCP transport) against the SAME
failing camera: returned a valid 2560×1440 JPEG on the first try. RTSP taps the continuously-
running encode rather than asking for an on-demand re-encoded JPEG, so it sidesteps whatever the
ISAPI snapshot path chokes on. Not yet built into `camera.ts` (would add an `ffmpeg` child-process
dependency + RTSP auth/transport handling) — filed as a viable, proven fallback if full-resolution
main-stream stills are ever needed; sub-stream ISAPI snapshot remains sufficient for current ANPR
use. Full comparison table + RTSP command on [[lpr-camera]].
## [2026-08-23] update | Firmware update tested and ruled out; sub-stream confirmed too weak for ANPR — RTSP is now required
Two developments on the DS-2CD1047G3H-LIU (`10.0.10.13`) main-stream 503: (1) the owner reports the
sub-stream (768×432) **fails to read plates "from time to time"** in real use — sub-stream-only is
no longer an acceptable mitigation, it's an accuracy problem. (2) Before building RTSP, tested
whether this was a day-one firmware bug: the camera's original `V5.8.11`/250415 build was confirmed
(via Hikvision's own release note) to be the FIRST H13U firmware to support this camera family at
all. Upgraded live to `V5.11.0`/260701 (~15 months newer, spanning an intermediate release that
explicitly claimed "image stability" fixes). Result: **no change** — identical `deviceBusy` 503,
5/5 attempts, post-upgrade. Firmware is now a ruled-out cause, not a theory; this looks like a real
encoder/hardware ceiling on this SKU. Next step: build the RTSP-based main-stream capture path into
`packages/devices/src/drivers/camera.ts` (not yet started). Full detail on [[lpr-camera]].
## [2026-08-23] update | Root cause nailed down: broken ISAPI handler, not "busy" — decision to REPLACE the camera line
Final test on the DS-2CD1047G3H-LIU snapshot 503: swept every channel/stream ID against
`GET .../channels/<id>/picture`, including nonexistent ones (1, 100, 103, 201, 999). Every single
ID returns the identical `503 deviceBusy` body EXCEPT exactly `102` (the real sub-stream), which is
always 200. A real busy/saturated encoder would not succeed on one specific value while failing
garbage IDs identically — this is a generic fallback error: the firmware's snapshot handler is only
correctly wired for channel 102, and everything else (valid main-stream 101 included) falls through
to a stock, mislabeled "Device Busy" response. Confirms the firmware-upgrade non-result from
earlier today (a wrong-code-path bug wouldn't be fixed by more capacity). Owner's decision: replace
the DS-2CD1047G3H-LIU units rather than carry an RTSP/ffmpeg workaround dependency — the sibling
DS-2CD1043G2-LIU (no such bug, ISAPI main-stream snapshot works natively) is the reference model
going forward. RTSP main-stream capture remains documented as a proven, viable fallback if a G3H
camera is ever unavoidable, but is not being built. Full sweep table + reasoning on [[lpr-camera]].
## [2026-08-30] update | Booth USB printer cover-open bug: leading theory is a stale container bind-mount, not a stale app-layer handle
Live troubleshooting request (park-buzi): opening the printer's paper-roll cover reliably wedges its
status to offline/faulty, surviving a full appliance reboot; only `docker restart server` clears it.
Traced `sendRawUsb`/`probeUsb` end-to-end in `printer-escpos.ts` plus both poll loops
(`device-monitor.ts`, `printer-monitor.ts`): every print AND every poll does a fresh
open→write/probe→close with no persistent fd/socket/driver instance anywhere — ruling out a naive
"stale Node handle" explanation. Leading hypothesis instead: the cover-open microswitch cuts power
to the printer's USB interface board, causing a real bus re-enumeration; the container's directory
bind-mount of `/dev/usb` (chosen specifically to survive `lpN` renumbering) can retain a stale view
of the old device node until the container's mount namespace is recreated — which `docker restart`
does and a policy-driven reboot-time restart may not (boot-order race). Not yet confirmed on
hardware (host-vs-container `stat`/inode comparison at the next occurrence is the next step); lab
repro is blocked because the lab has a RONGTA, not the park-buzi unit's actual (still unidentified,
"Generic (unknown)") model. Full writeup, confirmation commands, and candidate fixes on
[[printer-usb-transport]].
## [2026-08-30] update | Backup status "Never" despite valid rotating backups — restart amnesia in BackupService, fixed
Admin noticed park-buzi's Backup screen showed "last successful backup: Never" despite 7 real,
correctly-rotating encrypted backup files on disk, plus a 2-day gap since the last file. Traced
both symptoms to the same cause: `BackupService` tracked last-success/last-error as PLAIN
IN-PROCESS FIELDS (never written to the DB), and the daily schedule was a `setInterval(...,24h)`
measured from PROCESS START, not wall-clock time since the last real backup — so any server
restart (routine under `restart: always`: deploy/crash/OOM/host reboot) simultaneously wiped the
visible status back to "Never" and reset the 24h countdown, independent of the actual
file-writing/retention engine (`backup.ts`), which was working correctly the whole time and
explains why files existed on disk despite the UI's contradictory-seeming status. Fix: four new
nullable `site_config` columns (migration `0025_backup_last_status.sql`) persist last-success/
error there instead of in memory; `BackupService.status()` reads them fresh each call so a new
instance (= a restart) sees the prior instance's outcome; a new `isDue()` method computes
schedule-due-ness from the persisted last-success timestamp; `server.ts`'s scheduler is now a
15-minute poll gated by `isDue()` instead of a 24h `setInterval`, making the real cadence immune
to restart timing. New test file `backup-service.test.ts` (6 tests) covers restart-durability and
`isDue()` directly; full existing suite (319 tests) still green. No API/UI contract change. Not
yet committed (holding per instruction). Full writeup on [[backup-recovery]].
## [2026-08-30] update | Two Komodo Periphery gotchas: connect_as renaming, agent upgrade procedure
Two real incidents this session, both closed out as new gotchas (#12, #13) on
[[appliance-provisioning]] §7: (1) a lab box installed with a leftover template placeholder
left in `--connect-as` kept reappearing under that name in Core no matter how many times it was
renamed in the UI — because `connect_as` is a plain field in the agent's own
`periphery.config.toml`, and a Core-UI rename never touches it; fixed by editing the field
directly on the host + `systemctl --user restart periphery`, no reinstall needed. (2) Upgrading
Periphery from a version-mismatch (Core bumped to v2.3.2, an agent still on v2.2.0) has no
separate update mechanism — confirmed against Komodo's own `setup-periphery.py` source that
re-running the same installer with unchanged `--connect-as` is config-preserving (it explicitly
skips rewriting an existing config) and safe; verified dry-run on `art-docker-station` (lab) then
applied to `park-buzi` (live booth) with no disruption to the running app containers. Full detail
+ exact commands on [[appliance-provisioning]].
## [2026-09-03] fix | Desktop updater endpoint was unreachable — pointed at a private repo
The Tauri auto-updater ([[desktop-shell-tauri]]) was fully implemented — signed builds, keypair,
`latest.json`, `release.yml` — but its endpoint pointed at `mca/parking_solution`'s own Gitea
"latest release" redirect, and that repo is **private**. Field appliances have no Gitea
credentials, so every update check was silently failing (caught by a `try/catch`); this was never
actually field-verified end to end. Fix: signed installers now mirror to a new public,
installers-only repo `mca/public_releases` (org-shared, not parking-specific), published to a fixed
`desktop-latest` tag so other apps releasing there later can't shadow ours. Considered and rejected
embedding a `read:repository` token in the app instead — ruled out given the appliance's own threat
model (booth operator as primary adversary) makes an extractable, hard-to-rotate credential in every
deployed binary worse than just publishing installers publicly. `release.yml`,
`apps/desktop/src-tauri/tauri.conf.json`, `apps/desktop/README.md` updated; full detail on
[[desktop-shell-tauri]].
## [2026-09-03] fix | Desktop login broken by a VITE_API_BASE regression from the booth same-origin fix
The 2026-06-27 booth fix (commit 96fd97e) correctly blanked `apps/web/.env.production`'s
`VITE_API_BASE` for the browser/booth same-origin case, but the desktop build shares that same
file and was never given its own override — the desktop shell has been building with an empty
API base since that commit, unnoticed until now. Symptom: login threw `DOMException: "The string
did not match the expected pattern."` — WebKitGTK rejecting a relative `fetch()` URL with no base
to resolve against, since the desktop window's origin is `tauri://localhost`. Browser login was
unaffected (same-origin, no absolute URL needed), which is why this went unnoticed through the CI
mirror-repo debugging session. Fixed by setting `VITE_API_BASE=http://127.0.0.1:3000` inline in
`tauri.conf.json`'s `beforeBuildCommand`, overriding the shared `.env.production` for the desktop
build only (process env wins in Vite's load order) — verified both builds independently. Full
detail on [[desktop-shell-tauri]].
## [2026-09-03] fix | Desktop updater silently failed: tag/version drift + swallowed install errors
Two compounding bugs, both closed out on [[desktop-shell-tauri]] §"Desktop in CI": (1) the v0.1.1
release bumped only the git tag — tauri.conf.json's own "version" field (what Tauri actually bakes
into the bundle filename and internal version) stayed at 0.1.0, so the signed binary didn't match
what latest.json claimed to describe, and signature verification failed on every download; (2)
desktop-updater.ts's single blanket try/catch swallowed that failure identically to "offline/no
update," so the operator saw the prompt, watched it download, then nothing — repeating forever with
zero diagnostic trail. Fixed release.yml to sed-patch tauri.conf.json's version from the git tag
right before building (checked-in value is now dev-only, never hand-maintained for releases), and
split desktop-updater.ts's catch so a real post-accept failure logs instead of vanishing. Full
detail on [[desktop-shell-tauri]].
## [2026-09-03] fix | Desktop login "Load failed": WebKit mixed-content, not CORS/CSP
After fixing VITE_API_BASE, login still failed with WebKit's generic "Load failed" — a raw browser
fetch() rejection with no server-side trace, since the request never reached the network. Root
cause: WebKitGTK treats tauri://localhost as a secure origin, so http://127.0.0.1:3000 (and
ws://127.0.0.1:3000) from inside it is blocked as mixed content — a known WebKit limitation, NOT
fixable via CSP connect-src. Fixed by routing both through Tauri plugins that use the native (Rust)
HTTP/WS client instead of the webview's own: tauri-plugin-http (a genuine fetch() drop-in, wired
into api.ts/logger.ts via a new platformFetch() in origin.ts) and tauri-plugin-websocket (NOT a
drop-in — async/listener API — adapted behind a native-WebSocket-shaped interface in the new
platform-ws.ts so use-live-feed.ts needed no changes). Full detail on [[desktop-shell-tauri]].
## [2026-09-03] fix | Desktop live feed offline: native WS plugin sends no Origin, prod allowlist was empty
Login worked after the mixed-content fix, but the live feed showed offline in the desktop app while
the browser showed LIVE, same server. tauri-plugin-websocket's connect() runs on Tauri's Rust side,
not inside the webview page, so it never auto-attaches an Origin header — routes/ws.ts's anti-CSWSH
check treats a missing Origin as untrusted and 403s before auth. Compounded by a second, independent
gap: komodo/resources.toml's booth Stacks had WS_ALLOWED_ORIGINS= empty in production, despite
.env.example documenting tauri://localhost as required for the desktop app. Fixed both: platform-ws.ts
now passes Origin: tauri://localhost explicitly in connect()'s headers; resources.toml's two Stacks
get the real allowlist. Needs a Komodo sync + redeploy to reach a live booth, not just a git push.
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
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]].
## [2026-09-04] fix | Desktop in-app update never worked: latest.json described only the AppImage, booths run the .deb
tauri-plugin-updater looks up `{os}-{arch}-{installer}` first (linux-x86_64-deb — the bundler
stamps the installer type into the binary; verified with strings on a local .deb) and only then
bare linux-x86_64. release.yml's latest.json carried only the bare key → the AppImage, so every
.deb install downloaded the AppImage, passed signature verification, then failed install_deb()'s
is_deb check with InvalidUpdaterFormat — invisible until v0.1.6 fixed the desktop log channel.
This, not version drift or swallowed errors, is why v0.1.0→…→v0.1.6 never self-updated.
latest.json now has one signed entry per installer (deb, rpm, AppImage); a .deb update ends in a
polkit password prompt (pkexec dpkg -i), which is the intended admin gate on a root-installed
package. README + [[desktop-shell-tauri]] updated. First real test: tag v0.1.7 and accept the
prompt on the v0.1.6 booth.
+50
View File
@@ -0,0 +1,50 @@
#### HIKVISION DS-2CD1047G3H-LIU-F 4 MP ColorVu 3.0 Fixed Bullet Network Camera
![HIKVISION-DS-2CD1047G3H-LIU-F-4-MP-ColorVu-3.0-Fixed-Bullet-Network-Camera-PRODUCT](https://manuals.plus/wp-content/uploads/2025/07/HIKVISION-DS-2CD1047G3H-LIU-F-4-MP-ColorVu-3.0-Fixed-Bullet-Network-Camera-PRODUCT.png)
#### FEATURE
- HikAI-ISP for excellent noise reduction effect
- Super clear 24/7 colorful imaging with ColorVu 3.0 technology
- Motion Detection 3.0, more accurate on person and vehicle classification
- Strobe Light & Audio Alarm to deter intruders (optional)
- Two-Way Audio via camera and Hik-Connect
- Smart Hybrid Light: integrates IR and white lights, 3 supplemental lighting modes
- On-board storage with SD card up to 512 GB (optional)
- Water and dust resistant (IP67)![HIKVISION-DS-2CD1047G3H-LIU-F-4-MP-ColorVu-3.0-Fixed-Bullet-Network-Camera-FIG-1](https://manuals.plus/wp-content/uploads/2025/07/HIKVISION-DS-2CD1047G3H-LIU-F-4-MP-ColorVu-3.0-Fixed-Bullet-Network-Camera-FIG-1.png)
#### Specification
<table><tbody><tr><td colspan="2" width="681">n <strong>Specification</strong></td></tr><tr><td colspan="2" width="681"><strong>Camera</strong></td></tr><tr><td width="194">Max. Resolution</td><td width="487">2560 × 1440</td></tr><tr><td width="194">Min. Illumination</td><td width="487">Color: 0.0001 Lux @ (F1.0, AGC ON)</td></tr><tr><td width="194">Shutter Time</td><td width="487">1 s to 1/100,000 s</td></tr><tr><td width="194">Day & Night</td><td width="487">IR cut filter</td></tr><tr><td width="194">Angle Adjustment</td><td width="487">Pan: 0° to 360°,tilt: 0° to 90°,rotate: 0° to 360°</td></tr><tr><td colspan="2" width="681"><strong>Lens</strong></td></tr><tr><td width="194">Lens Type</td><td width="487">Fixed focal lens, 2.8 and 4 mm optional</td></tr><tr><td width="194">Focal Length & FOV</td><td width="487">2.8 mm, horizontal FOV 104°, vertical FOV 54.4°, diagonal FOV 126.7°<p>4 mm, horizontal FOV 89.3°, vertical FOV 48.2°, diagonal FOV 106.5°</p></td></tr><tr><td width="194">Lens Mount</td><td width="487">M16</td></tr><tr><td width="194">Iris Type</td><td width="487">Fixed</td></tr><tr><td width="194">Aperture</td><td width="487">F1.0</td></tr><tr><td width="194">Depth of Field</td><td width="487">2.8 mm: 2.5 m to ∞<p>4 mm: 2.8 m to ∞</p></td></tr><tr><td colspan="2" width="681"><strong>DORI</strong></td></tr><tr><td width="194">DORI</td><td width="487">2.8 mm, D: 61 m, O: 24 m, R: 12 m, I: 6 m<p>4 mm, D: 68 m, O: 27 m, R: 13 m, I: 6 m</p></td></tr><tr><td colspan="2" width="681"><strong>Illuminator</strong></td></tr><tr><td width="194">Supplement Light Type</td><td width="487">IR,White Light</td></tr><tr><td width="194">Supplement Light Range</td><td width="487">IR: up to 30 m<p>White Light: up to 20 m</p></td></tr><tr><td width="194">Smart Supplement Light</td><td width="487">Yes</td></tr><tr><td width="194">IR Wavelength</td><td width="487">850 nm</td></tr><tr><td colspan="2" width="681"><strong>Video</strong></td></tr><tr><td width="194"><strong>&nbsp;</strong><p><strong>&nbsp;</strong></p><p>Main Stream</p></td><td width="487">50 Hz:<p>25 fps (2560 × 1440, 1920 × 1080, 1280 × 720)</p><p>60 Hz:</p><p>24 fps (2560 × 1440, 1920 × 1080, 1280 × 720)</p></td></tr><tr><td width="194">Sub-Stream</td><td width="487">50 Hz: 25 fps (768 × 432, 640 × 360)<p>60 Hz: 24 fps (768 × 432, 640 × 360)</p></td></tr><tr><td width="194">Video Compression</td><td width="487">Main stream: H.265+/H.265/H.264+/H.264,<p>Sub-stream: H.265/H.264/MJPEG</p></td></tr><tr><td width="194">Video Bit Rate</td><td width="487">32 Kbps to 16 Mbps</td></tr><tr><td width="194">H.264 Type</td><td width="487">Baseline Profile,Main Profile,High Profile</td></tr><tr><td width="194">H.265 Type</td><td width="487">Main Profile</td></tr><tr><td width="194">Bit Rate Control</td><td width="487">CBR,VBR</td></tr><tr><td width="194">Scalable Video Coding (SVC)</td><td width="487">H.264 and H.265 encoding</td></tr><tr><td width="194">Region of Interest (ROI)</td><td width="487">1 fixed region for main stream</td></tr><tr><td colspan="2" width="681"><strong>Audio</strong></td></tr><tr><td width="194">Audio Type</td><td width="487">Mono sound</td></tr><tr><td width="194">Audio Compression</td><td width="487">G.711/G.722.1/G.726/MP2L2/PCM/MP3/AAC-LC</td></tr><tr><td width="194">Audio Bit Rate</td><td width="487">64 Kbps (G.711)/16 Kbps (G.722.1)/16 Kbps (G.726)/32 to 160 Kbps (MP2L2)/16 to 64<p>Kbps (AAC-LC)</p></td></tr><tr><td width="194">Audio Sampling Rate</td><td width="487">8 kHz/16 kHz</td></tr></tbody></table>
<table><tbody><tr><td width="199">Environment Noise Filtering</td><td width="482">Yes</td></tr><tr><td colspan="2" width="681"><strong>Network</strong></td></tr><tr><td width="199">Protocols</td><td width="482">TCP/IP, ICMP, DHCP, DNS, HTTP, RTP, RTSP, RTCP, NTP, IPv4, IPv6, IGMP, UDP, QoS,<p>FTP, SMTP</p></td></tr><tr><td width="199">Simultaneous Live View</td><td width="482">Up to 6 channels</td></tr><tr><td width="199">API</td><td width="482">ONVIF (Profile S, Profile G),ISAPI,SDK</td></tr><tr><td width="199">User/Host</td><td width="482">Up to 32 users<p>3 user levels: administrator, operator, and user</p></td></tr><tr><td width="199"><strong>&nbsp;</strong><p>Security</p></td><td width="482">Password protection, complicated password, watermark, basic and digest<p>authentication for HTTP, WSSE and digest authentication for Open Network Video</p><p>Interface, security audit log, host authentication (MAC address)</p></td></tr><tr><td width="199">Client</td><td width="482">iVMS-4200,Hik-Connect</td></tr><tr><td width="199">Web Browser</td><td width="482">Plug-in required live view: Chrome 80+, Firefox 80+, Edge 89+, Safari 13+,<p>Plug-in free live view: Chrome 80+, Firefox 80+, Edge 89+</p></td></tr><tr><td colspan="2" width="681"><strong>Image</strong></td></tr><tr><td width="199">Image Settings</td><td width="482">Rotate&nbsp; mode,saturation,brightness,contrast,sharpness,gain,white balance,adjustable<p>by client software or web browser</p></td></tr><tr><td width="199">Day/Night Switch</td><td width="482">Day,Night,Auto,Schedule</td></tr><tr><td width="199">Wide Dynamic Range (WDR)</td><td width="482">120 dB</td></tr><tr><td width="199">SNR</td><td width="482">≥ 52 dB</td></tr><tr><td width="199">Image Enhancement</td><td width="482">BLC,HLC,3D DNR</td></tr><tr><td width="199">Privacy Mask</td><td width="482">4 programmable polygon privacy masks</td></tr><tr><td colspan="2" width="681"><strong>Interface</strong></td></tr><tr><td width="199">Ethernet Interface</td><td width="482">1 RJ45 10 M/100 M self-adaptive Ethernet port</td></tr><tr><td width="199"><strong>&nbsp;</strong><p><strong>&nbsp;</strong></p><p><strong>&nbsp;</strong></p><p><strong>&nbsp;</strong></p><p><strong>&nbsp;</strong></p><p><strong>&nbsp;</strong></p><p><strong>&nbsp;</strong></p><p><strong>&nbsp;</strong></p><p>On-Board Storage</p></td><td width="482">DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-SL/SRB/LIUF:&nbsp; Yes<p>-LIU: NA</p></td></tr><tr><td width="199">Built-in Microphone</td><td width="482">Yes,1 built-in microphone</td></tr></tbody></table>
| Built-in Speaker | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-SL/SRB:  Yes \-LIU/LIUF: NA |
| --- | --- |
| Audio | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU:  NA |
| Alarm | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU:  NA |
| Reset Key | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-SL/SRB/LIUF:  Yes, \-LIU: NA |
<table><tbody><tr><td colspan="2" width="681"><strong>Event</strong></td></tr><tr><td width="192">Basic Event</td><td width="489">Motion detection (support alarm triggering by specified target types (human and<p>vehicle)),video tampering alarm,exception</p></td></tr><tr><td width="192">Linkage</td><td width="489">Upload to FTP,notify surveillance center,send email,trigger recording,trigger capture</td></tr><tr><td colspan="2" width="681"><strong>General</strong></td></tr><tr><td width="192">Power</td><td width="489">DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: 12 VDC ± 25%, 0.58 A, max. 7 W, Ø5.5 mm<p>coaxial power plug, reverse polarity protection, PoE: IEEE 802.3af, Class 3,max. 9 W LIUF: 12 VDC ± 25%, 0.67 A, max. 8 W, Ø5.5 mm coaxial power plug, reverse polarity protection, PoE: IEEE 802.3af, Class 3,max. 10 W</p><p>-SL/SRB: 12 VDC ± 25%, 0.84 A, max. 10.1 W, Ø5.5 mm coaxial power plug, reverse polarity protection, PoE: IEEE 802.3af, Class 3,max. 12.1 W</p><p>&nbsp;</p></td></tr><tr><td width="192"><strong>&nbsp;</strong><p><strong>&nbsp;</strong></p><p><strong>&nbsp;</strong></p><p><strong>&nbsp;</strong></p><p><strong>&nbsp;</strong></p><p><strong>&nbsp;</strong></p><p><strong>&nbsp;</strong></p><p><strong>&nbsp;</strong></p><p>Dimension</p></td><td width="489">DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: 69.1 mm × 66.9 mm × 172.9 mm (2.7″ × 2.6″ ×<p>6.8″)</p><p>-LIUF/SL/SRB: 69.1 mm × 67.4 mm × 179 mm (2.7″ × 2.7″ × 7.1″)</p><p>&nbsp;</p></td></tr><tr><td width="192">Package Dimension</td><td width="489">210 mm × 116 mm × 106 mm (8.3″ × 4.6″ × 4.2″)</td></tr></tbody></table>
| Weight | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: Approx. 310 g (0.7 lb.) \-LIUF: Approx. 315 g (0.7 lb.) \-SL/SRB: Approx. 325 g (0.7 lb.) |
| --- | --- |
| With Package Weight | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: Approx. 465 g (1.0 lb.) \-LIUF: Approx. 470 g (1.0 lb.) \-SL/SRB: Approx. 495 g (1.1 lb.) |
| Storage Conditions | \-30 °C to 60 °C (-22 °F to 140 °F). Humidity 95% or less (non-condensing) |
| Startup and Operating Conditions | \-30 °C to 60 °C (-22 °F to 140 °F). Humidity 95% or less (non-condensing) |
| Language | English, Russian, Ukrainian, Arabic, Spanish, French, Portuguese, Turkish, Polish, German |
| General Function | Heartbeat,mirror,flash log,password reset via email,password protection,anti -banding |
| Flashing Light | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-SL/SRB:  Yes \-LIU/LIUF: NA |
<table><tbody><tr><td colspan="2" width="681"><strong>Approval</strong></td></tr><tr><td width="194">EMC</td><td width="487">CE-EMC: EN 55032:2015+A1:2020, EN 50130-4:2011+A1:2014, EN IEC<p>61000-3-2:2019+A1:2021, EN&nbsp; 61000-3-3:2013+A1:2019+A2:2021</p></td></tr><tr><td width="194">Safety</td><td width="487">CB: IEC 62368-1: 2014+A11,<p>CE-LVD: EN 62368-1: 2014/A11: 2017</p></td></tr><tr><td width="194">Environment</td><td width="487">CE-RoHS: 2011/65/EU,<p>WEEE: 2012/19/EU</p></td></tr><tr><td width="194">Protection</td><td width="487">IP67: IEC 60529-2013</td></tr></tbody></table>
#### Available Model
- DS-2CD1047G3H-LIUF/SRB(2.8mm)
- DS-2CD1047G3H-LIUF/SRB(4mm)
- DS-2CD1047G3H-LIU(2.8mm)
- DS-2CD1047G3H-LIU(4mm)
- DS-2CD1047G3H-LIUF(2.8mm)
- DS-2CD1047G3H-LIUF(4mm)
- DS-2CD1047G3H-LIUF/SL(2.8mm)
- DS-2CD1047G3H-LIUF/SL(4mm)
+41
View File
@@ -0,0 +1,41 @@
---
type: source
tags: [parking, hardware, cameras, hikvision, datasheet]
sources: [DS-2CD1047G3H-LIU]
updated: 2026-08-23
---
# Source: HIKVISION DS-2CD1047G3H-LIU-F datasheet
Vendor spec sheet (`raw/DS-2CD1047G3H-LIU.md`) for the **DS-2CD1047G3H-LIU** — the exit-lane
camera at `10.0.10.13` (park-buzi) already covered extensively in [[lpr-camera]] for its
persistent main-stream ISAPI 503 (`deviceBusy`) and its 2026-06-27 config-DB-corruption incident.
This is the first *vendor-sourced* spec data for the model; everything before it was field-derived.
## Key takeaways
- **4 MP, max resolution 2560×1440** (ColorVu 3.0 — color night imaging, not IR-only).
- **Main stream: H.265+/H.265/H.264+/H.264 only — no MJPEG option.** **Sub-stream: H.265/H.264/
MJPEG** (MJPEG is sub-only). This is a concrete, spec-level asymmetry that plausibly explains
*why* the ISAPI on-demand JPEG snapshot is reliable on sub but structurally broken on main: sub
can serve a snapshot natively, main has to transcode out of H.264/H.265 on demand. Consistent
with — and a likely root cause for — the persistent `deviceBusy` behavior already logged in
[[lpr-camera]].
- **Bit rate range 32 Kbps–16 Mbps.** The site's main-stream config (6144–12288 Kbps, confirmed
live 2026-08-23) is well inside spec — rules out "misconfigured over the camera's own ceiling"
as a cause, confirming what live testing already showed.
- **ROI: 1 fixed region for main stream** — another main-only constraint/asymmetry vs. sub.
- **API: ONVIF (Profile S, Profile G), ISAPI, SDK.** ISAPI is fully in-spec for this model — the
503 is a real firmware/hardware limitation, not a case of using an unsupported API.
- **Protocols include RTSP** (alongside HTTP/ONVIF/etc.) — confirms RTSP is a documented, supported
surface on this model, not a workaround outside its design.
- Simultaneous live view: up to 6 channels. Available in `-LIU`/`-LIUF`/`-LIUF/SL`/`-LIUF/SRB`
variants at 2.8mm/4mm focal lengths; the deployed unit is the base `-LIU`.
## How this changes the picture
Confirms rather than overturns [[lpr-camera]]'s existing conclusion (sub-stream-only for ISAPI
snapshots on this model) — but gives it a concrete mechanical explanation (main has no MJPEG path)
instead of just an empirically-observed limitation. See [[lpr-camera]] § "Main-stream ISAPI
snapshot 503 vs. RTSP" for the 2026-08-23 live comparison against a `DS-2CD1043G2-LIU` sibling
(main-stream ISAPI snapshot works fine there) and the confirmed RTSP frame-grab workaround.