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
+325
View File
@@ -0,0 +1,325 @@
'use server'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import {
requireMember,
logActivity,
parseInput,
type Member,
type ServerSupabase,
} from '@/lib/auth-helpers'
import {
createApiButtonSchema,
updateApiButtonSchema,
apiButtonIdSchema,
} from '@/lib/validations'
import { redactDoorSecrets } from '@/lib/door-logic'
import { callApi } from '@/lib/door/executor'
import { resolveDoorSecret } from '@/lib/door/secret'
import { checkRateLimit } from '@/lib/security'
type Gate =
| { ok: true; supabase: ServerSupabase; member: Member }
| { ok: false; error: string }
// Managing API buttons reuses door.manage (the permission catalog already
// scopes door.manage to "door integrations, buttons, and member cards").
async function requireButtonManager(): Promise<Gate> {
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { ok: false, error: auth.error }
const { member } = auth
const { data: allowed, error } = await supabase.rpc('user_has_permission', {
uid: member.user_id as string,
sid: member.space_id,
perm: 'door.manage',
})
if (error) return { ok: false, error: error.message }
if (!allowed) return { ok: false, error: 'You do not have permission to manage API buttons' }
return { ok: true, supabase, member }
}
function isUniqueViolation(message: string): boolean {
return /duplicate key value|already exists|unique constraint/i.test(message)
}
// ─── Definition CRUD (door.manage) ───────────────────────────────────────────
const WRITE_FIELDS = [
'label', 'button_group', 'sort_order', 'method', 'base_url', 'pinned_host',
'url_template', 'headers', 'body_template', 'auth_mode', 'auth_param',
'secret_ref', 'required_permission', 'confirm', 'is_enabled',
] as const
export async function createApiButton(input: unknown) {
const gate = await requireButtonManager()
if (!gate.ok) return { error: gate.error }
const { supabase, member } = gate
const v = parseInput(createApiButtonSchema, input)
if (!v.ok) return { error: v.error }
const c = v.data
if (c.secret_ref) {
const admin = createAdminClient()
const { data: sec } = await admin
.from('secrets')
.select('id')
.eq('id', c.secret_ref)
.eq('space_id', member.space_id)
.maybeSingle()
if (!sec) return { error: 'The referenced secret was not found in this space.' }
}
const { data, error } = await supabase
.from('api_buttons')
.insert({
space_id: member.space_id,
label: c.label,
button_group: c.button_group,
sort_order: c.sort_order,
method: c.method,
base_url: c.base_url,
pinned_host: c.pinned_host,
url_template: c.url_template ?? null,
headers: c.headers,
body_template: c.body_template ?? null,
auth_mode: c.auth_mode,
auth_param: c.auth_param ?? null,
secret_ref: c.secret_ref ?? null,
required_permission: c.required_permission,
confirm: c.confirm,
is_enabled: c.is_enabled,
created_by: member.id,
})
.select('id')
.single()
if (error) return { error: error.message }
await logActivity(supabase, member, 'created', 'api_button', data.id as string, c.label)
revalidatePath('/door/buttons')
return { data: { id: data.id as string } }
}
export async function updateApiButton(input: unknown) {
const gate = await requireButtonManager()
if (!gate.ok) return { error: gate.error }
const { supabase, member } = gate
const v = parseInput(updateApiButtonSchema, input)
if (!v.ok) return { error: v.error }
const u = v.data
if (u.secret_ref) {
const admin = createAdminClient()
const { data: sec } = await admin
.from('secrets')
.select('id')
.eq('id', u.secret_ref)
.eq('space_id', member.space_id)
.maybeSingle()
if (!sec) return { error: 'The referenced secret was not found in this space.' }
}
const patch: Record<string, unknown> = {}
for (const k of WRITE_FIELDS) {
if (u[k] !== undefined) patch[k] = u[k] ?? null
}
if (Object.keys(patch).length === 0) return { data: { id: u.buttonId } }
const { error } = await supabase
.from('api_buttons')
.update(patch)
.eq('id', u.buttonId)
.eq('space_id', member.space_id)
if (error) return { error: error.message }
await logActivity(supabase, member, 'updated', 'api_button', u.buttonId)
revalidatePath('/door/buttons')
return { data: { id: u.buttonId } }
}
export async function deleteApiButton(input: unknown) {
const gate = await requireButtonManager()
if (!gate.ok) return { error: gate.error }
const { supabase, member } = gate
const v = parseInput(apiButtonIdSchema, input)
if (!v.ok) return { error: v.error }
const { error } = await supabase
.from('api_buttons')
.delete()
.eq('id', v.data.buttonId)
.eq('space_id', member.space_id)
if (error) return { error: error.message }
await logActivity(supabase, member, 'deleted', 'api_button', v.data.buttonId)
revalidatePath('/door/buttons')
return { data: { id: v.data.buttonId } }
}
// Full definitions for the builder UI (door.manage; RLS already gates SELECT).
// secret_ref (an id, not the value) is returned so the picker can show the
// current selection; the secret value never leaves the server.
export async function listApiButtons() {
const gate = await requireButtonManager()
if (!gate.ok) return { error: gate.error }
const { supabase, member } = gate
const { data, error } = await supabase
.from('api_buttons')
.select('id, label, button_group, sort_order, method, base_url, pinned_host, url_template, headers, body_template, auth_mode, auth_param, secret_ref, required_permission, confirm, is_enabled')
.eq('space_id', member.space_id)
.order('button_group', { ascending: true })
.order('sort_order', { ascending: true })
.order('label', { ascending: true })
if (error) return { error: error.message }
return { data: data ?? [] }
}
// ─── Member-facing invoke (per-button required_permission) ───────────────────
// The curated list a member may press: enabled buttons whose required_permission
// the caller holds. Members have NO RLS read on api_buttons (a button's url /
// headers / secret_ref are operator config), so this is a service-client read
// after requireMember; only presentational fields are returned (never url /
// headers / body / secret_ref).
export async function listInvokableButtons() {
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { member } = auth
const admin = createAdminClient()
const { data: buttons, error } = await admin
.from('api_buttons')
.select('id, label, button_group, sort_order, confirm, required_permission')
.eq('space_id', member.space_id)
.eq('is_enabled', true)
.order('button_group', { ascending: true })
.order('sort_order', { ascending: true })
.order('label', { ascending: true })
if (error) return { error: error.message }
// Resolve each distinct required_permission once for this caller.
const distinct = Array.from(new Set((buttons ?? []).map(b => b.required_permission as string)))
const allowed = new Set<string>()
for (const perm of distinct) {
const { data: ok } = await supabase.rpc('user_has_permission', {
uid: member.user_id as string,
sid: member.space_id,
perm,
})
if (ok) allowed.add(perm)
}
return {
data: (buttons ?? [])
.filter(b => allowed.has(b.required_permission as string))
.map(b => ({
id: b.id as string,
label: b.label as string,
group: b.button_group as string,
confirm: b.confirm as boolean,
})),
}
}
// Press a button. Any member, gated by the button's own required_permission.
// Service client (the definition is operator config a member cannot RLS-read);
// the secret is decrypted server-side and injected by callApi, never returned.
// Rate-limited; every attempt (incl. a denial) writes one redacted
// api_call_log row through the single hardened egress.
export async function invokeApiButton(input: unknown) {
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { member } = auth
const v = parseInput(apiButtonIdSchema, input)
if (!v.ok) return { error: v.error }
// Rate-limit before any DB work so a member cannot flood button loads,
// permission RPCs, or denial-audit rows.
const rl = checkRateLimit(`api-invoke:${member.space_id}:${member.id}`, 30, 60_000)
if (!rl.allowed) return { error: 'Too many actions. Slow down and try again shortly.' }
const admin = createAdminClient()
const { data: btn } = await admin
.from('api_buttons')
.select('id, label, method, base_url, pinned_host, url_template, headers, body_template, auth_mode, auth_param, secret_ref, required_permission, is_enabled')
.eq('id', v.data.buttonId)
.eq('space_id', member.space_id)
.maybeSingle()
if (!btn || !btn.is_enabled) return { error: 'That button is not available.' }
const { data: permitted } = await supabase.rpc('user_has_permission', {
uid: member.user_id as string,
sid: member.space_id,
perm: btn.required_permission as string,
})
if (!permitted) {
await admin.from('api_call_log').insert({
space_id: member.space_id,
button_id: btn.id as string,
actor_member_id: member.id,
action: 'invoke',
success: false,
detail: 'denied: missing permission',
})
return { error: 'You do not have permission to use that button.' }
}
const secret = await resolveDoorSecret(admin, member.space_id, (btn.secret_ref as string | null) ?? null)
const authParam = (btn.auth_param as string | null) ?? null
const result = await callApi({
method: btn.method as string,
baseUrl: btn.base_url as string,
urlTemplate: (btn.url_template as string | null) ?? null,
pinnedHost: btn.pinned_host as string,
headers: (btn.headers as Record<string, string> | null) ?? undefined,
body: (btn.body_template as string | null) ?? null,
authMode: (btn.auth_mode as 'none' | 'query' | 'header' | 'bearer') ?? 'none',
authParam,
secret,
})
const detail = redactDoorSecrets(
(result.ok ? `${btn.method} ${result.status}: ${result.snippet}` : `failed: ${result.reason}`).slice(0, 1000),
secret,
authParam,
)
await admin.from('api_call_log').insert({
space_id: member.space_id,
button_id: btn.id as string,
actor_member_id: member.id,
action: 'invoke',
success: result.ok,
detail,
})
revalidatePath('/door/buttons')
return result.ok
? { data: { ok: true, status: result.status } }
: { error: 'The API call failed. Ask an admin to check the API-call log for the redacted detail.' }
}
// The press audit (door.manage), for the builder page.
export async function listApiCallLog() {
const gate = await requireButtonManager()
if (!gate.ok) return { error: gate.error }
const { supabase, member } = gate
const { data, error } = await supabase
.from('api_call_log')
.select('id, button_id, action, success, detail, occurred_at')
.eq('space_id', member.space_id)
.order('occurred_at', { ascending: false })
.limit(200)
if (error) return { error: error.message }
return { data: data ?? [] }
}
+31 -49
View File
@@ -28,7 +28,7 @@ import {
} from '@/lib/door-logic'
import { pickLowestFreeSlot, slotCapacity } from '@/lib/door-slots-logic'
import { callDoor } from '@/lib/door/executor'
import { decryptSecret } from '@/lib/secrets/crypto'
import { resolveDoorSecret } from '@/lib/door/secret'
import { checkRateLimit } from '@/lib/security'
type Gate =
@@ -50,37 +50,10 @@ async function requireDoorManager(): Promise<Gate> {
return { ok: true, supabase, member }
}
// Load + decrypt a connection's door password from the encrypted secrets
// vault. Service client: a door manager may legitimately not hold
// ops.secrets.read, and the plaintext must never reach the browser. Returns
// null when no secret is referenced (auth_mode 'none').
async function resolveSecret(
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
}
// Connection door password is loaded + decrypted via the shared
// resolveDoorSecret (lib/door/secret.ts); service client, never returned to
// the browser. Aliased here to keep the existing call sites unchanged.
const resolveSecret = resolveDoorSecret
function isUniqueViolation(message: string): boolean {
return /duplicate key value|already exists|unique constraint/i.test(message)
@@ -97,15 +70,18 @@ export async function createDoorConnection(input: unknown) {
if (!v.ok) return { error: v.error }
const c = v.data
if (c.secret_ref) {
const refs = [c.secret_ref, c.inbound_secret_ref].filter(Boolean) as string[]
if (refs.length > 0) {
const admin = createAdminClient()
const { data: sec } = await admin
.from('secrets')
.select('id')
.eq('id', c.secret_ref)
.eq('space_id', member.space_id)
.maybeSingle()
if (!sec) return { error: 'The referenced secret was not found in this space.' }
for (const ref of refs) {
const { data: sec } = await admin
.from('secrets')
.select('id')
.eq('id', ref)
.eq('space_id', member.space_id)
.maybeSingle()
if (!sec) return { error: 'The referenced secret was not found in this space.' }
}
}
const { data, error } = await supabase
@@ -122,6 +98,8 @@ export async function createDoorConnection(input: unknown) {
verbs: c.verbs,
allow_member_self_entry: c.allow_member_self_entry,
is_enabled: c.is_enabled,
inbound_enabled: c.inbound_enabled,
inbound_secret_ref: c.inbound_secret_ref ?? null,
created_by: member.id,
})
.select('id')
@@ -145,21 +123,25 @@ export async function updateDoorConnection(input: unknown) {
if (!v.ok) return { error: v.error }
const u = v.data
if (u.secret_ref) {
const refs = [u.secret_ref, u.inbound_secret_ref].filter(Boolean) as string[]
if (refs.length > 0) {
const admin = createAdminClient()
const { data: sec } = await admin
.from('secrets')
.select('id')
.eq('id', u.secret_ref)
.eq('space_id', member.space_id)
.maybeSingle()
if (!sec) return { error: 'The referenced secret was not found in this space.' }
for (const ref of refs) {
const { data: sec } = await admin
.from('secrets')
.select('id')
.eq('id', ref)
.eq('space_id', member.space_id)
.maybeSingle()
if (!sec) return { error: 'The referenced secret was not found in this space.' }
}
}
const patch: Record<string, unknown> = {}
for (const k of [
'name', 'adapter', 'base_url', 'pinned_host', 'auth_mode',
'auth_param', 'secret_ref', 'verbs', 'allow_member_self_entry', 'is_enabled',
'inbound_enabled', 'inbound_secret_ref',
] as const) {
if (u[k] !== undefined) patch[k] = u[k] === undefined ? null : u[k] ?? null
}
@@ -204,7 +186,7 @@ export async function listDoorConnections() {
const { data, error } = await supabase
.from('door_connections')
.select('id, name, adapter, base_url, pinned_host, auth_mode, auth_param, secret_ref, verbs, allow_member_self_entry, is_enabled, updated_at')
.select('id, name, adapter, base_url, pinned_host, auth_mode, auth_param, secret_ref, verbs, allow_member_self_entry, is_enabled, inbound_enabled, inbound_secret_ref, updated_at')
.eq('space_id', member.space_id)
.order('name', { ascending: true })
if (error) return { error: error.message }
+1
View File
@@ -62,6 +62,7 @@ export * from './member-cards'
// Door connections + executor (Door epic)
export * from './door'
export * from './api-buttons'
// Presence & attendance (check-in / check-out / hosting)
export * from './presence'
+57
View File
@@ -0,0 +1,57 @@
// Pure, dependency-free logic for the universal API-call buttons (Door epic
// phase 5). No Supabase/React/Next/fetch. The security-relevant part -- where
// the secret is injected (query param / custom header / bearer) and how the
// request is assembled -- lives here so it is unit-tested in isolation; the
// executor (lib/door/executor.ts callApi) consumes the result and performs the
// hardened SSRF egress.
export type ApiAuthMode = 'none' | 'query' | 'header' | 'bearer'
export const API_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const
export type ApiMethod = (typeof API_METHODS)[number]
export type BuiltApiRequest = {
method: string
url: string
headers: Record<string, string>
body: string | null
}
// Assemble the outbound request. The secret (decrypted server-side, never from
// the client) is placed per auth_mode: a query param (name = authParam), a
// custom header (name = authParam), or an Authorization: Bearer header. A body
// is only carried for verbs that take one. Header keys are lower-cased so the
// caller cannot smuggle a second Host/host that would survive the executor's
// forced host (the executor also forces host last as defense-in-depth).
export function buildApiRequest(opts: {
baseUrl: string
urlTemplate?: string | null
method: string
headers?: Record<string, string> | null
body?: string | null
authMode: ApiAuthMode
authParam?: string | null
secret?: string | null
}): BuiltApiRequest {
const method = opts.method.toUpperCase()
let url = opts.baseUrl + (opts.urlTemplate ?? '')
const headers: Record<string, string> = {}
for (const [k, v] of Object.entries(opts.headers ?? {})) {
const key = k.trim().toLowerCase()
if (key && key !== 'host') headers[key] = v
}
if (opts.secret) {
if (opts.authMode === 'query' && opts.authParam) {
const sep = url.includes('?') ? '&' : '?'
url += `${sep}${encodeURIComponent(opts.authParam)}=${encodeURIComponent(opts.secret)}`
} else if (opts.authMode === 'header' && opts.authParam) {
headers[opts.authParam.trim().toLowerCase()] = opts.secret
} else if (opts.authMode === 'bearer') {
headers['authorization'] = `Bearer ${opts.secret}`
}
}
const body = method === 'GET' || method === 'HEAD' ? null : (opts.body ?? null)
return { method, url, headers, body }
}
+176
View File
@@ -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
}
+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
}
+2 -1
View File
@@ -29,6 +29,7 @@ export const PERMISSIONS = [
{ code: 'equipment.manage', group: 'Equipment', label: 'Manage equipment and reservations' },
{ code: 'door.manage', group: 'Access', label: 'Configure door integrations, buttons, and member cards' },
{ code: 'door.operate', group: 'Access', label: 'Operate doors: open/lock, push/revoke cards' },
{ code: 'apicall.invoke', group: 'Access', label: 'Use API-call buttons (non-door automations)' },
{ code: 'customize.manage', group: 'Admin', label: 'Customize roles, tiers, areas, invites, onboarding' },
{ code: 'settings.manage', group: 'Admin', label: 'Space settings, integrations, webhooks' },
] as const
@@ -51,7 +52,7 @@ export const DEFAULT_ROLE_PERMISSIONS: Record<string, PermissionCode[]> = {
'certifications.manage', 'certifications.grant',
'classes.manage', 'classes.instruct',
'equipment.manage',
'door.manage', 'door.operate',
'door.manage', 'door.operate', 'apicall.invoke',
'customize.manage', 'settings.manage',
],
treasurer: ['payments.manage', 'ops.kb.read', 'ops.process.read'],
+82
View File
@@ -2,6 +2,8 @@ import { z } from 'zod'
import { INVITE_ROLES } from './invite-logic'
import { MUTEABLE_CATEGORIES } from './notifications-prefs-logic'
import { DUES_LINK_PLATFORMS, isSafeDuesUrl } from './dues-payments-logic'
import { isValidPermission } from './permissions-catalog'
import { API_METHODS } from './api-call-logic'
/**
* Accepts every date-ish string the app produces and normalizes to a
@@ -953,6 +955,11 @@ export const createDoorConnectionSchema = z.object({
verbs: z.record(z.string().max(2000)).optional().default({}),
allow_member_self_entry: z.boolean().optional().default(false),
is_enabled: z.boolean().optional().default(true),
// Inbound access-log ingest (P4). inbound_enabled gates both the poll cron
// and the per-connection webhook; inbound_secret_ref is the webhook bearer
// secret (a secrets-vault row), distinct from secret_ref.
inbound_enabled: z.boolean().optional().default(false),
inbound_secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(),
})
export const updateDoorConnectionSchema = z.object({
@@ -967,6 +974,8 @@ export const updateDoorConnectionSchema = z.object({
verbs: z.record(z.string().max(2000)).optional(),
allow_member_self_entry: z.boolean().optional(),
is_enabled: z.boolean().optional(),
inbound_enabled: z.boolean().optional(),
inbound_secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(),
})
export const doorConnectionIdSchema = z.object({
@@ -992,6 +1001,79 @@ export const doorControlSchema = z.object({
verb: z.enum(['open', 'unlock', 'lock']),
})
// ─── Door inbound webhook (Door epic P4) ─────────────────────────────────────
// A controller or relay pushes access events to /api/door/inbound/[connection],
// authenticated by the connection's inbound bearer secret. Each event MUST
// carry a stable id (the dedupe token; retries are idempotent). At least one of
// card_uid / card_number identifies the card; result states the access
// decision. The batch is bounded so one request cannot enqueue unbounded work.
const doorWebhookEvent = z.object({
id: z.string().min(1).max(200),
card_uid: z.string().trim().min(1).max(200).optional().nullable(),
card_number: z.string().trim().regex(/^\d{1,20}$/, 'card_number must be digits').optional().nullable(),
result: z.enum(['granted', 'denied', 'unknown']).optional(),
occurred_at: z.string().datetime({ offset: true }).optional().nullable(),
})
export const doorWebhookPayloadSchema = z.object({
events: z.array(doorWebhookEvent).min(1).max(100),
})
// ─── Universal API-call buttons (Door epic P5) ───────────────────────────────
const apiMethod = z.enum(API_METHODS)
const apiAuthMode = z.enum(['none', 'query', 'header', 'bearer'])
// required_permission must be a real catalog code (any of them; the admin picks
// which capability gates pressing the button, default apicall.invoke).
const requiredPermission = z
.string()
.max(60)
.refine(isValidPermission, 'Unknown permission code')
// A flat string->string header map, bounded in count and value length.
const apiHeaders = z
.record(z.string().max(2000))
.refine(h => Object.keys(h).length <= 20, 'Too many headers (max 20)')
export const createApiButtonSchema = z.object({
label: z.string().min(1, 'Label is required').max(120),
button_group: z.string().min(1).max(60).optional().default('General'),
sort_order: z.number().int().min(0).max(100000).optional().default(0),
method: apiMethod.optional().default('POST'),
base_url: httpUrl,
pinned_host: z.string().min(1, 'A pinned host is required').max(255),
url_template: z.string().max(2000).optional().nullable(),
headers: apiHeaders.optional().default({}),
body_template: z.string().max(8000).optional().nullable(),
auth_mode: apiAuthMode.optional().default('none'),
auth_param: z.string().max(120).optional().nullable(),
secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(),
required_permission: requiredPermission.optional().default('apicall.invoke'),
confirm: z.boolean().optional().default(true),
is_enabled: z.boolean().optional().default(true),
})
export const updateApiButtonSchema = z.object({
buttonId: z.string().uuid('Invalid button ID'),
label: z.string().min(1).max(120).optional(),
button_group: z.string().min(1).max(60).optional(),
sort_order: z.number().int().min(0).max(100000).optional(),
method: apiMethod.optional(),
base_url: httpUrl.optional(),
pinned_host: z.string().min(1).max(255).optional(),
url_template: z.string().max(2000).optional().nullable(),
headers: apiHeaders.optional(),
body_template: z.string().max(8000).optional().nullable(),
auth_mode: apiAuthMode.optional(),
auth_param: z.string().max(120).optional().nullable(),
secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(),
required_permission: requiredPermission.optional(),
confirm: z.boolean().optional(),
is_enabled: z.boolean().optional(),
})
export const apiButtonIdSchema = z.object({
buttonId: z.string().uuid('Invalid button ID'),
})
// ─── Presence / attendance ───────────────────────────────────────────────────
export const checkInSchema = z.object({