From 9c6741a4852071ff71da81ed941c3372a1dc3ef8 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Mon, 29 Jun 2026 17:38:24 +0200 Subject: [PATCH 1/9] docs(wiki): record backup deploy gotchas (compose allowlist + host mount) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two lessons from the first park-buzi staging deploy, both in backup-recovery.md: - A new server env var (BACKUP_KEY) must be added to docker-compose.yml's server.environment: allowlist, not just the Komodo secret/Stack env — otherwise the container never receives it (inspect shows it absent, not empty). - The backup target must be a host path bind-mounted into the container; a desktop- automounted USB (/run/media/...) is invisible inside the container, so Test target reports 'does not exist'. Destinations are admin-provisioned (fstab + compose bind- mount), not operator-pluggable — partly a threat-model feature. Acknowledged as a flexibility limitation; USB-automount-to-container flow deferred. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- wiki/concepts/backup-recovery.md | 44 ++++++++++++++++++++++++++++++++ wiki/log.md | 17 ++++++++++++ 2 files changed, 61 insertions(+) diff --git a/wiki/concepts/backup-recovery.md b/wiki/concepts/backup-recovery.md index 645dc29..d8c3d32 100644 --- a/wiki/concepts/backup-recovery.md +++ b/wiki/concepts/backup-recovery.md @@ -99,6 +99,38 @@ All three supported in the first cut; the manual button and the periodic timer s - **SFTP** — push to an SFTP endpoint, useful for an offsite copy. **FTP is excluded** (plaintext credentials + data); SFTP is the safe equivalent. +### The target must be a bind-mounted host path — NOT a casually-plugged USB (2026-06-29) + +The server runs **inside the `parking-server` container**, so it can only `stat()`/write paths that +are **bind-mounted into that container**. A USB stick the operator plugs in lands at a desktop +auto-mount path on the *host* (`/run/media//`), which **does not exist inside the +container** — so the in-UI **Test target** correctly reports *"location does not exist."* This bit on +the first booth deploy (2026-06-29): `BACKUP_KEY` was finally injected, then the target test failed +because the USB path wasn't visible to the process. + +**So a backup destination is provisioned by the ADMIN at the host level, not chosen ad hoc by the +operator.** The procedure: + +1. Attach the disk (external HDD/SSD/USB) and mount it at a **stable host path** (e.g. `/mnt/backup`) + via **`/etc/fstab` by UUID** — *not* the desktop automounter, whose UUID-named path changes per + drive and vanishes on unplug. +2. **Bind-mount that host path into the container** in the prod compose (e.g. + `/mnt/backup:/mnt/backup` on the `server` service — same pattern as the `/dev/usb` printer + passthrough in [[container-deployment]]). +3. In the UI (Setup → Backup), set the **target directory to the in-container path** (`/mnt/backup`) + and **Test target** — now writable. + +> **This is partly a feature, not just a limitation** ([[threat-model]]): because the destination is +> a host-provisioned bind-mount, the **booth operator cannot redirect backups to a removable stick +> they walk off with** — real destinations are an admin/host decision, on the trusted side of the +> [[trust-boundary]]. A network share (SMB/NFS) is the same shape: mount on the host, bind-mount in. +> +> **Limitation acknowledged:** the backup target is therefore **not operator-flexible** — you cannot +> just plug in a USB and back up from the UI. Adding a new destination = a host `fstab` + compose +> bind-mount change + redeploy. For the appliance model (single-purpose, admin-provisioned) this is +> the right trade; a future "back up to a freshly-plugged removable drive" flow would need host-level +> automount detection wired to the container, which is **deferred / not built**. + ## Retention at the destination **Keep last N + thinned dailies** (e.g. last 7 daily / last 4 weekly) — bounded disk use, and it @@ -155,6 +187,18 @@ timer + the manual route**. What landed: - **Komodo wiring.** `BACKUP_KEY` is a **per-booth Komodo secret** (`[[park_buzi_backup_key]]` in `komodo/resources.toml`; documented in `komodo/.env.komodo.example`), escrowed offsite alongside `EVENT_SIGNING_KEY`. It is the *only* backup env var — target + retention are in the DB. + +> **Gotcha — compose `environment:` is an ALLOWLIST (cost a full booth-deploy session, 2026-06-29).** +> Wiring `BACKUP_KEY` as a Komodo secret + Stack-env line is **necessary but not sufficient**: +> `docker-compose.yml`'s `server.environment:` block only forwards the variables it *names*. The key +> was wired everywhere (secret store, Stack env, `.env.example`, schema) but **never added to that +> compose block**, so the container came up *without* it — `docker inspect ...Config.Env` showed +> `JWT_SECRET`/`EVENT_SIGNING_KEY` present and `BACKUP_KEY` **absent (not empty)**, while the Backup +> screen correctly reported "BACKUP_KEY missing". Diagnosis was muddied by chasing Komodo (secret +> name, re-sync, destroy/redeploy, env-only-change-doesn't-recreate) before checking the compose +> allowlist. **Lesson: a new server env var needs a line in `docker-compose.yml` `server.environment:` +> too — that's the only place env reaches the container.** Fixed: `BACKUP_KEY: ${BACKUP_KEY:-}` next to +> `EVENT_SIGNING_KEY`. Quick check on a booth: `docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -i backup`. - **`server.ts`** — an **unref'd daily timer** (`backupService.runScheduled`), a **no-op until configured**, and **deliberately NOT run at startup** (a just-power-cut booth shouldn't write to a possibly-unmounted disk; the daily cadence + the manual button cover it). diff --git a/wiki/log.md b/wiki/log.md index 8b8af4f..c97a8a1 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1976,3 +1976,20 @@ TAG=stage-84f00db; komodo/README.md promotion section + per-booth secret list no fleet-deployment-komodo open-item resolved + new 'Promotion tiers' table; container-deployment tag list + :stage. Per-booth secrets (jwt/event_signing/backup) must pre-exist in Core for park-buzi; migrations run at boot so a promotion auto-migrates the staging ledger (where a bad migration is caught before prod). + +## [2026-06-29] fix+doc | First park-buzi backup deploy: BACKUP_KEY allowlist + container mount constraint +First real-world staging deploy surfaced two backup gotchas, both now in [[backup-recovery]]: +(1) BACKUP_KEY was wired as a Komodo secret + Stack-env line but NEVER added to docker-compose.yml's +server `environment:` ALLOWLIST — so the container came up without it (docker inspect: JWT/SIGN present, +BACKUP_KEY absent-not-empty; Backup screen "BACKUP_KEY missing"). A whole session was lost chasing Komodo +(secret name, re-sync, destroy/redeploy, env-only-change-doesn't-force-recreate) before checking the +compose allowlist. Fix: `BACKUP_KEY: ${BACKUP_KEY:-}` next to EVENT_SIGNING_KEY (commit on dev 8f32d90, +promoted dev→stage merge d0b609e → built stage-d0b609e). Lesson recorded: a new server env var ALSO needs a +line in the compose environment block. +(2) The backup target must be a HOST path BIND-MOUNTED into the container — a casually-plugged USB at +/run/media// is invisible inside the container, so Test target rightly says "does not exist". +Provisioning = fstab-by-UUID a stable host path (e.g. /mnt/backup) + bind-mount it in prod compose + set +the in-container path as the UI target. Acknowledged limitation: backups are NOT operator-flexible (no +plug-a-USB-and-go); adding a destination is an admin host+compose change. Partly a feature vs the +operator-adversary threat model (operator can't redirect backups to a removable stick). USB-automount-to- +container flow deferred/not built. From 1b86750b0d2929d68c9845966be9e9b823779c5d Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Tue, 30 Jun 2026 15:21:32 +0200 Subject: [PATCH 2/9] docs(wiki): firmware/dbx-vs-TPM hardening + create disk-os-hardening page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-world park-buzi episode: a UEFI dbx update (delivered via fwupd/LVFS, NOT apt) revoked a stale GRUB -> panic, and moved PCR 7 -> broke TPM-sealed LUKS auto-unlock -> passphrase prompt. Recovered by re-sealing PCR 7. - appliance-provisioning.md: extend the §4 re-seal runbook to name dbx; new §4a (fwupd-not-apt, GRUB-panic ordering, PCR-7 re-seal, operator lockdown: mask fwupd + remove firmware-updater snap + BIOS-password + passphrase escrow) incl. the --test-passphrase-silently-passes-via-TPM trap (--disable-external-tokens); gotchas #12/#13. - disk-os-hardening.md: NEW — resolves a long-dangling wikilink referenced from ~18 pages. The *why* of host hardening (5 controls + firmware lockdown); commands stay in appliance-provisioning; reconciliation remains the primary anti-fraud control. - index.md: expand the disk-os-hardening catalog line. - log.md: two note entries. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- wiki/decisions/appliance-provisioning.md | 76 ++++++++++++++++++-- wiki/decisions/disk-os-hardening.md | 92 ++++++++++++++++++++++++ wiki/index.md | 2 +- wiki/log.md | 35 +++++++++ 4 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 wiki/decisions/disk-os-hardening.md diff --git a/wiki/decisions/appliance-provisioning.md b/wiki/decisions/appliance-provisioning.md index 6904140..91e7ee3 100644 --- a/wiki/decisions/appliance-provisioning.md +++ b/wiki/decisions/appliance-provisioning.md @@ -2,7 +2,7 @@ type: reference tags: [parking, deployment, appliance, hardening, runbook, offline-first] sources: [] -updated: 2026-06-27 +updated: 2026-06-30 status: settled --- @@ -103,9 +103,67 @@ sudo reboot - Still prompts = PCR mismatch; type the passphrase (NOT locked out), then retry with `--tpm2-pcrs=0`. The `password` slot + `crypttab.bak` make this fully reversible. -> **Re-seal runbook:** a BIOS update / Secure Boot change alters PCR 7 → the TPM refuses → boot -> falls back to the passphrase prompt (not a brick). After such a change, re-run step 4's -> `systemd-cryptenroll --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=7 /dev/sda3` to re-bind. +> **Re-seal runbook:** a BIOS update / Secure Boot change / **UEFI dbx (revocation list) update** +> alters PCR 7 → the TPM refuses → boot falls back to the passphrase prompt (not a brick). After +> such a change, re-run step 4's +> `systemd-cryptenroll --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=7 /dev/sda3` to re-bind, then +> reboot to confirm unattended unlock returned. + +### 4a. Firmware / UEFI dbx updates break PCR 7 — and are an OPERATOR threat (VERIFIED 2026-06-30) + +The PCR-7 re-seal hazard above is **not** a rare event — the most common trigger is a **UEFI `dbx` +(Secure Boot revocation database) update**, and it bit the real `park-buzi` booth on 2026-06-28: + +- **What `dbx` is:** the Secure Boot blocklist of known-vulnerable bootloader/shim hashes + (vendor = Microsoft). It is delivered by **`fwupd`/LVFS — a channel SEPARATE from APT** (the GNOME + "Firmware Updater", which on Ubuntu is the **`firmware-updater` snap**, surfaces it). `apt list + --upgradable` being clean does NOT mean a firmware/dbx update isn't pending. +- **The GRUB panic (root cause):** applying a *new* dbx against a *stale* GRUB/shim revokes the + installed bootloader → Secure Boot refuses to load it → **unbootable / GRUB "panic"**. The fix is + ordering: `apt full-upgrade` (current `grub-efi`/`shim-signed`) FIRST, *then* dbx. A fresh reinstall + ships a current GRUB, so reinstalling recovers it. +- **It moves PCR 7:** even with a current GRUB, applying dbx changes the Secure-Boot-policy + measurement → the TPM (slot 1) refuses to release the key → next boot **drops to the slot-0 + passphrase prompt**. Recover with the re-seal runbook above. VERIFIED: on `park-buzi` the dbx + update went through, the box rebooted to a passphrase prompt, the slot-0 passphrase unlocked it, + and `systemd-cryptenroll --wipe-slot=tpm2 … --tpm2-pcrs=7` restored silent auto-unlock. + +**Threat-model consequence ([[threat-model]]: the operator is the adversary).** A firmware/dbx update +on a TPM-sealed booth → the booth won't boot unattended and needs the slot-0 passphrase. So the +operator must be unable to *trigger* a firmware update, and must never hold the passphrase. Lock it +down (DONE on `park-buzi` 2026-06-30): + +```bash +# 1. Kill the firmware-update DAEMON (the GUI "Update" button then fails with no daemon): +sudo systemctl mask fwupd.service fwupd-refresh.timer +systemctl is-enabled fwupd.service fwupd-refresh.timer # → masked / masked (persists across reboot) + +# 2. Remove the operator-facing GUI so the screen is never even presented (Ubuntu = a snap): +sudo snap remove firmware-updater +snap list | grep -i firmware # → no output (re-check: seeded snaps can re-install) +``` + +Plus: the **BIOS admin password** (§1) must gate *entering setup / changing settings* (a +supervisor/admin password, not just a boot password) so the operator can't disable Secure Boot or +change boot order — either of which also breaks the seal. And the **slot-0 passphrase stays +off-machine / escrowed** (same custody as `EVENT_SIGNING_KEY` / `BACKUP_KEY`); it is an admin-only +recovery secret, used on-site during a maintenance window, never known to operators. + +> **Net:** firmware/dbx updates become an **admin-only, on-site, deliberate** action. The booth is +> unattended-bootable only while the firmware/Secure-Boot state is frozen — that is the security +> property, not a bug. Legitimate firmware maintenance now costs: physical presence + the slot-0 +> passphrase + a PCR-7 re-enroll. + +> **⚠ Gotcha — `cryptsetup … --test-passphrase` SILENTLY passes via the TPM.** Before any +> firmware/dbx change, you must *prove a typed passphrase still unlocks the disk* (the TPM-independent +> safety net). But `sudo cryptsetup open --test-passphrase /dev/sda3` with a TPM2 token enrolled will +> succeed **without prompting** — the TPM auto-answers (it unlocks the tpm2 *slot*, e.g. slot 1), a +> FALSE positive that proves nothing about a human-typeable key. Force a real test with +> `--disable-external-tokens` (→ `No usable token is available.` then it prompts; success on slot 0 = +> the passphrase genuinely works): +> ```bash +> sudo cryptsetup open --test-passphrase /dev/sda3 --disable-external-tokens --verbose +> ``` ## 5. GRUB password — EDIT-ONLY (VERIFIED 2026-06-23) @@ -317,6 +375,16 @@ works; the desktop app is a separate workstream. 6. GRUB password MUST be **edit-only** (`--unrestricted` on entries) or it prompts on EVERY boot → breaks unattended reboot. Verify `grep -c unrestricted /boot/grub/grub.cfg` ≥1 before rebooting. +### Firmware / dbx gotchas (2026-06-30, §4a) + +12. **UEFI dbx ships via `fwupd`/LVFS, NOT APT.** `apt list --upgradable` clean ≠ no firmware update + pending. A new dbx vs a stale GRUB → revoked bootloader → **unbootable / GRUB panic** (`apt + full-upgrade` first, then dbx). And dbx **moves PCR 7** → breaks TPM auto-unlock → passphrase + prompt → re-seal (§4 runbook). Mask `fwupd` + remove the `firmware-updater` snap so the operator + can't trigger it. +13. `cryptsetup … --test-passphrase` **silently passes via the TPM token** (false safety signal). Use + `--disable-external-tokens` to actually force a typed-passphrase test before any firmware change. + ### Komodo deploy gotchas (2026-06-27) 7. Periphery `core_address` is **Core's reverse-proxy URL** (`https://komodo.infra.msai.al`), NOT diff --git a/wiki/decisions/disk-os-hardening.md b/wiki/decisions/disk-os-hardening.md new file mode 100644 index 0000000..6307784 --- /dev/null +++ b/wiki/decisions/disk-os-hardening.md @@ -0,0 +1,92 @@ +--- +type: decision +tags: [parking, hardening, threat-model, luks, tpm, secure-boot, grub, firmware, offline-first] +sources: [parking-system-architecture] +updated: 2026-06-30 +status: settled +--- + +# Disk & OS hardening (booth appliance) + +The host-level defences that raise the cost of **offline, physical tamper** of a booth PC: full-disk +encryption, TPM-sealed auto-unlock, Secure Boot, a GRUB edit-lock, an unprivileged operator account, +and locking firmware updates away from the operator. This page is the **rationale (the *why*)**; the +step-by-step verified commands live in the [[appliance-provisioning]] runbook (§3–5c, §4a). Settled +across the first real provisioning (2026-06-23) and the firmware-update episode (2026-06-30). + +> ⚠ **This is the secondary control, not the main event.** The load-bearing anti-fraud mechanism is +> [[reconciliation]] over the [[append-only-event-chain|signed event chain]]. Disk/OS hardening +> defends the [[threat-model|outsider-with-the-box]] and raises the cost of offline tamper — it does +> **not** replace reconciliation, and it cannot stop a *legitimate, logged-in* operator from +> committing fraud through the app (that's what the signed ledger + reconciliation are for). + +## What it defends against + +The appliance sits on-site, physically reachable by the [[threat-model|booth operator (the primary +adversary)]] and by an outsider who can open the case. Without host hardening, either can: + +- **Pull the SSD** and read/alter the SQLite ledger offline → FDE (LUKS) defeats this. +- **Boot a live USB** to mount and edit the disk → Secure Boot + TPM-sealing (PCR 7) defeats booting + a tampered/unsigned kernel; FDE keeps the data unreadable. +- **Edit the GRUB cmdline** (`init=/bin/bash`) for a no-login root shell on the *decrypted* disk → + the GRUB edit-lock defeats this (the TPM seal does NOT — see below). +- **Escalate from the operator login** (sudo, `docker`/`lxd` groups) → the unprivileged-operator + model defeats this. + +## The five controls and why each is shaped the way it is + +| Control | Choice | Why this shape (the load-bearing nuance) | +| --- | --- | --- | +| **FDE** | LUKS, **passphrase** at install (not the installer's "hardware-backed" option) | The 26.04 installer's automated FDE profiler fails on this firmware (`PCR_UNUSABLE`/`dbt`). Passphrase LUKS + a *manual* TPM seal sidesteps it and lets us pick PCRs. The passphrase slot is the **permanent recovery key**. | +| **TPM auto-unlock** | `systemd-cryptenroll`, **PCR 7 only** | Unattended reboot is a hard requirement (no operator types a passphrase). PCR 7 = Secure-Boot policy: catches the attack that matters (disabling Secure Boot) **without** churning on kernel/GRUB updates (PCRs 4/8/9 → would drop to passphrase every boot). **Keep BOTH slots** — slot 0 password (recovery), slot 1 tpm2 (auto-unlock); the TPM is never the only key. | +| **Secure Boot** | Enabled, **Deployed Mode**, stock MS keys | Ubuntu's signed shim needs stock `db`. Reaching the installer with Secure Boot ON is itself proof the MS third-party UEFI CA is trusted. | +| **GRUB edit-lock** | password, **edit-only** (`--unrestricted`) | Closes the `init=/bin/bash` root-shell hole. **The PCR-7 TPM seal does NOT cover this** — editing the cmdline doesn't change PCR 7, so the TPM still releases the key and the attacker lands on the decrypted disk. Edit-only so the box still boots **unattended** (password required only to *edit* entries). | +| **Operator account** | unprivileged, auto-login; separate **admin**+sudo | The operator is the adversary; their OS identity must not be able to escalate. Strip `sudo`, and the latent-escalation groups `lxd`/`docker` (both root-equivalent) + `lpadmin`. Admin is a distinct, no-auto-login identity. | + +See [[tpm]] for the TPM-2.0 analysis (why PCR-only sealing, bus-sniff limits, TPM-vs-[[atecc608|ATECC608]]). + +## Firmware / UEFI dbx updates — a hardening surface AND an operator threat + +Settled 2026-06-30 after a real incident on `park-buzi`. This is the non-obvious one, because it +turns a routine "security update" into a booth-availability risk: + +- **UEFI `dbx`** (the Secure Boot revocation database) and BIOS firmware are delivered by + **`fwupd`/LVFS — a channel SEPARATE from APT** (Ubuntu's GNOME "Firmware Updater" = the + `firmware-updater` snap). A clean `apt list --upgradable` does NOT mean no firmware update is pending. +- **It can brick boot:** a new dbx against a *stale* GRUB/shim **revokes the installed bootloader** → + Secure Boot refuses it → unbootable / GRUB panic. Correct order: `apt full-upgrade` (current + `grub-efi`/`shim-signed`) **first**, then dbx. +- **It breaks auto-unlock:** even with a current GRUB, applying dbx **moves PCR 7** → the TPM refuses + the LUKS key → next boot falls back to the slot-0 passphrase prompt (not a brick). Recover with the + PCR-7 re-seal runbook ([[appliance-provisioning]] §4/§4a). +- **Threat-model consequence:** a firmware/dbx update makes the booth need a passphrase to boot + unattended — so the **operator must be unable to trigger one, and must never hold the passphrase.** + Lock it down: `systemctl mask fwupd.service fwupd-refresh.timer`, `snap remove firmware-updater`, + a **BIOS admin password** that gates *entering setup* (so the operator can't disable Secure Boot / + change boot order), and the **slot-0 passphrase escrowed off-machine** (same custody as + `EVENT_SIGNING_KEY` / `BACKUP_KEY`). Firmware maintenance becomes **admin-only, on-site, deliberate**. + +> The booth is unattended-bootable **only while the firmware / Secure-Boot state is frozen** — that is +> the security property, not a bug. The cost is that legitimate firmware maintenance now needs physical +> presence + the slot-0 passphrase + a PCR-7 re-enroll. + +> **⚠ Verification trap:** `cryptsetup … --test-passphrase` **silently passes via the TPM token** (a +> false safety signal). Before any firmware change, prove a *typed* passphrase still unlocks the disk +> with `--disable-external-tokens` — see [[appliance-provisioning]] §4a. + +## Where the commands live + +This page is the rationale. The **verified, run-on-real-hardware commands** are in +[[appliance-provisioning]]: §1 BIOS, §2 Secure-Boot live-USB check, §3 encrypted install (the `dbt` +workaround), §4 TPM seal (PCR 7) + re-seal runbook, **§4a firmware/dbx lockdown**, §5 GRUB edit-lock, +§5c admin-vs-operator accounts. Komodo Periphery is folded into the same hardened surface as a +root-capable remote agent — see [[fleet-deployment-komodo]] (bind to the NetBird interface only). + +## Relates + +- [[appliance-provisioning]] — the runbook (commands); this page is its *why*. +- [[tpm]] — TPM 2.0 analysis (sealing, PCR choice, limits, vs ATECC608). +- [[threat-model]] — the operator-adversary framing this hardening serves. +- [[reconciliation]] / [[append-only-event-chain]] — the **primary** anti-fraud control this + complements, never replaces. +- [[fleet-deployment-komodo]] — Periphery as part of the trusted computing base. diff --git a/wiki/index.md b/wiki/index.md index 83664ad..1a06386 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -55,7 +55,7 @@ Counts: 4 sources · 19 entities · 46 concepts · 7 decision records. ## Concepts — integrity & anti-fraud - [[append-only-event-chain]] — append-only + hash chain + ATECC608 signing = unforgeable log. - [[reconciliation]] — the real anti-fraud control; what remote sync actually is. -- [[disk-os-hardening]] — LUKS/GRUB/Secure Boot; worthwhile but not the main event. +- [[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. ## Concepts — device architecture & safety diff --git a/wiki/log.md b/wiki/log.md index c97a8a1..6faa7ed 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1993,3 +1993,38 @@ the in-container path as the UI target. Acknowledged limitation: backups are NOT plug-a-USB-and-go); adding a destination is an admin host+compose change. Partly a feature vs the operator-adversary threat model (operator can't redirect backups to a removable stick). USB-automount-to- container flow deferred/not built. + +## [2026-06-30] note | UEFI dbx / firmware update vs TPM-sealed LUKS — GRUB panic + PCR-7 re-seal + operator lockdown (park-buzi) + +Real-world on park-buzi. The GNOME "Firmware Updater" (Ubuntu = the `firmware-updater` snap) surfaced a +pending UEFI dbx (Secure Boot revocation DB) update, vendor Microsoft, delivered by fwupd/LVFS — a channel +SEPARATE from APT (apt list --upgradable was clean except 2 cups packages). 2026-06-28 a dbx update against a +stale GRUB revoked the bootloader → GRUB panic / unbootable → user reinstalled Ubuntu 26.04 LTS (resolute) to +recover (fresh install ships a current GRUB). Correct order is `apt full-upgrade` (current grub-efi/shim-signed) +FIRST, then dbx. + +Even with a current GRUB, applying dbx moves PCR 7 (Secure-Boot-policy measurement) → the TPM (slot 1) refuses +to release the LUKS key → next boot drops to the slot-0 passphrase prompt. VERIFIED end-to-end: proved the +slot-0 typed passphrase first, applied dbx, rebooted to a passphrase prompt, unlocked with slot 0, re-enrolled +`systemd-cryptenroll --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=7 /dev/sda3` → silent auto-unlock restored. + +Gotcha: `cryptsetup open --test-passphrase /dev/sda3` SILENTLY passes via the TPM token (auto-unlocks the tpm2 +slot without prompting) — a false safety signal. Force a real typed-passphrase test with +`--disable-external-tokens` (→ "No usable token is available." then prompts; success on slot 0 proves it). + +Threat-model lockdown (operator is the adversary): a firmware/dbx update makes the booth need the passphrase to +boot unattended, so operators must not be able to trigger one and must never hold the passphrase. Applied on +park-buzi: `systemctl mask fwupd.service fwupd-refresh.timer` (→ masked/masked, persists), `snap remove +firmware-updater` (remove the GUI; re-check, seeded snaps can re-install), BIOS admin password gates setup +entry, slot-0 passphrase stays escrowed off-machine. Firmware updates are now admin-only/on-site/deliberate. +Recorded in appliance-provisioning.md §4 re-seal runbook + new §4a + gotchas #12/#13. + +## [2026-06-30] note | Created disk-os-hardening.md (resolved a long-standing orphan) + +`[[disk-os-hardening]]` was referenced from ~18 pages (overview, threat-model, tpm, fleet-deployment, +appliance-provisioning, backup-recovery, index, …) but never written — a dangling wikilink. Wrote it as +the *rationale* page (the why): the five host controls (LUKS FDE, TPM-sealed PCR-7 auto-unlock, Secure +Boot Deployed, GRUB edit-lock, unprivileged-operator) + the firmware/dbx lockdown (§4a cross-ref), each +with its load-bearing nuance, plus the standing caveat that this is the SECONDARY control — +reconciliation over the signed chain is the main anti-fraud event. Commands stay in appliance-provisioning +(the how); this page points there. Updated the index.md line accordingly. From cfac14e09e746537f48e87e630b58be9ba2c9adb Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Tue, 30 Jun 2026 17:58:06 +0200 Subject: [PATCH 3/9] fix(snapshots): normalize content-type on serve so stored images render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cameras (Hikvision) return `Content-Type: image/jpeg; charset="UTF-8"` — a charset param on a binary body is malformed, and browsers refuse to decode an declared that way. Old capture code persisted that raw header into snapshots.content_type (100/101 dev-DB rows); GET /api/snapshots/:id re-emitted it verbatim, so every legacy snapshot rendered blank in the booth modal. Capture was already hardened (encodeForStorage re-encodes to a clean image/jpeg, fail-soft via cleanType), but the serve route trusted the stored value. Export cleanType and apply it when setting the response header, so a bare image/jpeg is sent regardless of what was stored — un-breaks all legacy rows with no data migration. A stored value from an untrusted device is itself input; normalize on capture AND on serve. Adds cleanType unit tests. Verified: a previously-unrenderable 2560x1440 row now decodes in-browser. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/server/src/routes/snapshots.ts | 6 +++++- apps/server/src/snapshot.test.ts | 19 ++++++++++++++++++- apps/server/src/snapshot.ts | 10 +++++++--- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/apps/server/src/routes/snapshots.ts b/apps/server/src/routes/snapshots.ts index ec413cc..b545a3d 100644 --- a/apps/server/src/routes/snapshots.ts +++ b/apps/server/src/routes/snapshots.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from "fastify"; import { and, desc, eq, deviceEvents, snapshots, type Db } from "@parking/db"; import { requirePermission } from "../auth.js"; +import { cleanType } from "../snapshot.js"; // Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see // packages/db schema + wiki/concepts/lane-direction.md). Snapshots are evidence @@ -116,7 +117,10 @@ export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise { const row = db.select().from(snapshots).where(eq(snapshots.id, req.params.id)).get(); if (!row) return reply.code(404).send({ error: "no such snapshot" }); - reply.header("content-type", row.contentType); + // Normalize on the way OUT too: legacy rows stored a camera's malformed + // `image/jpeg; charset="UTF-8"`, which browsers refuse to render. cleanType strips + // the bogus params back to a bare `image/jpeg` so every stored image displays. + reply.header("content-type", cleanType(row.contentType)); reply.header("cache-control", "private, max-age=31536000, immutable"); return reply.send(row.bytes); }, diff --git a/apps/server/src/snapshot.test.ts b/apps/server/src/snapshot.test.ts index 81e5630..d7a4f65 100644 --- a/apps/server/src/snapshot.test.ts +++ b/apps/server/src/snapshot.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import sharp from "sharp"; import type { CameraDevice, Snapshot } from "@parking/devices"; -import { captureSnapshotShared, encodeForStorage } from "./snapshot.js"; +import { captureSnapshotShared, cleanType, encodeForStorage } from "./snapshot.js"; import { silentLogger } from "./test-helpers.js"; // captureSnapshotShared: one HTTP pull per camera per vehicle. A Hikvision unit serves @@ -139,3 +139,20 @@ describe("encodeForStorage", () => { expect(out.contentType).toBe("text/plain"); // charset stripped even on the fallback }); }); + +describe("cleanType", () => { + it("strips a camera's charset cruft so a binary JPEG renders", () => { + // The exact malformed value some cameras (Hikvision) return, which broke the + // snapshot strip for every legacy row until the serve route normalized it. + expect(cleanType('image/jpeg; charset="UTF-8"')).toBe("image/jpeg"); + expect(cleanType("image/jpeg; charset=utf-8")).toBe("image/jpeg"); + }); + + it("passes a clean type through and defaults a missing one", () => { + expect(cleanType("image/jpeg")).toBe("image/jpeg"); + expect(cleanType("image/png")).toBe("image/png"); + expect(cleanType(null)).toBe("image/jpeg"); + expect(cleanType(undefined)).toBe("image/jpeg"); + expect(cleanType("")).toBe("image/jpeg"); + }); +}); diff --git a/apps/server/src/snapshot.ts b/apps/server/src/snapshot.ts index a708ed3..bd07572 100644 --- a/apps/server/src/snapshot.ts +++ b/apps/server/src/snapshot.ts @@ -40,9 +40,13 @@ import type { VisionClient } from "./vision-client.js"; const SNAP_MAX_EDGE = Number(process.env.SNAPSHOT_MAX_EDGE ?? 1280); const SNAP_QUALITY = Number(process.env.SNAPSHOT_JPEG_QUALITY ?? 80); -/** Strip a camera's `; charset=...` cruft from a content type (a JPEG is binary). */ -function cleanType(ct: string): string { - const base = ct.split(";")[0]?.trim(); +/** Strip a camera's `; charset=...` cruft from a content type (a JPEG is binary). A bare + * `image/jpeg` renders; `image/jpeg; charset="UTF-8"` (what some cameras return, e.g. + * Hikvision) is malformed for a binary body and browsers refuse to decode it. Applied + * both on capture AND when serving, so legacy rows stored before this normalization + * existed still serve a clean type. */ +export function cleanType(ct: string | null | undefined): string { + const base = ct?.split(";")[0]?.trim(); return base || "image/jpeg"; } From 61de1fe772a7478c6e594d46fefef86e7d60274d Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Tue, 30 Jun 2026 17:58:24 +0200 Subject: [PATCH 4/9] feat(booth): rework Active Sessions + pay/exit modal around barrier re-open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the audited barrier re-open out of the inline Active-Sessions row button and into the modal, and turn the modal's dead-ends into useful views. - Remove the inline per-row "Open barrier" button. Clicking a row opens the modal, which carries the action. - Modal recognizes a closed-within-grace transient (found && !open && withinGrace) and shows the session view + Open barrier instead of dead-ending on "already closed" — the exact case (paid, barrier unconfirmed) that needs a re-pulse. Server reopenBarrier guard unchanged. - Active-Sessions rows show a live grace-remaining countdown badge (exited - M:SS, 1s tick off graceExpiresAt) via new formatCountdown helper. - Settled sessions show the ACTUAL sum paid (new SessionLookup.paidMinor, summed across payment events) instead of a flat "PAID" badge. - A fully-closed (grace-expired) session's modal is no longer a dead-end: it shows a read-only review view (figures + paid amount + entry/exit snapshot strip) for dispute/audit review, with no pay/exit/open controls. i18n sq+en parity kept; web build/lint/test green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/server/src/pay-station.ts | 18 ++++- apps/web/src/ActiveSessions.tsx | 118 ++++++++++++-------------------- apps/web/src/BoothPayModal.tsx | 116 +++++++++++++++++++++++++------ apps/web/src/api.ts | 3 + apps/web/src/lib/format.ts | 16 +++++ apps/web/src/lib/i18n/en.ts | 8 +++ apps/web/src/lib/i18n/sq.ts | 20 ++++-- 7 files changed, 194 insertions(+), 105 deletions(-) diff --git a/apps/server/src/pay-station.ts b/apps/server/src/pay-station.ts index e8d87a3..901b046 100644 --- a/apps/server/src/pay-station.ts +++ b/apps/server/src/pay-station.ts @@ -98,6 +98,11 @@ export interface SessionLookup { /** Amount owed right now (the quote). Null when no session / no active tariff. */ readonly amountMinor: number | null; readonly currency: string | null; + /** Amount actually PAID (from the latest payment event), if any. Distinct from + * `amountMinor` (what's owed now): once a transient is settled `amountMinor` is null, + * but the operator still wants to see the sum that was collected. */ + readonly paidMinor: number | null; + readonly paidCurrency: string | null; /** True when paid AND still within the walk-back grace window. */ readonly withinGrace: boolean; /** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */ @@ -274,7 +279,8 @@ export class PayStation { if (!entry) { return { identity: id, found: false, open: false, enteredAt: null, exitedAt: null, - paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null, + paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null, + withinGrace: false, graceExpiresAt: null, overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null, }; } @@ -289,11 +295,17 @@ export class PayStation { let paidAt: string | null = null; let graceExitMin: number | null = null; + let paidMinor: number | null = null; + let paidCurrency: string | null = null; for (const r of rows) { if (r.type === "payment") { paidAt = r.occurredAt; - const p = (r.payload ?? {}) as { graceExitMin?: number }; + const p = (r.payload ?? {}) as { graceExitMin?: number; amountMinor?: number; currency?: string }; if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin; + // Sum payments (overstay top-ups append a second one) so the displayed paid total + // reflects everything collected for the session, not just the last slip. + if (typeof p.amountMinor === "number") paidMinor = (paidMinor ?? 0) + p.amountMinor; + if (typeof p.currency === "string") paidCurrency = p.currency; } } const graceExpiresAt = @@ -328,7 +340,7 @@ export class PayStation { return { identity: id, found: true, open, enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null, - paidAt, amountMinor, currency, withinGrace, graceExpiresAt, overstay, + paidAt, amountMinor, currency, paidMinor, paidCurrency, withinGrace, graceExpiresAt, overstay, subscription: isSubscription, subscriptionId, subscriptionHolder: this.#holderOf(subscriptionId), plate: plateForIdentity(this.#db, id)?.plate ?? null, diff --git a/apps/web/src/ActiveSessions.tsx b/apps/web/src/ActiveSessions.tsx index 8621c49..55999b5 100644 --- a/apps/web/src/ActiveSessions.tsx +++ b/apps/web/src/ActiveSessions.tsx @@ -1,10 +1,9 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js"; +import { useQuery } from "@tanstack/react-query"; +import { fetchActiveSessions } from "./api.js"; import { qk } from "./lib/query.js"; -import { useShift } from "./lib/use-shift.js"; -import { formatDuration, formatRelativeDateTime } from "./lib/format.js"; +import { formatCountdown, formatDuration, formatRelativeDateTime } from "./lib/format.js"; import { Panel } from "./ui/Panel.js"; import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js"; @@ -12,13 +11,8 @@ import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js"; // within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed // possibly-present until grace runs out). Lets the operator find a stuck car — // damaged ticket, dead scanner, or a phantom barrier re-close — without a scan: -// - click a row → the pay/exit modal (pay an unpaid car, settle a subscriber's -// out-of-window charge, assist-open a prepaid subscriber, or review), -// - "Open barrier" (PAID transient sessions only) → an audited human-intervention -// re-pulse for a car that paid but whose barrier didn't confirm. -// No payment → no Open barrier button (the no-unpaid-bypass rule). Subscriptions get -// NO inline open here — their assist-open / window-charge payment is modal-only, so -// the list can't one-click past an unpaid out-of-window charge. +// click a row → the pay/exit modal (pay an unpaid car, settle a subscriber's +// out-of-window charge, assist-open a prepaid subscriber, or review). // // OVERSTAY sessions (paid, grace expired, no signed exit) are no longer aged out — they // stay listed with a distinct badge. A new period has begun (the car re-parked or is @@ -30,11 +24,6 @@ type KindFilter = "transient" | "subscription"; export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) { const { t } = useTranslation(); - const qc = useQueryClient(); - // The audited barrier re-open is a money-path action (server-gated on an open - // shift); disable it unless this operator's shift is open. - const { isOpen: shiftOpen, isMine: shiftMine } = useShift(); - const shiftReady = shiftOpen && shiftMine; const { data, isLoading } = useQuery({ queryKey: qk.activeSessions, queryFn: fetchActiveSessions, @@ -43,14 +32,13 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void refetchInterval: 15_000, }); - const reopen = useMutation({ - mutationFn: (identity: string) => reopenBarrier(identity), - onSettled: () => { - void qc.invalidateQueries({ queryKey: qk.activeSessions }); - void qc.invalidateQueries({ queryKey: qk.events }); - }, - }); - const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null); + // A 1-second clock so the within-grace countdown badge ticks live (the query only + // refetches every 15s; the badge needs per-second resolution). + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setNowMs(Date.now()), 1000); + return () => clearInterval(id); + }, []); // Filters: free-text search + transient-vs-subscriber. (No status filter — the status // column was dropped; an unpaid transient is normal and a subscriber is marked ★.) @@ -77,20 +65,6 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void { value: "subscription", label: t("booth.fKindSubscription") }, ]; - async function handleReopen(s: ActiveSession) { - setReopenMsg(null); - try { - const r = await reopen.mutateAsync(s.identity); - setReopenMsg({ - id: s.identity, - ok: r.opened, - text: r.opened ? t("booth.barrierOpened") : r.reason ?? t("booth.openManually"), - }); - } catch (e) { - setReopenMsg({ id: s.identity, ok: false, text: (e as Error).message }); - } - } - return ( void : t("booth.noMatch")} ) : ( - // A real table — aligned columns (who · plate · entry · elapsed · action). No - // status column: an unpaid transient is the normal case, and a subscriber is - // already marked with ★ + holder name. Overstay (a top-up is owed) keeps a row - // tint so that fraud-relevant signal isn't lost. The whole row is clickable - // (→ pay/exit modal); the trailing cell holds the audited Open-barrier action. + // A real table — aligned columns (who · plate · entry · elapsed). No status + // column: an unpaid transient is the normal case, and a subscriber is already + // marked with ★ + holder name. Overstay (a top-up is owed) keeps a row tint so + // that fraud-relevant signal isn't lost. The whole row is clickable (→ pay/exit + // modal). @@ -129,31 +103,43 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void - {filtered.map((s) => { - const msg = reopenMsg?.id === s.identity ? reopenMsg : null; - // Paid-and-in-grace TRANSIENT only: an audited re-pulse for a car that paid - // but the barrier didn't confirm. NOT overstay (owes a top-up → modal) and - // NOT a subscription (assist-open lives in the modal). An unpaid transient - // gets no button (no-unpaid-bypass). Mirrors reopenBarrier's server guard. - const canReopen = s.paidAt && !s.overstay && !s.subscription; + // EXITED-WITHIN-GRACE: a paid transient whose exit is recorded but the + // barrier didn't confirm — it lingers here until grace runs out. Mark it + // so the operator can tell it apart from a still-inside car (clicking it + // opens the modal's manual barrier re-open, not a pay flow). + const closedInGrace = !s.open && s.withinGrace && !s.subscription; + // Live grace-remaining for the badge (M:SS). Null once it lapses — the + // next refetch (≤15s) reclassifies the row (overstay / gone); until then + // we show a generic label so the badge doesn't flicker empty. + const graceLeft = closedInGrace ? formatCountdown(s.graceExpiresAt, nowMs) : null; return ( onPick(s.identity)} className={`cursor-pointer border-t border-term-border/50 hover:bg-term-panel-2 ${ - s.overstay ? "bg-term-red/5" : "" + s.overstay ? "bg-term-red/5" : closedInGrace ? "bg-term-amber/5 text-term-muted" : "" }`} - title={t("booth.openPayExit")} + title={closedInGrace ? t("booth.openReopenBarrier") : t("booth.openPayExit")} > - ); diff --git a/apps/web/src/BoothPayModal.tsx b/apps/web/src/BoothPayModal.tsx index 34a658b..b88a979 100644 --- a/apps/web/src/BoothPayModal.tsx +++ b/apps/web/src/BoothPayModal.tsx @@ -73,6 +73,12 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose // exit. A normal within-grace paid session is NOT payable (it's settled). See // booth-exit-flow.md / reopenBarrier server guard. const isOverstay = s?.overstay === true; + // CLOSED-WITHIN-GRACE: a paid transient whose exit was already signed but the barrier + // didn't confirm — it lingers in the active list until grace runs out (the "phantom + // re-close" / damaged-ticket case). `s.open` is false, so it's not payable and not the + // normal review flow; the only action is an audited manual re-pulse of the barrier. + // (A grace-EXPIRED closed session falls through to the plain "already closed" notice.) + const closedWithinGrace = !!(s?.found && !s.open && s.withinGrace && !isSubscription); // A subscription is normally prepaid (never charged). EXCEPTION: a time-window plan can // owe an out-of-window TARIFF-BRIDGE charge (early entry / late exit) — lookup() returns // it as s.amountMinor, and exit is GATED until it's paid. So a subscription IS payable @@ -278,21 +284,54 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose )} - {s && s.found && !s.open && ( -
- {t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })} -
+ {s && s.found && !s.open && !closedWithinGrace && ( + // A fully-closed session (exited, grace expired): no action to take, but the + // operator may still need to REVIEW the evidence (entry/exit snapshots + plate) + // — e.g. a dispute about a car that just left. Show the closed notice, the + // figures, and the snapshot strip read-only. No tender / voucher / open here. + <> +
+ {t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })} +
+ +
+ + + + {alreadyPaid && s.paidMinor != null && s.paidCurrency && ( + + )} +
+ + + )} - {s && s.found && s.open && ( + {s && s.found && (s.open || closedWithinGrace) && ( <> {/* Session figures */}
- + {/* Closed-within-grace shows the recorded EXIT; an open session shows now. */} +
@@ -322,7 +365,16 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose amount is the TOP-UP delta, not the whole stay. */}
- {subWindowDue ? t("pay.windowCharge") : isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")} + {subWindowDue + ? t("pay.windowCharge") + : isSubscription + ? t("pay.plan") + : isOverstay + ? t("pay.topUp") + : alreadyPaid && s.paidMinor != null + ? // Settled session — the figure is the sum collected, not a quote. + t("pay.paidAmount") + : t("pay.total")} {subWindowDue && s.amountMinor != null && s.currency @@ -331,9 +383,12 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose ? t("pay.prepaid") : s.amountMinor != null && s.currency ? formatMoney(s.amountMinor, s.currency) - : alreadyPaid - ? t("booth.badgePaid") - : t("pay.noTariff")} + : alreadyPaid && s.paidMinor != null && s.paidCurrency + ? // Settled (within-grace / closed): show the sum actually collected. + formatMoney(s.paidMinor, s.paidCurrency) + : alreadyPaid + ? t("booth.badgePaid") + : t("pay.noTariff")}
@@ -361,6 +416,14 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose )} + {/* Closed-within-grace: the exit is already paid + recorded; the barrier + just didn't confirm. Explain that the only action is a manual re-pulse. */} + {closedWithinGrace && ( +
+ {t("pay.closedWithinGraceHint")} +
+ )} + {/* Snapshots */} @@ -382,8 +445,9 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose )} - {/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */} - {phase !== "done" && !isSubscription && ( + {/* Voucher checkbox (transient only; a subscriber doesn't self-exit). Not + for a closed-within-grace session — its exit is already recorded. */} + {phase !== "done" && !isSubscription && !closedWithinGrace && (
{t("booth.colPlate")} {t("booth.colEntry")} {t("booth.colElapsed")}
{s.subscription ? ( ★ {s.subscriptionHolder ?? t("subs.unnamed")} ) : ( - s.identity + + {s.identity} + {closedInGrace && ( + + {graceLeft ? t("booth.exitedGraceLeft", { time: graceLeft }) : t("booth.exitedGrace")} + + )} + )} @@ -170,28 +156,8 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void {formatRelativeDateTime(s.enteredAt, t)} - {formatDuration(s.enteredAt, new Date().toISOString())} - - {canReopen && ( - - )} - {msg && ( - - {msg.text} - - )} + {/* Freeze the elapsed at the recorded exit for a closed-in-grace row. */} + {formatDuration(s.enteredAt, (closedInGrace ? s.exitedAt : null) ?? new Date().toISOString())}
+ + + + + + + {canReview && } + + {canReview && + + + {movements.map((m) => ( + void qc.invalidateQueries({ queryKey: ["drawer"] })} /> + ))} + +
{t("drawer.colWhen")}{t("drawer.colType")}{t("drawer.colAmount")}{t("drawer.colReason")}{t("drawer.colOperator")}{t("drawer.colStatus")}} +
+ )} + + +
+ + ); +} + +function RecordPanel({ onDone }: { onDone: () => void }) { + const { t } = useTranslation(); + const [amount, setAmount] = useState(""); + const [reason, setReason] = useState(""); + const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null); + const record = useMutation({ + mutationFn: (type: "cash_in" | "cash_out") => + recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim() }), + onSuccess: (r) => { + setMsg({ ok: true, text: t("drawer.recorded", { no: r.voucherNo, amount: money(r.balanceMinor, null) }) }); + setAmount(""); + setReason(""); + onDone(); + }, + onError: (e) => setMsg({ ok: false, text: (e as Error).message }), + }); + + function submit(type: "cash_in" | "cash_out") { + setMsg(null); + const major = Number(amount); + if (!Number.isFinite(major) || major <= 0) { + setMsg({ ok: false, text: t("drawer.enterPositive") }); + return; + } + record.mutate(type); + } + + return ( + +
+
+ setAmount(e.target.value)} + placeholder={t("drawer.amount")} + inputMode="decimal" + /> + setReason(e.target.value)} + placeholder={t("drawer.reasonPlaceholder")} + /> +
+
{t("drawer.recordHint")}
+ {msg && ( +
{msg.text}
+ )} +
+ + +
+
+
+ ); +} + +function MovementRow({ m, canReview, onReviewed }: { m: DrawerMovement; canReview: boolean; onReviewed: () => void }) { + const { t } = useTranslation(); + const [note, setNote] = useState(""); + const [noteOpen, setNoteOpen] = useState(false); + const review = useMutation({ + mutationFn: (decision: "authorize" | "deny") => + reviewDrawerMovement({ refId: m.id, decision, note: note.trim() || undefined }), + onSuccess: onReviewed, + }); + // Direction sign for display: cash_in is +, cash_out is −. + const signed = m.type === "cash_in" ? m.amountMinor : -m.amountMinor; + return ( + + {formatRelativeDateTime(m.at, t)} + + + {m.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")} + + {m.voucherNo && {m.voucherNo}} + + + {money(signed, m.currency)} + + {m.reason || "—"} + {canReview && {m.operator}} + + + {m.status !== "pending" && m.reviewedBy && ( +
+ {m.reviewedBy} + {m.reviewNote ? ` · ${m.reviewNote}` : ""} +
+ )} + + {canReview && ( + + {m.status === "pending" ? ( +
+
+ + +
+ {noteOpen && ( + setNote(e.target.value)} + placeholder={t("drawer.denyNotePlaceholder")} + /> + )} + {review.isError && {(review.error as Error).message}} +
+ ) : null} + + )} + + ); +} diff --git a/apps/web/src/ShiftsHistory.tsx b/apps/web/src/ShiftsHistory.tsx index 4fff06b..81828c3 100644 --- a/apps/web/src/ShiftsHistory.tsx +++ b/apps/web/src/ShiftsHistory.tsx @@ -8,12 +8,12 @@ import { fetchShiftReport, fetchShifts, openShift, - recordCashVoucher, type ShiftReport, type ShiftSummary, type SessionUser, } from "./api.js"; import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js"; +import { CARD_PAYMENTS_ENABLED } from "./lib/features.js"; import { Modal } from "./ui/Modal.js"; import { EventDetailModal, EventRow } from "./ui/event-detail.js"; import type { LedgerEvent } from "@parking/shared"; @@ -88,7 +88,7 @@ function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; i }; } -export function ShiftsHistory({ user, canManage = false, canVoucher = false }: { user: SessionUser | null; canManage?: boolean; canVoucher?: boolean }) { +export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | null; canManage?: boolean }) { const { t } = useTranslation(); const [preset, setPreset] = useState("week"); const [operator, setOperator] = useState(""); @@ -198,7 +198,6 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: { isMine={isMine} showOperator={isAdmin} canManage={canManage} - canVoucher={canVoucher} onChanged={refreshAll} /> ) : ( @@ -257,7 +256,7 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
{t("shifts.payments")} {s.paymentCount} {money(s.cashTotalMinor, cur)} - {money(s.cardTotalMinor, cur)} + {CARD_PAYMENTS_ENABLED && {money(s.cardTotalMinor, cur)}} {money(s.expectedDrawerMinor, cur)}
@@ -270,7 +269,6 @@ function ShiftActivityLog({ isMine, showOperator, canManage, - canVoucher, onChanged, }: { shift: ShiftSummary; @@ -278,11 +276,10 @@ function ShiftActivityLog({ isMine: boolean; showOperator: boolean; canManage: boolean; - canVoucher: boolean; onChanged: () => void; }) { const { t } = useTranslation(); - const [modal, setModal] = useState(null); + const [modal, setModal] = useState(null); // Click an activity row → the SAME read-only event-detail modal the booth feed opens // (full signed payload + snapshots + chain provenance). const [detailEvent, setDetailEvent] = useState(null); @@ -311,7 +308,6 @@ function ShiftActivityLog({ {isCurrent && isMine && canManage && ( - {canVoucher && } )} @@ -325,7 +321,7 @@ function ShiftActivityLog({
-
+ {CARD_PAYMENTS_ENABLED &&
}
@@ -340,7 +336,6 @@ function ShiftActivityLog({ {detailEvent && setDetailEvent(null)} />} {modal === "end" && setModal(null)} onDone={onChanged} />} - {modal === "voucher" && setModal(null)} onDone={onChanged} />} {modal === "takings" && setModal(null)} />} ); @@ -385,7 +380,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
-
+ {CARD_PAYMENTS_ENABLED &&
}
@@ -411,7 +406,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
-
+ {CARD_PAYMENTS_ENABLED &&
} {/* Drawer math made explicit: opening cash + cash taken = expected drawer. */}
@@ -430,54 +425,6 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos ); } -function VoucherModal({ currency, onClose, onDone }: { currency: string | null; onClose: () => void; onDone: () => void }) { - const { t } = useTranslation(); - const [amount, setAmount] = useState(""); - const [reason, setReason] = useState(""); - const [authName, setAuthName] = useState(""); - const [authPassword, setAuthPassword] = useState(""); - const [msg, setMsg] = useState(null); - - async function submit(type: "cash_in" | "cash_out") { - setMsg(null); - const major = Number(amount); - if (!Number.isFinite(major) || major <= 0) return setMsg(t("shift.enterPositive")); - if (!authName.trim() || !authPassword) return setMsg(t("shift.authRequired")); - try { - const r = await recordCashVoucher({ type, amountMinor: Math.round(major * 100), reason: reason.trim(), authorizedBy: authName.trim(), authorizerPassword: authPassword }); - setMsg(t("shift.voucherRecorded", { no: r.voucherNo, amount: money(r.balanceMinor, currency) })); - setAmount(""); - setReason(""); - setAuthPassword(""); - onDone(); - } catch (e) { - setMsg((e as Error).message); - } - } - - return ( - -
-
- setAmount(e.target.value)} placeholder={t("shift.amount")} inputMode="decimal" /> - setReason(e.target.value)} placeholder={t("shift.reasonPlaceholder")} /> -
-
- setAuthName(e.target.value)} placeholder={t("shift.authName")} autoComplete="off" /> - setAuthPassword(e.target.value)} placeholder={t("shift.authPassword")} autoComplete="off" /> -
-
{t("shift.voucherHint")}
- {msg &&
{msg}
} -
- - - -
-
-
- ); -} - function TakingsModal({ onClose }: { onClose: () => void }) { const { t } = useTranslation(); const q = useQuery({ queryKey: ["shift", "xreport", "modal"], queryFn: fetchShiftReport }); @@ -500,7 +447,7 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
-
+ {CARD_PAYMENTS_ENABLED &&
}
diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 350f080..91580fc 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1019,15 +1019,38 @@ export async function fetchShiftReport(): Promise { return (await apiFetch("/api/shift/report")) ?? null; } -/** A drawer cash voucher: Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese - * (cash_out / pay-OUT). Direction is the TYPE, amountMinor a positive magnitude. - * Operator-raised, admin-authorized (authorizedBy + their password). */ -export function recordCashVoucher(args: { +// --- Drawer cash movements (operator records, admin reviews) --------------------- +// Redesigned 2026-07-01: an operator RECORDS a receipt/disbursement freely; an admin +// REVIEWS it after the fact (authorize/deny — a flag, never a cash reversal). See +// wiki/concepts/shift.md. + +export type MovementStatus = "pending" | "authorized" | "denied"; + +/** A drawer movement with its admin-review status. */ +export interface DrawerMovement { + id: string; + type: "cash_in" | "cash_out"; + /** Positive magnitude; direction is the type. */ + amountMinor: number; + currency: string | null; + reason: string | null; + operator: string; + voucherNo: string | null; + at: string; + status: MovementStatus; + reviewedBy: string | null; + reviewNote: string | null; + reviewedAt: string | null; +} + +/** Operator RECORDS a drawer movement — cash_in (Mandat Arkëtimi / pay-IN) or cash_out + * (Mandat Pagese / pay-OUT). Direction is the TYPE; amountMinor a positive magnitude. + * No admin sign-off at creation — it's reviewed afterward. */ +export function recordDrawerMovement(args: { type: "cash_in" | "cash_out"; amountMinor: number; reason: string; - authorizedBy: string; - authorizerPassword: string; + currency?: string; }): Promise<{ type: "cash_in" | "cash_out"; amountMinor: number; @@ -1035,10 +1058,26 @@ export function recordCashVoucher(args: { balanceMinor: number; printed: boolean; }> { - return apiFetch("/api/cash-voucher", { - method: "POST", - body: JSON.stringify(args), - }); + return apiFetch("/api/drawer/movement", { method: "POST", body: JSON.stringify(args) }); +} + +/** List drawer movements + review status. Operators get their OWN; a reviewer gets all + * and may filter by status (the pending review queue). */ +export function fetchDrawerMovements(status?: MovementStatus): Promise<{ + movements: DrawerMovement[]; + scope: "all" | "self"; +}> { + const qs = status ? `?status=${encodeURIComponent(status)}` : ""; + return apiFetch(`/api/drawer/movements${qs}`); +} + +/** Admin AUTHORIZES or DENIES a recorded movement (a flag — never a cash reversal). */ +export function reviewDrawerMovement(args: { + refId: string; + decision: "authorize" | "deny"; + note?: string; +}): Promise<{ refId: string; decision: "authorize" | "deny"; reviewedBy: string; at: string }> { + return apiFetch("/api/drawer/review", { method: "POST", body: JSON.stringify(args) }); } /** A completed shift (reconstructed from its signed Z-report). */ diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 4206c9c..c8ac358 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -58,12 +58,42 @@ export const en: Catalog = { users: "Users", roles: "Roles", shifts: "Shifts", + drawer: "Drawer", reports: "Reports", recycleBin: "Recycle bin", logs: "Logs", backup: "Backup", profile: "Profile", }, + drawer: { + recordTitle: "Record a cash movement", + amount: "amount", + reasonPlaceholder: "reason (e.g. supplier payment, bank drop)", + recordHint: "Recorded to the drawer immediately. An admin reviews it afterward.", + mandatArketimi: "Receipt (in) +", + mandatPagese: "Disbursement (out) −", + enterPositive: "Enter a positive amount.", + recorded: "{{no}} recorded. Drawer now {{amount}}.", + myTitle: "My cash movements", + allTitle: "Cash movements", + pendingCount: "{{count}} pending", + filterAll: "All", + empty: "No cash movements yet.", + colWhen: "When", + colType: "Type", + colAmount: "Amount", + colReason: "Reason", + colOperator: "Operator", + colStatus: "Status", + status: { + pending: "pending", + authorized: "authorized", + denied: "denied", + }, + authorize: "Authorize", + deny: "Deny", + denyNotePlaceholder: "reason for denial (optional)", + }, profile: { title: "My profile", accountSection: "Account", @@ -183,6 +213,8 @@ export const en: Catalog = { evtCashMovement: "CASH", evtCashIn: "PAY-IN", evtCashOut: "PAY-OUT", + evtCashReview: "REVIEW", + decision: { authorize: "authorized", deny: "denied" }, evtAnomaly: "ANOMALY", evtRefused: "REFUSED", // live-feed event detail line + classification badges (computed from payload) @@ -224,6 +256,10 @@ export const en: Catalog = { edPlate: "Plate", edCategory: "Category", edOperator: "Operator", + edDecision: "Review decision", + edReviewedBy: "Reviewed by", + edReviewNote: "Note", + edReviewRef: "Movement ref", edTariffVersion: "Tariff version", edRawPayload: "Raw signed payload", edOccurrence: "Occurrence id", @@ -709,9 +745,9 @@ export const en: Catalog = { srcSubWindow: "out-of-window", drawerSection: "— Drawer —", openingFloat: "Opening cash:", - cashTaken: "Cash taken:", - cashAdded: "Cash added:", - cashRemoved: "Cash removed:", + cashTaken: "Daily takings:", + cashAdded: "Receipts:", + cashRemoved: "Disbursements:", expectedDrawer: "Expected drawer:", printedToReceipt: "Printed to booth receipt.", recordedNoPrinter: "Recorded (no printer to print to).", @@ -760,9 +796,9 @@ export const en: Catalog = { current: "current", drawerSection: "Drawer", openingFloat: "Opening cash", - cashTaken: "Cash taken", - cashAdded: "Cash added", - cashRemoved: "Cash removed", + cashTaken: "Daily takings", + cashAdded: "Receipts", + cashRemoved: "Disbursements", loadFailed: "Failed to load shifts.", }, reports: { diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 332657e..c8ed5d4 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -60,12 +60,42 @@ export const sq = { users: "Përdoruesit", roles: "Rolet", shifts: "Turnet", + drawer: "Arka", reports: "Raportet", recycleBin: "Koshi", logs: "Loget", backup: "Kopje rezervë", profile: "Profili", }, + drawer: { + recordTitle: "Regjistro një lëvizje arke", + amount: "shuma", + reasonPlaceholder: "arsyeja (p.sh. pagesë furnitori, depozitë banke)", + recordHint: "Regjistrohet menjëherë në arkë. Një admin e shqyrton më pas.", + mandatArketimi: "Arkëtim (hyrje) +", + mandatPagese: "Pagesë (dalje) −", + enterPositive: "Fut një shumë pozitive.", + recorded: "{{no}} u regjistrua. Arka tani {{amount}}.", + myTitle: "Lëvizjet e mia të arkës", + allTitle: "Lëvizjet e arkës", + pendingCount: "{{count}} në pritje", + filterAll: "Të gjitha", + empty: "Asnjë lëvizje arke ende.", + colWhen: "Kur", + colType: "Lloji", + colAmount: "Shuma", + colReason: "Arsyeja", + colOperator: "Operatori", + colStatus: "Statusi", + status: { + pending: "në pritje", + authorized: "autorizuar", + denied: "refuzuar", + }, + authorize: "Autorizo", + deny: "Refuzo", + denyNotePlaceholder: "arsyeja e refuzimit (opsionale)", + }, profile: { title: "Profili im", accountSection: "Llogaria", @@ -187,6 +217,8 @@ export const sq = { evtCashMovement: "ARKË", evtCashIn: "ARKËTIM", evtCashOut: "PAGESË", + evtCashReview: "SHQYRTIM", + decision: { authorize: "autorizuar", deny: "refuzuar" }, evtAnomaly: "ANOMALI", evtRefused: "REFUZUAR", // rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload) @@ -228,6 +260,10 @@ export const sq = { edPlate: "Targa", edCategory: "Kategoria", edOperator: "Operatori", + edDecision: "Vendimi i shqyrtimit", + edReviewedBy: "Shqyrtuar nga", + edReviewNote: "Shënim", + edReviewRef: "Ref. lëvizjes", edTariffVersion: "Versioni i tarifës", edRawPayload: "Të dhënat e papërpunuara të nënshkruara", edOccurrence: "ID e hyrjes", @@ -722,9 +758,9 @@ export const sq = { srcSubWindow: "jashtë orarit", drawerSection: "— Arka —", openingFloat: "Arka fillestare:", - cashTaken: "Para të marra:", - cashAdded: "Para të shtuara:", - cashRemoved: "Para të hequra:", + cashTaken: "Xhiro ditore:", + cashAdded: "Arkëtime:", + cashRemoved: "Pagesa:", expectedDrawer: "Gjëndje Arke:", printedToReceipt: "Printuar te printeri i kabinës.", recordedNoPrinter: "Regjistruar (pa printer për të printuar).", @@ -775,9 +811,9 @@ export const sq = { // Expanded drawer detail. drawerSection: "Arka", openingFloat: "Arka fillestare", - cashTaken: "Para të marra", - cashAdded: "Para të shtuara", - cashRemoved: "Para të hequra", + cashTaken: "Xhiro ditore", + cashAdded: "Arkëtime", + cashRemoved: "Pagesa", loadFailed: "Ngarkimi i turneve dështoi.", }, reports: { diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index fc8a6e3..384d1b0 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -41,6 +41,8 @@ import { SiteSettings } from "./SiteSettings.js"; import { UsersManager } from "./UsersManager.js"; import { RolesManager } from "./RolesManager.js"; import { ShiftsHistory } from "./ShiftsHistory.js"; +import { DrawerManager } from "./DrawerManager.js"; +import { CARD_PAYMENTS_ENABLED } from "./lib/features.js"; import { LogsViewer } from "./LogsViewer.js"; import { BackupSettings } from "./BackupSettings.js"; import { RecycleBin } from "./RecycleBin.js"; @@ -379,7 +381,7 @@ function CloseShiftConfirm({
- + {CARD_PAYMENTS_ENABLED && } {/* Drawer math made explicit: opening float + cash taken = expected drawer. */} @@ -430,6 +432,11 @@ function RootLayout() {
)} + {/* PLATE-SWAP warning: the exiting plate is already inside under another + ticket. A prominent, deliberate hold — the operator must consciously + override to release. See wiki/concepts/plate-reconciliation.md. */} + {swap && ( +
+
+ {t("pay.swapTitle")} +
+
+ {t("pay.swapBody", { + plate: swap.plate, + other: swap.otherIdentity, + when: swap.otherEnteredAt ? formatRelativeDateTime(swap.otherEnteredAt, t) : "—", + })} +
+
{t("pay.swapHint")}
+
+ )} + {error &&
{error}
} {result && (
{result}
@@ -602,26 +632,39 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose {t("pay.cancelTicket")} )} - + ) : ( + + ? t("pay.printingVoucher") + : t("pay.opening") + : alreadyPaid + ? voucher + ? t("pay.printVoucher") + : t("pay.openBarrier") + : voucher + ? t("pay.payAndVoucher") + : t("pay.payAndOpen")} + + )} )} diff --git a/apps/web/src/BoothScreen.tsx b/apps/web/src/BoothScreen.tsx index b43e139..f9685da 100644 --- a/apps/web/src/BoothScreen.tsx +++ b/apps/web/src/BoothScreen.tsx @@ -1,7 +1,8 @@ import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useQuery } from "@tanstack/react-query"; -import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { can, fetchEvents, fetchOccupancy, issueEntryTicket, type LedgerEvent, type Occupancy } from "./api.js"; +import { rootRoute } from "./router.js"; import { qk } from "./lib/query.js"; import { useLiveStore } from "./lib/live-store.js"; import { useShift } from "./lib/use-shift.js"; @@ -116,16 +117,40 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) { * - radar present + camera NOT busy → BLINK green↔red (~1 Hz): "detected, not yet confirmed" * - camera busy → SOLID red: a vehicle is confirmed at the lane vicinity * - otherwise → SOLID green: free - * Advisory only; it gates nothing. The blink uses the `.lane-blink` keyframe (index.css), - * whose children inherit the alternating colour via `currentColor`. */ -function BarrierLight({ label, busy, radar }: { label: string; busy: boolean; radar: boolean }) { + * Advisory only; it gates nothing. On the ENTRY light, when the operator holds `session:create` + * and BOTH presence conditions meet (radar present AND camera busy = a real car at the entry), + * the light becomes a CLICKABLE issue-ticket control (broken physical button). Same presence + * rule as the physical button; the server re-checks it. See operator-issued-entry.md. */ +function BarrierLight({ + label, + busy, + radar, + onIssue, + issuing, +}: { + label: string; + busy: boolean; + radar: boolean; + /** When set (entry light + permission), clicking issues an entry ticket — only enabled + * when both presence conditions meet (radar && busy). */ + onIssue?: () => void; + issuing?: boolean; +}) { + const { t } = useTranslation(); // Blink only when the radar sees something the camera hasn't confirmed. const blinking = radar && !busy; const solid = busy ? "border-term-red bg-term-red/10 text-term-red" : "border-term-green bg-term-green/10 text-term-green"; + // The issue control is active only with a REAL car present (radar AND camera). + const canIssue = !!onIssue && radar && busy && !issuing; + const clickable = !!onIssue && radar && busy; return (
{/* Barrier glyph: a post + an arm. `currentColor` follows the (possibly blinking) state. */} @@ -135,22 +160,58 @@ function BarrierLight({ label, busy, radar }: { label: string; busy: boolean; ra
{label}
-
{busy ? "●" : blinking ? "◐" : "○"}
+
+ {issuing ? "…" : clickable ? t("booth.issueEntry") : busy ? "●" : blinking ? "◐" : "○"} +
); } /** The two lane barrier lights (entry / exit) fed by the live lane-status (camera busy/free) - * and lane-presence (radar). */ + * and lane-presence (radar). The ENTRY light doubles as an operator issue-ticket control when + * the physical button is broken (permission + presence gated). */ function LaneIndicators() { const { t } = useTranslation(); const lanes = useLiveStore((s) => s.lanes); const radar = useLiveStore((s) => s.radar); + const { user } = rootRoute.useRouteContext(); + const { isOpen: shiftOpen, isMine } = useShift(); + const qc = useQueryClient(); + const canIssue = can(user, "session:create") && shiftOpen && isMine; + const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null); + + const issue = useMutation({ + mutationFn: issueEntryTicket, + onSuccess: (r) => { + setMsg({ ok: true, text: t("booth.issueEntryOk", { ticket: r.ticketId }) }); + void qc.invalidateQueries({ queryKey: qk.events }); + void qc.invalidateQueries({ queryKey: qk.occupancy }); + setTimeout(() => setMsg(null), 4000); + }, + onError: (e) => { + setMsg({ ok: false, text: (e as Error).message }); + setTimeout(() => setMsg(null), 4000); + }, + }); + + function onIssue() { + if (window.confirm(t("booth.issueEntryConfirm"))) issue.mutate(); + } + return (
- + + {msg && ( + {msg.text} + )}
); } diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 91580fc..6a57731 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -30,7 +30,7 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise } const res = await fetch(apiUrl(path), { ...init, headers, credentials: "include" }); if (!res.ok) { - const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[] }; + const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[]; [k: string]: unknown }; const error = msg.error ?? `${path}: ${res.status}`; // Ship the failed request to the backend log store (best-effort, loop-safe — the // logger itself never logs the /api/logs call). 401s are normal pre-login churn, @@ -38,7 +38,7 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise if (res.status !== 401) { logFailedRequest({ path, method, status: res.status, error }); } - throw new ApiError(error, res.status, msg.problems); + throw new ApiError(error, res.status, msg.problems, msg); } if (res.status === 204) return undefined as T; return res.json() as Promise; @@ -50,6 +50,9 @@ export class ApiError extends Error { readonly status: number, /** Field-level problems from a validation error (e.g. tariff publish), if any. */ readonly problems?: string[], + /** The full parsed error body, for callers that need extra fields (e.g. a booth + * exit's plate-swap detail: { status, plate, otherIdentity, otherEnteredAt }). */ + readonly body?: Record, ) { super(message); } @@ -1284,12 +1287,43 @@ export function voidTicket(identity: string, reason: string): Promise<{ ok: bool } /** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't - * open (payment stands; operator opens manually). */ -export type BoothExitResult = { ok: true; opened: boolean; reason?: string }; + * open (payment stands; operator opens manually). `swapSuspected` = the exiting car's + * plate is already inside under a DIFFERENT ticket (possible ticket-swap); the operator + * must review and re-call with override:true to release. See plate-reconciliation.md. */ +export type BoothExitResult = + | { ok: true; opened: boolean; reason?: string } + | { ok: false; swapSuspected: true; reason: string; plate: string; otherIdentity: string; otherEnteredAt: string | null }; -/** Validate + open the barrier for a session from the booth (when near the exit). */ -export function boothExit(identity: string): Promise { - return apiFetch("/api/exit", { method: "POST", body: JSON.stringify({ identity }) }); +/** Validate + open the barrier for a session from the booth (when near the exit). + * Pass override:true to consciously release a suspected plate-swap exit. */ +export async function boothExit(identity: string, override = false): Promise { + try { + return await apiFetch<{ ok: true; opened: boolean; reason?: string }>("/api/exit", { + method: "POST", + body: JSON.stringify({ identity, ...(override ? { override: true } : {}) }), + }); + } catch (e) { + // A suspected plate-swap comes back 409 with status:"swap_suspected" + detail — surface + // it as a structured result (not a thrown error) so the modal can warn + offer override. + if (e instanceof ApiError && e.body?.status === "swap_suspected") { + const b = e.body; + return { + ok: false, + swapSuspected: true, + reason: String(b.error ?? ""), + plate: String(b.plate ?? ""), + otherIdentity: String(b.otherIdentity ?? ""), + otherEnteredAt: (b.otherEnteredAt as string | null) ?? null, + }; + } + throw e; + } +} + +/** Operator issues an entry ticket when the physical button is broken. A FLAGGED mint, + * server-gated on real vehicle presence (radar + camera). Returns the new ticket id. */ +export function issueEntryTicket(): Promise<{ ok: true; ticketId: string; opened: boolean; overCapacity: boolean }> { + return apiFetch("/api/entry/issue", { method: "POST", body: JSON.stringify({}) }); } /** Print an exit voucher (paid ticket id reprinted as a barcode) + payment detail, diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index c8ac358..61ae09d 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -191,6 +191,10 @@ export const en: Catalog = { fEvtAnomaly: "Anomaly", openPayExit: "Open pay / exit", openReopenBarrier: "Open — paid, awaiting barrier", + issueEntry: "Issue ticket", + issueEntryTitle: "Issue an entry ticket & open the barrier (physical button broken)", + issueEntryConfirm: "A vehicle is at the entry. Issue an entry ticket and open the barrier?", + issueEntryOk: "Entry ticket {{ticket}} issued.", exitedGrace: "exited · grace", exitedGraceLeft: "exited · {{time}}", exitedGraceTitle: "Paid and exited — barrier not confirmed; waiting out the grace period.", @@ -275,6 +279,8 @@ export const en: Catalog = { reason: { "entry.refused.full": "Entry refused — lot full ({{count}}/{{capacity}})", "entry.held.noTicket": "Entry held — ticket not printed: {{detail}}", + "entry.operatorIssued": "Entry ticket issued by operator {{operator}} (physical button broken)", + "entry.issue.noPresence": "Operator entry refused — no vehicle detected at the entry", "exit.refused.closed": "Exit refused — session already closed", "exit.refused.noSession": "Exit refused — unknown ticket", "exit.refused.unpaid": "Exit refused — not paid (take payment first)", @@ -284,6 +290,8 @@ export const en: Catalog = { "exit.open.failed": "Exit recorded, but the barrier did not open — open manually", "exit.freeGrace": "Free entry-grace (no charge)", "exit.manualOpen": "Manual barrier open (human intervention)", + "exit.plateSwapSuspected": "Possible ticket swap — plate {{plate}} is already inside under ticket {{otherIdentity}}", + "exit.plateSwapOverride": "Operator {{operator}} released a suspected ticket-swap exit (plate {{plate}}, also open under {{otherIdentity}})", "sub.refused.notFound": "Subscription refused — not found", "sub.refused.outOfWindow": "Subscription refused — {{status}}/out-of-window", "sub.refused.noSession": "Subscription exit with no open session (already out / never entered)", @@ -944,6 +952,10 @@ export const en: Catalog = { lookingUp: "looking up…", paidBarrierOpened: "Paid — barrier opened. Car may exit.", paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.", + swapTitle: "Possible ticket swap", + swapBody: "Plate {{plate}} is already inside under ticket {{other}} (entered {{when}}). This car may be exiting on a different ticket than it entered on.", + swapHint: "Verify the vehicle before releasing. Overriding is recorded against you.", + swapOverride: "Override & release", subscription: "SUBSCRIPTION", plan: "Plan", prepaid: "PREPAID", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index c8ed5d4..a571331 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -193,6 +193,10 @@ export const sq = { fEvtAnomaly: "Anomali", openPayExit: "Hap pagesën / daljen", openReopenBarrier: "Hap — paguar, pret barrierën", + issueEntry: "Lësho biletë", + issueEntryTitle: "Lësho një biletë hyrjeje & hap barrierën (butoni fizik i prishur)", + issueEntryConfirm: "Një automjet është te hyrja. Të lëshohet një biletë hyrjeje dhe të hapet barriera?", + issueEntryOk: "Bileta e hyrjes {{ticket}} u lëshua.", exitedGrace: "doli · në afat", exitedGraceLeft: "doli · {{time}}", exitedGraceTitle: "Paguar dhe dalur — barriera nuk u konfirmua; po pret afatin kohor.", @@ -278,6 +282,8 @@ export const sq = { reason: { "entry.refused.full": "Hyrja u refuzua — parkimi plot ({{count}}/{{capacity}})", "entry.held.noTicket": "Hyrja u mbajt — bileta nuk u printua: {{detail}}", + "entry.operatorIssued": "Biletë hyrjeje e lëshuar nga operatori {{operator}} (butoni fizik i prishur)", + "entry.issue.noPresence": "Hyrja nga operatori u refuzua — asnjë automjet te hyrja", "exit.refused.closed": "Dalja u refuzua — sesioni është mbyllur tashmë", "exit.refused.noSession": "Dalja u refuzua — biletë e panjohur", "exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)", @@ -287,6 +293,8 @@ export const sq = { "exit.open.failed": "Dalja u regjistrua, por barriera nuk u hap — hape manualisht", "exit.freeGrace": "Periudhë pa pagesë në hyrje (pa tarifë)", "exit.manualOpen": "Hapje manuale e barrierës (ndërhyrje njerëzore)", + "exit.plateSwapSuspected": "Mundësi ndërrimi biletash — targa {{plate}} është tashmë brenda me biletën {{otherIdentity}}", + "exit.plateSwapOverride": "Operatori {{operator}} lëshoi një dalje me dyshim ndërrimi biletash (targa {{plate}}, edhe e hapur me {{otherIdentity}})", "sub.refused.notFound": "Abonimi u refuzua — nuk u gjet", "sub.refused.outOfWindow": "Abonimi u refuzua — {{status}}/jashtë afatit", "sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)", @@ -960,6 +968,10 @@ export const sq = { lookingUp: "Duke kërkuar…", paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.", paidExitRecorded: "Paguar dhe dalja u regjistrua, por barriera nuk u hap: {{reason}}.", + swapTitle: "Mundësi ndërrimi biletash", + swapBody: "Targa {{plate}} është tashmë brenda me biletën {{other}} (hyri {{when}}). Ky automjet mund të jetë duke dalë me një biletë tjetër nga ajo me të cilën hyri.", + swapHint: "Verifiko automjetin para se ta lëshosh. Anashkalimi regjistrohet në emrin tënd.", + swapOverride: "Anashkalo & lësho", subscription: "ABONIM", plan: "Plani", prepaid: "I PARAPAGUAR", diff --git a/packages/db/drizzle/0019_operator_session_create.sql b/packages/db/drizzle/0019_operator_session_create.sql new file mode 100644 index 0000000..6211ef9 --- /dev/null +++ b/packages/db/drizzle/0019_operator_session_create.sql @@ -0,0 +1,9 @@ +-- Operator-issued entry (2026-07-01): when the physical entry button is broken, an operator +-- may ISSUE an entry ticket (a flagged mint, gated on real vehicle presence — radar + camera). +-- New permission `session:create` in @parking/shared. The built-in `admin` role gets ALL +-- permissions in code (auth.ts ADMIN_PERMS), so no seed row is needed for it. This grants the +-- default `operator` role the ability to issue — an admin can revoke it per-role in the Roles +-- UI (it's data). Idempotent via the UNIQUE(role_id, permission) index. +-- See wiki/concepts/operator-issued-entry.md. +INSERT OR IGNORE INTO `role_permissions` (`role_id`, `permission`) VALUES + ('operator','session:create'); diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 89bb580..d934776 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1781886100000, "tag": "0018_drawer_permissions", "breakpoints": true + }, + { + "idx": 19, + "version": "6", + "when": 1781886200000, + "tag": "0019_operator_session_create", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3838287..ee54fa8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -62,7 +62,10 @@ export const PERMISSIONS: readonly Permission[] = [ // a judgment about the operator, settled outside the app. See wiki/concepts/shift.md. "drawer:create", "drawer:review", "payment:read", "payment:create", - "session:read", + // session:create = the operator ISSUES an entry ticket when the physical entry button + // is broken (a flagged mint, gated on real vehicle presence). Admin-revocable per role. + // See wiki/concepts/operator-issued-entry.md. + "session:read", "session:create", "event:read", "event:void", "report:read", "log:read", @@ -360,6 +363,10 @@ export const REASON_CODES = [ // entry "entry.refused.full", "entry.held.noTicket", + // operator-issued entry (physical button broken) — a flagged mint, gated on real + // vehicle presence (radar + camera). See wiki/concepts/operator-issued-entry.md. + "entry.operatorIssued", + "entry.issue.noPresence", // exit refusals "exit.refused.closed", "exit.refused.noSession", @@ -373,6 +380,11 @@ export const REASON_CODES = [ "exit.freeGrace", // manual / human-intervention barrier open "exit.manualOpen", + // plate reconciliation: the exiting car's plate is already OPEN under a DIFFERENT + // ticket (possible ticket-swap fraud). Suspected = flagged; Override = operator + // consciously released it. See wiki/concepts/plate-reconciliation.md. + "exit.plateSwapSuspected", + "exit.plateSwapOverride", // subscriptions "sub.refused.notFound", "sub.refused.outOfWindow", @@ -397,6 +409,8 @@ export type ReasonCode = (typeof REASON_CODES)[number]; export const REASON_EN: Record = { "entry.refused.full": "entry refused — lot full ({count}/{capacity})", "entry.held.noTicket": "entry held — ticket not printed: {detail}", + "entry.operatorIssued": "entry ticket issued by operator {operator} (physical button)", + "entry.issue.noPresence": "operator entry refused — no vehicle detected at the entry", "exit.refused.closed": "exit refused — session already closed", "exit.refused.noSession": "exit refused — no open session for ticket", "exit.refused.unpaid": "exit refused — not paid (take payment first)", @@ -406,6 +420,8 @@ export const REASON_EN: Record = { "exit.open.failed": "exit recorded, but the barrier did not open — open manually", "exit.freeGrace": "free entry-grace (no charge)", "exit.manualOpen": "manual barrier open (human intervention)", + "exit.plateSwapSuspected": "possible ticket swap — plate {plate} is already inside under ticket {otherIdentity}", + "exit.plateSwapOverride": "operator {operator} released a suspected ticket-swap exit (plate {plate}, also open under {otherIdentity})", "sub.refused.notFound": "subscription refused — not found", "sub.refused.outOfWindow": "subscription refused — {status}/out-of-window", "sub.refused.noSession": "subscription exit with no open session (already out / never entered)", diff --git a/wiki/concepts/capacity-occupancy.md b/wiki/concepts/capacity-occupancy.md index 7677890..e3fe4e4 100644 --- a/wiki/concepts/capacity-occupancy.md +++ b/wiki/concepts/capacity-occupancy.md @@ -32,6 +32,9 @@ editable and drifts; the chain is the truth). Spaces-free = `capacity − occupa diverge from physical reality. The count is the *system's* occupancy; periodic ground-truth (a loop count, or the [[opencv-anpr-service|vision]] count) reconciles it — surfaced as an anomaly, not silently corrected. +- **A DELIBERATE drift attack — the ticket swap:** a paid car let out on a fresh $0 ticket leaves its + original ticket "inside" forever, inflating occupancy by phantom cars. Defended by + [[plate-reconciliation]] (the exiting plate is already open under the original ticket → flag/hold). ## Reserved subscriber spots (admin toggle, built 2026-06-20) diff --git a/wiki/concepts/entry-exit-points.md b/wiki/concepts/entry-exit-points.md index 5a8ffcd..0475e86 100644 --- a/wiki/concepts/entry-exit-points.md +++ b/wiki/concepts/entry-exit-points.md @@ -157,4 +157,5 @@ as "⚠ camera unreachable" tiles (see [[booth-console]]). [[entry-exit-readers]] · [[device-events]] · [[parking-session]] · [[anti-passback]] · [[append-only-event-chain]] · [[barrier-not-a-door]] · [[opencv-anpr-service]] · -[[dingtian-relay]] · [[first-run-setup]] +[[dingtian-relay]] · [[first-run-setup]] · [[operator-issued-entry]] (mint when the button +is broken) · [[plate-reconciliation]] (the entry snapshot's plate defends the exit) diff --git a/wiki/concepts/operator-issued-entry.md b/wiki/concepts/operator-issued-entry.md new file mode 100644 index 0000000..85f474c --- /dev/null +++ b/wiki/concepts/operator-issued-entry.md @@ -0,0 +1,70 @@ +--- +type: concept +tags: [parking, booth, entry, threat-model, anpr, presence] +sources: [] +updated: 2026-07-01 +status: settled +--- + +# Operator-issued entry (broken entry button) + +When the physical entry button is broken, an operator can **issue an entry ticket** from the booth so +a real car isn't blocked out of the lot. This hands the [[threat-model|operator (the adversary)]] a way +to mint entries — so it is **flagged, presence-gated, and paired with an exit defense** +([[plate-reconciliation]]). Built 2026-07-01. Companion to [[entry-exit-points]] (the entry flow it +reuses) and [[capacity-occupancy]]. + +## Why give the operator this at all +An operator *could* mint tickets to defraud — but a broken entry button otherwise **blocks the whole +lot**, which is worse and more common. So the feature exists, and the fraud it enables is defended +downstream (see the "ticket-swap" scenario in [[plate-reconciliation]]) rather than by withholding the +capability. + +## The three controls that make it safe + +### 1. PRESENCE-GATED — a real car must be there (radar AND camera) +The operator button obeys the **same rule as the physical button**: it is only active when **BOTH** +presence conditions meet — +- **radar/loop present** (a presence input is shorted at the entry barrier), AND +- **camera confirms** a vehicle in the zone (the entry lane is "busy"). + +This ties every mint to a **real vehicle physically at the entry** — the operator can't pad occupancy +with phantom tickets, and (crucially) it guarantees the entry snapshot captures a **plate**, which is +what [[plate-reconciliation]] reads at exit. **No presence loop configured → the feature is +unavailable** at that site (we require both; no weaker camera-only fallback). + +**Enforced on BOTH sides.** The UI only enables the entry [[booth-console|BarrierLight]] as a clickable +issue-control when `radar.entry && lanes.entry` (both true) and the operator holds `session:create`. +The **server re-checks** current presence (`LaneStatus.snapshot().entry === true` AND the entry relay's +guard `present === true`) and **refuses** otherwise — so a direct `POST /api/entry/issue` by the +operator-adversary can't bypass a disabled button. A refused (no-presence) attempt signs an +`anomaly` (`entry.issue.noPresence`) so probing the endpoint is itself in the tamper-evident record. + +### 2. FLAGGED — every operator mint leaves a red-flag row +The issued entry is a **real** `vehicle_entry` (so occupancy/tariff/exit all work), but: +- `source: "manual"` + `operatorInitiated: true` + `operator` on the signed payload, AND +- a **companion `anomaly`** (`entry.operatorIssued`) — mirroring the [[booth-exit-flow|barrier + re-open]]: the operator-adversary path always leaves an explicit anomaly for [[reconciliation]]. + +### 3. Capacity OVERRIDE is allowed but recorded +Unlike the physical button (which refuses transient entry when the lot is [[capacity-occupancy|full]]), +the operator **can** issue over capacity — a broken button mustn't trap a legit car when the count is +near/at the cap (and the count may itself be inflated by the very fraud this defends). But an over-cap +mint stamps `lotFull: true` + the occupancy on the events, so the override is visible. + +## Wiring +- **Permission:** `session:create` (new; migration 0019 grants it to the default `operator` role; + admin-revocable per role, so an admin can turn off an operator's ability to mint). Admin has it in code. +- **Route:** `POST /api/entry/issue` — `session:create` + an **open shift** (a minted entry belongs to + an accountable operator, like the money path). +- **Server:** `EntryFlow.issueForOperator(operator, cameraBusy)`. The fraud-critical + print → sign(vehicle_entry) → pulseOpen → snapshot → cache sequence is a **single shared + `#issueTicket`** used by both the physical button and this path (no divergent copy). +- **UI:** the entry `BarrierLight` becomes clickable (confirm → issue) only when presence + permission + + shift are satisfied; the exit light stays a pure indicator. + +## Relates +- [[plate-reconciliation]] — the exit-side defense against the ticket-swap this capability enables. +- [[entry-exit-points]] — the entry flow + snapshot/ANPR path reused here. +- [[capacity-occupancy]] — why occupancy integrity matters (the swap fraud drifts it upward). +- [[threat-model]] — the operator-adversary framing all three controls serve. diff --git a/wiki/concepts/plate-reconciliation.md b/wiki/concepts/plate-reconciliation.md new file mode 100644 index 0000000..00b49c8 --- /dev/null +++ b/wiki/concepts/plate-reconciliation.md @@ -0,0 +1,75 @@ +--- +type: concept +tags: [parking, anpr, exit, threat-model, reconciliation, fraud] +sources: [] +updated: 2026-07-01 +status: settled +--- + +# Plate reconciliation at exit (ticket-swap defense) + +Uses the ANPR **plate as an invariant** to catch a **ticket-swap fraud**: the car's plate is the same +regardless of which ticket it holds, so if a car tries to exit on a ticket whose plate is **already +inside under a different ticket**, something is wrong. Built 2026-07-01 alongside +[[operator-issued-entry]] (the capability that makes the fraud easy). The [[threat-model|adversary is +the operator]], but the same swap happens innocently (two people mix up tickets). + +## The fraud (worked scenario) +A lot with 1000 spots: +1. Real car enters on ticket **1234** → ANPR records plate **AA123BB** at entry. +2. Car comes to exit owing 10,000 ALL. Operator scans 1234, **pockets the cash, does NOT record the + payment**. +3. Operator **mints a fresh ticket 1237** (age ≈ 0 → owes ~0) and lets the car out on 1237. +4. **1234 lingers "inside" forever** — a phantom car. Repeat → +100, +200 phantom cars; occupancy + becomes meaningless and the operator skims cash while the books look internally consistent (a ticket + was "paid" — 1237 for 0; a ticket is "inside" — 1234). + +The plate is what the swap can't hide: entry-1234 = AA123BB, and the car exiting on 1237 **is** AA123BB. + +## The check +`ExitFlow.#reconcilePlateAtExit(exitingId)`: +1. Resolve the **exiting** ticket's plate (its own exit read, else its entry read). +2. Enumerate all **currently-open** sessions (projection cache) and their **entry** plates + (`platesForIdentities`). +3. If the exiting plate **exactly** matches an open session under a **DIFFERENT** identity → **swap + suspected**, returning `{ plate, otherIdentity, otherEnteredAt }`. + +**EXACT, HIGH-CONFIDENCE only.** Both the exiting read AND the matched session's entry read must be +≥ `PLATE_MATCH_MIN_CONFIDENCE` (0.85), normalized exact string match. No fuzzy/edit-distance matching. +Rationale: ANPR is **advisory and misses** (G3H snapshot 503s, camera-side push failures, no-plate +reads — see the ANPR memory notes). A fuzzy/low-confidence read must **never** be the reason a car is +held — so a shaky read simply doesn't trigger the warning (fails toward not-annoying). + +## What happens on a suspected swap + +### Booth path (operator-mediated) — FLAG LOUDLY + require an override +Exit fails-OPEN for safety and a plate is **never the sole gate**, so we do **not** silently hard-block +(that would trap a legit car on a bad read). Instead: +- `exitForBooth` returns status **`swap_suspected`** with the detail; the barrier does **not** open. +- A **`anomaly` (`exit.plateSwapSuspected`)** is signed immediately — so even if the operator walks + away, the suspicion is in the tamper-evident record. +- The pay/exit modal shows a **prominent red warning** ("Plate AA123BB is already inside under ticket + 1234, entered 3h ago") with an explicit **"Override & release"** action. +- On override, `exitForBooth(id, { override, operator })` proceeds AND signs an attributed + **`anomaly` (`exit.plateSwapOverride`)** — the override is itself a signed, named decision. + +### Reader path (automated, no operator) — LOG-ONLY, fail-open +At an unmanned exit lane there's no one to make the override decision, and exit fails-open, so the +reader path **signs the `exit.plateSwapSuspected` anomaly and still lets the car out**. The anomaly is +the control there (a manager reconciles it later). This is a smaller surface — the fraud scenario is +booth-mediated. + +## Why this is the right shape +- **Occupancy stops drifting.** A swap can no longer silently strand ticket 1234 "inside" — the exit + attempt on 1237 surfaces it. Directly serves [[capacity-occupancy]] integrity. +- **The signed anomaly is the audit signal** a manager reconciles ([[reconciliation]]) — consistent + with "the fraud control lives in the signed chain + human review, not a real-time hard gate". +- **Advisory-not-a-gate is preserved both ways:** a plate never *opens* a barrier by itself, and now a + plate never *traps* a car by itself either (flag + override, never a silent hard block). + +## Relates +- [[operator-issued-entry]] — the capability whose fraud this defends. +- [[capacity-occupancy]] — occupancy integrity the swap attacks. +- [[reconciliation]] — where the signed anomalies are ultimately settled. +- [[entry-exit-points]] — the ANPR-on-snapshot path that records the plates compared here. +- [[threat-model]] — operator-as-adversary. diff --git a/wiki/index.md b/wiki/index.md index ce2ce48..fe616bf 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -88,6 +88,8 @@ Counts: 4 sources · 19 entities · 46 concepts · 7 decision records. - [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window. - [[tariff-time-tiers]] — BUILT (V2 tariff): happy-hour/off-peak/weekend/seasonal + vehicle category + flat rate via wall-clock windowed cards; tz frozen per version. - [[booth-exit-flow]] — manned booth: pay → voucher (self-exit later) or immediate exit; active sessions; audited barrier re-open. +- [[operator-issued-entry]] — operator mints an entry ticket when the physical button is broken; presence-gated (radar AND camera, both sides), flagged (source=manual + operatorInitiated + anomaly), capacity-override allowed; needs `session:create` (2026-07-01). +- [[plate-reconciliation]] — ANPR plate-as-invariant catches the ticket-swap fraud (paid car let out on a fresh $0 ticket, original lingers "inside"); exact/high-conf match vs open sessions; booth = flag + operator override, reader = log-only fail-open (2026-07-01). - [[shift]] — manned-only accountability period; explicit Start/End; End → signed + printed Z-report; drawer float carries across shifts. Drawer cash movements (operator records, admin reviews via signed cash_review — a flag, not a reversal) live at the /drawer route (2026-07-01). - [[card-payments]] — card tender DISABLED (no P2PE POS on-site yet, 2026-07-01); cash-only UI gate (`CARD_PAYMENTS_ENABLED`); future POS keeps PCI scope out of the app; how to re-enable. - [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked. diff --git a/wiki/log.md b/wiki/log.md index 9d196ca..85373a2 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2108,3 +2108,34 @@ DrawerManager.tsx, drawer.* i18n (sq+en). Verified: full monorepo build/lint/tes tests incl. the op1-denied → op2-drawer-unchanged regression); Playwright end-to-end on /drawer (record disbursement → pending → authorize → status flips, ledger shows cash_out + cash_review with no authorizedBy). Recorded in shift.md "Drawer review". + +## [2026-07-01] feat | Operator-issued entry + exit plate-swap reconciliation (one anti-fraud design) + +Two halves of one design. (A) When the physical entry button is broken, an operator can ISSUE an entry +ticket so a real car isn't blocked out of the lot — but this hands the operator-adversary a mint, so +it's (1) PRESENCE-GATED exactly like the physical button (radar/loop present AND camera busy = a real +car; enforced BOTH sides, server re-checks so a direct POST can't bypass a disabled button; no presence +loop → feature unavailable; a no-presence attempt signs an entry.issue.noPresence anomaly), (2) FLAGGED +(vehicle_entry source=manual + operatorInitiated + operator, PLUS a companion entry.operatorIssued +anomaly), (3) capacity-OVERRIDE allowed but stamped lotFull (a broken button mustn't trap a legit car). +New session:create permission (migration 0019 → operator role; admin-revocable), POST /api/entry/issue +(open-shift gated), EntryFlow.issueForOperator; the fraud-critical print→sign→open→snapshot sequence +factored into one shared #issueTicket (button + operator). UI: the entry BarrierLight becomes a +clickable issue-control when presence+permission+shift meet (confirm → issue). + +(B) Plate-swap fraud (user's scenario): operator scans exiting ticket 1234 (owes 10000), pockets cash +WITHOUT recording payment, mints fresh 1237 (owes ~0), lets the car out on 1237 → 1234 lingers "inside" +forever, occupancy drifts up by phantom cars. Defense = ANPR plate as invariant: the car's plate is the +same either way. ExitFlow.#reconcilePlateAtExit compares the exiting plate against all OPEN sessions' +entry plates — EXACT, HIGH-CONFIDENCE only (≥0.85; a fuzzy/low read never gates, ANPR is advisory). On +a match under a DIFFERENT ticket: BOOTH path returns swap_suspected + signs exit.plateSwapSuspected +anomaly + the pay/exit modal shows a red warning with "Override & release" (override signs an attributed +exit.plateSwapOverride) — flag+override, never a silent hard block (exit fails-open, plate never the +sole gate). READER path (no operator) = log-only anomaly + fail-open (user's call). Extended +BoothExitResult + /api/exit (override param), boothExit client returns a structured swap result. + +Verified: full monorepo build/lint/test green (229 server tests incl. 4 new: hold-on-swap, +override-releases-with-attribution, low-confidence-no-warning, own-plate-no-warning). New wiki pages +operator-issued-entry.md + plate-reconciliation.md; cross-linked from entry-exit-points, +capacity-occupancy, index. Preserves "a plate never OPENS a barrier alone — and now never TRAPS a car +alone either."