f486dcbbfcd3014357032f1de9c3f1f9da35c30e
242 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f486dcbbfc |
docs(wiki): vision-service hardening backlog + boot-migration data-seed note
Two unrelated leftover wiki edits from earlier sessions:
- NEW concepts/vision-service-hardening.md: the prioritised to-do list from the
2026-07-02 code + security reviews of apps/vision/ (DoS gaps, unauthenticated/
operator-writable model weights, 0.0.0.0 default bind). Cross-linked from
opencv-anpr-service.md ("consult before touching this service").
- container-deployment.md: note that a boot-time migration can be a DATA SEED
(e.g. an RBAC permission granted to the operator role via INSERT OR IGNORE),
and that a built-in-role grant does not auto-apply to a custom role.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
c142166972 |
docs(wiki): ATECC608 is upcoming — retag ledger signing to the on-host reality
No secure element is on-site: event signing runs on the software HMAC (EVENT_SIGNING_KEY, an env var on the host disk), so the ledger is tamper-EVIDENT but forgeable by anyone who owns the host. Several pages overstated it as present-tense "ATECC608-signed / unforgeable"; correct them. - NEW concepts/hardware-signer-options.md: four options for a non-extractable signing key (USB HSM / YubiKey / reuse the TPM / plain-dongle trap) + the recommendation (TPM interim → USB-HSM target; ATECC608 stays for the embedded ESP32, wrong part for a PC host). - entities/atecc608.md: UPCOMING-not-present status banner + PC-vs-embedded. - disk-os-hardening.md: fix the live-USB row (BIOS boot-order password is load-bearing, not Secure Boot — a signed live USB runs); add a physical-tamper chain (Dell 7070 CMOS-reset → live-USB → PCR-7 same-signer unseal) + accepted risks (that unseal, unsigned-initramfs evil-maid, operator-USB read TODO). - open-questions #6 reframed; standing-decisions / overview / threat-model / index de-overstated; log query entry. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
306d136a08 |
feat(setup): operator-tested relay pulse, signed into the ledger
Add a per-relay "Test" control on each saved controller in /setup so an admin can prove barrier wiring without a vehicle. POST /api/setup/test-relay pulses a barrier relay — but because a physical open with no matching signed command is the fraud signal, the route SIGNS a barrier_open_command (reason setup.relayTest, source manual, attributed to the acting admin) BEFORE it fires. Reconciliation then reads the open as explained, not an anomaly, and there's an audit trail. - Admin-only (site:update), CSRF-guarded; fires only against a SAVED controller (real id → clean attribution; also stops a redirected/unsaved config from opening an arbitrary host's barrier). Sign-before-fire; a pulse failure is reported, not a 500. radarAlert relays (lamps) are excluded from the UI. - New reason code setup.relayTest in @parking/shared (+ EN template); sq/en keys. - EventLog constructed before setupRoutes so the route can sign. - Integration test (stub controller, no hardware): RBAC 403, CSRF 403, signed barrier_open_command on success, 400 unknown relay w/ no ledger row, 404 unknown controller, 400 bad relay value. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
61b9955160 |
deploy(park-buzi): pin TAG=stage-d2ab2e0 (keep dev in sync with stage)
Mirrors the pin on the stage branch so resources.toml agrees regardless of which branch the Komodo ResourceSync reads. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
33c4ea1e91 |
feat(entry): operator-issued entry + exit plate-swap reconciliation
Two halves of one anti-fraud design.
(A) Operator-issued entry — 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.
This hands the operator-adversary a mint, so it is:
- PRESENCE-GATED like the physical button: a real car must be present (radar/
loop AND camera busy). Enforced BOTH sides — the server re-checks current
presence so a direct POST can't bypass a disabled button; no presence loop
=> feature unavailable; a no-presence attempt signs an anomaly.
- FLAGGED: vehicle_entry source=manual + operatorInitiated + operator, PLUS a
companion entry.operatorIssued anomaly (the adversary path always leaves a
red-flag row).
- 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 is
factored into one shared #issueTicket (button + operator). UI: the entry
BarrierLight becomes a clickable issue-control when presence+permission+shift
meet (confirm -> issue).
(B) Exit plate-swap reconciliation — defends the ticket-swap fraud the mint
enables (paid car let out on a fresh $0 ticket, original ticket lingers
"inside", occupancy drifts up by phantom cars). The plate is the invariant:
ExitFlow.#reconcilePlateAtExit compares the exiting plate against all OPEN
sessions' entry plates, EXACT + HIGH-CONFIDENCE only (>=0.85; a fuzzy read never
gates — ANPR is advisory). On a match under a DIFFERENT ticket:
- BOOTH path: returns swap_suspected + signs exit.plateSwapSuspected; the
pay/exit modal shows a red warning + "Override & release" (override signs an
attributed exit.plateSwapOverride). Flag+override, never a silent hard block
(exit fails-open; a plate is never the sole gate).
- READER path (no operator): log-only anomaly + fail-open.
Extended BoothExitResult + /api/exit (override); 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: 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."
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
114a32e6f2 |
feat(drawer): operator records cash movements, admin reviews after (own /drawer route)
Rework drawer cash movements from synchronous admin-authorization-at-creation
(operator typed an admin's password inline for every receipt/disbursement) to
operator-records-freely -> admin-reviews-after.
- New `drawer` resource: drawer:create (operator records; admin-revocable per
role) + drawer:review (admin authorizes/denies). Migration 0018 grants the
default operator role drawer:create; admin gets all in code.
- New signed `cash_review` ledger event { refId, decision, reviewedBy, note? }.
A DENIAL is a FLAG, not a reversal: it never appends reversing cash and never
touches the drawer balance (the correction is settled outside the app). This
is what keeps a late review from leaking into the next operator's inherited
drawer — a denial that lands after the reviewed shift closed moves no cash.
Regression test: op1 disburses -> closes -> op2 inherits -> admin denies ->
op2 drawer unchanged.
- Move the feature OFF the polluted /shifts route to a top-level /drawer
(operator: record + own; admin: review queue + all). routes/drawer.ts lifted
from routes/shift.ts (retired the authorizer-password gate; kept shift:cash
for its other job = admin-sees-all-shifts). New DrawerManager.tsx.
Display fixes bundled:
- Render cash_review in the event-detail modal (decision / reviewed-by / note /
movement ref) — previously showed nothing.
- Relabel the shift drawer figures for clarity: Daily takings / Receipts /
Disbursements (was Cash payments / Cash added / Cash removed).
- Hide the Card figure everywhere when CARD_PAYMENTS_ENABLED is false (no POS
on-site), matching the card-tender gate.
shared/db/server/web all typecheck; 225 server tests pass (incl. the drawer
review + cross-shift-leak regression); web build + i18n parity green. Verified
end-to-end via Playwright. Recorded in wiki/concepts/shift.md.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
018328a877 |
feat(booth): disable card tender until a P2PE POS is on-site (cash-only)
No card processor / POS terminal on any site yet. Offering "Card" would let an operator record a card payment that never cleared a terminal, corrupting the till reconciliation — a fraud/error surface on an operator-adversary system. Add apps/web/src/lib/features.ts → CARD_PAYMENTS_ENABLED=false, gating both tender pickers (BoothPayModal, SubscriptionManager). With card off there's nothing to choose, so the tender row is suppressed and payment defaults to cash. UI-only gate: the Tender type, payment events, shift accounting, and reports still understand `card`, so historical card events and a future re-enable stay coherent. Verified via Playwright: an unpaid-ticket modal shows Total + "Pay + open barrier" with no tender/cash/card row. Wiki: new concepts/card-payments.md records the current cash-only state, the PCI-scope-out-of-app constraint, the future-POS device requirements, and the re-enable path (flip the flag once a bank-certified P2PE terminal is provisioned). Linked from index, parking-session, open-questions #3. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
266e9b0027 |
docs(wiki): record session findings — snapshot fix, booth rework, db reset
- entry-exit-points.md: the snapshot content-type bug + serve-side cleanType fix (Hikvision image/jpeg; charset="UTF-8" broke every legacy render). - booth-exit-flow.md: the Active-Sessions/modal rework — inline barrier button removed -> modal; closed-within-grace view; live grace countdown; actual paid amount; read-only snapshot review in the closed-session view. - local-dev-workflow.md: the gated `pnpm db:reset` training tool + flag table + the booth (docker exec, no pnpm) note. - appliance-provisioning.md: new §7d — reset on the booth via docker exec into the server container (script ships in the deploy bundle; DATABASE_URL= /data/parking.sqlite), ledger-truncation warning + the two safety gates. - index.md catalog line; log.md entries. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
d92b8d1e6a |
feat(db): gated training/demo database reset CLI
A site is sometimes run live to train operators/admins; afterwards the demo data must go without an obvious self-serve button (the operator must not be able to wipe history). Adds packages/db/scripts/reset-db.mjs, exposed as `pnpm db:reset` (dev) and run via `docker exec ... node node_modules/@parking/db/scripts/reset-db.mjs` on the booth (no pnpm there). Category flags (combinable): --financial (ledger + telemetry + snapshots + subscription instances + blocklist; keeps users/devices/config/tariffs/plans), --config, --users, --all. Shifts/cash/payments live as event types inside the hash-chained ledger_events, so --financial truncates the whole signed ledger back to empty (re-seed starts a new chain under the SAME EVENT_SIGNING_KEY — keys untouched). Two safety gates: RESET_ALLOWED=1 env (a real booth never sets it) + typed DB-filename confirmation (--yes skips for CI). Single transaction + VACUUM. Verified on throwaway dev-DB copies: both gates refuse correctly; each flag wipes/keeps the right tables; the real dev DB is never touched. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
61de1fe772 |
feat(booth): rework Active Sessions + pay/exit modal around barrier re-open
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 |
||
|
|
cfac14e09e |
fix(snapshots): normalize content-type on serve so stored images render
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 <img> 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 |
||
|
|
1b86750b0d |
docs(wiki): firmware/dbx-vs-TPM hardening + create disk-os-hardening page
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 |
||
|
|
9c6741a485 |
docs(wiki): record backup deploy gotchas (compose allowlist + host mount)
CI / check (push) Successful in 40s
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 |
||
|
|
8f32d90d28 |
fix(compose): pass BACKUP_KEY through to the server container
The server's compose environment: block is an allowlist — it only forwards the vars
it names. BACKUP_KEY was never added when the backup feature landed, so even though
Komodo wrote BACKUP_KEY into the Stack .env, compose dropped it and the container
came up without it (docker inspect showed JWT/SIGN present, BACKUP_KEY absent — not
empty, absent). The Backup screen correctly reported 'BACKUP_KEY missing'.
Add BACKUP_KEY: ${BACKUP_KEY:-} next to EVENT_SIGNING_KEY (optional, empty default —
backups stay off until it's set). The prod overlay only merges VISION_URL, so the
base addition flows through to prod.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
07295f8063 |
deploy(park-buzi): pin TAG=stage-39c778f
The first :stage image is built and in the registry (stage-39c778f). Pin it in the IaC so git matches Core's Stack env and a ResourceSync won't revert TAG to the placeholder. Bump this on each promotion. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
652d6599d3 |
ci(build-images): trigger on komodo/** too
A push only builds if it touches a path in the filter. The first stage commit was komodo-only, so no :stage image was ever built. Add komodo/** so IaC/Stack changes (and a komodo-only push to stage) also build+check — a deploy-config change gets the same sanity pass before it reaches a booth. This commit itself touches the workflow file (already filtered), so it triggers the build that produces the first :stage image. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
e16bccc2f5 |
chore(deploy): park-buzi TAG is a placeholder, pinned at deploy time
A committed stage-<sha> can never match the commit that introduces it (the pin commit changes HEAD), so a hardcoded sha here is always stale by one. Make it an explicit placeholder (stage-REPLACE_WITH_BUILT_SHA); the real immutable sha is set when you deploy from Komodo Core after CI builds :stage-<sha>. No moving tag on a booth still holds. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
381046190b |
feat(deploy): add stage tier — park-buzi as the staging booth
Model the staging-vs-production split that fleet-deployment-komodo flagged as open. Three tiers: dev (working, no booth) -> stage (staging booth park-buzi, real-world test) -> main (production, manual + pinned). - build-images.yml: trigger on [dev, stage, main]. The tag computation is already branch-derived, so :stage / :stage-<sha> build with no other change. - komodo/resources.toml: park-buzi now branch=stage + TAG=stage-<sha> (pinned; no webhook even on staging). BACKUP_KEY already wired as a per-booth secret. - komodo/README.md: a Promotion (dev->stage->main) section; per-booth secret list now includes backup_key; hard-rule #1 generalised to pinned <branch>-<sha>. - wiki: fleet-deployment-komodo open-item resolved + a Promotion-tiers table; deploy-trigger choice generalised; container-deployment tag list gains :stage. Promotion is a merge: when dev is ready, merge dev->stage, CI builds the image, bump TAG=stage-<sha> in resources.toml, deploy from Core. stage is branched from dev HEAD so the first real-world test carries the full current app. Per-booth secrets must pre-exist in Core; migrations run at boot so a promotion auto-migrates the staging ledger (where a bad migration is caught before production). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
84f00db48b |
feat(backup): admin-tunable retention + BACKUP_KEY as a Komodo secret
Retention (keep-last / keep-daily-days) is operational policy the on-site admin should tune, not a server env var requiring a redeploy -- same reasoning that moved the target directory to the UI. - Migration 0017: site_config.backup_keep_last + backup_keep_daily_days (nullable; null = code default 7 / 30 per field). - BackupService reads retention fresh each run; status() exposes keepLast + keepDailyDays. DEFAULT_BACKUP_RETENTION is now a pure code default (env reads gone). - PUT /api/backup/config accepts keepLast / keepDailyDays (non-negative int, or null to reset to default; 400 on negative). - UI: two retention fields on the Backup config card; one Save covers target + retention. i18n sq + en. BACKUP_KEY wired into Komodo: - komodo/resources.toml: BACKUP_KEY=[[park_buzi_backup_key]] (per-booth secret, alongside JWT / signing keys). - komodo/.env.komodo.example: documents it as the ONLY backup env var -- escrow it offsite alongside EVENT_SIGNING_KEY (recovery needs both); target + retention are admin-chosen in the UI / DB, not env. Server .env.example trimmed to just BACKUP_KEY. Also carries the small in-progress setup-intro i18n copy trim. Tests: 218 server tests green, incl. retention persist / reset-to-default / reject- negative and the updated status shape. Migration applies cleanly (needed a statement-breakpoint between the two ALTERs). Wiki backup-recovery updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
d5e41500a8 |
feat(backup): admin UI with admin-chosen target directory
The backup destination is now chosen by the on-site admin in the UI (Setup -> Backup), not a server env var. An env-pinned target defeats the purpose: the admin can't point backups at a freshly-plugged USB or a NAS mount without editing .env and restarting. The encryption key stays a server secret. Target storage: - New site_config.backup_target_dir (migration 0016, nullable; null = not configured). BackupService reads it fresh each run, so a UI change takes effect with no restart. Only BACKUP_KEY stays env -- a key must never live in the DB it backs up. Routes: - PUT /api/backup/config -- set/clear the target (backup:update; upserts id=1). - POST /api/backup/test -- probe a candidate path server-side (exists / is a directory / writable) so the admin gets feedback before relying on it. - status() now exposes targetDir + keyPresent, so the UI distinguishes 'no target set' from 'BACKUP_KEY missing'. UI (apps/web/src/BackupSettings.tsx): - A Setup -> Backup tab (gated backup:read): an editable target-path field with a Test-target probe (localized ok/missing/not-a-dir/not-writable), Save, the status panel (config state, last-run size/pruned/error, a distinct amber missing-key warning), a Back up now button, and the restore-is-out-of-band note. Full i18n (sq + en); nav.backup. - API client: fetchBackupStatus / setBackupTarget / testBackupTarget / runBackup. Also includes a small in-progress copy trim to the setup-intro i18n strings. Verified live with Playwright: typed a path -> Test reported writable -> Save persisted it -> status reflected it and showed the key-missing warning. Whole monorepo build/lint/test green. Wiki backup-recovery + open-question #5 updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
0c218179c4 |
feat(backup): encrypted on-site DB backup engine + local target
The SQLite DB is the signed append-only ledger, so a disk failure / stolen or destroyed PC means total revenue-history loss (open-question #5). This is the first slice of the backup-recovery design: the engine + a local/mounted target + a daily timer + a manual route. Engine (apps/server/src/backup.ts): - Consistent online copy of the live WAL DB via better-sqlite3's native .backup() (not a raw file copy, which can capture a torn WAL) — the restored copy is a byte-identical, queryable DB. - AES-256-GCM with a scrypt-derived key from BACKUP_KEY; self-describing header (magic|version|salt|iv|...|authTag) so a restore tool needs only the key + file. Zero new dependencies (Node crypto). - The plaintext intermediate is kept in scratch (not the removable/network target) and wiped in a finally, success or fail. - Retention: keep-last-N + one-per-day within N days. Wiring: - BackupService (env config, single in-flight guard, last-success/last-error). - routes/backup.ts: GET /api/backup/status (backup:read), POST /api/backup/run (backup:create), 409 when unconfigured. No restore route — restore is an out-of-band runbook action on a fresh appliance, not a console call. - New permission resource in @parking/shared. - server.ts: an unref'd daily timer, a no-op until BACKUP_TARGET_DIR + BACKUP_KEY are set, deliberately not run at startup (a just-power-cut booth shouldn't write to a possibly-unmounted disk). - openRawDb() added to @parking/db/testing (open a file without migrating, for restore-verification tests). BACKUP_KEY is deliberately SEPARATE from EVENT_SIGNING_KEY (independent rotation; backups travel, the signing key shouldn't). SMB/NFS work as mount paths; SFTP + admin UI + restore runbook are deferred slices. Tests: round-trip byte-identical, GCM tamper/wrong-key fail, short-key rejected, scratch cleaned, route auth/RBAC + 409. build/lint/test green (212 server tests). Wiki + open-question #5 updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
9e442586af |
docs(wiki): settle on-site encrypted backup + disaster-recovery design
New concept page backup-recovery.md resolving the design half of open-question #5. Driving scenario: a stolen/destroyed PC whose LUKS+TPM disk is unrecoverable by design — recovery stands up a NEW PC, restores a backup, and keeps signing the SAME chain. Settled: admin-driven encrypted full-DB backup (SQLite online-backup/VACUUM INTO, snapshots included) to local/USB, SMB/NFS, or SFTP targets; manual button + an in-process daily timer; keep-last-N + dailies retention; restore is admin-only / out-of-band (operator-adversary surface). A restored copy must still verifyChain. Key custody (the load-bearing decision, bears on #6): three independent keys — EVENT_SIGNING_KEY kept an extractable, escrowed software key DECOUPLED from the TPM so the ledger survives total hardware loss (the conscious trade: a TPM-sealed signing key would be unforgeable but permanently unverifiable after the machine dies); a NEW dedicated park_buzi_backup_key in Komodo for backup encryption, separate from the signing key; the LUKS/TPM disk key, appliance-only and deliberately non-recoverable. Keys are never inside the backup they unlock. Updated open-questions #5 (design SETTLED) + #10 note; disk-os-hardening deploy runbook (why the signing key is not sealed + park_buzi_backup_key); index catalog + concept count. Design only — not yet built. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
11567a417f |
docs(wiki): catch concept pages up to the booth-UX/shift/font-scale work
Bring three queryable pages current with the booth-UX commit (
|
||
|
|
f6e35bbebf |
fix(reader): correct the QR reader's identity — Dingtian DT-008, not "GEE"
An early wrong assumption named the QR/RFID access reader "GEE" / "GEE/Fondvision" / "GEE-QR-ER80" (and summarized a raw GEE PDF as its datasheet). There is no GEE device — it's the Dingtian DT-008 (dingtian-tech.com/en_us/qr_code_reader.html), the same vendor as the relay board, which is why it integrates the identical HTTP-GET-push way. Code: - Driver symbol geeQrReaderDriver → dingtianQrReaderDriver; label → "Dingtian DT-008 QR/RFID reader (HTTP push)"; comments/description rewritten to the real DT-008 facts (Wiegand 26/34, TCP/IP, USB, RS485 — not RS-232; QR/barcode + ID/IC/NFC — not DataMatrix/1D). - Persisted driverId "gee-qr-reader" → "dingtian-qr-reader" (the registry lookup key + the row created on assign in qr-reader.ts). - Migration 0015 rewrites existing devices.driver_id rows so configured readers keep resolving (applied to the dev DB — 2 rows; the booth applies it on boot). Behaviour is unchanged: naming + the persisted id only. Wiki + memory: - Renamed entities/gee-qr-er80.md → dingtian-dt008-reader.md and sources/gee-qr-er80.md → dingtian-dt008.md; rewrote both to the real DT-008 product-page specs while KEEPING all the verified-on-hardware protocol facts (cjihao serial, .jsp path, Connection: close). Fixed every cross-reference + "GEE" mention in 6 other pages. Memory gee-reader-serial-binding → dingtian-reader-serial-binding. The only surviving "GEE" mentions are deliberate naming-correction notes, the raw PDF filename, and the append-only log history. Full workspace build/lint/test green; dev DB readers verified resolving to the registered dingtian-qr-reader driver. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
96acd6b662 |
feat(snapshot): re-encode captures + disk-pressure retention
Camera snapshots were stored RAW — the camera's full-res JPEG straight into the BLOB, no resize/recompress. Measured on the dev DB: 300 snapshots = 81.7 MB = ~72% of the 114 MB SQLite file (the big ones 2688×1520 / ~600 KB, Hikvision main stream). They dominated the appliance's single backed-up DB file. Re-encode on capture (snapshot.ts): - Downscale each frame to SNAPSHOT_MAX_EDGE (1280px long edge) + recompress at SNAPSHOT_JPEG_QUALITY (80) via sharp (libvips, Apache-2.0) before storage — ~6-10× smaller (verified 2688×1520 → 1280×724, ~8×), plate still readable, clean image/jpeg (drops the camera's charset cruft). STORAGE-ONLY: recognition keeps the ORIGINAL full-res bytes (downscaling hurts OCR). Fail-soft — a re-encode error stores the original, never drops the snapshot or blocks the (already-open) path. sharp lives in apps/server (owns the capture path), where bcrypt already establishes the native-dep pattern. Disk-pressure retention (snapshot-retention.ts) — a SAFETY VALVE, not the daily mechanism (the re-encode does that). Daily check reads the DB filesystem used% (statfs on db.$client.name); no-op unless ≥ SNAPSHOT_DISK_HIGH_PCT (70). Over the mark: delete the OLDEST until an estimated SNAPSHOT_DISK_FREE_TARGET_PCT (10%) of disk is freed — never below SNAPSHOT_MIN_KEEP (500) — then VACUUM once to return space to the OS. A DELETE only frees SQLite pages (disk doesn't drop until VACUUM), so the loop is driven by estimated freed bytes (SUM(length(bytes))), not a live disk re-read; the prune owns the DB-locking VACUUM, run daily off-peak. diskUsage is injectable for tests. None of this touches the signed ledger — snapshots are unsigned/advisory, referenced only by id. Tests: encodeForStorage (downscale / clean-type / no-enlarge / fail-soft) + pruneSnapshots (no-op below mark / delete-oldest-to-target + VACUUM / MIN_KEEP floor / skip-VACUUM-when-empty). All four snapshot env knobs documented in the komodo env reference. Full workspace build/lint/test green; the prune smoke-verified on a scratch DB copy (file shrank after VACUUM). Existing ~81.7 MB of raw snapshots are unchanged (a one-off re-encode backfill is a separate optional follow-up). Updated entry-exit-points + technology-stack wiki. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
cce99aadfd |
fix(web): booth UI/UX pass — readable font scaling + booth layout/report clarity
A round of operator-facing fixes on the booth screen, shift views, and the
font-scale control. (Follows the font-scale feature in
|
||
|
|
f706726eeb |
feat(prefs): per-user UI font scale (A−/A+), saved to the profile
A header A−/value/A+ control scales the whole UI, persisted per user and restored on login from any booth — cloning the theme-pref pattern end to end. - DB: users.font_scale (migration 0014; percent, 100 = base, NOT NULL default). - Server: PUT /api/auth/font-scale (auth-guarded; clamps to 80–160, snaps to a 10-step); fontScale flows through sessionView → login + /me. - Client: setFontScalePref + applyFontScale; applied in App alongside theme; FontScaleToggle in the header; i18n sq+en. Scaling uses CSS `zoom` on the root, NOT root font-size: the app's type is pinned in px (text-[12px] etc., ~230 spots), which a font-size change would not scale — so the dense Active-sessions / Live-feed logs stayed tiny. `zoom` scales everything uniformly (text, spacing, icons) like the browser's Ctrl+/−, which is the readability win for operators who need larger text. Tests: 4 font-scale auth-route cases (persist + /me, clamp/snap, 400, default-100). Full workspace build/lint/test green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
6734e9815e |
fix(booth): backfill the live-feed plate + make plate search work
Two booth feed fixes: - Plate not showing until refresh. Plate recognition is async/advisory (snapshot.ts recognizePlate → a kind:"read" device_event keyed by the session identity), so it lands AFTER the entry/exit event already shipped over the WS without a plate; a refresh re-fetched via the bulk enrich path and showed it. Added a `plate-recognized` bus event (device-events.ts) emitted when the read is written; ws.ts forwards it; the client patchPlate(identity, plate) (live-store) backfills the already-rendered feed row in place and invalidates the Query-owned active-sessions list. No refresh. - Plate search didn't filter. Both the live-feed (BoothScreen) and active-sessions (ActiveSessions) search haystacks matched the wrong field — the displayed plate is the ENRICHED top-level e.plate/s.plate (set by enrichEvent), not payload.plate (the plate is unsigned, never in the signed payload). Switched the haystacks to the displayed field. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
38481f105f |
feat(booth): blink the Entry/Exit lights on radar presence (mirror relay 3)
The on-screen Hyrje/Dalje barrier lights were 2-state (green=free / red=busy)
off the camera lane-status only — they couldn't show the radar-only "detected,
not yet confirmed" state that makes the physical button lamp (relay 3) blink.
Now they mirror the lamp's 3-state rule per lane:
radar present + camera not busy → BLINK green↔red (~1 Hz)
camera busy → SOLID red
otherwise → SOLID green
End-to-end:
- LanePresence (lane-presence.ts): subscribes to deviceEvents.onInput, resolves
each presence edge to its lane via the new direction-agnostic presenceLaneOf()
(device-resolve.ts) — entry AND exit, unlike the entry-gated relayForPresence
the one-car-one-ticket gate uses — and emits a lane-presence {entry,exit} bus
event on change. Wired in server.ts (start + onClose).
- WS forwards it (hello snapshot + push) into live-store.radar.
- BarrierLight (BoothScreen.tsx) is now 3-state; blinks via the .lane-blink
keyframe (index.css), which holds solid-red under prefers-reduced-motion.
Same input + same rule as the lamp, so the screen and the post never disagree.
A new test (lane-presence.test.ts) caught a real bug: the first cut reused
relayForPresence, so the EXIT lane never resolved (it's entry-gated) and never
blinked — presenceLaneOf fixes it. Covers entry/exit independence, de-dupe
across several radars on one lane, and ignoring non-presence inputs.
Full workspace build/lint/test green (185 server tests). Updated the
button-light-indicator wiki page ("On-screen twin").
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
4418594af0 |
refactor(setup): unify controller I/O — event-driven relays[] + generic inputs[]
The controller new/edit modal hardcoded both its outputs and its inputs, so an
operator could neither add a generic event-driven relay nor a free-standing input
(e.g. a second radar at the exit). This unifies both into symmetric, first-class
lists. Behaviour for existing booths is unchanged (back-compat, no DB migration).
Outputs — one event→action relays[] list:
- A relay is "when EVENT X happens, do its action": entry/exit/both pulse a
barrier; a new `radarAlert` event drives a non-barrier alert lamp (blink while
its trigger input is active, SOLID once the camera confirms a car).
- Dropped the separate config.buttonLight block — the lamp is just a relays[] row
with direction:"radarAlert" (triggerInput + blink cadence). `alertRelaysOf()`
replaces `buttonLightOf()`; ButtonLightController keeps its proven 3-state
machine (serialized UDP, fail-OFF, hot-reload), now keyed per controllerId:relay
so several alert lamps on one controller run independently. Every barrier
resolver skips radarAlert rows (no auto-open; barrier-not-a-door intact).
Inputs — one first-class config.inputs[] list (the twin of relays[]):
- Each row is { input, role, relay?, kind?, activeLow?, cooldownSec? } with a
"+ Add input" button. role ∈ button | presence | alertTrigger; button/presence
name the relay they serve. An exit radar is just another presence row.
- Keystone `inputsOf(row)`: returns config.inputs[] or SYNTHESIZES it from the
legacy relays[].button/presenceInput/... fields, so relayForButton /
relayForPresence resolve identically from either shape — zero-downtime, no
migration. entry-flow.ts is unchanged (resolves through the same functions).
- Fixed a latent bug this exposed: the alert lamp's camera lock was hardcoded to
the ENTRY camera. Added relays[].lockLane ("entry"|"exit", default entry); the
lamp now locks on its own lane's camera, so an exit radar's lamp tracks the exit
camera. button-light tracks both #entryBusy/#exitBusy.
- Driver: extracted activeLowFrom(config) — merges inputs[] activeLow, legacy
relays[].presenceActiveLow, and the inputActiveLow escape hatch.
UI: the relay dropdown gained a "Radar alert" option (reveals trigger/lock/blink
inputs); InputEditor is rewritten to a generic list (role select folds loop/radar);
i18n sq+en kept at type-parity.
Tests: new device-resolve.test.ts (inputs[] resolution + legacy fallback identical
+ exit-radar resolves to the exit relay); button-light gains a two-independent-
alert-relays case and an exit-lamp lockLane case; access-dingtian gains
activeLowFrom cases. Full workspace build/lint/test green (i18n parity included).
Wiki + memory updated (button-light-indicator, entry-double-press, dingtian-relay).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
25a72ff20a |
feat(anpr): per-camera auto-open toggle (anprAutoTrigger) for shared lanes
A shared entry/exit lane has both an entry and an exit camera on ONE lane: a subscriber driving IN is admitted by the entry cam, but the exit cam sees the same car leaving its frame and phantom-EXITs the occurrence just opened (its back plate). Separate RECOGNITION from AUTO-OPEN per camera: - config.anpr (unchanged) = run snapshots through the recognizer, record the plate (evidence), BOTH directions — stays on. - config.anprAutoTrigger (new, absent ⇒ on when anpr is on) = may THIS camera auto-open the barrier. Set false on the shared-lane exit cam: it still recognises plates but never auto-triggers. The bridge gates on it (anpr-entry.ts), before the poll loop. UI: a "Auto open/close on subscriber plate" checkbox under ANPR in the camera setup (shown when anpr is on); persisted true/false so a park can explicitly disable it. i18n sq+en (also corrected the now-stale anprHint "never opens a barrier" wording — it does, via the bridge). +1 server test (anprAutoTrigger=false → no snapshot, no read); 172 green. Documented the two toggle levels (site-wide + per-camera) in lane-presence-and-anpr-entry. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
c2a861208f |
fix(anpr): sliding poll window so a car arriving mid-loop isn't lost
A loop started by a far/early car would (a) give up before the REAL car settled at the barrier, and (b) swallow the real car's pushes (the #polling guard dropped them). So a confident-but-wrong far-car plate could win, or the intended car get debounced out after the loop ended — wrong car acted on, right car blocked. Fix: a push that JOINS a running loop now EXTENDS the deadline (lastPush + ANPR_POLL_WINDOW_MS) instead of being dropped, capped at start + ANPR_POLL_MAX_MS (30s) so a continuously-busy lane can't slide forever. Each tick still pulls a FRESH frame, so the loop tracks whoever is at the barrier NOW, not the car that started it. Per-camera sliding deadline in #pollDeadline (cleared with #polling in finally). +1 test (push mid-poll keeps the loop alive past the initial deadline); 171 server tests green. New knob ANPR_POLL_MAX_MS documented in the komodo env reference + the two concurrency guards written up in lane-presence-and-anpr-entry. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
a888125eca |
docs(komodo): complete env reference — every server/vision var + defaults
The Stack example only listed the required set; expand it to the FULL reference so an operator can see (and tweak) every env without digging through code. Grouped: - IMAGE SELECTION (REGISTRY, TAG) - REQUIRED (JWT_SECRET, EVENT_SIGNING_KEY, COOKIE_SECURE — no safe default) - COMMONLY SET (VISION_ENABLED, WS_ALLOWED_ORIGINS) - SET BY COMPOSE — don't put in the Stack (VISION_URL, DATABASE_URL, VISION_RECOGNIZER) - OPTIONAL TUNABLES with code defaults: ports, logging/retention, device+printer poll intervals, lane/capture TTLs, and the ANPR knobs incl. this session's new ANPR_POLL_MS=1000 / ANPR_POLL_WINDOW_MS=8000 (raise the window for a slow barrier) - VISION CONTAINER env (the Python service's own VISION_* vars) All defaults pulled from the code (process.env.X ?? default). Documentation only. |
||
|
|
96fd97efa9 |
fix(web): VITE_API_BASE relative (empty) for the booth's same-origin SPA
apps/web/.env.production hardcoded VITE_API_BASE=http://127.0.0.1:3000 — a
desktop-only value that's WRONG for the booth, which serves the SPA same-origin
(Fastify dist/ via Caddy :80) and needs a RELATIVE /api base. An absolute origin
baked at build would point the browser at localhost. origin.ts treats empty as
relative (API_BASE=""), matching the deploy (the
|
||
|
|
2a13b95da6 |
fix(anpr): abort the poll loop if the subscriber transacts by card/QR mid-poll
The poll-until-confident loop (prev commit) opened a race: during its ~8s window a subscriber could scan their card/QR at the reader and exit immediately — but the ANPR loop kept polling and would ALSO emit a confident read a moment later, exiting the NEXT open occurrence (a phantom double-exit, worst for a fleet sub with several open). Guard it with the subscriber's open-occurrence count: the bridge identifies the subscription as soon as a frame reads the bound plate (identity needs no confidence), baselines openOccurrenceCount, then each tick AND before emit checks if it moved. If a credential closed/opened an occurrence mid-poll, the subscriber already transacted → abort, don't emit. New public SubscriptionFlow.openOccurrenceCount(). Bounded loop is unchanged (ANPR_POLL_WINDOW_MS=8000 cap; never infinite). +1 test (credential transacts mid-poll → no double-act); 170 server tests green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
513566c89e |
chore(debug): add test-post-camera-events.py — a dumb HTTP sink for camera pushes
A tiny stdlib HTTP server that logs every request (source IP, method, path, full body, JPEG part stripped) to verify whether a Hikvision camera actually POSTs its Alarm Server events — independent of our app's parsing/acceptance. It cracked the 2026-06-27 "auto-exit" investigation: proved the exit camera was sending NOTHING (corrupt config DB), then later that it sent plain VMD without targetType=vehicle. python3 test-post-camera-events.py [port] # default 8099 Point a camera's Alarm Server at this host:port; drive a car. A line from the camera IP = it sends (debug downstream); silence = the camera isn't POSTing. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
f77ed11782 |
feat(anpr): poll snapshots until a confident plate, so auto-exit works
The ANPR bridge took ONE snapshot at the camera's vehicle-alarm instant — but the
alarm fires as the car APPROACHES, so that frame's plate is small/blurry/half-in-
frame and ANPR returns a low-confidence misread ('111'@0.20). The manual test reads
the SAME car at ~100% because by then it's STOPPED at the barrier, well-framed. So
subscriber auto-exit silently never fired (read below the 0.85 floor → ignored).
Fix (the car-stops-at-the-barrier insight): the bridge now PULLS A FRESH FRAME every
ANPR_POLL_MS (1000) and re-runs ANPR until one clears VISION_ENTRY_MIN_CONFIDENCE, or
ANPR_POLL_WINDOW_MS (8000) elapses (drove off / non-subscriber → give up cleanly).
- One loop per camera (#polling set) — the camera's ~1Hz alarm re-fires JOIN the
running loop instead of spawning N concurrent loops.
- Fresh camera.captureSnapshot each tick, NOT captureSnapshotShared (its 1.5s TTL
would re-serve the same bad approach frame).
- Camera-level debounce stamp moved to AFTER a successful emit (suppresses re-fires
for ANPR_DEBOUNCE_MS once we've acted), not before the loop.
VERIFIED on hardware (DS-2CD1047G3H-LIU exit lane): 7 garbage approach frames →
AA890XX@0.999 at the barrier → signed vehicle_exit. Still advisory + fail-soft; a
barrier never opens on a low-confidence read. anpr-entry.test.ts +1 (poll
escalation low→low→high); 169 server tests green. Documented in
lane-presence-and-anpr-entry + the lpr-camera camera-fault writeup.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
e4a17efd97 |
feat(setup): reveal toggle for secret fields (the device web password)
The admin needs the device web password (to reach a controller/camera's own web UI), and it's already stored + sent to this admin-only view (redactSecrets strips only the machine secrets relay/push pw, NOT webPassword — by design, per the SECRET_CONFIG_KEYS comment). But the form rendered every `secret` field as a masked password input with no way to unmask it, so the value was present yet unreadable. Add a per-field show/hide eye toggle on `secret` inputs. No new exposure: the field is already admin-gated and the value already reaches the client; this just makes the intended-visible credential readable/copyable. Machine secrets are redacted server-side and never arrive, so there's nothing there to reveal. i18n sq+en. |
||
|
|
6d32e0fc0f | fix(i18n): correct translation for 'addAnother' in Albanian | ||
|
|
3a60367232 |
feat(setup): print a real test slip from the printer "Test connection" modal
healthCheck only opens the transport (TCP connect / USB open) — it proves the printer is REACHABLE, not that paper feeds and the head fires. Add a "Print test slip" action so the admin can physically confirm a printer is live (the new host-net USB /dev/usb/lpN path, or a network printer). - server: POST /api/setup/test-print — printer-only, re-merges stored secrets like /test (so an edited network printer authenticates), creates the device, and pushes a short slip via the device-agnostic printReport(). Fail-soft: a print error (paper out, head fault, transport drop) is reported, never a 500. Mirrors the test-anpr pattern. - web: testPrint() client + PrintTestResult; a button in the device modal shown for category=printer, with ok/fail rendering. i18n keys in sq + en (parity holds). Server 168 tests pass; web + server typecheck clean. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
045892bc94 |
fix(deploy): group_add lp (gid 7) so the server can write the USB printer node
USB passthrough (
|
||
|
|
916c147b4d |
fix(camera): drop debug console.log(c) leaking the camera password to logs
The Hikvision driver's create() had a leftover `console.log(c)` that dumped the ENTIRE camera config — including the plaintext `password` — to stdout every time the adapter was built, on every request that resolves a camera. That puts a device credential in the logs (which get shipped/cached/read — the booth operator is the adversary). Removed. Swept the rest of the shipped source: no other console.* leaks. |
||
|
|
1ea1aa4189 |
fix(deploy): pass the USB printer (usblp) into the host-net server container
The USB ESC/POS printer is the host's /dev/usb/lpN (usblp char device, major 180), but the container has its own /dev — `docker exec server ls /dev/usb` → "No such file or directory", so probeUsb's open() ENOENTs and the printer is always offline regardless of the path set in setup. Containerization isolates host hardware (same root cause as the network fix); USB needs explicit passthrough: - volumes: /dev/usb:/dev/usb → the lpN NODES appear inside the container - device_cgroup_rules: 'c 180:* rmw' → permit the usblp char major (180), and the `:*` minor wildcard survives lp0/lp1/lp2 renumbering across replug/boot-order. Binding the /dev/usb DIR (not a single `devices:` node) is what survives renumber. Merge verified: parking-data ledger volume preserved (lists append), host net intact, config valid. Booth prereq: `usblp` loaded at boot + printer attached before start, else /dev/usb is absent. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
9c20faf8de |
fix(deploy): drop server sysctls under host net (runc rejects per-netns sysctl)
network_mode: host + sysctls: net.ipv4.ping_group_range fails at container create:
"sysctl not allowed in host network namespace" — runc refuses a per-netns sysctl
when there's no separate netns. Remove it; under host net the server uses the HOST's
ping_group_range (set on the booth via /etc/sysctl.d). Fixes the park-buzi-server-1
start failure introduced by
|
||
|
|
a68dc23393 | fix(i18n): update placeholder text for park name in English and Albanian translations | ||
|
|
c87dcb2253 |
fix(deploy): server on host network so it sees the booth LAN / device VLAN
In prod the containerized server sat on the Docker bridge (172.18.0.x) and could
only see eth0 — so the setup backend-IP picker (net.ts networkInterfaces) showed
just the Docker IP, the server couldn't reach the relay or fetch Hikvision ISAPI
snapshots, and push devices (readers/cameras) couldn't reach it. The server is the
ONLY container doing device I/O, so put it on the HOST network namespace.
- docker-compose.prod.yml: server + proxy → network_mode: host (server detaches the
base `parking` network via `networks: !reset []`). server VISION_URL=127.0.0.1:8089.
vision stays BRIDGED (it never touches a device — the server hands it JPEG bytes)
but publishes 8089 on 127.0.0.1 only, so the host-net server reaches it over
loopback while the ANPR service stays off the LAN.
- docker-compose.yml: VISION_URL is now ${VISION_URL:-http://vision:8089} so dev keeps
compose-DNS service-name routing; prod overrides to loopback.
- Caddyfile: reverse_proxy 127.0.0.1:3000 (was server:3000 — service DNS doesn't
resolve on host net). Dev doesn't use Caddy, so unaffected.
Merge validated for both envs (booth.sh config, exit 0). Host-net side effect: the
container ping_group_range sysctl is a no-op — the HOST must set it for reader ICMP
liveness (see appliance-provisioning).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
7eadf71a0b |
docs(wiki): appliance-provisioning — Komodo deploy is now the primary flow
§6 split: §6 = Docker engine only; new §7 = the Komodo Periphery deploy (PRIMARY, verified end-to-end on park-buzi 2026-06-27): - 7a install Periphery (onboarding key, user-mode/outbound, runs as admin, no inbound port; core_address = Core's proxy URL) - 7b deploy the Stack in Core (registry+git accounts, per-booth [[..]] secrets, env incl. COOKIE_SECURE=0; seed admin via Komodo's container terminal — no SSH) - 7b-bis fleet-as-code via komodo/resources.toml + ResourceSync (empty diff = in sync) - 7c break-glass: manual booth.sh when mesh/Core is down Added Komodo deploy gotchas 7-11 (core_address is the proxy URL not :9120; git-auth ≠ registry-auth; user-mode vs /etc/komodo root_directory; core_address singular; empty-diff/disabled-Execute = success). §5b SSH TODO reframed (Komodo removes SSH from routine ops). Header + date updated; log entry added. Fixed a stale [[atecc608-secure-element]] alias in the prior log entry. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
9918f278b2 |
feat(deploy): Komodo fleet deployment — resources.toml + decision
CI / check (push) Successful in 36s
Adopt Komodo Periphery (over the NetBird mesh) as the booth fleet control plane, superseding SSH-and-booth.sh. The booth runs the SAME compose files; Komodo Core drives them remotely. booth.sh is demoted to a break-glass local fallback. - komodo/resources.toml mirrors the working park-buzi Stack (built by hand in the Core UI, then exported to TOML — field names match the running v2.2). Stack-only: servers are created by the agent onboarding OUTBOUND (one-time onboarding key → Periphery self-registers, auto-rotating keys, booth opens no inbound port), so there is no [[server]] block. Per-booth secrets via [[...]] refs to Core's store. - komodo/README.md + .env.komodo.example document the flow and the hard rules (no webhook; onboarding/outbound/mesh-only; per-booth unique secrets; never down -v the ledger volume). - wiki/decisions/fleet-deployment-komodo.md records the decision + threat-model analysis (Periphery is a root agent → mesh-bound; EVENT_SIGNING_KEY-in-Core is a fraud-root blast radius until ATECC608 signs; Core is now Tier-0; GPL-3.0 is fine as external ops tooling). container-deployment reframed (booth.sh = fallback); index + log updated. Verified end-to-end against a real booth (park-buzi): onboarded OK, Stack deployed, all containers green, admin seeded. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |
||
|
|
83298bc0c5 |
fix(deploy): booth.sh works in the flat /opt layout; .env TAG=dev default
The booth deploys the compose files FLAT (e.g. /opt/parking_systems/) with
booth.sh next to them, but the script assumed it lived in <repo>/scripts/ and
blindly did `cd ..` — so REPO_DIR resolved to the parent, where there are no
compose files, and every subcommand operated on the wrong dir. `usage()` then
sed-read a relative $0 that no longer existed after the cd ("can't read
booth.sh"). Discover the compose files instead: check the script's own dir,
then ../, then $PWD, and cd to whichever has docker-compose.yml. usage() reads
an absolute $SELF so it survives the cd.
Also: .env.example defaulted TAG=main, but the registry only has dev-* tags
(no main build yet), so `compose pull` 404s. Default to TAG=dev and document
the moving-vs-immutable (dev / dev-<sha>) tag scheme.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
|
||
|
|
898cf1953a |
docs(wiki): camera 503/stream, alarm URL helper, reader ICMP liveness
- lpr-camera.md: "503 Device Busy" can be PERSISTENT (main-stream saturation on the G3H) — the real fix is sub-stream selection, not just retry. - device-status-monitoring.md: QR reader health was false-healthy (hardcoded "ready") until the ICMP-ping fix; document the push-device monitoring model. - log entries for both 2026-06-26 sessions. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V |