From 094e963e5e8aee93d1e4bcec31e96ed9e5bdc94a Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 4 Jul 2026 17:11:10 +0200 Subject: [PATCH 1/4] deploy(park-buzi): pin TAG=stage-6505a4a (presence-bypass promotion) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotion of the entry presence-gate bypass (+ signed relay test): merge is on stage, CI built :stage-6505a4a (pull verified). Bump the park-buzi Stack pin to the new immutable sha. Deploy is the manual Komodo step: refresh ResourceSync → Execute → Deploy; watch for [migrate] done (carries 0020 — two site_config bypass columns, additive, applied at boot against the /data volume). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- komodo/resources.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/komodo/resources.toml b/komodo/resources.toml index ab04afa..69dc6c1 100644 --- a/komodo/resources.toml +++ b/komodo/resources.toml @@ -49,7 +49,7 @@ REGISTRY=git.infra.msai.al/mca/parking_solution # Staging booth: pinned immutable stage-. After each promotion (merge dev → stage, CI builds # :stage-), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag # exists as the pointer; we deploy the sha, not the mover. -TAG=stage-d2ab2e0 +TAG=stage-6505a4a COOKIE_SECURE=0 VISION_ENABLED=1 WS_ALLOWED_ORIGINS= From b4f1418858cad1a70f0aef2bb0d96f0a37b88c1f Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 4 Jul 2026 18:41:06 +0200 Subject: [PATCH 2/4] fix(entry): enforce the camera press-gate + duplicate-ticket defenses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field report (park-buzi): a BLINKING entry button still printed — the lamp encoded blink-vs-solid (radar-only vs radar+camera) but #suppressReason only checked the radar, so a radar false-positive (rain, pedestrian) minted a real signed ticket. Three layered fixes: 1. CAMERA gate on the physical press: with an entry camera configured, a press is live only in the lamp's SOLID state (LaneStatus.entry busy, mirrored into EntryFlow via onLaneStatus). Suppress-only — the camera stays advisory (never opens, never traps). Camera-less sites keep the radar-only gate; a faulty camera is dropped via the existing bypassPresenceCamera admin toggle. 2. Cooldown as a REAL backstop behind presence: the presence branch returned early, so entryCooldownSec was dead wherever a loop was wired. Now it bounds the stationary-car double-ticket (a motion radar drops a motionless car → spurious loop-clear re-arms one-car-one-ticket → same car reprints). 3. Post-hoc duplicate-plate anomaly (entry-side twin of plateSwapSuspected): when entry ANPR recognizes a plate already OPEN under another session entered within ENTRY_DUP_PLATE_WINDOW_MIN (default 15 min), sign ONE entry.duplicatePlate anomaly naming both tickets for the operator to void. ANPR stays non-blocking (rides the post-open snapshot as before). REJECTED: camera-vetoed re-arm (defer re-arm until the lane flips free). The camera has no leave events — "free" is a ~30s silence timeout that never lapses inside a queue, so every queued car after the first would be suppressed until an operator intervened. Blocking legit entry at peak beats nothing; the proper preventive fix is a pass-through sensor (passedInput) — recorded as open in wiki/concepts/entry-double-press.md. Also: setup.relayTest reason was missing from both web catalogs (parity is only enforced sq<->en, so the build passed) — added. Tests: entry-press-gate.test.ts (blink suppresses / solid prints / camera-less unaffected / bypass honored / cooldown catches the dropout re-press / residual risk documented / still-present re-press stays suppressed) + entry-duplicate-plate.test.ts (flags open dup, ignores closed/stale/self/other plates). Suite 258 green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/server/src/entry-duplicate-plate.test.ts | 107 +++++++++ apps/server/src/entry-flow.ts | 60 ++++- apps/server/src/entry-press-gate.test.ts | 213 ++++++++++++++++++ apps/server/src/server.ts | 4 + apps/server/src/snapshot.ts | 83 ++++++- apps/web/src/lib/i18n/en.ts | 2 + apps/web/src/lib/i18n/sq.ts | 2 + packages/shared/src/index.ts | 6 + wiki/concepts/entry-double-press.md | 59 ++++- wiki/log.md | 16 ++ 10 files changed, 532 insertions(+), 20 deletions(-) create mode 100644 apps/server/src/entry-duplicate-plate.test.ts create mode 100644 apps/server/src/entry-press-gate.test.ts diff --git a/apps/server/src/entry-duplicate-plate.test.ts b/apps/server/src/entry-duplicate-plate.test.ts new file mode 100644 index 0000000..ceed037 --- /dev/null +++ b/apps/server/src/entry-duplicate-plate.test.ts @@ -0,0 +1,107 @@ +import { randomUUID } from "node:crypto"; +import { beforeEach, describe, expect, it } from "vitest"; +import { deviceEvents as deviceEventsTable, ledgerEvents, sessions, type Db } from "@parking/db"; +import { createTestDb } from "@parking/db/testing"; +import { flagDuplicateEntryPlate } from "./snapshot.js"; +import { makeLog, silentLogger } from "./test-helpers.js"; +import type { EventLog } from "./event-log.js"; + +// Entry-side duplicate-plate reconciliation (2026-07-04): when ANPR recognizes a plate on +// a fresh transient entry and that plate is already OPEN under another RECENT session, +// the same car most likely minted a second ticket (a motion radar dropped the stationary +// car → the button re-armed). We sign ONE entry.duplicatePlate anomaly for the operator +// to void. Post-hoc + advisory: recognition never gates the (already-open) barrier — +// exactly the non-blocking role the plate can play here. + +let db: Db; +let log: EventLog; + +const PLATE = "AA111BB"; +const OLD = "11111111111"; +const NEW = "22222222222"; + +beforeEach(() => { + ({ db } = createTestDb()); + log = makeLog(db); +}); + +/** Seed the prior entry's unsigned plate-read telemetry (what recognizePlate records). */ +function seedPriorRead(opts: { identity?: string; plate?: string; direction?: string; agoMs?: number } = {}) { + db.insert(deviceEventsTable).values({ + id: randomUUID(), + deviceId: "cam-entry", + category: "camera", + kind: "read", + detail: { + identity: opts.identity ?? OLD, + direction: opts.direction ?? "entry", + plate: opts.plate ?? PLATE, + snapshotId: "snap-old", + source: "entry-exit-snapshot", + }, + occurredAt: new Date(Date.now() - (opts.agoMs ?? 60_000)).toISOString(), + }).run(); +} + +function seedSession(id: string, state: "open" | "closed") { + db.insert(sessions).values({ + id, + identity: id, + source: "ticket", + enteredAt: new Date(Date.now() - 60_000).toISOString(), + state, + }).run(); +} + +const flag = () => + flagDuplicateEntryPlate({ db, log, identity: NEW, plate: PLATE, snapshotId: "snap-new", logger: silentLogger() }); + +const anomalies = () => + db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly"); + +describe("flagDuplicateEntryPlate", () => { + it("same plate OPEN under another recent session → signs ONE entry.duplicatePlate anomaly", async () => { + seedPriorRead(); + seedSession(OLD, "open"); + await flag(); + expect(anomalies()).toHaveLength(1); + const a = anomalies()[0]; + expect(a.identity).toBe(NEW); // keyed to the NEW (suspect) ticket + expect(a.payload).toMatchObject({ + reasonCode: "entry.duplicatePlate", + duplicateEntrySuspected: true, + plate: PLATE, + otherIdentity: OLD, + snapshotId: "snap-new", + }); + }); + + it("prior session already CLOSED → no anomaly (that car drove off; a re-visit is legit)", async () => { + seedPriorRead(); + seedSession(OLD, "closed"); + await flag(); + expect(anomalies()).toHaveLength(0); + }); + + it("prior read outside the window → no anomaly (stale coincidence, not a double press)", async () => { + seedPriorRead({ agoMs: 30 * 60_000 }); // beyond the 15-min default window + seedSession(OLD, "open"); + await flag(); + expect(anomalies()).toHaveLength(0); + }); + + it("own read (same identity) never flags itself", async () => { + seedPriorRead({ identity: NEW }); + seedSession(NEW, "open"); + await flag(); + expect(anomalies()).toHaveLength(0); + }); + + it("different plate / exit-side reads are ignored", async () => { + seedPriorRead({ plate: "ZZ999ZZ" }); + seedPriorRead({ direction: "exit" }); + seedSession(OLD, "open"); + await flag(); + expect(anomalies()).toHaveLength(0); + }); +}); diff --git a/apps/server/src/entry-flow.ts b/apps/server/src/entry-flow.ts index 2c6f2ee..93fa2bc 100644 --- a/apps/server/src/entry-flow.ts +++ b/apps/server/src/entry-flow.ts @@ -12,7 +12,7 @@ import { } from "@parking/devices"; import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared"; import type { FastifyBaseLogger } from "fastify"; -import type { DeviceInputEvent } from "./device-events.js"; +import type { DeviceInputEvent, LaneStatusEvent } from "./device-events.js"; import { getOccupancy } from "./occupancy.js"; import type { EventLog } from "./event-log.js"; import { devicesByDirection, firstRelayByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js"; @@ -48,6 +48,14 @@ import type { VisionClient } from "./vision-client.js"; // input edges to track presence + "armed" per relay. // - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses on // the relay for N seconds after a ticket. A timer — mitigation, not a guarantee. +// When a loop IS wired the cooldown still runs as a BACKSTOP behind it: a motion +// radar can drop a STATIONARY car (no doppler return) and spuriously re-arm, and the +// cooldown bounds how fast that re-armed press can mint a second ticket. +// - CAMERA (when an entry camera is configured): a press is live only while the entry +// lane camera confirms a vehicle — the button lamp's SOLID state (button-light.ts). +// A radar false-positive (rain, a pedestrian) blinks the lamp but prints nothing. +// Camera-less sites keep the radar-only gate; a faulty camera is dropped via the +// admin bypass (wiki/concepts/entry-presence-bypass.md). // A suppressed press is recorded as UNSIGNED telemetry (a no-op, not a fraud anomaly). // See wiki/concepts/entry-double-press.md. @@ -76,6 +84,10 @@ export class EntryFlow { readonly #guard = new Map(); /** Optional vision client — passed to snapshotAsync so ANPR runs on the entry image. */ readonly #vision: VisionClient | null; + /** Live entry-lane camera state (LaneStatus mirror, fed by onLaneStatus). Gates the + * physical press when an entry camera is configured — advisory sensor, but here it + * only ever SUPPRESSES a reprint; it never opens a barrier or traps a car. */ + #entryBusy = false; constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) { this.#db = db; @@ -125,6 +137,12 @@ export class EntryFlow { } } + /** Track the entry lane's camera state (wired to deviceEvents.onLaneStatus in + * server.ts). LaneStatus emits on every flip, so this mirror stays current. */ + onLaneStatus(s: LaneStatusEvent): void { + this.#entryBusy = s.entry; + } + /** Stable per-relay key for the guard map. */ #relayKey(r: ResolvedRelay): string { return `${r.controller.id}:${r.relay}`; @@ -157,22 +175,35 @@ export class EntryFlow { } /** Why a press should be SUPPRESSED (no ticket), or null if it may proceed. - * PRESENCE mode is authoritative when a loop is wired; otherwise COOLDOWN; else no - * guard (legacy). The two can coexist — presence first, cooldown as a backstop. */ + * Three layered gates: CAMERA (when an entry camera is configured), PRESENCE + * (when a loop is wired), and COOLDOWN — no longer alternatives: the cooldown + * runs as a backstop BEHIND presence, because a motion radar can drop a + * stationary car and spuriously re-arm one-car-one-ticket. */ #suppressReason(r: ResolvedRelay): string | null { const s = this.#guardState(r); + const bypass = this.#presenceBypass(); + + // CAMERA GATE — the lamp's blink-vs-solid rule, enforced at the press: with an entry + // camera configured, a press is live only once the camera confirms a vehicle in the + // entry zone (SOLID). Blink (radar-only — rain, a pedestrian, a reflection) prints + // nothing. Only ever suppresses a ticket; never opens or traps (advisory rule kept). + // A camera-less site skips this; a faulty camera is dropped via the admin bypass. + if (!bypass.camera && !this.#entryBusy && this.#entryCameraConfigured()) { + return "no camera-confirmed vehicle in the entry zone"; + } // Admin bypass for a FAULTY radar/loop: skip the presence-loop check so a press prints. - // We fall THROUGH to the cooldown backstop below (a dead loop can't re-arm one-car-one- - // ticket, so the time cooldown is what stops a held button minting a burst). If no - // cooldown is configured there's no anti-double-press left — that's the admin's accepted - // tradeoff while bypassed. See wiki/concepts/entry-presence-bypass.md. - if (typeof r.presenceInput === "number" && !this.#presenceBypass().radar) { + // A dead loop can't re-arm one-car-one-ticket, so the cooldown below is what stops a + // held button minting a burst. If no cooldown is configured there's no anti-double-press + // left — that's the admin's accepted tradeoff while bypassed. See + // wiki/concepts/entry-presence-bypass.md. + if (typeof r.presenceInput === "number" && !bypass.radar) { // Physical one-car-one-ticket: a car must be present AND we must be armed (no // ticket already issued for this still-present car). if (!s.present) return "no vehicle at the barrier (presence loop clear)"; if (!s.armed) return "ticket already issued for the car at the barrier"; - return null; + // Fall THROUGH to the cooldown backstop: a presence-approved press can still be the + // SAME stationary car after a radar dropout re-armed the guard. } if (typeof r.entryCooldownSec === "number" && r.entryCooldownSec > 0) { @@ -185,6 +216,13 @@ export class EntryFlow { return null; } + /** Is at least one enabled camera bound to the entry lane? The camera gate applies only + * then — a site with no entry camera keeps the radar-only press gate. Read live (like + * the bypass flags) so adding/removing a camera needs no restart. */ + #entryCameraConfigured(): boolean { + return devicesByDirection(this.#db, "camera", "entry").length > 0; + } + /** Record a suppressed (repeat/no-car) entry press as UNSIGNED telemetry — a no-op, * not a fraud anomaly, so the signed ledger stays clean (the operator's choice). */ #recordSuppressedPress(e: DeviceInputEvent, r: ResolvedRelay, reason: string): void { @@ -452,7 +490,9 @@ export class EntryFlow { * Used on both the OPEN path and the refused/held anomaly paths — a turned-away or * held car is exactly when the operator wants the photo. */ #fireSnapshot(direction: "entry", identity: string): void { - void snapshotAsync({ db: this.#db, direction, identity, logger: this.#logger, vision: this.#vision }).catch( + // `log` lets the ANPR ride-along flag a duplicate-plate entry (a signed anomaly) — + // still fire-and-forget; recognition never gates the open. See snapshot.ts. + void snapshotAsync({ db: this.#db, direction, identity, logger: this.#logger, vision: this.#vision, log: this.#log }).catch( (err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`), ); } diff --git a/apps/server/src/entry-press-gate.test.ts b/apps/server/src/entry-press-gate.test.ts new file mode 100644 index 0000000..c755d55 --- /dev/null +++ b/apps/server/src/entry-press-gate.test.ts @@ -0,0 +1,213 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { devices, siteConfig, ledgerEvents, deviceEvents as deviceEventsTable, type Db } from "@parking/db"; +import { createTestDb } from "@parking/db/testing"; +import { registry, type PrinterDevice } from "@parking/devices"; +import { EntryFlow } from "./entry-flow.js"; +import { makeLog, silentLogger } from "./test-helpers.js"; + +// The PHYSICAL entry button's press gate (#suppressReason), layered (2026-07-04): +// CAMERA — with an entry camera configured, a press is live only while the entry lane +// camera confirms a vehicle (the button lamp's SOLID state). Blink (radar-only) prints +// nothing. Camera-less sites skip this; the admin camera bypass drops it. +// PRESENCE — one-car-one-ticket off the loop (unchanged). +// COOLDOWN — now a BACKSTOP behind presence, not an alternative: a motion radar drops a +// stationary car (no doppler return), spuriously re-arming the guard; the cooldown bounds +// how fast that re-armed press can mint a second ticket for the same car. +// A suppressed press is unsigned telemetry (entrySuppressed), never a ledger anomaly. + +let db: Db; +let flow: EntryFlow; + +const CTL = "ctl-entry"; +const BUTTON_INPUT = 1; +const PRESENCE_INPUT = 2; + +// A no-op printer that always succeeds, so the happy path reaches the signed +// vehicle_entry (the real drivers need hardware). Registered once (registry is global). +const noopPrinter: PrinterDevice = { + driverId: "test-printer-ok", + connect: async () => {}, + disconnect: async () => {}, + healthCheck: async () => ({ status: "ready" as const }), + printTicket: async () => {}, + printReport: async () => {}, + printSubscriptionCard: async () => {}, + printReceipt: async () => {}, + printWindowChargeNotice: async () => {}, +}; +if (!registry.get("test-printer-ok")) { + registry.register({ + id: "test-printer-ok", + category: "printer", + label: "Test printer", + description: "always-succeeds stub for tests", + transports: [], + configFields: [], + create: () => noopPrinter, + }); +} + +beforeEach(() => { + ({ db } = createTestDb()); + db.insert(devices).values({ + id: CTL, + category: "access", + driverId: "stub-access", + config: { + relays: [{ relay: 1, direction: "entry" }], + inputs: [ + { input: BUTTON_INPUT, role: "button", relay: 1 }, + { input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "radar" }, + ], + }, + enabled: true, + }).run(); + db.insert(devices).values({ + id: "printer-entry", + category: "printer", + driverId: "test-printer-ok", + config: { direction: "entry" }, + enabled: true, + }).run(); + flow = new EntryFlow(db, makeLog(db), silentLogger()); +}); + +/** Add an entry camera row. The driver never builds (unknown id) — only its EXISTENCE + * matters to the press gate; snapshot capture failing is the normal fire-and-forget path. */ +function addEntryCamera() { + db.insert(devices).values({ + id: "cam-entry", + category: "camera", + driverId: "no-such-camera-driver", + config: { direction: "entry" }, + enabled: true, + }).run(); +} + +function setCameraBypass(on: boolean) { + db.insert(siteConfig) + .values({ id: 1, bypassPresenceCamera: on }) + .onConflictDoUpdate({ target: siteConfig.id, set: { bypassPresenceCamera: on } }) + .run(); +} + +async function edge(input: number, edge: "on" | "off") { + await flow.onInput({ + driverId: "stub-access", + deviceId: CTL, + input, + edge, + at: new Date().toISOString(), + source: "poll", + }); +} + +const press = () => edge(BUTTON_INPUT, "on"); +const radar = (present: boolean) => edge(PRESENCE_INPUT, present ? "on" : "off"); + +const entries = () => + db.select().from(ledgerEvents).all().filter((r) => r.type === "vehicle_entry"); +const suppressed = () => + db.select().from(deviceEventsTable).all() + .map((r) => r.detail as { entrySuppressed?: boolean; reason?: string }) + .filter((d) => d.entrySuppressed === true); + +describe("entry press gate — camera (blink vs solid)", () => { + it("BLINK state (radar present, no camera confirmation) → press suppressed, nothing signed", async () => { + addEntryCamera(); + await radar(true); // lamp would blink: radar sees something, camera does not + await press(); + expect(entries()).toHaveLength(0); + expect(db.select().from(ledgerEvents).all()).toHaveLength(0); // no anomaly either — telemetry only + expect(suppressed()).toHaveLength(1); + expect(suppressed()[0].reason).toMatch(/camera/); + }); + + it("SOLID state (radar present + camera busy) → press prints and signs a vehicle_entry", async () => { + addEntryCamera(); + await radar(true); + flow.onLaneStatus({ entry: true, exit: false }); // camera confirms → SOLID + await press(); + expect(entries()).toHaveLength(1); + expect(suppressed()).toHaveLength(0); + }); + + it("camera-less site → the camera gate does not apply (radar-only, as before)", async () => { + await radar(true); // no camera row; lane state irrelevant + await press(); + expect(entries()).toHaveLength(1); + }); + + it("camera bypassed (faulty camera) → press prints without camera confirmation", async () => { + addEntryCamera(); + setCameraBypass(true); + await radar(true); + await press(); + expect(entries()).toHaveLength(1); + }); + + it("no car at all (radar clear too) → suppressed even with the camera bypassed", async () => { + addEntryCamera(); + setCameraBypass(true); + await press(); // radar never went on + expect(entries()).toHaveLength(0); + expect(suppressed()[0].reason).toMatch(/presence loop clear/); + }); +}); + +describe("entry press gate — cooldown backstop behind presence", () => { + /** Same lane but the button carries a cooldown, making it a backstop behind the loop. */ + function setButtonCooldown(sec: number) { + db.delete(devices).run(); + db.insert(devices).values({ + id: CTL, + category: "access", + driverId: "stub-access", + config: { + relays: [{ relay: 1, direction: "entry" }], + inputs: [ + { input: BUTTON_INPUT, role: "button", relay: 1, cooldownSec: sec }, + { input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "radar" }, + ], + }, + enabled: true, + }).run(); + db.insert(devices).values({ + id: "printer-entry", + category: "printer", + driverId: "test-printer-ok", + config: { direction: "entry" }, + enabled: true, + }).run(); + } + + it("radar dropout re-arm + quick re-press → caught by the cooldown (one ticket)", async () => { + setButtonCooldown(60); + await radar(true); + await press(); // ticket 1 (no camera configured — radar-only site) + expect(entries()).toHaveLength(1); + // The motion radar loses the STATIONARY car and re-fires: off (re-arms!) then on. + await radar(false); + await radar(true); + await press(); // presence gate says yes (present + re-armed) — the backstop must catch it + expect(entries()).toHaveLength(1); + expect(suppressed().some((d) => /cooldown/.test(d.reason ?? ""))).toBe(true); + }); + + it("without a cooldown the dropout re-press mints a second ticket (the documented residual risk)", async () => { + await radar(true); + await press(); + await radar(false); + await radar(true); + await press(); + expect(entries()).toHaveLength(2); + }); + + it("still-present car re-pressing (no dropout) stays suppressed by one-car-one-ticket", async () => { + await radar(true); + await press(); + await press(); // car never left the loop → not re-armed + expect(entries()).toHaveLength(1); + expect(suppressed().some((d) => /already issued/.test(d.reason ?? ""))).toBe(true); + }); +}); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 014c962..056c56c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -203,6 +203,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise unsubscribeEntry()); + // The camera press-gate: the entry flow mirrors the entry lane's camera state so a + // physical press is live only in the lamp's SOLID state (see entry-flow.ts). + const unsubscribeEntryLane = deviceEvents.onLaneStatus((s) => entryFlow.onLaneStatus(s)); + app.addHook("onClose", async () => unsubscribeEntryLane()); // Button-light indicator: drives the entry button's lamp on a spare relay from the // RADAR input vs. the camera lane status (blink = radar-only, solid = radar+camera, diff --git a/apps/server/src/snapshot.ts b/apps/server/src/snapshot.ts index bd07572..a9b0ce6 100644 --- a/apps/server/src/snapshot.ts +++ b/apps/server/src/snapshot.ts @@ -1,10 +1,12 @@ import { randomUUID } from "node:crypto"; import sharp from "sharp"; -import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db"; +import { and, eq, gte, sessions, deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db"; import { registry, type CameraDevice, type Snapshot } from "@parking/devices"; +import { reasonPayload } from "@parking/shared"; import type { FastifyBaseLogger } from "fastify"; import { devicesByDirection, type FlowDirection } from "./device-resolve.js"; import { deviceEvents } from "./device-events.js"; +import type { EventLog } from "./event-log.js"; import type { VisionClient } from "./vision-client.js"; // Camera snapshot capture, fired AFTER the barrier opens and never awaited on the @@ -79,6 +81,10 @@ interface SnapshotJob { /** Optional vision client — when present, ANPR runs on each captured image from an * `anpr`-enabled camera and records the plate against `identity`. Advisory only. */ readonly vision?: VisionClient | null; + /** Optional signed ledger — when present (the transient ENTRY path passes it), a + * recognized entry plate that is already OPEN under another recent session signs an + * `entry.duplicatePlate` anomaly (same car, second ticket). Post-hoc; never a gate. */ + readonly log?: EventLog | null; } /** Camera config flag opting it into snapshot-triggered ANPR. */ @@ -93,7 +99,7 @@ interface CameraConfig { * The caller must NOT block its open path on this. */ export function snapshotAsync(job: SnapshotJob): Promise { - const { db, direction, identity, logger, vision } = job; + const { db, direction, identity, logger, vision, log } = job; const rows = devicesByDirection(db, "camera", direction); if (rows.length === 0) return Promise.resolve([]); @@ -129,7 +135,7 @@ export function snapshotAsync(job: SnapshotJob): Promise { // ANPR off the SAME image, tied to the SAME session — when vision is enabled // and this camera opts in. Fire-and-forget: never delays the open path. if (vision?.enabled && (row.config as CameraConfig)?.anpr === true) { - void recognizePlate(db, vision, row.id, direction, identity, id, shot, logger); + void recognizePlate(db, vision, row.id, direction, identity, id, shot, logger, log); } return id; } catch (err) { @@ -157,6 +163,7 @@ async function recognizePlate( snapshotId: string, shot: { bytes: Buffer; contentType: string }, logger: FastifyBaseLogger, + log?: EventLog | null, ): Promise { try { const result = await vision.analyze(shot.bytes, shot.contentType); @@ -187,11 +194,81 @@ async function recognizePlate( // The session's entry/exit event already shipped without this (async) plate — tell the // booth so it backfills the plate badge in place (no refresh). Advisory; ledger untouched. deviceEvents.emitPlateRecognized({ identity, plate, direction }); + + // ENTRY-SIDE duplicate check: this plate already OPEN under another recent session is + // most likely the SAME car that minted a second ticket (a motion radar drops a + // stationary car → the button re-arms). Signed anomaly for the operator to void. + if (direction === "entry" && log) { + await flagDuplicateEntryPlate({ db, log, identity, plate, snapshotId, logger }); + } } catch (err) { logger.warn(`anpr recognize failed (${identity}): ${(err as Error).message}`); } } +/** How far back a recognized entry plate is compared against other OPEN sessions' + * entry plates. Short on purpose: the duplicate-ticket scenario is the same car + * re-pressing within minutes; a long window would flag legit re-visits. */ +function dupPlateWindowMs(): number { + const raw = Number(process.env.ENTRY_DUP_PLATE_WINDOW_MIN ?? 15); + return (Number.isFinite(raw) && raw > 0 ? raw : 15) * 60_000; +} + +/** + * Flag a freshly-recognized ENTRY plate that is already open under a DIFFERENT recent + * session: sign ONE `entry.duplicatePlate` anomaly keyed to the new session, pointing at + * the prior one. Mirrors the exit-side plateSwapSuspected pattern (advisory, post-hoc — + * the barrier already opened; the operator voids the duplicate ticket). Exported for tests. + */ +export async function flagDuplicateEntryPlate(opts: { + db: Db; + log: EventLog; + /** The session the plate was just recognized for (the NEW ticket). */ + identity: string; + plate: string; + snapshotId: string; + logger: FastifyBaseLogger; +}): Promise { + const { db, log, identity, plate, snapshotId, logger } = opts; + try { + const cutoff = new Date(Date.now() - dupPlateWindowMs()).toISOString(); + // Recent entry-plate reads (unsigned `kind:"read"` telemetry, written above) for the + // same plate under a different identity. detail is JSON — filter in JS; read volume + // inside the window is tiny (one row per entry). + const reads = db + .select() + .from(deviceEventsTable) + .where(and(eq(deviceEventsTable.kind, "read"), gte(deviceEventsTable.occurredAt, cutoff))) + .all(); + const prior = reads + .map((r) => r.detail as { identity?: string; direction?: string; plate?: string }) + .find((d) => d.direction === "entry" && d.plate === plate && d.identity && d.identity !== identity); + if (!prior?.identity) return; + // Only a still-OPEN prior session is a duplicate suspect (a closed one drove off). + const open = db + .select() + .from(sessions) + .where(and(eq(sessions.id, prior.identity), eq(sessions.state, "open"))) + .get(); + if (!open) return; + await log.append({ + type: "anomaly", + identity, + payload: { + ...reasonPayload("entry.duplicatePlate", { plate, otherIdentity: prior.identity }), + duplicateEntrySuspected: true, + plate, + otherIdentity: prior.identity, + snapshotId, + }, + }); + logger.warn(`duplicate entry suspected: plate ${plate} on ${identity} already open under ${prior.identity}`); + } catch (err) { + // Best-effort, post-hoc — never let the duplicate check surface on the open path. + logger.error(`duplicate-plate check failed (${identity}): ${(err as Error).message}`); + } +} + /** Build a live camera adapter from a resolved devices row, or null. Exported so the * ANPR bridge (anpr-entry.ts) reuses the identical registry-build-or-null logic. */ export function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null { diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 4b3e016..a608ad9 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -282,6 +282,7 @@ export const en: Catalog = { "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", + "entry.duplicatePlate": "Possible duplicate entry — plate {{plate}} is already inside under ticket {{otherIdentity}}", "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)", @@ -299,6 +300,7 @@ export const en: Catalog = { "sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)", "sub.refused.unpaidWindow": "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth", "void.ticketCancelled": "Ticket cancelled — {{reason}}", + "setup.relayTest": "Relay test — admin {{operator}} pulsed relay {{relay}} on controller {{controller}} from Setup", }, tariff: { title: "Tariff", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index cee4505..864c4cb 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -285,6 +285,7 @@ export const sq = { "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", + "entry.duplicatePlate": "Hyrje e dyfishtë e mundshme — targa {{plate}} është tashmë brenda me biletën {{otherIdentity}}", "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)", @@ -302,6 +303,7 @@ export const sq = { "sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)", "sub.refused.unpaidWindow": "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë", "void.ticketCancelled": "Bileta u anulua — {{reason}}", + "setup.relayTest": "Test releje — admini {{operator}} aktivizoi relenë {{relay}} te kontrolluesi {{controller}} nga Konfigurimi", }, tariff: { title: "Tarifa", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 10fd3b0..98f31d2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -374,6 +374,11 @@ export const REASON_CODES = [ // vehicle presence (radar + camera). See wiki/concepts/operator-issued-entry.md. "entry.operatorIssued", "entry.issue.noPresence", + // entry-side plate reconciliation: the plate recognized on a fresh transient entry is + // already OPEN under another recent session — likely the SAME car minting a second + // ticket (e.g. a motion radar dropped the stationary car and re-armed the button). + // Post-hoc + advisory (ANPR never gates); the operator voids the duplicate. + "entry.duplicatePlate", // exit refusals "exit.refused.closed", "exit.refused.noSession", @@ -421,6 +426,7 @@ export const REASON_EN: Record = { "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", + "entry.duplicatePlate": "possible duplicate entry — plate {plate} is already inside under ticket {otherIdentity}", "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)", diff --git a/wiki/concepts/entry-double-press.md b/wiki/concepts/entry-double-press.md index 5898ca2..c44994f 100644 --- a/wiki/concepts/entry-double-press.md +++ b/wiki/concepts/entry-double-press.md @@ -2,7 +2,7 @@ type: concept tags: [parking, entry, anti-fraud, safety, devices] sources: [] -updated: 2026-06-19 +updated: 2026-07-04 status: open --- @@ -53,7 +53,42 @@ car to pull through, short enough not to block the next legitimate car). It is a mitigation, not a guarantee** — a determined abuser can wait it out. Use it only where presence feedback isn't available; prefer wiring a loop. -The two can coexist (presence first, cooldown as a backstop), but presence is authoritative when set. +The two **coexist** (2026-07-04): presence is checked first, and the cooldown now genuinely runs as +a **backstop behind it** (before that date the presence branch returned early and the cooldown never +ran when a loop was wired). The backstop exists because of the **stationary-car radar dropout**: a +motion (doppler) radar loses a car that stops moving — the flow sees a spurious loop-clear, re-arms +one-car-one-ticket, and the *same* car's next press mints a second ticket. A configured cooldown +bounds how fast that can happen. Keep it short (~10–15 s): in presence mode it only fires on a +press the loop already approved, which includes the *next legitimate car in a queue*. + +### CAMERA gate (2026-07-04 — when an entry camera is configured) +The [[button-light-indicator]] lamp always encoded the intended UX — **blink** = radar-only +(something in the zone, no confirmed car), **solid** = radar + camera agree — but the press handler +only ever checked the radar, so a blinking button still printed (radar false-positives: rain, a +pedestrian, reflections). Now the press gate enforces the lamp's rule: with an entry camera +configured, a press is live **only while the entry lane's camera confirms a vehicle** +([[lane-presence-and-anpr-entry|LaneStatus]] busy — the SOLID state). This keeps the camera +**advisory** in the safety sense: it only ever *suppresses a ticket*, never opens a barrier and +never traps a car. A camera-less site keeps the radar-only gate; a faulty camera is dropped via the +admin [[entry-presence-bypass]] (`bypassPresenceCamera`), same as the operator-issued path. + +### REJECTED: camera-vetoed re-arm (the queue trap) +The obvious fix for the stationary-car dropout — *don't re-arm on loop-clear while the camera still +sees a car; re-arm when the lane flips free* — was designed and **rejected** (2026-07-04). The +entry camera sends no leave events; "free" is a ~30 s detection-silence timeout. In any queue the +next car occupies the zone before that timeout can lapse, so the lane never flips free between two +legitimate cars — every queued car after the first would be suppressed until an operator intervened. +Blocking legitimate entry at peak load is strictly worse than the occasional duplicate ticket. The +duplicate is handled **post-hoc** instead: + +### Duplicate-plate reconciliation (post-hoc, entry-side twin of [[plate-reconciliation]]) +ANPR already rides the entry snapshot (never blocking — the barrier is open before recognition +starts). When the recognized plate is **already OPEN under another session entered within the last +~15 min** (`ENTRY_DUP_PLATE_WINDOW_MIN`), the flow signs ONE `entry.duplicatePlate` **anomaly** +pointing at both tickets; the operator voids the duplicate. Window short on purpose — a closed +session or an old read is a legit re-visit, not a double press. The proper *preventive* fix is a +**pass-through sensor** (closing loop / photocell past the barrier) as an unambiguous "a car went +through" signal — noted below as open. ## A suppressed press is a NO-OP, not an anomaly @@ -82,17 +117,27 @@ in telemetry if ever needed. through `inputsOf`, carry `presenceInput`/`entryCooldownSec` onto the `ResolvedRelay`, and only ever gate entry/both relays. An exit radar = a `presence` row on the exit relay. - `EntryFlow` (`entry-flow.ts`) keeps a `#guard` map keyed `controllerId:relay`: `#onPresenceEdge` - tracks the loop, `#suppressReason` decides presence/cooldown, `#recordSuppressedPress` writes the - telemetry. The guard disarms + stamps the cooldown on **print success** (not on open). + tracks the loop, `#suppressReason` decides camera → presence → cooldown (in that order), + `#recordSuppressedPress` writes the telemetry. The guard disarms + stamps the cooldown on + **print success** (not on open). The camera state is a live `LaneStatus` mirror + (`onLaneStatus`, wired in `server.ts`); "an entry camera is configured" is read per press so + adding/removing one needs no restart. The duplicate-plate check is + `flagDuplicateEntryPlate` in `snapshot.ts`, called from the ANPR ride-along when the entry + flow passes its `EventLog`. - [[first-run-setup|SetupWizard]] relay editor: entry/both relays expose a **Presence loop (terminal)** field and, when no loop is set, a **Cooldown after ticket (s)** field (localized sq+en — see [[i18n]]). ## Open -- **No automated test yet** (the standing harness gap) — verify on hardware: with a loop, a held - button issues one ticket; after the car clears the loop a new car gets a fresh one. Without a loop, - a cooldown blocks the repeat and the suppressed press lands in telemetry. +- ~~No automated test yet~~ **Covered 2026-07-04**: `entry-press-gate.test.ts` pins the camera + gate (blink suppresses / solid prints / camera-less unaffected / bypass honored), the cooldown + backstop behind presence, and one-car-one-ticket; `entry-duplicate-plate.test.ts` pins the + post-hoc plate anomaly. Hardware verification on park-buzi still worthwhile. +- **Pass-through sensor** (closing loop / photocell past the barrier, a `passedInput` role): the + only *unambiguous* "the car went through" signal. Would let re-arm key on actual passage instead + of loop-clear, killing the stationary-car dropout without the queue trap. Procurement + wiring + question for the lanes. - **Exit side:** the symmetric concern (re-reading a ticket at exit) is already handled differently — exit validates against an open session, so a second read finds the session closed (no double-exit). No presence gate needed there today. diff --git a/wiki/log.md b/wiki/log.md index f7f0b19..68c5aa5 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2207,3 +2207,19 @@ all interfaces; plus correctness/hygiene (cwd-relative `.env`, `/health` always- swallowing failures, unbounded `min_confidence`). Reassurance recorded: a forged image can't open a barrier (server re-gates at 0.85 + debounce), content-type isn't trusted, non-root, `.env` not baked into the image. Nothing fixed yet — this is the backlog to work from. + +## [2026-07-04] update | Entry press gate: camera enforced, cooldown backstop, duplicate-plate anomaly + +Field report from park-buzi: a BLINKING entry button (radar-only, no camera confirmation) still +printed a ticket — the [[button-light-indicator]] encoded blink-vs-solid but `#suppressReason` +only ever checked the radar. Fixed in [[entry-double-press]]: (1) CAMERA gate on the physical +press — live only in the lamp's SOLID state when an entry camera is configured; honors +[[entry-presence-bypass]]; suppress-only, so the camera stays advisory; (2) cooldown now a REAL +backstop behind presence (the old code returned early, so `entryCooldownSec` was dead in presence +mode) — bounds the stationary-car motion-radar-dropout double-ticket; (3) post-hoc +`entry.duplicatePlate` signed anomaly when the recognized entry plate is already open under a +recent session (entry-side twin of [[plate-reconciliation]]; ANPR stays non-blocking). REJECTED +along the way: camera-vetoed re-arm (defer re-arm until the lane flips free) — the camera's ~30 s +silence-timeout "free" never fires inside a queue, so it would suppress every queued car after the +first. Proper preventive fix noted open: a pass-through sensor (`passedInput`). 13 new tests +(`entry-press-gate.test.ts`, `entry-duplicate-plate.test.ts`); suite 258 green. From 35e593ab63e52fe87a0d084e8b3252294aab44e2 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 4 Jul 2026 19:00:49 +0200 Subject: [PATCH 3/4] docs(wiki): DT-008 phantom-scan diagnosis + backfill bypass/relay-test concept pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent wiki updates bundled (all docs): 1. dingtian-dt008-reader.md: phantom optical decodes on the park-buzi EXIT reader (empty pre-opening site, low-sun afternoons). Chain of evidence: READ log lines carry the reader's own serial (H05MA5B0) → physical device, not a network source; snapshot shows nobody present; code shapes are the giveaway (6-digit numerics = checksum-less Interleaved 2-of-5, lone "C" = Code39/Codabar artifact) → 1D engine decoding sun-made stripe patterns (striped arm, fence shadows, glare). No fraud exposure (11-digit Luhn ids can't match); noise only. Fix on the entity page: vendor-tool symbology cut to QR+Code128 + min decode length, BOTH readers; config lives ON the device → re-apply after any factory reset/swap. Deliberately NOT filtering impossible codes server-side — probe recording is the anomaly path's job. 2. Backfilled two shipped-but-undocumented features (six code files already linked the first page as if it existed): - concepts/entry-presence-bypass.md — admin drops a FAULTY presence signal (granular radar/camera by decision, not a master switch); every flip is a signed config_change; persists till off; tickets stamped presenceBypassed; radar-bypass cooldown tradeoff; "the admin is not the adversary, but trusted never means invisible". - concepts/setup-relay-test.md — admin-only commissioning pulse, signed barrier_open_command BEFORE the fire so a test open never reads as the out-of-band-open fraud signal; saved controllers/declared relays only; radarAlert lamps excluded; pulseOpen only. Cross-linked from operator-issued-entry.md, cataloged in index.md, logged. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- wiki/concepts/entry-presence-bypass.md | 65 ++++++++++++++++++++++++++ wiki/concepts/operator-issued-entry.md | 7 ++- wiki/concepts/setup-relay-test.md | 48 +++++++++++++++++++ wiki/entities/dingtian-dt008-reader.md | 29 +++++++++++- wiki/index.md | 6 ++- wiki/log.md | 24 ++++++++++ 6 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 wiki/concepts/entry-presence-bypass.md create mode 100644 wiki/concepts/setup-relay-test.md diff --git a/wiki/concepts/entry-presence-bypass.md b/wiki/concepts/entry-presence-bypass.md new file mode 100644 index 0000000..627091b --- /dev/null +++ b/wiki/concepts/entry-presence-bypass.md @@ -0,0 +1,65 @@ +--- +type: concept +tags: [parking, entry, admin, devices, threat-model, presence] +sources: [] +updated: 2026-07-04 +status: settled +--- + +# Entry presence-gate bypass (faulty radar / faulty camera) + +The transient entry gates require a **real vehicle** before a ticket can mint: the radar/loop +presence input and the camera vehicle-detection ([[entry-double-press]] for the physical button, +[[operator-issued-entry]] for the booth button). When one of those devices is **faulty** — a dead +radar, a camera that stopped pushing ([[g3h-anpr-push-gotchas|it happens]]) — the gate blocks +**legitimate** transient entry until support fixes the hardware. This feature lets the **admin** +drop a specific signal as a requirement until then. Built 2026-07-02 (migration `0020`). + +## The principle: the admin is NOT the adversary + +The [[threat-model]] adversary is the **booth operator**; the admin is the trusted party. So the +admin *may* weaken an anti-fraud gate — but weakening it stays **attributed and auditable** +(signed), because "trusted" never means "invisible" in this system. Compare +[[operator-issued-entry]], where the untrusted role gets capability + a red flag on every use; +here the trusted role gets a switch + a signed record of flipping it. + +## Decisions (2026-07-02) + +- **Granular, not a master switch.** One flag per signal: a faulty camera drops only the camera + check (radar still gates); a faulty radar drops only radar. Chosen over a single "bypass all" + toggle so a single broken device never silently disables the *other*, still-working gate. +- **Persists until turned OFF, and every flip is signed.** No auto-expiry (support visits are + unpredictable); instead each enable/disable appends a **`config_change`** ledger event + (`setting: entryPresenceBypass.`, `value`, `prev`, `operator`) — the append-only chain + records *who weakened the gate and when*, and the OFF transition too. A no-op write (same value) + signs nothing. + +## What a bypass changes + +- **Physical button** (`EntryFlow.#suppressReason`): camera bypassed → the camera press-gate is + skipped; radar bypassed → the presence-loop check is skipped and the press falls through to the + **cooldown backstop**. ⚠️ A dead loop can't re-arm one-car-one-ticket, so with radar bypassed + and **no `entryCooldownSec` configured there is no anti-double-press left** — the admin's + accepted tradeoff while bypassed (set a cooldown when bypassing radar). +- **Operator button** (`issueForOperator`): the bypassed signal is dropped as a requirement; a + refusal only happens when a **still-required** signal fails to confirm. +- **Auditability on every ticket:** a ticket minted under a bypass carries + `presenceBypassed: ["radar"|"camera", ...]` on its signed `vehicle_entry` (and refusal anomalies + record it too) — so reconciliation can always tell which entries happened under a weakened gate. +- **Booth lights** ([[booth-console]]): a bypassed signal renders as satisfied, so the operator's + button-enable logic matches the server's. + +## As-built + +- **Schema:** `site_config.bypass_presence_radar` / `bypass_presence_camera` (booleans, default + off = the normal both-required gate), migration `0020_entry_presence_bypass.sql`. Read **live** + per decision — a toggle needs no restart. +- **Endpoint:** `PUT /api/site-config/presence-bypass` (`routes/site.ts`), admin-only + (`site:update`), signs one `config_change` per actually-changed signal, 400 on non-boolean/empty. +- **UI:** SetupWizard "presence gate" panel (two checkboxes + an amber "active" warning) after the + controller section; `config_change` events render amber in the booth feed. +- **Tests:** `entry-presence-bypass.test.ts` (gate decisions under each combination), + `presence-bypass-route.test.ts` (RBAC, signing, no-op, validation), and + `entry-press-gate.test.ts` (camera bypass on the physical press). +- Fixing this surfaced a latent bug: `firstRelayByDirection` never attached `presenceInput`, so + the operator-issue radar check had always read "presence loop unavailable" (fixed 2026-07-02). diff --git a/wiki/concepts/operator-issued-entry.md b/wiki/concepts/operator-issued-entry.md index 85f474c..bac2a8f 100644 --- a/wiki/concepts/operator-issued-entry.md +++ b/wiki/concepts/operator-issued-entry.md @@ -2,7 +2,7 @@ type: concept tags: [parking, booth, entry, threat-model, anpr, presence] sources: [] -updated: 2026-07-01 +updated: 2026-07-04 status: settled --- @@ -33,6 +33,11 @@ with phantom tickets, and (crucially) it guarantees the entry snapshot captures 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). +> **Bypass (2026-07-02):** when one of the two devices is FAULTY, the admin can drop that signal +> as a requirement via [[entry-presence-bypass]] (granular, signed `config_change`, persists till +> turned off). The gate then requires only the still-working signal; tickets minted under a bypass +> carry `presenceBypassed` on the signed entry, and refusal anomalies record it too. + **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 diff --git a/wiki/concepts/setup-relay-test.md b/wiki/concepts/setup-relay-test.md new file mode 100644 index 0000000..b8cf024 --- /dev/null +++ b/wiki/concepts/setup-relay-test.md @@ -0,0 +1,48 @@ +--- +type: concept +tags: [parking, setup, devices, integrity, admin] +sources: [] +updated: 2026-07-04 +status: settled +--- + +# Setup relay test (signed, admin-only barrier pulse) + +Commissioning a lane needs a way to prove **wiring**: does relay N on this controller actually +lift *this* barrier? Before this feature (built 2026-07-01) the only way was to fake a credential +or short an input — both of which pollute the flows they exercise. Now the Setup controller +section offers a per-relay **Test** button that pulses the relay directly. + +## The design constraint: a test open must be SIGNED + +The core anti-fraud rule ([[append-only-event-chain]]) is that **a physical barrier open with no +matching signed command is THE fraud signal**. An unsigned test pulse would therefore read as +fraud in any reconciliation of controller logs vs. ledger. So the test **signs a +`barrier_open_command` BEFORE the pulse fires** — same ordering invariant as every real open: + +- `source: "manual"` (a deliberate human action, same as an operator barrier open), + `identity: relay-test::`, +- payload: `reasonPayload("setup.relayTest", { operator, relay, controller })` + `relayTest: true` + so reconciliation and the feed can tell a test from an intervention. +- No `EventLog` available (boot ordering) → the endpoint refuses (503) rather than fire unsigned. + +## Guardrails + +- **Admin-only** (`site:update`) + CSRF — the operator (the [[threat-model]] adversary) cannot + pulse barriers from Setup. +- **Saved controllers only**, and only relays the saved config **declares** (unknown relay → 400, + unknown controller → 404, nothing signed on any refusal). No free-form "pulse anything" seam. +- **`radarAlert` (lamp) relays are excluded** in the UI — they are aux outputs, not barriers + ([[button-light-indicator]]); the test drives `pulseOpen` only, so [[barrier-not-a-door]] holds + (intent-only; the barrier firmware owns the close). +- UI confirms before firing (`Test` → confirm dialog → pulse), per-relay buttons in the + controller assignment row. + +## As-built + +- Endpoint `POST /api/setup/test-relay` (`routes/setup.ts`) — validates, signs, then + `registry.create(...)` → `pulseOpen(relay)`. Feature-detects `pulseOpen` on the built device. +- UI: `RelayTester` in `SetupWizard.tsx` (access category only). Reason code `setup.relayTest` + in `@parking/shared` + both web catalogs ([[i18n]]). +- Tests: `setup-relay-test.test.ts` — RBAC 403, CSRF 403, signed command on success, 400/404 + refusals sign nothing, bad relay value. diff --git a/wiki/entities/dingtian-dt008-reader.md b/wiki/entities/dingtian-dt008-reader.md index 5a318bc..35116d2 100644 --- a/wiki/entities/dingtian-dt008-reader.md +++ b/wiki/entities/dingtian-dt008-reader.md @@ -2,7 +2,7 @@ type: entity tags: [parking, hardware, readers, qr, dingtian] sources: [dingtian-dt008] -updated: 2026-06-28 +updated: 2026-07-04 status: open --- @@ -120,6 +120,33 @@ host in the **vendor tool**; assign + enter its serial + bind it here. - `output` is replied as `0` (Access). Confirm on hardware whether the reader needs `1`/`2` (WG26/34) to drive its access line, vs. `0`. +## ⚠️ Phantom optical decodes from sunlight patterns (park-buzi, 2026-07-04) + +With the site EMPTY (pre-opening, verified live + by snapshot), the **exit reader +(`cjihao=H05MA5B0`) pushed spontaneous scans** at random afternoon times (observed 16:38–18:14, +low-western-sun hours): `cardid` values like `997492`, `389861`, `024358`, `192793` — and once a +lone **`C`**. Server logs (`READ serial=` lines) confirm the pushes carry the reader's own serial +and resolve to its assigned row, so this is **the physical reader decoding, not a network source**. + +**Diagnosis:** the scan engine ships with many 1D symbologies enabled, some with weak/no checksums. +Six-digit all-numeric strings are the signature of **Interleaved 2-of-5** (even-length, digits-only, +no checksum — any high-contrast stripe pattern of the right proportions "decodes"); a lone `C` is a +**Code39/Codabar** artifact (Codabar start/stop chars are A–D). Low sun creates exactly such +patterns at a gate: the striped barrier arm, fence/railing shadows sweeping as the sun moves, glare +bands. RFID noise would instead give repeating UID-shaped values. + +**Impact: noise, not risk.** Every phantom was REFUSED fail-closed (`exit.refused.noSession`, a +signed anomaly — #52–58 in the feed); a phantom can never match a ticket ([[ticket-encoding]] ids +are 11-digit + Luhn, so a 6-digit read has nothing to match). Do NOT filter "impossible" codes +server-side — recording every probe of an exit reader is what the anomaly path is for; fix at the +source instead: + +**Fix (vendor tool, per reader — config lives ON THE DEVICE, not in our DB):** disable every +symbology except **QR + Code128** (all our credentials); if offered, set **minimum decode length +≥ 10** and require checksums. Apply to BOTH readers. ⚠️ A factory reset or a swapped unit silently +re-enables the phantom symbologies — re-apply after any reset/replacement. Physical fallback if any +noise survives: hood/visor the window, tilt it down, avoid facing the striped arm. + ## ⚠️ Reply MUST set `Connection: close` (verified on hardware) The reader sends `Connection: keep-alive` but **only acts on the verdict (beep/output) once the TCP diff --git a/wiki/index.md b/wiki/index.md index 1a82db9..79c8e48 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -80,8 +80,10 @@ Counts: 4 sources · 19 entities · 47 concepts · 7 decision records. - [[challenge-response-auth]] — asymmetric nonce scheme for the ESP32 (auth + anti-replay). - [[entry-exit-readers]] — two populations, two integration paths; both can share a relay. - [[entry-exit-points]] — pool-of-spaces model (no lane); per-relay direction, reader→relay binding, camera snapshots. -- [[entry-double-press]] — one car = one ticket: per-relay presence gate (loop OR radar) preferred, cooldown fallback; suppressed press = telemetry. -- [[button-light-indicator]] — entry button lamp on a spare relay: radar × camera 3-state (blink/solid/off); aux-output; fails OFF. +- [[entry-double-press]] — one car = one ticket: per-relay presence gate (loop OR radar) preferred, cooldown fallback (a real backstop since 2026-07-04); camera press-gate + post-hoc duplicate-plate anomaly (2026-07-04). +- [[entry-presence-bypass]] — admin drops a FAULTY presence signal (radar/camera, granular) until support fixes it; every flip is a signed config_change; tickets minted under bypass are stamped (2026-07-02). +- [[setup-relay-test]] — admin-only per-relay Test button in Setup; signs a barrier_open_command BEFORE the pulse so a test open never reads as fraud (2026-07-01). +- [[button-light-indicator]] — entry button lamp on a spare relay: radar × camera 3-state (blink/solid/off); aux-output; fails OFF. The press gate enforces its SOLID state since 2026-07-04. - [[uhppote-vs-esp32]] — comparison: detection vs. prevention. ## Concepts — business domain diff --git a/wiki/log.md b/wiki/log.md index 68c5aa5..b06302f 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2223,3 +2223,27 @@ along the way: camera-vetoed re-arm (defer re-arm until the lane flips free) — silence-timeout "free" never fires inside a queue, so it would suppress every queued car after the first. Proper preventive fix noted open: a pass-through sensor (`passedInput`). 13 new tests (`entry-press-gate.test.ts`, `entry-duplicate-plate.test.ts`); suite 258 green. + +## [2026-07-04] update | Exit reader phantom scans traced to optical 1D decodes (sun patterns) + +Pre-opening park-buzi, empty site: the exit [[dingtian-dt008-reader]] pushed spontaneous 6-digit +numeric scans (+ one lone "C") at low-sun afternoon hours; all refused fail-closed as +exit.refused.noSession. Server READ logs confirmed the reader's own serial (H05MA5B0) → the +physical device decoding, not a network source; a live snapshot confirmed nobody present. +Diagnosis: default-enabled weak-checksum 1D symbologies (I2of5 6-digit signature; "C" = +Code39/Codabar artifact) decoding sun-made stripe patterns (striped barrier arm, fence shadows, +glare). No fraud exposure (11-digit Luhn ids can't match a 6-digit read). Fix recorded on the +entity page: vendor-tool symbology cut to QR+Code128 + min length, BOTH readers, re-apply after +any factory reset (config lives on the device). Deliberately NOT filtering impossible codes +server-side — probe recording is the anomaly path's job. + +## [2026-07-04] update | Backfilled missing concept pages: entry-presence-bypass + setup-relay-test + +Two shipped features (2026-07-01/02) had no wiki pages — worse, six code files and +[[entry-double-press]] already linked [[entry-presence-bypass]] as if it existed. Written now: +[[entry-presence-bypass]] (admin drops a faulty radar/camera signal, granular by decision, every +flip a signed config_change, tickets stamped presenceBypassed, radar-bypass cooldown tradeoff, +"the admin is not the adversary" threat-model nuance) and [[setup-relay-test]] (admin-only +commissioning pulse, signed barrier_open_command BEFORE the fire so a test open never reads as the +out-of-band-open fraud signal, saved-controllers-only, radarAlert lamps excluded). Cross-linked +from [[operator-issued-entry]] (bypass note) and cataloged in index.md. From 43c1f45e29644bf0719bd6a5adb20eb4f286c8bf Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 4 Jul 2026 19:34:48 +0200 Subject: [PATCH 4/4] feat(reader): channel tagging (clone defense) + structural filter for phantom scans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reader-hardening changes born from the park-buzi phantom-scan investigation (empty pre-opening site, exit reader pushing sun-decoded garbage codes). 1. CHANNEL TAGGING — closes the printed-card-clone hole. The DT-008 push is channel-blind (one opaque cardid from either engine) and SubscriptionFlow matched by value only, so printing an RF card's UID (often written on the card face, e.g. 86A158) as a barcode cloned the card. Now: - Vendor tool sets output prefixes (QRCode "Q:", Card "K:"; server env overrides READER_QR_PREFIX / READER_CARD_PREFIX). - routes/qr-reader.ts strips the prefix and tags the read's confirmed channel (DeviceReadEvent.channel optical|rf; kind qr|card). Enrollment capture stores the BARE value. READ log lines carry ch=… (permanent phantom attribution). - SubscriptionFlow.match requires channel agreement: an optical decode may not claim an rf credential (and vice versa) — refused + signed sub.refused.channelMismatch anomaly (a clone attempt is a fraud signal). - Unprefixed reads keep the legacy untagged shape and match as before, so enforcement only bites where prefixes are deployed. Deploy server FIRST, then set prefixes in the vendor tool. 2. STRUCTURAL FILTER — phantom decodes out of the signed feed (operator- requested, reverses the earlier "record every probe" position — red "who is exiting?" rows for NOBODY train the operator to ignore the feed). read-dispatch.ts drops a no-match reader value that cannot possibly be a credential we issue (no ticket Luhn shape, no SUB-/SUBSESS- prefix, not confirmed-RF, not a plate) to UNSIGNED device_events telemetry (unrecognizedRead:true). Deliberately WIDE plausibility: forged ticket shapes, unknown physical cards, unknown SUB- codes all still sign the normal refusal anomaly; enrolled credentials match before the filter and can never be hidden. Works for legacy unprefixed reads too — the feed cleans up on deploy, before any vendor-tool change. Wiki: dingtian-dt008-reader.md records the clone hole + fix, the filter (as a recorded position reversal), and the two device-side settings now part of the credential contract (output prefixes + Card Input format, moving 6H→8H at the next vendor-tool session; both live ON the device — re-apply after any factory reset/swap). Tests: qr-reader-channel.test.ts (prefix split, route tagging, bare-value capture), subscription-channel.test.ts (channel agreement matrix + anomaly), read-dispatch-filter.test.ts (filter boundary: phantoms dropped, probes kept, enrolled never hidden). Suite 278 green. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/server/src/device-events.ts | 7 ++ apps/server/src/read-dispatch-filter.test.ts | 103 ++++++++++++++++++ apps/server/src/read-dispatch.ts | 69 +++++++++++- .../src/routes/qr-reader-channel.test.ts | 101 +++++++++++++++++ apps/server/src/routes/qr-reader.ts | 51 +++++++-- apps/server/src/subscription-channel.test.ts | 88 +++++++++++++++ apps/server/src/subscription-flow.ts | 34 ++++++ apps/web/src/lib/i18n/en.ts | 1 + apps/web/src/lib/i18n/sq.ts | 1 + packages/shared/src/index.ts | 5 + wiki/entities/dingtian-dt008-reader.md | 45 +++++++- wiki/log.md | 25 +++++ 12 files changed, 515 insertions(+), 15 deletions(-) create mode 100644 apps/server/src/read-dispatch-filter.test.ts create mode 100644 apps/server/src/routes/qr-reader-channel.test.ts create mode 100644 apps/server/src/subscription-channel.test.ts diff --git a/apps/server/src/device-events.ts b/apps/server/src/device-events.ts index cfbacf8..51255cc 100644 --- a/apps/server/src/device-events.ts +++ b/apps/server/src/device-events.ts @@ -24,6 +24,13 @@ export interface DeviceReadEvent { readonly deviceId: string; // devices id of the reader/scanner/camera readonly value: string; // the ticket id / plate / card number readonly kind: "ticket" | "plate" | "qr" | "card"; + /** The CONFIRMED physical channel the value arrived on, when the reader tags it + * (the DT-008 output prefixes — see routes/qr-reader.ts). `optical` = decoded by + * the barcode/QR engine; `rf` = read from a card/chip. Undefined = legacy reader + * with no prefixes configured (channel unknown — flows must not assume). Lets the + * subscription match refuse an OPTICAL decode claiming an RF credential (a printed + * copy of a card's UID must not clone the card). */ + readonly channel?: "optical" | "rf"; readonly at: string; // ISO-8601 } diff --git a/apps/server/src/read-dispatch-filter.test.ts b/apps/server/src/read-dispatch-filter.test.ts new file mode 100644 index 0000000..f3a1c01 --- /dev/null +++ b/apps/server/src/read-dispatch-filter.test.ts @@ -0,0 +1,103 @@ +import { randomUUID } from "node:crypto"; +import { beforeEach, describe, expect, it } from "vitest"; +import { devices, deviceEvents as deviceEventsTable, ledgerEvents, subscriptionCredentials, type Db } from "@parking/db"; +import { createTestDb } from "@parking/db/testing"; +import { ReadDispatcher } from "./read-dispatch.js"; +import { ExitFlow } from "./exit-flow.js"; +import { SubscriptionFlow } from "./subscription-flow.js"; +import type { DeviceReadEvent } from "./device-events.js"; +import { makeLog, silentLogger } from "./test-helpers.js"; + +// STRUCTURAL FILTER at the dispatcher (2026-07-04): a reader value that matched +// nothing AND can't possibly be a credential we issued (no ticket Luhn shape, no +// SUB-/SUBSESS- prefix, not a confirmed-RF read) is refused with UNSIGNED telemetry +// instead of reaching the exit flow and signing a noSession anomaly. Born from the +// park-buzi phantom optical decodes: red "who is exiting?" rows for NOBODY train the +// operator to ignore the signed feed. Anything plausibly ours STILL signs normally. + +let db: Db; +let dispatcher: ReadDispatcher; + +const READER = "reader-exit"; + +beforeEach(() => { + ({ db } = createTestDb()); + db.insert(devices).values({ + id: "ctl-exit", + category: "access", + driverId: "stub-access", + config: { relays: [{ relay: 1, direction: "exit" }] }, + enabled: true, + }).run(); + db.insert(devices).values({ + id: READER, + category: "reader", + driverId: "dingtian-qr-reader", + config: { serial: "H05MA5B0", direction: "exit" }, + enabled: true, + }).run(); + const log = makeLog(db); + dispatcher = new ReadDispatcher(db, new ExitFlow(db, log, silentLogger()), new SubscriptionFlow(db, log, silentLogger()), silentLogger()); +}); + +function read(value: string, opts: { kind?: DeviceReadEvent["kind"]; channel?: DeviceReadEvent["channel"] } = {}): DeviceReadEvent { + return { + driverId: "dingtian-qr-reader", + deviceId: READER, + value, + kind: opts.kind ?? "qr", + ...(opts.channel ? { channel: opts.channel } : {}), + at: new Date().toISOString(), + }; +} + +const ledger = () => db.select().from(ledgerEvents).all(); +const unrecognized = () => + db.select().from(deviceEventsTable).all() + .map((r) => r.detail as { unrecognizedRead?: boolean; value?: string }) + .filter((d) => d.unrecognizedRead === true); + +describe("read-dispatch structural filter", () => { + it("phantom 6-digit optical decode → refused, telemetry only, NOTHING signed", async () => { + const out = await dispatcher.dispatch(read("999459", { channel: "optical" })); + expect(out.accepted).toBe(false); + expect(out.reason).toMatch(/unrecognized/); + expect(ledger()).toHaveLength(0); // the whole point: no red row in the feed + expect(unrecognized()).toHaveLength(1); + expect(unrecognized()[0].value).toBe("999459"); + }); + + it("legacy untagged garbage ('C') → filtered too (works before prefixes are deployed)", async () => { + const out = await dispatcher.dispatch(read("C")); + expect(out.accepted).toBe(false); + expect(ledger()).toHaveLength(0); + expect(unrecognized()).toHaveLength(1); + }); + + it("Luhn-valid unknown ticket → NOT filtered: the exit flow signs the noSession anomaly", async () => { + const out = await dispatcher.dispatch(read("00000000000")); // valid shape, no session + expect(out.accepted).toBe(false); + expect(unrecognized()).toHaveLength(0); + const anomalies = ledger().filter((r) => r.type === "anomaly"); + expect(anomalies.length).toBeGreaterThan(0); // a real probe stays in the signed feed + }); + + it("unknown card on a CONFIRMED RF channel → NOT filtered (a physical card is a real event)", async () => { + await dispatcher.dispatch(read("1A86A158", { kind: "card", channel: "rf" })); + expect(unrecognized()).toHaveLength(0); + expect(ledger().filter((r) => r.type === "anomaly").length).toBeGreaterThan(0); + }); + + it("unknown SUB- code → NOT filtered (our own prefix = an interesting probe)", async () => { + await dispatcher.dispatch(read("SUB-DOESNOTEXIST", { channel: "optical" })); + expect(unrecognized()).toHaveLength(0); + expect(ledger().filter((r) => r.type === "anomaly").length).toBeGreaterThan(0); + }); + + it("an ENROLLED credential is matched BEFORE the filter (never hidden by it)", async () => { + // A card UID that would fail every shape test — enrolled, so it must still match. + db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: "sub-1", kind: "rf", value: "999459" }).run(); + await dispatcher.dispatch(read("999459")); // legacy untagged read of it + expect(unrecognized()).toHaveLength(0); // reached the subscription flow, not the filter + }); +}); diff --git a/apps/server/src/read-dispatch.ts b/apps/server/src/read-dispatch.ts index 9beebf9..03dd856 100644 --- a/apps/server/src/read-dispatch.ts +++ b/apps/server/src/read-dispatch.ts @@ -1,7 +1,9 @@ -import { devices, eq, type Db } from "@parking/db"; +import { randomUUID } from "node:crypto"; +import { devices, deviceEvents as deviceEventsTable, eq, type Db } from "@parking/db"; import type { FastifyBaseLogger } from "fastify"; import type { DeviceReadEvent, ReadOutcome } from "./device-events.js"; import type { ExitFlow } from "./exit-flow.js"; +import { validateTicketCode } from "./entry-flow.js"; import type { SubscriptionFlow } from "./subscription-flow.js"; import { relayForDevice } from "./device-resolve.js"; @@ -17,6 +19,22 @@ import { relayForDevice } from "./device-resolve.js"; // it opens that exact barrier. An "entry" reader drives the entry side, an "exit" // reader the exit side; "both" defers to the flow's own inference (subscription: // session state; transient: exit). +// +// STRUCTURAL FILTER (2026-07-04, operator-requested). The DT-008's scan engine +// false-decodes sunlight stripe patterns into short garbage codes (phantom reads — +// see wiki/entities/dingtian-dt008-reader.md), and each one was reaching the exit +// flow and signing an exit.refused.noSession anomaly: red "who is trying to exit?" +// rows for NOBODY, training the operator to ignore the feed (alarm fatigue is the +// adversary's friend). So a reader value that matched nothing AND cannot possibly be +// a credential we issued is dropped to UNSIGNED telemetry (device_events, still +// auditable) instead of the signed ledger. "Possibly ours" stays deliberately wide — +// any of these still reaches the flows and signs the normal refusal anomaly: +// - a Luhn-valid ticket shape (validateTicketCode — a forged/expired ticket is a +// real probe), +// - our issued-code prefixes (SUB- / SUBSESS-), +// - ANY read on a CONFIRMED RF channel (a physically present card, enrolled or +// not, is a real event — RF is never sun noise), +// - plates (different population; never shape-filtered here). export class ReadDispatcher { readonly #db: Db; @@ -45,6 +63,20 @@ export class ReadDispatcher { if (sub) { return this.#subscription.run(resolved, e, sub); } + + // Matched nothing — if the value can't even BE one of ours, it's scanner noise + // (phantom optical decode): refuse with unsigned telemetry, keep the signed feed + // for events that involve an actual credential or an actual card. + if ((e.kind === "qr" || e.kind === "card" || e.kind === "ticket") && !plausibleCredential(e)) { + this.#recordUnrecognized(e); + this.#logger.info(`read filtered (not a credential shape): '${e.value}' from ${e.deviceId}${e.channel ? ` ch=${e.channel}` : ""}`); + return { + accepted: false, + direction: resolved.direction === "entry" ? "entry" : "exit", + reason: "unrecognized code (no credential shape — telemetry only)", + }; + } + // Not a subscription → transient ticket exit. An ENTRY reader can't produce a // transient exit (transient entry is the button flow, not a reader), so reject+log // rather than treat an entry scan as an exit. @@ -53,4 +85,39 @@ export class ReadDispatcher { } return this.#exit.handleAt(resolved, e); } + + /** Unsigned telemetry for a filtered read — auditable in device_events, out of the + * signed feed. Mirrors the entry flow's suppressed-press pattern. */ + #recordUnrecognized(e: DeviceReadEvent): void { + try { + this.#db + .insert(deviceEventsTable) + .values({ + id: randomUUID(), + deviceId: e.deviceId, + category: "reader", + kind: "read", + detail: { + unrecognizedRead: true, + value: e.value, + readKind: e.kind, + ...(e.channel ? { channel: e.channel } : {}), + reason: "no credential shape (phantom decode / garbage scan)", + }, + occurredAt: e.at, + }) + .run(); + } catch (err) { + this.#logger.error(`unrecognized-read telemetry insert failed: ${(err as Error).message}`); + } + } +} + +/** Could this reader value possibly be a credential WE issued (or a real card)? + * Deliberately WIDE — only shapes that can't be anything of ours are filtered. */ +function plausibleCredential(e: DeviceReadEvent): boolean { + if (e.channel === "rf") return true; // a physically present card — never sun noise + if (validateTicketCode(e.value)) return true; // ticket shape (10–14 digits + Luhn) + if (/^SUB(SESS)?-/.test(e.value)) return true; // our subscription QR / window-slip ids + return false; } diff --git a/apps/server/src/routes/qr-reader-channel.test.ts b/apps/server/src/routes/qr-reader-channel.test.ts new file mode 100644 index 0000000..9d84c15 --- /dev/null +++ b/apps/server/src/routes/qr-reader-channel.test.ts @@ -0,0 +1,101 @@ +import Fastify from "fastify"; +import { beforeEach, afterEach, describe, expect, it } from "vitest"; +import { devices, type Db } from "@parking/db"; +import { createTestDb } from "@parking/db/testing"; +import { qrReaderRoutes, splitChannel } from "./qr-reader.js"; +import { CredentialCapture } from "../credential-capture.js"; +import type { DeviceReadEvent, ReadOutcome } from "../device-events.js"; +import type { ReadDispatcher } from "../read-dispatch.js"; + +// CHANNEL TAGGING (2026-07-04): the DT-008's "QRCode Output Prefix" / "Card Output +// Prefix" (vendor tool) mark which engine produced a push — Q: = optical, K: = RF. +// The route strips the prefix, tags the read's confirmed channel, and enrollment +// capture stores the BARE value. Unprefixed reads stay the legacy untagged shape so +// an unconfigured reader keeps working. These tests pin the route-side contract; +// the match-side enforcement is pinned in ../subscription-channel.test.ts. + +const SERIAL = "H05MA5B0"; +const READER_ID = "reader-exit"; + +let db: Db; +let app: ReturnType; +let capture: CredentialCapture; +let seen: DeviceReadEvent[]; + +/** Dispatcher stub: records the event the route built, always rejects. */ +const fakeDispatcher = { + dispatch: async (e: DeviceReadEvent): Promise => { + seen.push(e); + return { accepted: false, reason: "test" }; + }, +} as unknown as ReadDispatcher; + +beforeEach(async () => { + ({ db } = createTestDb()); + db.insert(devices).values({ + id: READER_ID, + category: "reader", + driverId: "dingtian-qr-reader", + config: { serial: SERIAL }, + enabled: true, + }).run(); + seen = []; + capture = new CredentialCapture(); + app = Fastify({ logger: false }); + await qrReaderRoutes(app as never, db, fakeDispatcher, capture); +}); + +afterEach(async () => { + await app.close(); +}); + +const scan = (cardid: string) => + app.inject({ method: "GET", url: `/qa/mcardsea.php?cardid=${encodeURIComponent(cardid)}&cjihao=${SERIAL}&mjihao=1&status=10` }); + +describe("splitChannel", () => { + it("K: prefix → bare value, kind card, channel rf", () => { + expect(splitChannel("K:86A158")).toEqual({ value: "86A158", kind: "card", channel: "rf" }); + }); + it("Q: prefix → bare value, kind qr, channel optical", () => { + expect(splitChannel("Q:12345678901")).toEqual({ value: "12345678901", kind: "qr", channel: "optical" }); + }); + it("no prefix → value untouched, legacy untagged qr", () => { + expect(splitChannel("86A158")).toEqual({ value: "86A158", kind: "qr" }); + }); +}); + +describe("qr-reader route channel tagging", () => { + it("card-prefixed push dispatches a stripped, rf-tagged read", async () => { + const res = await scan("K:86A158"); + expect(res.statusCode).toBe(200); + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ value: "86A158", kind: "card", channel: "rf", deviceId: READER_ID }); + }); + + it("qr-prefixed push dispatches a stripped, optical-tagged read", async () => { + await scan("Q:00000000000"); + expect(seen[0]).toMatchObject({ value: "00000000000", kind: "qr", channel: "optical" }); + }); + + it("unprefixed push stays legacy: kind qr, no channel", async () => { + await scan("86A158"); + expect(seen[0]).toMatchObject({ value: "86A158", kind: "qr" }); + expect(seen[0].channel).toBeUndefined(); + }); + + it("a bare prefix (empty value after strip) dispatches nothing", async () => { + await scan("K:"); + expect(seen).toHaveLength(0); + }); + + it("enrollment capture stores the BARE value, not the prefixed one", async () => { + capture.arm(READER_ID); + const res = await scan("K:86A158"); + expect(seen).toHaveLength(0); // intercepted — never dispatched to the access flow + const state = capture.state(); + expect(state.status).toBe("captured"); + if (state.status === "captured") expect(state.value).toBe("86A158"); + // Beeps "ok" so the operator knows the card was read. + expect(res.json().data[0].status).toBe(1); + }); +}); diff --git a/apps/server/src/routes/qr-reader.ts b/apps/server/src/routes/qr-reader.ts index 8962a0d..b5f14a3 100644 --- a/apps/server/src/routes/qr-reader.ts +++ b/apps/server/src/routes/qr-reader.ts @@ -27,6 +27,36 @@ interface ReaderQuery { time?: string; } +// ── CHANNEL TAGGING (2026-07-04) ──────────────────────────────────────────────── +// The DT-008 push carries one opaque `cardid` whether its OPTICAL engine decoded a +// QR/barcode or its RF engine read a card — the server can't tell them apart. That +// enabled a cheap clone: print a card's UID (often written on the card face) as a +// barcode and the optical decode matches the RF credential. Fix: the vendor tool's +// "QRCode Output Prefix" / "Card Output Prefix" are set to the markers below on every +// reader; the route strips the prefix and tags the read's confirmed channel, and the +// subscription match refuses a channel-mismatched credential. A read with NO prefix +// stays the legacy untagged shape (kind "qr", channel undefined) so an unconfigured +// reader keeps working — the enforcement only bites where prefixes are deployed. +// ⚠️ Prefixes must MATCH the vendor tool; also FREEZE "Card Input format" (6H) — that +// setting defines the UID shape we enroll. See wiki/entities/dingtian-dt008-reader.md. +const QR_CHANNEL_PREFIX = process.env.READER_QR_PREFIX ?? "Q:"; +const CARD_CHANNEL_PREFIX = process.env.READER_CARD_PREFIX ?? "K:"; + +/** Split a raw pushed `cardid` into its bare value + confirmed channel (if prefixed). */ +export function splitChannel(raw: string): { + value: string; + kind: "qr" | "card"; + channel?: "optical" | "rf"; +} { + if (CARD_CHANNEL_PREFIX.length > 0 && raw.startsWith(CARD_CHANNEL_PREFIX)) { + return { value: raw.slice(CARD_CHANNEL_PREFIX.length), kind: "card", channel: "rf" }; + } + if (QR_CHANNEL_PREFIX.length > 0 && raw.startsWith(QR_CHANNEL_PREFIX)) { + return { value: raw.slice(QR_CHANNEL_PREFIX.length), kind: "qr", channel: "optical" }; + } + return { value: raw, kind: "qr" }; // legacy: unprefixed reader, channel unknown +} + export async function qrReaderRoutes( app: FastifyInstance, db: Db, @@ -55,6 +85,7 @@ export async function qrReaderRoutes( // See wiki/sources/qrcode-sdk.md, entities/dingtian-dt008-reader.md. reply.header("connection", "close"); const cardid = (q.cardid ?? "").trim(); + const scan = splitChannel(cardid); // bare value + confirmed channel (if prefixed) const mjihao = q.mjihao != null ? Number(q.mjihao) : 0; const serial = (q.cjihao ?? "").trim(); @@ -65,35 +96,37 @@ export async function qrReaderRoutes( const deviceId = matchedRowId ?? serial; let accepted = false; - if (cardid) { + if (scan.value) { // ENROLLMENT INTERCEPT: if THIS reader is armed for credential capture, grab the // value for the subscription form and do NOT run the access flow (we must not // open a barrier for a card being enrolled). Single-shot — capture auto-disarms. // Reads from the OTHER reader are untouched and dispatch normally below. - if (capture.tryConsume(deviceId, cardid)) { - app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${cardid}`); + // Captured BARE (prefix stripped) so enrolled values match future stripped reads. + if (capture.tryConsume(deviceId, scan.value)) { + app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${scan.value}${scan.channel ? ` ch=${scan.channel}` : ""}`); accepted = true; // beep "ok" so the operator knows the card was read } else { const read: DeviceReadEvent = { driverId: "dingtian-qr-reader", deviceId, - value: cardid, - kind: "qr", + value: scan.value, + kind: scan.kind, + ...(scan.channel ? { channel: scan.channel } : {}), at: new Date().toISOString(), }; try { const outcome = await dispatcher.dispatch(read); accepted = outcome.accepted; // Per-read diagnostic: which reader (serial) sent it, which configured device - // it mapped to, and the verdict — so a barrier/serial mismatch is visible in - // the logs (e.g. an entry-side scan resolving to the exit relay). + // it mapped to, the confirmed channel (if prefixed), and the verdict — so a + // barrier/serial mismatch or a channel anomaly is visible in the logs. app.log.info( `READ serial=${serial || "?"} → device=${matchedRowId ? matchedRowId.slice(0, 8) : "UNASSIGNED"} ` + - `card=${cardid} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` + + `card=${scan.value}${scan.channel ? ` ch=${scan.channel}` : ""} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` + `${accepted ? "" : ` reason="${outcome.reason ?? "?"}"`}`, ); } catch (err) { - app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`); + app.log.error(`QR dispatch failed for ${scan.value}: ${(err as Error).message}`); } } } diff --git a/apps/server/src/subscription-channel.test.ts b/apps/server/src/subscription-channel.test.ts new file mode 100644 index 0000000..0ea0d3f --- /dev/null +++ b/apps/server/src/subscription-channel.test.ts @@ -0,0 +1,88 @@ +import { randomUUID } from "node:crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ledgerEvents, subscriptionCredentials, type Db } from "@parking/db"; +import { createTestDb } from "@parking/db/testing"; +import { SubscriptionFlow } from "./subscription-flow.js"; +import type { DeviceReadEvent } from "./device-events.js"; +import { makeLog, silentLogger } from "./test-helpers.js"; + +// CHANNEL AGREEMENT in SubscriptionFlow.match (2026-07-04): when the reader CONFIRMED +// the physical channel (DT-008 output prefixes → DeviceReadEvent.channel), the +// credential kind must agree. An OPTICAL decode claiming an RF credential is the +// cheap clone (print the card's UID as a barcode) — refused + ONE signed anomaly. +// Legacy untagged reads (channel undefined) match as before, so readers without +// prefixes keep working. + +let db: Db; +let flow: SubscriptionFlow; + +const SUB = "sub-1"; +const CARD_UID = "86A158"; +const QR_CODE = "SUB-TESTQR"; + +beforeEach(() => { + ({ db } = createTestDb()); + db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: SUB, kind: "rf", value: CARD_UID }).run(); + db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: SUB, kind: "qr", value: QR_CODE }).run(); + flow = new SubscriptionFlow(db, makeLog(db), silentLogger()); +}); + +function read(value: string, opts: { kind?: DeviceReadEvent["kind"]; channel?: DeviceReadEvent["channel"] } = {}): DeviceReadEvent { + return { + driverId: "dingtian-qr-reader", + deviceId: "reader-1", + value, + kind: opts.kind ?? "qr", + ...(opts.channel ? { channel: opts.channel } : {}), + at: new Date().toISOString(), + }; +} + +const anomalies = () => + db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly"); + +describe("subscription match — credential channel agreement", () => { + it("OPTICAL read of an RF card's UID → no match + signed channelMismatch anomaly (the clone)", async () => { + const m = flow.match(read(CARD_UID, { kind: "qr", channel: "optical" })); + expect(m).toBeNull(); + await vi.waitFor(() => expect(anomalies()).toHaveLength(1)); // append is fire-and-forget + expect(anomalies()[0].identity).toBe(SUB); + expect(anomalies()[0].payload).toMatchObject({ + reasonCode: "sub.refused.channelMismatch", + channelMismatch: true, + credentialKind: "rf", + channel: "optical", + value: CARD_UID, + }); + }); + + it("RF read of the same card → matches (via card), nothing signed", () => { + const m = flow.match(read(CARD_UID, { kind: "card", channel: "rf" })); + expect(m).toMatchObject({ subscriptionId: SUB, via: "card" }); + expect(anomalies()).toHaveLength(0); + }); + + it("legacy untagged read of the card → still matches (unprefixed readers keep working)", () => { + const m = flow.match(read(CARD_UID)); // kind qr, channel undefined — today's shape + expect(m).toMatchObject({ subscriptionId: SUB, via: "card" }); + expect(anomalies()).toHaveLength(0); + }); + + it("OPTICAL read of a QR credential → matches (the legit path)", () => { + const m = flow.match(read(QR_CODE, { kind: "qr", channel: "optical" })); + expect(m).toMatchObject({ subscriptionId: SUB, via: "qr" }); + }); + + it("RF read claiming a QR credential → refused symmetrically (mis-encoded clone tag)", async () => { + const m = flow.match(read(QR_CODE, { kind: "card", channel: "rf" })); + expect(m).toBeNull(); + await vi.waitFor(() => expect(anomalies()).toHaveLength(1)); + expect(anomalies()[0].payload).toMatchObject({ credentialKind: "qr", channel: "rf" }); + }); + + it("unknown value → plain no-match, no anomaly (a phantom/typo is not a clone attempt)", () => { + const m = flow.match(read("999459", { kind: "qr", channel: "optical" })); + expect(m).toBeNull(); + expect(anomalies()).toHaveLength(0); + }); +}); diff --git a/apps/server/src/subscription-flow.ts b/apps/server/src/subscription-flow.ts index 405ce69..e777583 100644 --- a/apps/server/src/subscription-flow.ts +++ b/apps/server/src/subscription-flow.ts @@ -77,6 +77,40 @@ export class SubscriptionFlow { .where(eq(subscriptionCredentials.value, e.value)) .get(); if (cred) { + // CHANNEL AGREEMENT (clone defense, 2026-07-04). When the reader CONFIRMED the + // physical channel (DT-008 output prefixes), the credential kind must agree: an + // OPTICAL decode may not claim an RF credential — otherwise printing a card's + // UID (often written on the card face) as a barcode clones the card. Symmetric + // for an RF read claiming a QR credential (a mis-encoded clone tag). A legacy + // untagged read (channel undefined) matches as before — enforcement only bites + // where prefixes are deployed. The attempt itself is a fraud signal → signed + // anomaly, then treated as no-match (the flows refuse it as unknown). + const mismatch = + (e.channel === "optical" && cred.kind === "rf") || + (e.channel === "rf" && cred.kind === "qr"); + if (mismatch) { + this.#logger.warn( + `credential channel mismatch: ${cred.kind} credential '${e.value}' presented via ${e.channel} (sub ${cred.subscriptionId}) — possible clone`, + ); + void this.#log + .append({ + type: "anomaly", + identity: cred.subscriptionId, + payload: { + ...reasonPayload("sub.refused.channelMismatch", { + credentialKind: cred.kind, + channel: e.channel === "optical" ? "optical" : "rf", + }), + channelMismatch: true, + credentialKind: cred.kind, + channel: e.channel, + value: e.value, + deviceId: e.deviceId, + }, + }) + .catch((err) => this.#logger.error(`channel-mismatch anomaly append failed: ${(err as Error).message}`)); + return null; + } return { subscriptionId: cred.subscriptionId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" }; } // Plate binding: a read plate that matches a subscription's bound plate is an identity. diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index a608ad9..4c5168a 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -299,6 +299,7 @@ export const en: Catalog = { "sub.refused.noSession": "Subscription exit with no open session (already out / never entered)", "sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)", "sub.refused.unpaidWindow": "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth", + "sub.refused.channelMismatch": "Credential refused — a {{credentialKind}} credential arrived via the {{channel}} channel (possible cloned credential)", "void.ticketCancelled": "Ticket cancelled — {{reason}}", "setup.relayTest": "Relay test — admin {{operator}} pulsed relay {{relay}} on controller {{controller}} from Setup", }, diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 864c4cb..01239b9 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -302,6 +302,7 @@ export const sq = { "sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)", "sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)", "sub.refused.unpaidWindow": "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë", + "sub.refused.channelMismatch": "Kredenciali u refuzua — kredencial {{credentialKind}} i paraqitur në kanalin {{channel}} (kredencial i mundshëm i klonuar)", "void.ticketCancelled": "Bileta u anulua — {{reason}}", "setup.relayTest": "Test releje — admini {{operator}} aktivizoi relenë {{relay}} te kontrolluesi {{controller}} nga Konfigurimi", }, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 98f31d2..e925c4d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -405,6 +405,10 @@ export const REASON_CODES = [ // a subscriber owes an out-of-window (early-entry / late-exit) transient charge and // hasn't paid it — exit is gated until they settle (the tariff-bridge gate). "sub.refused.unpaidWindow", + // a credential value arrived on the WRONG physical channel (e.g. an RF card's UID + // presented as a printed barcode — a cloned-credential attempt). Channel comes from + // the reader's output prefixes; see wiki/entities/dingtian-dt008-reader.md. + "sub.refused.channelMismatch", // a wrongly-printed transient ticket cancelled by the operator (signed void event). "void.ticketCancelled", // an admin fired a barrier relay from Setup to test the wiring. The physical open is @@ -443,6 +447,7 @@ export const REASON_EN: Record = { "sub.refused.noSession": "subscription exit with no open session (already out / never entered)", "sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)", "sub.refused.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth", + "sub.refused.channelMismatch": "credential refused — a {credentialKind} credential arrived via the {channel} channel (possible cloned credential)", "void.ticketCancelled": "ticket cancelled — {reason}", "setup.relayTest": "relay test — admin {operator} pulsed relay {relay} on controller {controller} from Setup", }; diff --git a/wiki/entities/dingtian-dt008-reader.md b/wiki/entities/dingtian-dt008-reader.md index 35116d2..b82bfa8 100644 --- a/wiki/entities/dingtian-dt008-reader.md +++ b/wiki/entities/dingtian-dt008-reader.md @@ -120,6 +120,32 @@ host in the **vendor tool**; assign + enter its serial + bind it here. - `output` is replied as `0` (Access). Confirm on hardware whether the reader needs `1`/`2` (WG26/34) to drive its access line, vs. `0`. +## Channel tagging — output prefixes close the printed-card-clone hole (2026-07-04) + +The push carries ONE opaque `cardid` whether the **optical** engine decoded a QR/barcode or the +**RF** engine read a card — the server couldn't tell. And `SubscriptionFlow.match` matched by +**value only** (the stored `rf`/`qr` kind was a label). Consequence: printing a card's UID (often +written on the card face, e.g. `86A158`) as a barcode and holding it up **cloned the RF card** — +the optical decode matched the RF credential and opened the barrier. In-threat-model and cheap. + +**Fix (both sides):** +- **Reader (vendor tool, both units):** set `QRCode Output Prefix` = `Q:` and `Card Output + Prefix` = `K:` (env-overridable server-side: `READER_QR_PREFIX` / `READER_CARD_PREFIX`). +- **Server:** `routes/qr-reader.ts` strips the prefix and tags the read's confirmed channel + (`DeviceReadEvent.channel: "optical"|"rf"`; kind `qr`/`card`). Enrollment capture stores the + **bare** value. `SubscriptionFlow.match` then requires **channel agreement**: an optical read + may not claim an `rf` credential (and vice versa) — a mismatch is refused AND signs a + `sub.refused.channelMismatch` **anomaly** (a clone attempt is a fraud signal). An **unprefixed** + read keeps the legacy untagged shape and matches as before — enforcement only bites where + prefixes are deployed, so an unconfigured reader never breaks. +- Bonus: every `READ` log line now carries `ch=optical|rf`, which permanently attributes any + future phantom (see below) to its engine. + +⚠️ **Two device-side settings are now part of the credential contract** (they live ON the reader, +not in our DB — re-apply after any factory reset/swap): the two **output prefixes** (must match +the server's expected `Q:`/`K:`), and **`Card Input format` (currently `6H`)** — it defines the +UID shape we enroll; changing it later silently orphans every enrolled card. + ## ⚠️ Phantom optical decodes from sunlight patterns (park-buzi, 2026-07-04) With the site EMPTY (pre-opening, verified live + by snapshot), the **exit reader @@ -135,11 +161,20 @@ no checksum — any high-contrast stripe pattern of the right proportions "decod patterns at a gate: the striped barrier arm, fence/railing shadows sweeping as the sun moves, glare bands. RFID noise would instead give repeating UID-shaped values. -**Impact: noise, not risk.** Every phantom was REFUSED fail-closed (`exit.refused.noSession`, a -signed anomaly — #52–58 in the feed); a phantom can never match a ticket ([[ticket-encoding]] ids -are 11-digit + Luhn, so a 6-digit read has nothing to match). Do NOT filter "impossible" codes -server-side — recording every probe of an exit reader is what the anomaly path is for; fix at the -source instead: +**Impact: noise, not risk.** Every phantom was REFUSED fail-closed; a phantom can never match a +ticket ([[ticket-encoding]] ids are 11-digit + Luhn, so a 6-digit read has nothing to match). + +**Feed filter (2026-07-04 — supersedes the earlier "do not filter" position).** Initially each +phantom signed an `exit.refused.noSession` anomaly (#52–58 in the feed) and the position was to +keep it that way. The operator overruled it, correctly: red "who is trying to exit?" rows for +NOBODY train the operator to ignore the signed feed — alarm fatigue is the adversary's friend. +`read-dispatch.ts` now drops a no-match value that **cannot possibly be a credential we issue** to +UNSIGNED telemetry (`device_events`, `unrecognizedRead:true` — still auditable). "Possibly ours" +is deliberately WIDE and everything in it still signs the normal refusal anomaly: Luhn-valid +ticket shapes (a forged ticket is a real probe), `SUB-`/`SUBSESS-` prefixes, ANY read on a +confirmed-RF channel (a physical card is a real event, never sun noise), and plates (never +shape-filtered). The filter also works pre-prefix (legacy untagged reads). Still fix at the +source too: **Fix (vendor tool, per reader — config lives ON THE DEVICE, not in our DB):** disable every symbology except **QR + Code128** (all our credentials); if offered, set **minimum decode length diff --git a/wiki/log.md b/wiki/log.md index b06302f..6d743b2 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2247,3 +2247,28 @@ flip a signed config_change, tickets stamped presenceBypassed, radar-bypass cool commissioning pulse, signed barrier_open_command BEFORE the fire so a test open never reads as the out-of-band-open fraud signal, saved-controllers-only, radarAlert lamps excluded). Cross-linked from [[operator-issued-entry]] (bypass note) and cataloged in index.md. + +## [2026-07-04] update | Reader channel tagging: printed-card-clone hole closed + +Investigating the phantom scans surfaced a real vulnerability: the DT-008 push is channel-blind +and SubscriptionFlow.match matched by value only, so printing an RF card's UID (written on the +card face) as a barcode cloned the card. Fixed with channel tagging: vendor-tool output prefixes +(Q:/K:) → routes/qr-reader.ts strips + tags DeviceReadEvent.channel (optical|rf) → match requires +channel agreement, refusing a mismatch + signing a sub.refused.channelMismatch anomaly (a clone +attempt is a fraud signal). Untagged (unprefixed) reads keep legacy behavior — enforcement only +bites where prefixes are deployed. Enrollment capture stores bare values. Recorded on +[[dingtian-dt008-reader]] incl. the two device-side settings now part of the credential contract +(prefixes + Card Input format 6H — re-apply after factory reset). 14 new tests; suite 272 green. + +## [2026-07-04] update | Structural read filter: phantom scans out of the signed feed + +Operator-requested reversal of the earlier "do not filter" position (recorded as superseded on +[[dingtian-dt008-reader]]): phantom decodes were signing exit.refused.noSession anomalies — red +rows for nobody, training the operator to ignore the feed. read-dispatch.ts now drops a no-match +reader value that cannot possibly be ours (no ticket Luhn shape, no SUB-/SUBSESS- prefix, not +confirmed-RF, not a plate) to unsigned device_events telemetry (unrecognizedRead:true). The +plausibility rule is deliberately wide so every real probe (forged ticket shape, unknown physical +card, unknown SUB- code) still signs the normal anomaly; enrolled credentials match before the +filter and can never be hidden by it. Works for legacy unprefixed reads too, so the feed cleans up +before the vendor-tool visit. 6 new tests; suite 278 green. Also this session: reader channel +tagging (clone defense) — see the prior entry.