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:
Moheeb Zara
2026-05-20 20:45:44 -07:00
parent 6a892cbad5
commit 7ec90b0f1e
33 changed files with 2277 additions and 95 deletions
+115 -30
View File
@@ -1,12 +1,12 @@
// Server-only. The single hardened egress point for door-controller HTTP.
// Every door call funnels through here so the SSRF guard, no-redirect rule,
// timeout, response cap and secret redaction cannot be bypassed. The secret
// is already embedded in `url` by the caller (per the verified firmware,
// auth is a query param); it is never logged -- only a redacted snippet is
// ever returned.
// Server-only. The single hardened egress point for both the door controller
// HTTP (callDoor) and the universal API-call buttons (callApi). Every outbound
// call funnels through `egress` so the SSRF guard, no-redirect rule, timeout,
// response cap and secret redaction cannot be bypassed. Secrets are never
// logged -- only a redacted snippet is ever returned.
import dns from 'node:dns/promises'
import { validateDoorTarget, redactDoorSecrets, isBlockedDoorIp } from '@/lib/door-logic'
import { buildApiRequest, type ApiAuthMode } from '@/lib/api-call-logic'
export type DoorCallResult =
| { ok: true; status: number; snippet: string }
@@ -14,39 +14,57 @@ export type DoorCallResult =
const MAX_BODY = 4096
const SNIPPET = 500
// Log-ingest polls (HeatSync ?z) need the whole ring-buffer dump, which is
// larger than a status snippet. Capped so a hostile/garbage response still
// cannot exhaust memory.
const MAX_BODY_INGEST = 32768
// API-button responses: enough for a useful audit detail, not unbounded.
const MAX_BODY_API = 8192
const SNIPPET_API = 1000
export async function callDoor(opts: {
// The shared hardened egress. Validates the target against the connection's
// pinned host, resolves once and connects to the validated IP literal (so
// fetch performs no second resolution -> closes the DNS-rebind TOCTOU),
// refuses redirects, caps time and body, and redacts secrets from anything
// returned. `headers.host` is always forced to the original pinned host, so a
// caller-supplied header map can never repoint routing or override it.
async function egress(opts: {
method: string
url: string
pinnedHost: string
password?: string | null
authParam?: string | null
headers?: Record<string, string>
body?: string | null
redactSecret?: string | null
redactAuthParam?: string | null
timeoutMs?: number
maxBody: number
snippetLen: number
}): Promise<DoorCallResult> {
const guard = validateDoorTarget(opts.url, opts.pinnedHost)
if (!guard.ok) return { ok: false, reason: guard.reason }
// Defeat DNS-rebinding: validateDoorTarget only string-checked the host,
// and a bare fetch would do its OWN resolution (a hostname could resolve
// to 127.0.0.1 / 169.254.169.254 between check and connect). Resolve once
// here, reject if ANY resolved address is blocked, then connect to the
// validated IP literal so fetch performs no second resolution. Plaintext
// HTTP to LAN devices that don't vhost, so connecting by IP (with a
// best-effort original Host header) is correct; no TLS/SNI concern.
// Defeat DNS-rebinding: validateDoorTarget only string-checked the host, and
// a bare fetch would do its OWN resolution (a hostname could resolve to
// 127.0.0.1 / 169.254.169.254 between check and connect). Resolve once here,
// reject if ANY resolved address is blocked, then connect to the validated
// IP literal so fetch performs no second resolution. Plaintext HTTP to LAN
// devices that don't vhost, so connecting by IP (with a best-effort original
// Host header) is correct; no TLS/SNI concern.
const target = new URL(guard.url)
let resolved: { address: string; family: number }[]
try {
resolved = await dns.lookup(target.hostname, { all: true, verbatim: true })
} catch {
return { ok: false, reason: 'Could not resolve the controller host.' }
return { ok: false, reason: 'Could not resolve the target host.' }
}
if (resolved.length === 0) {
return { ok: false, reason: 'The controller host did not resolve.' }
return { ok: false, reason: 'The target host did not resolve.' }
}
for (const a of resolved) {
if (isBlockedDoorIp(a.address)) {
return {
ok: false,
reason: 'The controller host resolves to a blocked address (loopback / link-local / metadata).',
reason: 'The target host resolves to a blocked address (loopback / link-local / metadata).',
}
}
}
@@ -57,22 +75,22 @@ export async function callDoor(opts: {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 6000)
try {
// Host forced last so a custom header map cannot override the pinned host.
const headers: Record<string, string> = { ...(opts.headers ?? {}), host: target.host }
const res = await fetch(pinned.toString(), {
method: 'GET',
// Never chase a redirect: a 3xx could bounce us off the pinned host
// (and would re-resolve, escaping the IP pin).
method: opts.method,
// Never chase a redirect: a 3xx could bounce us off the pinned host (and
// would re-resolve, escaping the IP pin).
redirect: 'manual',
signal: controller.signal,
// Host preserved best-effort so a vhosting controller still routes;
// single-purpose door firmware ignores it. The UA may drop a forbidden
// Host header — harmless for these devices.
headers: { accept: 'text/plain, text/html', host: target.host },
headers,
body: opts.body ?? undefined,
cache: 'no-store',
})
if (res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400)) {
return {
ok: false,
reason: 'The controller returned a redirect, which is refused (anti-SSRF).',
reason: 'The target returned a redirect, which is refused (anti-SSRF).',
status: res.status || undefined,
}
}
@@ -81,7 +99,7 @@ export async function callDoor(opts: {
const reader = res.body?.getReader()
if (reader) {
const dec = new TextDecoder()
while (received.length < MAX_BODY) {
while (received.length < opts.maxBody) {
const { done, value } = await reader.read()
if (done) break
received += dec.decode(value, { stream: true })
@@ -92,12 +110,79 @@ export async function callDoor(opts: {
/* ignore */
}
}
const snippet = redactDoorSecrets(received.slice(0, SNIPPET), opts.password, opts.authParam)
const snippet = redactDoorSecrets(received.slice(0, opts.snippetLen), opts.redactSecret, opts.redactAuthParam)
return { ok: res.ok, status: res.status, snippet }
} catch (e) {
const msg = e instanceof Error ? e.message : 'request failed'
return { ok: false, reason: redactDoorSecrets(msg, opts.password, opts.authParam) }
return { ok: false, reason: redactDoorSecrets(msg, opts.redactSecret, opts.redactAuthParam) }
} finally {
clearTimeout(timer)
}
}
// Door controller call. GET only; the secret (if any) is already embedded in
// `url` by the caller (per the verified firmware, auth is a query param).
// `fullBody` returns the whole capped body (the inbound-log poll); otherwise a
// 500-char snippet. Behavior is unchanged from the original single-function
// executor.
export async function callDoor(opts: {
url: string
pinnedHost: string
password?: string | null
authParam?: string | null
timeoutMs?: number
fullBody?: boolean
}): Promise<DoorCallResult> {
return egress({
method: 'GET',
url: opts.url,
pinnedHost: opts.pinnedHost,
headers: { accept: 'text/plain, text/html' },
redactSecret: opts.password,
redactAuthParam: opts.authParam,
timeoutMs: opts.timeoutMs,
maxBody: opts.fullBody ? MAX_BODY_INGEST : MAX_BODY,
snippetLen: opts.fullBody ? MAX_BODY_INGEST : SNIPPET,
})
}
// Universal API-call button. Full verbs + custom headers + optional body, with
// the secret injected server-side per auth_mode (query param / custom header /
// bearer) and never returned. Same SSRF guard, host pin, no-redirect, caps and
// redaction as the door path.
export async function callApi(opts: {
method: string
baseUrl: string
urlTemplate?: string | null
pinnedHost: string
headers?: Record<string, string> | null
body?: string | null
authMode: ApiAuthMode
authParam?: string | null
secret?: string | null
timeoutMs?: number
}): Promise<DoorCallResult> {
const req = buildApiRequest({
baseUrl: opts.baseUrl,
urlTemplate: opts.urlTemplate,
method: opts.method,
headers: { accept: '*/*', ...(opts.headers ?? {}) },
body: opts.body,
authMode: opts.authMode,
authParam: opts.authParam,
secret: opts.secret,
})
return egress({
method: req.method,
url: req.url,
pinnedHost: opts.pinnedHost,
headers: req.headers,
body: req.body,
redactSecret: opts.secret,
redactAuthParam: opts.authParam,
timeoutMs: opts.timeoutMs,
maxBody: MAX_BODY_API,
snippetLen: SNIPPET_API,
})
}
+159
View File
@@ -0,0 +1,159 @@
// Server-only. The shared ingest core for Door epic Phase 4. Both transports
// (the poll cron and the inbound webhook) parse/normalize events into
// DoorIngestEvent[] and hand them here to resolve each card to a member and
// idempotently insert audit rows. door_access_log has no client write policy,
// so the service (admin) client is the only writer, exactly as for the
// outbound actions.
import type { createAdminClient } from '@/lib/supabase/admin'
import { callDoor } from './executor'
import { resolveDoorSecret } from './secret'
import { encodeHeatSyncControl, applyTemplate, redactDoorSecrets } from '@/lib/door-logic'
import {
parseHeatSyncLog,
cardUidsEquivalent,
eventCardValue,
type DoorIngestEvent,
} from '@/lib/door-log-logic'
type Admin = ReturnType<typeof createAdminClient>
// The subset of door_connections the ingest paths read (service client).
export type IngestConn = {
id: string
space_id: string
adapter: string
base_url: string
pinned_host: string
auth_param: string | null
secret_ref: string | null
verbs: Record<string, string> | null
is_enabled: boolean
}
function isUniqueViolation(message: string): boolean {
return /duplicate key value|already exists|unique constraint/i.test(message)
}
function actionFor(result: DoorIngestEvent['result']): string {
if (result === 'granted') return 'entry'
if (result === 'denied') return 'entry_denied'
return 'inbound'
}
// Resolve each event's card to a member and insert one redacted, deduped
// door_access_log row per new event. Returns counts for the caller's response.
// Dedup is two-layered: a pre-filter against existing (connection_id,
// dedupe_key) cuts the common re-poll case to a single SELECT and zero writes,
// and the partial-unique index makes a concurrent insert race a no-op (the
// unique violation is swallowed per row).
export async function ingestEvents(
admin: Admin,
spaceId: string,
connectionId: string,
events: DoorIngestEvent[],
): Promise<{ inserted: number; resolved: number; skipped: number }> {
if (events.length === 0) return { inserted: 0, resolved: 0, skipped: 0 }
const { data: cards } = await admin
.from('member_cards')
.select('id, card_uid, member_id')
.eq('space_id', spaceId)
.eq('is_active', true)
const cardList = (cards ?? []) as { id: string; card_uid: string; member_id: string | null }[]
const keys = events.map(e => e.dedupeKey)
const { data: existing } = await admin
.from('door_access_log')
.select('dedupe_key')
.eq('connection_id', connectionId)
.in('dedupe_key', keys)
const have = new Set((existing ?? []).map(r => r.dedupe_key as string))
let inserted = 0
let resolved = 0
let skipped = 0
for (const ev of events) {
if (have.has(ev.dedupeKey)) {
skipped++
continue
}
const value = eventCardValue(ev)
const card = value ? cardList.find(c => cardUidsEquivalent(c.card_uid, value)) : undefined
const targetMemberId = card?.member_id ?? null
if (card) resolved++
const detail = redactDoorSecrets(
`${ev.detail} (${card ? 'member matched' : 'no card match'})`.slice(0, 1000),
null,
null,
)
const row: Record<string, unknown> = {
space_id: spaceId,
connection_id: connectionId,
actor_member_id: null,
target_member_id: targetMemberId,
action: actionFor(ev.result),
success: ev.result === 'granted',
detail,
dedupe_key: ev.dedupeKey,
}
// A webhook caller supplies occurred_at; accept only a parseable,
// not-in-the-future time (60s skew) so a relay cannot pin spoofed rows to
// the top of the operator's occurred_at-ordered log. Otherwise the row
// defaults to ingest time.
if (ev.occurredAt) {
const t = Date.parse(ev.occurredAt)
if (!Number.isNaN(t) && t <= Date.now() + 60_000) {
row.occurred_at = new Date(t).toISOString()
}
}
const { error } = await admin.from('door_access_log').insert(row)
if (error) {
// A concurrent ingest already inserted this exact event: not an error.
if (!isUniqueViolation(error.message)) {
// Anything else (e.g. a transient DB error) is surfaced via console so
// the row is not silently lost; the next poll re-attempts it.
console.error('[door-ingest] insert failed:', error.message)
}
continue
}
inserted++
}
return { inserted, resolved, skipped }
}
// Poll a single native-HeatSync connection's `?z` log and return parsed events.
// Only native_heatsync is parsed (its wire format is characterized); generic
// controllers ingest via the webhook. The full ring-buffer body is requested
// (fullBody) so the parser sees every slot.
export async function pollConnectionLog(
admin: Admin,
conn: IngestConn,
): Promise<{ ok: boolean; events: DoorIngestEvent[]; detail: string }> {
const password = await resolveDoorSecret(admin, conn.space_id, conn.secret_ref)
let query: string
if (conn.adapter === 'native_heatsync') {
const enc = encodeHeatSyncControl('log', password ?? '')
if (!enc.ok) return { ok: false, events: [], detail: `encode failed: ${enc.reason}` }
query = enc.query
} else {
return { ok: false, events: [], detail: 'poll supports native_heatsync only; use the webhook' }
}
const result = await callDoor({
url: conn.base_url + query,
pinnedHost: conn.pinned_host,
password,
authParam: conn.auth_param,
fullBody: true,
})
if (!result.ok) {
return { ok: false, events: [], detail: redactDoorSecrets(`poll failed: ${result.reason}`, password, conn.auth_param) }
}
const events = parseHeatSyncLog(result.snippet)
return { ok: true, events, detail: `polled ${events.length} event(s)` }
}
+36
View File
@@ -0,0 +1,36 @@
// Server-only. Load + decrypt a door connection's secret from the AES-256-GCM
// secrets vault. Used for both the OUTBOUND door password (secret_ref) and the
// INBOUND webhook secret (inbound_secret_ref). The plaintext is decrypted
// server-side only and never returned to the browser or logged. Returns null
// when no secret is referenced.
import type { createAdminClient } from '@/lib/supabase/admin'
import { decryptSecret } from '@/lib/secrets/crypto'
export async function resolveDoorSecret(
admin: ReturnType<typeof createAdminClient>,
spaceId: string,
secretRef: string | null,
): Promise<string | null> {
if (!secretRef) return null
const { data } = await admin
.from('secrets')
.select('encryption_version, encrypted_value, value')
.eq('id', secretRef)
.eq('space_id', spaceId)
.maybeSingle()
if (!data) return null
if (data.encryption_version === 1 && data.encrypted_value) {
const raw = data.encrypted_value as unknown
const buf =
typeof raw === 'string'
? Buffer.from((raw as string).replace(/^\\x/, ''), 'hex')
: Buffer.from(raw as Uint8Array)
try {
return decryptSecret(buf, 1)
} catch {
return null
}
}
return (data.value as string | null) ?? null
}