Files
Moheeb Zara 7ec90b0f1e 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.
2026-05-20 20:45:44 -07:00

58 lines
2.3 KiB
TypeScript

// 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 }
}