Files
hackerspace.sh/lib/door-log-logic.ts
T
Moheeb ZaraandClaude Opus 4.8 90c68faa25 fix(door): correct parseHeatSyncLog stale time + token reuse (L5)
(a) Reset h/mi/e after each event so a later event with no H:M:E of its own no
longer inherits the previous event's time-of-day (fabricated audit timestamp +
dedupeKey). (b) Track consumed high-half token indices so one g/d token can no
longer be paired with two adjacent grants (wrong reconstructed card numbers).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-01 21:31:34 -07:00

195 lines
8.2 KiB
TypeScript

// Pure, dependency-free logic for Door epic Phase 4 (inbound access-log
// ingest). No Supabase/React/Next. Two transports feed one normalized event
// shape:
// * poll: parse a HeatSync `?z` log dump (parseHeatSyncLog)
// * webhook: normalize pushed event JSON (normalizeWebhookEvents)
// The ingest action resolves each event's card to a member and dedupe-inserts
// it. Card matching is here so it is unit-tested in isolation.
// HeatSync firmware splits a card number across two log keys with this divisor
// (zyphlar/Open_Access_Control_Ethernet.ino, const int divisor = 32767):
// granted G(=num%divisor)+g(=num/divisor); denied D+d. Reconstruct num =
// high*divisor + low.
const HEATSYNC_DIVISOR = 32767n
export type DoorIngestEvent = {
// 'granted'/'denied' for an access decision; 'unknown' when a webhook payload
// does not state one.
result: 'granted' | 'denied' | 'unknown'
// Reconstructed decimal card number (HeatSync poll). null for webhook events
// that send a uid string instead.
cardNumber: string | null
// Raw card identifier string as sent (webhook). null for poll events.
cardUid: string | null
// ISO 8601 event time when the source provides one (webhook). Poll has no
// reliable absolute date, so it is null and the row uses ingest time.
occurredAt: string | null
// Human-readable audit detail. Redacted again at write time.
detail: string
// Per-event idempotency token, unique per connection. Poll keys include the
// ring-buffer slot position so an unchanged re-poll dedupes; webhook keys are
// the caller-supplied event id.
dedupeKey: string
}
function pad2(n: number): string {
return String(n).padStart(2, '0')
}
// Parse a HeatSync `?z` response. Output is `<pre>`-wrapped lines of "K: N"
// dumped from a fixed 40-slot ring buffer in raw slot order (empty slots print
// as a NUL key with 0). We pair G+g (granted) and D+d (denied), reconstruct
// the decimal card number, and stamp best-effort H:M:E time-of-day when the
// firmware verbosity emitted it. Raw reads (R/r) are intentionally NOT ingested
// (they duplicate the grant decision at high verbosity and carry no decision).
//
// The ring buffer has no per-entry sequence id, so dedup keys include the slot
// position: an unchanged re-poll dedupes, a genuinely new event in a different
// slot is distinct. The unavoidable limit is that overwriting a slot with
// byte-identical content (same card, same second) collides; the webhook path
// (explicit event ids) is the reliable transport for production.
export function parseHeatSyncLog(text: string, opts?: { maxEvents?: number }): DoorIngestEvent[] {
const max = opts?.maxEvents ?? 200
const body = text.replace(/<\/?pre>/gi, '')
const lines = body.split(/\r?\n/)
type Tok = { key: string; val: number; slot: number }
const toks: Tok[] = []
let slot = 0
// The body is from an untrusted controller over plaintext HTTP (MITM-able).
// Bound the value to 15 digits: real firmware values are int32 (<=10 digits),
// and capping here keeps Number()/BigInt() exact and prevents an Infinity ->
// BigInt() throw from a hostile response. Over-long digit runs simply don't
// match and are skipped as junk.
for (const line of lines) {
const m = line.match(/^\s*(\S):\s*(-?\d{1,15})\s*$/)
if (!m) continue
const key = m[1]
// Every printed slot advances the slot counter, but NUL/control keys are
// empty ring slots and never emit an event.
if (key.charCodeAt(0) >= 32) toks.push({ key, val: Number(m[2]), slot })
slot++
}
const events: DoorIngestEvent[] = []
let h: number | null = null
let mi: number | null = null
let e: number | null = null
// High-half tokens already paired to a low half. Each g/d belongs to exactly
// one grant/deny, so a consumed index must not be borrowed by a later event.
const usedHi = new Set<number>()
for (let i = 0; i < toks.length && events.length < max; i++) {
const t = toks[i]
if (t.key === 'H') { h = t.val; continue }
if (t.key === 'M') { mi = t.val; continue }
if (t.key === 'E') { e = t.val; continue }
if (t.key !== 'G' && t.key !== 'D') continue
const result = t.key === 'G' ? 'granted' : 'denied'
const hiKey = t.key === 'G' ? 'g' : 'd'
// The firmware emits the high half immediately after the low half, so look
// only a few tokens ahead. A bounded window keeps this linear and avoids
// pairing a G with a distant unrelated g when this event's half is missing.
let hiTok: Tok | undefined
for (let j = i + 1; j < toks.length && j <= i + 3; j++) {
if (toks[j].key === hiKey && !usedHi.has(j)) { hiTok = toks[j]; usedHi.add(j); break }
}
const low = t.val >= 0 ? BigInt(t.val) : 0n
const high = hiTok && hiTok.val >= 0 ? BigInt(hiTok.val) : 0n
const cardNumber = (high * HEATSYNC_DIVISOR + low).toString()
const tod = h !== null && mi !== null && e !== null ? `${pad2(h)}:${pad2(mi)}:${pad2(e)}` : null
const raw = hiTok ? `${t.key}:${t.val} ${hiKey}:${hiTok.val}` : `${t.key}:${t.val}`
events.push({
result,
cardNumber,
cardUid: null,
occurredAt: null,
detail: tod ? `${result} card #${cardNumber} at ${tod}` : `${result} card #${cardNumber}`,
dedupeKey: `hs:${t.slot}:${t.key}:${cardNumber}:${tod ?? ''}`,
})
// Time-of-day applies only to the event it immediately preceded; clear it so
// a later event without its own H/M/E does not inherit a stale timestamp.
h = mi = e = null
}
return events
}
// A single inbound webhook event after Zod validation (see
// doorWebhookPayloadSchema). The caller-supplied `id` MUST be stable per event
// so retries dedupe; everything else is best-effort.
export type WebhookEventInput = {
id: string
card_uid?: string | null
card_number?: string | null
result?: 'granted' | 'denied' | 'unknown'
occurred_at?: string | null
}
export function normalizeWebhookEvents(events: WebhookEventInput[]): DoorIngestEvent[] {
return events.map(ev => {
const result = ev.result ?? 'unknown'
const id = String(ev.id)
const cardUid = ev.card_uid ? String(ev.card_uid) : null
const cardNumber = ev.card_number ? String(ev.card_number) : null
const who = cardUid ? `uid ${cardUid}` : cardNumber ? `card #${cardNumber}` : 'unknown card'
return {
result,
cardNumber,
cardUid,
occurredAt: ev.occurred_at ? String(ev.occurred_at) : null,
detail: `${result} ${who}`,
dedupeKey: `wh:${id}`,
}
})
}
// Parse a stored card_uid as HEX. HeatSync stores the uid as hex (the grant
// encoder requires 1-8 hex chars), so this is the canonical reading. Returns
// null for a non-hex value (it cannot be a HeatSync card).
function hexToBigInt(uid: string): bigint | null {
const u = uid.trim().toLowerCase().replace(/^0x/, '')
if (!/^[0-9a-f]+$/.test(u)) return null
try {
return BigInt('0x' + u)
} catch {
return null
}
}
// True when a stored member_cards.card_uid refers to the same physical card as
// an ingested event. The match is source-anchored to the HeatSync model rather
// than guessing radixes symmetrically (which mis-attributes all-digit uids,
// e.g. stored "16" = hex 0x16 = 22 must NOT match a decimal poll of card 16):
//
// * A reported DECIMAL number (poll `?z`, or a webhook card_number, or an
// all-digit webhook card_uid) matches when hexInt(storedUid) equals it,
// because the stored uid is hex and the controller reports the decimal
// card number.
// * A webhook card_uid that is not purely decimal is matched by exact
// (case-insensitive) string equality -- a relay should send the uid as
// stored.
export function cardMatchesEvent(storedUid: string, ev: DoorIngestEvent): boolean {
const stored = storedUid.trim().toLowerCase()
if (!stored) return false
// Exact uid match (a webhook relay sending the stored hex uid).
if (ev.cardUid && stored === ev.cardUid.trim().toLowerCase()) return true
// Decimal card number: the poll number, an explicit webhook card_number, or
// an all-digit webhook card_uid.
const decStr =
ev.cardNumber ?? (ev.cardUid && /^\d+$/.test(ev.cardUid.trim()) ? ev.cardUid.trim() : null)
if (decStr) {
const h = hexToBigInt(stored)
if (h !== null) {
try {
if (h === BigInt(decStr)) return true
} catch {
/* ignore */
}
}
}
return false
}