Door epic phases 4-5: inbound access-log ingest + universal API-call buttons
Phase 4 (migration 053): pull entry/denied events into door_access_log and match each card to a member, via two transports sharing one ingest core. Poll: a CRON_SECRET-guarded /api/cron/door-ingest reads each opted-in native-HeatSync connection's ?z log through the existing hardened executor and parses it (firmware-characterized, best-effort ring buffer). Webhook: /api/door/inbound/[connection] accepts pushed event JSON authenticated by a per-connection bearer secret from the vault. Both dedupe on (connection_id, dedupe_key) so re-polls and retries are no-ops. Adds an inbound config panel to /door/manage. Phase 5 (migration 054): admins define api_buttons that fire a configured HTTP call (full verbs + headers + body) when an authorized member presses them, through the same hardened egress (now shared between callDoor and the new callApi). Each button carries a required_permission (default the new apicall.invoke code); members see only buttons they may press. Builder at /door/buttons with a door-template preset; members press from /doors. Every press is recorded in api_call_log with secrets redacted. Both subsystems are inert until an admin enables inbound on a connection or creates a button. 624 unit + 50 integration tests; migrations applied top-to-bottom in schema.sql; types and docs updated.
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
// 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
|
||||
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) { hiTok = toks[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 ?? ''}`,
|
||||
})
|
||||
}
|
||||
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}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Interpret a card identifier string as both decimal and hex (HeatSync stores
|
||||
// uids as hex but the schema does not enforce it; the poll log reconstructs a
|
||||
// decimal number). Returns every plausible numeric value.
|
||||
function toBigIntCandidates(s: string): bigint[] {
|
||||
const u = s.trim().toLowerCase().replace(/^0x/, '')
|
||||
const out: bigint[] = []
|
||||
// A digit-only string gets both readings (it may be a decimal number or a
|
||||
// hex uid that happens to be all digits); a string with a-f is hex only.
|
||||
if (/^[0-9]+$/.test(u)) {
|
||||
try { out.push(BigInt(u)) } catch { /* ignore */ }
|
||||
}
|
||||
if (/^[0-9a-f]+$/.test(u)) {
|
||||
try { out.push(BigInt('0x' + u)) } catch { /* ignore */ }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// True when a stored member_cards.card_uid refers to the same physical card as
|
||||
// the value seen in a log/webhook event. Matches on exact (case-insensitive)
|
||||
// string equality OR numeric equality across decimal/hex interpretations, so a
|
||||
// hex-stored uid resolves a decimal-reconstructed poll number and vice versa.
|
||||
export function cardUidsEquivalent(stored: string, seen: string): boolean {
|
||||
const a = stored.trim().toLowerCase()
|
||||
const b = seen.trim().toLowerCase()
|
||||
if (!a || !b) return false
|
||||
if (a === b) return true
|
||||
const ca = toBigIntCandidates(stored)
|
||||
const cb = toBigIntCandidates(seen)
|
||||
return ca.some(x => cb.some(y => x === y))
|
||||
}
|
||||
|
||||
// Given an event, the value to resolve against stored card uids (the decimal
|
||||
// number for poll, the raw uid for webhook).
|
||||
export function eventCardValue(ev: DoorIngestEvent): string | null {
|
||||
return ev.cardUid ?? ev.cardNumber
|
||||
}
|
||||
Reference in New Issue
Block a user