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
102 lines
3.9 KiB
TypeScript
102 lines
3.9 KiB
TypeScript
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<typeof Fastify>;
|
|
let capture: CredentialCapture;
|
|
let seen: DeviceReadEvent[];
|
|
|
|
/** Dispatcher stub: records the event the route built, always rejects. */
|
|
const fakeDispatcher = {
|
|
dispatch: async (e: DeviceReadEvent): Promise<ReadOutcome> => {
|
|
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);
|
|
});
|
|
});
|