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/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/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/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/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 4b3e016..4c5168a 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)", @@ -298,7 +299,9 @@ 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", }, tariff: { title: "Tariff", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index cee4505..01239b9 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)", @@ -301,7 +302,9 @@ 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", }, tariff: { title: "Tarifa", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 10fd3b0..e925c4d 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", @@ -400,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 @@ -421,6 +430,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)", @@ -437,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/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/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..b82bfa8 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,68 @@ 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 +(`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; 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 +≥ 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 f7f0b19..6d743b2 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2207,3 +2207,68 @@ 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. + +## [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. + +## [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.