43c1f45e29
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
124 lines
5.7 KiB
TypeScript
124 lines
5.7 KiB
TypeScript
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";
|
||
|
||
// Routes a credential read (ticket scan / plate / card) to the right flow. A read
|
||
// can mean a subscription entry/exit OR a transient exit, so we dispatch by WHAT the
|
||
// credential is (decision 2026-06-15):
|
||
// - matches a subscription (card/QR/bound plate) → SUBSCRIPTION flow,
|
||
// - else → transient EXIT flow (open ticket session → exit, else reject+log).
|
||
//
|
||
// The reader is BOUND to a controller relay (config.controllerId + relay), so a read
|
||
// resolves to exactly the barrier it sits at, and the direction is inherited from
|
||
// that relay (see entry-exit-points.md). The resolved relay is handed to the flow so
|
||
// 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;
|
||
readonly #exit: ExitFlow;
|
||
readonly #subscription: SubscriptionFlow;
|
||
readonly #logger: FastifyBaseLogger;
|
||
|
||
constructor(db: Db, exit: ExitFlow, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
||
this.#db = db;
|
||
this.#exit = exit;
|
||
this.#subscription = subscription;
|
||
this.#logger = logger;
|
||
}
|
||
|
||
async dispatch(e: DeviceReadEvent): Promise<ReadOutcome> {
|
||
const reader = this.#db.select().from(devices).where(eq(devices.id, e.deviceId)).get();
|
||
if (!reader || !reader.enabled) {
|
||
return { accepted: false, reason: "read from unknown/disabled device" };
|
||
}
|
||
const resolved = relayForDevice(this.#db, reader);
|
||
if (!resolved) {
|
||
return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" };
|
||
}
|
||
|
||
const sub = this.#subscription.match(e);
|
||
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.
|
||
if (resolved.direction === "entry") {
|
||
return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" };
|
||
}
|
||
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;
|
||
}
|