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
@@ -0,0 +1,276 @@
'use client'
import { useState } from 'react'
import { toast } from 'sonner'
import { Zap, Plus } from 'lucide-react'
import { PageHeader, PageTitle } from '@/components/ui/page-title'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
import { useConfirm } from '@/components/ui/confirm'
import { createApiButton, updateApiButton, deleteApiButton } from '@/lib/actions'
type Btn = {
id: string
label: string
button_group: string
sort_order: number
method: string
base_url: string
pinned_host: string
url_template: string | null
headers: Record<string, string> | null
body_template: string | null
auth_mode: string
auth_param: string | null
secret_ref: string | null
required_permission: string
confirm: boolean
is_enabled: boolean
}
type LogRow = { id: string; action: string; success: boolean; detail: string | null; occurred_at: string }
type Secret = { id: string; title: string }
type Perm = { code: string; label: string }
const input = 'w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary'
const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
const AUTH_MODES = ['none', 'query', 'header', 'bearer']
function emptyDraft() {
return {
label: '', button_group: 'General', method: 'POST', base_url: '', pinned_host: '',
url_template: '', headersText: '', body_template: '',
auth_mode: 'none', auth_param: '', secret_ref: '',
required_permission: 'apicall.invoke', confirm: true,
}
}
// Parse "Key: Value" lines into a header map (blank lines ignored).
function parseHeaders(text: string): Record<string, string> {
const out: Record<string, string> = {}
for (const line of text.split('\n')) {
const i = line.indexOf(':')
if (i <= 0) continue
const k = line.slice(0, i).trim()
const v = line.slice(i + 1).trim()
if (k) out[k] = v
}
return out
}
export function ApiButtonsClient({
initial,
secrets,
log,
permissions,
}: {
initial: unknown[]
secrets: Secret[]
log: unknown[]
permissions: Perm[]
}) {
const confirm = useConfirm()
const [buttons, setButtons] = useState<Btn[]>(initial as Btn[])
const [showNew, setShowNew] = useState(false)
const [d, setD] = useState(emptyDraft())
const [busy, setBusy] = useState(false)
const rows = log as LogRow[]
function doorTemplate() {
setShowNew(true)
setD({
...emptyDraft(),
label: 'Open door', button_group: 'Door', method: 'GET',
url_template: '?o1', auth_mode: 'query', auth_param: 'e',
required_permission: 'door.operate', confirm: true,
})
toast.message('Door template loaded', { description: 'Set the base URL, pinned host, and password secret, then create.' })
}
async function onCreate(e: React.FormEvent) {
e.preventDefault()
if (!d.label.trim() || !d.base_url.trim() || !d.pinned_host.trim()) {
return toast.error('Label, base URL and pinned host are required')
}
const headers = parseHeaders(d.headersText)
setBusy(true)
const res = await createApiButton({
label: d.label.trim(),
button_group: d.button_group.trim() || 'General',
method: d.method,
base_url: d.base_url.trim(),
pinned_host: d.pinned_host.trim(),
url_template: d.url_template.trim() || null,
headers,
body_template: d.body_template.trim() || null,
auth_mode: d.auth_mode,
auth_param: d.auth_param.trim() || null,
secret_ref: d.secret_ref || null,
required_permission: d.required_permission,
confirm: d.confirm,
})
setBusy(false)
if ('error' in res && res.error) return toast.error(res.error)
const id = (res as { data: { id: string } }).data.id
setButtons(prev => [
{
id, label: d.label.trim(), button_group: d.button_group.trim() || 'General', sort_order: 0,
method: d.method, base_url: d.base_url.trim(), pinned_host: d.pinned_host.trim(),
url_template: d.url_template.trim() || null, headers, body_template: d.body_template.trim() || null,
auth_mode: d.auth_mode, auth_param: d.auth_param.trim() || null, secret_ref: d.secret_ref || null,
required_permission: d.required_permission, confirm: d.confirm, is_enabled: true,
},
...prev,
])
setD(emptyDraft())
setShowNew(false)
toast.success('Button created')
}
async function onToggleEnabled(b: Btn) {
const res = await updateApiButton({ buttonId: b.id, is_enabled: !b.is_enabled })
if ('error' in res && res.error) return toast.error(res.error)
setButtons(prev => prev.map(x => (x.id === b.id ? { ...x, is_enabled: !x.is_enabled } : x)))
}
async function onDelete(b: Btn) {
const ok = await confirm({
title: 'Delete button',
description: `"${b.label}" will be removed. Existing API-call log entries are kept.`,
confirmText: 'Delete',
destructive: true,
})
if (!ok) return
const res = await deleteApiButton({ buttonId: b.id })
if ('error' in res && res.error) return toast.error(res.error)
setButtons(prev => prev.filter(x => x.id !== b.id))
toast.success('Deleted')
}
const permLabel = (code: string) => permissions.find(p => p.code === code)?.label ?? code
return (
<>
<PageHeader>
<PageTitle>API buttons</PageTitle>
<div className="flex items-center gap-1.5">
<Button size="sm" variant="outline" onClick={doorTemplate}>Door template</Button>
<Button size="sm" onClick={() => setShowNew(v => !v)}>
<Plus className="size-4" /> {showNew ? 'Cancel' : 'New button'}
</Button>
</div>
</PageHeader>
<div className="p-4 md:p-6 space-y-4">
<p className="font-sans text-sm text-muted-foreground max-w-2xl">
A button fires a configured HTTP request when an authorized member presses it. The
request runs through the same hardened egress as door control: the server only ever
calls the exact pinned host (any public or private host you set, except
cloud-metadata/link-local), never follows redirects, caps time and response size, and
records every press in the API-call log with secrets redacted. The secret is read from
your encrypted Secrets vault and injected server-side, never stored here. Each button is
gated by a permission you choose; a member sees only the buttons they may press.
</p>
{showNew && (
<form onSubmit={onCreate} className="rounded-lg border border-border bg-card p-4 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input className={input} placeholder="Label (e.g. Open front door)" maxLength={120}
value={d.label} onChange={e => setD({ ...d, label: e.target.value })} />
<input className={input} placeholder="Group (e.g. Door, Lights)" maxLength={60}
value={d.button_group} onChange={e => setD({ ...d, button_group: e.target.value })} />
<select className={input} value={d.method} onChange={e => setD({ ...d, method: e.target.value })}>
{METHODS.map(m => <option key={m} value={m}>{m}</option>)}
</select>
<select className={input} value={d.required_permission} onChange={e => setD({ ...d, required_permission: e.target.value })}>
{permissions.map(p => <option key={p.code} value={p.code}>Requires: {p.label}</option>)}
</select>
<input className={input} placeholder="Base URL (https://device.lan/ or http://192.168.1.50/)"
value={d.base_url} onChange={e => setD({ ...d, base_url: e.target.value })} />
<input className={input} placeholder="Pinned host (device.lan or 192.168.1.50)"
value={d.pinned_host} onChange={e => setD({ ...d, pinned_host: e.target.value })} />
<input className={input} placeholder="URL path/query appended to base (optional)"
value={d.url_template} onChange={e => setD({ ...d, url_template: e.target.value })} />
<select className={input} value={d.auth_mode} onChange={e => setD({ ...d, auth_mode: e.target.value })}>
{AUTH_MODES.map(m => <option key={m} value={m}>auth: {m}</option>)}
</select>
{(d.auth_mode === 'query' || d.auth_mode === 'header') && (
<input className={input} placeholder={d.auth_mode === 'query' ? 'query param name (e.g. e, api_key)' : 'header name (e.g. X-Api-Key)'}
value={d.auth_param} onChange={e => setD({ ...d, auth_param: e.target.value })} />
)}
{d.auth_mode !== 'none' && (
<select className={input} value={d.secret_ref} onChange={e => setD({ ...d, secret_ref: e.target.value })}>
<option value="">No secret</option>
{secrets.map(s => <option key={s.id} value={s.id}>Secret: {s.title}</option>)}
</select>
)}
</div>
<textarea className={`${input} font-mono text-xs`} rows={2} placeholder="Custom headers, one per line: Header-Name: value (optional)"
value={d.headersText} onChange={e => setD({ ...d, headersText: e.target.value })} />
{d.method !== 'GET' && (
<textarea className={`${input} font-mono text-xs`} rows={3} placeholder="Request body (optional; set Content-Type in headers if needed)"
value={d.body_template} onChange={e => setD({ ...d, body_template: e.target.value })} />
)}
<label className="flex items-center gap-2 font-mono text-[11px] text-muted-foreground">
<input type="checkbox" checked={d.confirm} onChange={e => setD({ ...d, confirm: e.target.checked })} />
Ask for confirmation before firing
</label>
<Button type="submit" size="sm" disabled={busy}>Create button</Button>
</form>
)}
{buttons.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon"><Zap /></EmptyMedia>
<EmptyTitle>No API buttons</EmptyTitle>
<EmptyDescription>Create a button to give members a one-press action (a door, lights, a webhook).</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="divide-y rounded-lg border border-border">
{buttons.map(b => (
<div key={b.id} className="p-4 flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-sans text-sm font-semibold text-foreground">{b.label}</span>
<Badge variant="outline">{b.button_group}</Badge>
<Badge variant="outline">{b.method}</Badge>
{!b.is_enabled && <Badge variant="outline">Disabled</Badge>}
</div>
<p className="font-mono text-[10px] text-muted-foreground mt-1 break-all">
{b.base_url}{b.url_template ?? ''} · pinned {b.pinned_host} · auth {b.auth_mode}
{' · '}requires {permLabel(b.required_permission)}
</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Button size="sm" variant="outline" onClick={() => onToggleEnabled(b)}>{b.is_enabled ? 'Disable' : 'Enable'}</Button>
<Button size="sm" variant="outline" onClick={() => onDelete(b)}>Delete</Button>
</div>
</div>
))}
</div>
)}
<section>
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-2 mt-6">API-call log</h2>
{rows.length === 0 ? (
<p className="font-mono text-[10px] text-muted-foreground">No API calls recorded yet.</p>
) : (
<ul className="divide-y rounded border border-border">
{rows.map(r => (
<li key={r.id} className="px-3 py-2 flex items-center justify-between gap-3">
<span className="font-mono text-[11px] text-muted-foreground">
<span className={r.success ? 'text-primary' : 'text-red-500'}>{r.success ? 'ok' : 'fail'}</span>
{' · '}{r.action}{r.detail ? ` · ${r.detail}` : ''}
</span>
<span className="font-mono text-[10px] text-muted-foreground/70">{new Date(r.occurred_at).toLocaleString()}</span>
</li>
))}
</ul>
)}
</section>
</div>
</>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { requireDoorManagerPage } from '@/lib/door-guard'
import { listApiButtons, listSecretTitles, listApiCallLog } from '@/lib/actions'
import { PERMISSIONS } from '@/lib/permissions-catalog'
import { ApiButtonsClient } from './api-buttons-client'
export const dynamic = 'force-dynamic'
export default async function ApiButtonsPage() {
await requireDoorManagerPage()
const [buttons, secrets, log] = await Promise.all([
listApiButtons(),
listSecretTitles(),
listApiCallLog(),
])
return (
<ApiButtonsClient
initial={'data' in buttons ? (buttons.data as unknown[]) : []}
secrets={'data' in secrets ? (secrets.data as { id: string; title: string }[]) : []}
log={'data' in log ? (log.data as unknown[]) : []}
permissions={PERMISSIONS.map(p => ({ code: p.code, label: p.label }))}
/>
)
}
@@ -32,6 +32,8 @@ type Conn = {
verbs: Record<string, string> | null
allow_member_self_entry: boolean
is_enabled: boolean
inbound_enabled: boolean
inbound_secret_ref: string | null
}
type LogRow = { id: string; action: string; success: boolean; detail: string | null; occurred_at: string }
type DoorCard = { cardId: string; memberName: string; label: string | null; last4: string; slot: number | null }
@@ -65,6 +67,7 @@ export function DoorManageClient({
const [cardsFor, setCardsFor] = useState<string | null>(null)
const [cards, setCards] = useState<DoorCard[]>([])
const [cardsBusy, setCardsBusy] = useState(false)
const [inboundFor, setInboundFor] = useState<string | null>(null)
const rows = log as LogRow[]
async function onControl(c: Conn, verb: 'open' | 'unlock' | 'lock') {
@@ -161,6 +164,7 @@ export function DoorManageClient({
pinned_host: d.pinned_host.trim(), auth_mode: d.secret_ref ? 'query' : 'none',
auth_param: d.adapter === 'native_heatsync' ? 'e' : null, secret_ref: d.secret_ref || null,
verbs: buildVerbs(d), allow_member_self_entry: d.allow_member_self_entry, is_enabled: true,
inbound_enabled: false, inbound_secret_ref: null,
},
...prev,
])
@@ -191,6 +195,36 @@ export function DoorManageClient({
setConns(prev => prev.map(x => (x.id === c.id ? { ...x, allow_member_self_entry: next } : x)))
}
async function onSetInboundSecret(c: Conn, ref: string) {
const res = await updateDoorConnection({ connectionId: c.id, inbound_secret_ref: ref || null })
if ('error' in res && res.error) return toast.error(res.error)
setConns(prev => prev.map(x => (x.id === c.id ? { ...x, inbound_secret_ref: ref || null } : x)))
}
async function onToggleInbound(c: Conn) {
const next = !c.inbound_enabled
if (next && !c.inbound_secret_ref) {
return toast.error('Choose a webhook secret first; it authenticates inbound events.')
}
const res = await updateDoorConnection({ connectionId: c.id, inbound_enabled: next })
if ('error' in res && res.error) return toast.error(res.error)
setConns(prev => prev.map(x => (x.id === c.id ? { ...x, inbound_enabled: next } : x)))
}
function webhookUrl(id: string): string {
const origin = typeof window !== 'undefined' ? window.location.origin : ''
return `${origin}/api/door/inbound/${id}`
}
async function copyWebhookUrl(id: string) {
try {
await navigator.clipboard.writeText(webhookUrl(id))
toast.success('Webhook URL copied')
} catch {
toast.error('Could not copy. Select and copy the URL manually.')
}
}
async function onTest(c: Conn) {
setBusy(true)
const res = await testDoorConnection({ connectionId: c.id })
@@ -332,6 +366,7 @@ export function DoorManageClient({
<Badge variant="outline">{c.adapter === 'native_heatsync' ? 'HeatSync' : 'Generic'}</Badge>
{!c.is_enabled && <Badge variant="outline">Disabled</Badge>}
{c.allow_member_self_entry && <span className="font-mono text-[10px] text-amber-600">self-entry on</span>}
{c.inbound_enabled && <span className="font-mono text-[10px] text-primary">inbound on</span>}
</div>
<p className="font-mono text-[10px] text-muted-foreground mt-1">
{c.base_url} · pinned {c.pinned_host} · auth {c.secret_ref ? 'secret' : 'none'}
@@ -343,6 +378,9 @@ export function DoorManageClient({
<Button size="sm" variant="outline" onClick={() => onToggleSelfEntry(c)}>
{c.allow_member_self_entry ? 'Self-entry off' : 'Self-entry on'}
</Button>
<Button size="sm" variant="outline" onClick={() => setInboundFor(v => (v === c.id ? null : c.id))}>
{inboundFor === c.id ? 'Hide inbound' : 'Inbound'}
</Button>
<Button size="sm" variant="outline" onClick={() => onDelete(c)}>Delete</Button>
</div>
</div>
@@ -359,6 +397,45 @@ export function DoorManageClient({
</div>
)}
{inboundFor === c.id && (
<div className="rounded border border-border bg-background p-3 space-y-3">
<div className="text-xs text-muted-foreground space-y-1">
<p className="font-sans">
Inbound ingest pulls real entry/denied events into the access log and
matches each presented card to a member. Two transports, one switch:
the once-a-minute poll reads a HeatSync controller&rsquo;s log, and the
webhook accepts events POSTed by any controller or relay. Turn it on per
connection after choosing a webhook secret.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<select
className={input}
value={c.inbound_secret_ref ?? ''}
onChange={e => onSetInboundSecret(c, e.target.value)}
>
<option value="">No webhook secret</option>
{secrets.map(s => <option key={s.id} value={s.id}>Secret: {s.title}</option>)}
</select>
<Button size="sm" variant="outline" onClick={() => onToggleInbound(c)}>
{c.inbound_enabled ? 'Turn inbound off' : 'Turn inbound on'}
</Button>
</div>
<div className="rounded border border-border bg-card p-2 space-y-2">
<p className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Webhook URL</p>
<div className="flex items-center gap-2">
<code className="font-mono text-[10px] text-foreground break-all min-w-0">{webhookUrl(c.id)}</code>
<Button size="sm" variant="outline" className="shrink-0" onClick={() => copyWebhookUrl(c.id)}>Copy</Button>
</div>
<p className="font-mono text-[10px] text-muted-foreground">
POST with header <code>Authorization: Bearer &lt;your webhook secret&gt;</code> and a JSON
body <code>{'{ "events": [{ "id": "<stable-id>", "card_uid": "<hex>", "result": "granted" }] }'}</code>.
The <code>id</code> dedupes retries; <code>card_uid</code> or <code>card_number</code> identifies the card.
</p>
</div>
</div>
)}
{canOperate && cardsFor === c.id && (
<div className="rounded border border-border bg-background p-3">
{cardsBusy && cards.length === 0 ? (
+53
View File
@@ -0,0 +1,53 @@
'use client'
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { useConfirm } from '@/components/ui/confirm'
import { invokeApiButton } from '@/lib/actions'
type InvokeButton = { id: string; label: string; group: string; confirm: boolean }
export function ApiButtonsInvoke({ buttons }: { buttons: InvokeButton[] }) {
const confirm = useConfirm()
const [busy, setBusy] = useState<string | null>(null)
const groups = new Map<string, InvokeButton[]>()
for (const b of buttons) {
if (!groups.has(b.group)) groups.set(b.group, [])
groups.get(b.group)!.push(b)
}
async function press(b: InvokeButton) {
if (b.confirm) {
const ok = await confirm({
title: `${b.label}?`,
description: 'This fires a configured action. Continue?',
confirmText: b.label,
})
if (!ok) return
}
setBusy(b.id)
const res = await invokeApiButton({ buttonId: b.id })
setBusy(null)
if ('error' in res && res.error) return toast.error(res.error)
toast.success(`${b.label} sent`)
}
return (
<div className="space-y-4">
{Array.from(groups.entries()).map(([group, items]) => (
<div key={group}>
<p className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground/70 mb-2">{group}</p>
<div className="flex flex-wrap gap-2">
{items.map(b => (
<Button key={b.id} size="sm" disabled={busy === b.id} onClick={() => press(b)}>
{b.label}
</Button>
))}
</div>
</div>
))}
</div>
)
}
+16 -3
View File
@@ -4,8 +4,9 @@ import { PageHeader, PageTitle } from '@/components/ui/page-title'
import { Badge } from '@/components/ui/badge'
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
import { DoorClosed } from 'lucide-react'
import { listSelfEntryDoors, getMyCards, listMyDoorActivity } from '@/lib/actions'
import { listSelfEntryDoors, getMyCards, listMyDoorActivity, listInvokableButtons } from '@/lib/actions'
import { DoorSelfEntry } from '../dashboard/door-self-entry'
import { ApiButtonsInvoke } from './api-buttons-invoke'
export const dynamic = 'force-dynamic'
@@ -27,16 +28,18 @@ export default async function DoorsPage() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const [doorsRes, cardsRes, activityRes] = await Promise.all([
const [doorsRes, cardsRes, activityRes, buttonsRes] = await Promise.all([
listSelfEntryDoors(),
getMyCards(),
listMyDoorActivity(),
listInvokableButtons(),
])
const doors = ('data' in doorsRes ? doorsRes.data : []) as { id: string; name: string }[]
const cards = ('data' in cardsRes ? cardsRes.data : []) as Card[]
const activity = ('data' in activityRes ? activityRes.data : []) as Activity[]
const buttons = ('data' in buttonsRes ? buttonsRes.data : []) as { id: string; label: string; group: string; confirm: boolean }[]
const nothing = doors.length === 0 && cards.length === 0 && activity.length === 0
const nothing = doors.length === 0 && cards.length === 0 && activity.length === 0 && buttons.length === 0
return (
<>
@@ -69,6 +72,16 @@ export default async function DoorsPage() {
</section>
)}
{buttons.length > 0 && (
<section>
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-3">Actions</h2>
<ApiButtonsInvoke buttons={buttons} />
<p className="font-mono text-[10px] text-muted-foreground mt-2">
Each action fires a configured request. Every press is logged.
</p>
</section>
)}
{cards.length > 0 && (
<section>
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-3">My access cards</h2>
+80
View File
@@ -0,0 +1,80 @@
// Door inbound-log poll. Hit once a minute by the droplet's crontab (see
// docs/DEPLOYMENT.md) to pull access events from each native-HeatSync
// connection that has opted into inbound ingest. Unauthenticated by session
// (cron has none); proxy.ts whitelists this exact path and trust is the
// CRON_SECRET shared secret, compared in constant time.
//
// Each connection's `?z` log is read through the SAME hardened executor as the
// outbound actions (SSRF host pin, no redirects, time/body caps, secret
// redaction). Parsed events are resolved to members and idempotently inserted
// (dedupe_key + ON-CONFLICT no-op), so overlapping runs and unchanged re-polls
// are safe. Generic controllers ingest via the per-connection webhook instead;
// only native_heatsync has a characterized log format to parse.
import { NextRequest, NextResponse } from 'next/server'
import { timingSafeEqual } from 'node:crypto'
import { createAdminClient } from '@/lib/supabase/admin'
import { pollConnectionLog, ingestEvents, type IngestConn } from '@/lib/door/ingest'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
// Bounded work per run: cap the connections polled so a misconfigured fleet
// cannot make one cron tick run unbounded. A single hackerspace has one or two
// doors, so this is generous.
const MAX_CONNECTIONS = 50
function authorized(req: NextRequest): boolean {
const expected = process.env.CRON_SECRET
if (!expected) return false
const header = req.headers.get('authorization') ?? ''
const provided = header.startsWith('Bearer ') ? header.slice(7) : header
const a = Buffer.from(provided)
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}
export async function POST(req: NextRequest) {
if (!process.env.CRON_SECRET) {
return NextResponse.json({ error: 'CRON_SECRET not configured' }, { status: 503 })
}
if (!authorized(req)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const admin = createAdminClient()
const { data: conns, error } = await admin
.from('door_connections')
.select('id, space_id, adapter, base_url, pinned_host, auth_param, secret_ref, verbs, is_enabled')
.eq('is_enabled', true)
.eq('inbound_enabled', true)
.eq('adapter', 'native_heatsync')
.order('created_at', { ascending: true })
.limit(MAX_CONNECTIONS)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
let polled = 0
let inserted = 0
let resolved = 0
let failed = 0
for (const c of (conns ?? []) as unknown as IngestConn[]) {
polled++
// Isolate each connection: a single misbehaving controller (the body is
// untrusted, plaintext HTTP) must not 500 the whole run and starve the rest.
try {
const poll = await pollConnectionLog(admin, c)
if (!poll.ok) {
failed++
console.error(`[door-ingest] connection ${c.id}: ${poll.detail}`)
continue
}
const res = await ingestEvents(admin, c.space_id, c.id, poll.events)
inserted += res.inserted
resolved += res.resolved
} catch (e) {
failed++
console.error(`[door-ingest] connection ${c.id} threw:`, e instanceof Error ? e.message : e)
}
}
return NextResponse.json({ ok: true, polled, inserted, resolved, failed })
}
@@ -0,0 +1,89 @@
// Per-connection inbound door-event webhook. A controller or a relay (e.g. a
// small agent on the space's LAN that reads a non-HeatSync controller, or any
// system that can POST) pushes normalized access events here. Unauthenticated
// by session (the caller has none); proxy.ts whitelists the /api/door/inbound
// prefix and trust is the per-connection bearer secret in the AES vault,
// compared in constant time. Every DB write is post-auth.
//
// This is the reliable inbound transport: each event carries a caller-supplied
// stable id, so retries are idempotent (dedupe_key) and there is none of the
// ring-buffer ambiguity of the HeatSync `?z` poll. Events are resolved to
// members and inserted through the same shared ingest core as the poll.
import { NextRequest, NextResponse } from 'next/server'
import { timingSafeEqual } from 'node:crypto'
import { createAdminClient } from '@/lib/supabase/admin'
import { resolveDoorSecret } from '@/lib/door/secret'
import { ingestEvents } from '@/lib/door/ingest'
import { normalizeWebhookEvents } from '@/lib/door-log-logic'
import { doorWebhookPayloadSchema } from '@/lib/validations'
import { checkRateLimit } from '@/lib/security'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
function bearerMatches(req: NextRequest, expected: string): boolean {
const header = req.headers.get('authorization') ?? ''
const provided = header.startsWith('Bearer ') ? header.slice(7) : header
const a = Buffer.from(provided)
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ connection: string }> },
) {
const { connection: connectionId } = await params
// Generic 404 for a malformed id so the endpoint does not double as a
// uuid-shape oracle.
if (!UUID_RE.test(connectionId)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const admin = createAdminClient()
const { data: conn } = await admin
.from('door_connections')
.select('id, space_id, inbound_enabled, inbound_secret_ref')
.eq('id', connectionId)
.maybeSingle()
// Do not reveal whether the connection exists vs. has inbound disabled vs.
// has no secret: an unauthenticated caller gets the same 401 for all of
// "can't authenticate this request".
const secret = conn && conn.inbound_enabled
? await resolveDoorSecret(admin, conn.space_id as string, (conn.inbound_secret_ref as string | null) ?? null)
: null
if (!secret || !bearerMatches(req, secret)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const rl = checkRateLimit(`door-inbound:${connectionId}`, 120, 60_000)
if (!rl.allowed) {
return NextResponse.json({ error: 'Rate limited' }, { status: 429 })
}
// Bound the body before parsing it into memory. The schema caps at 100
// events, but req.json() reads the whole body first, so reject an oversized
// declared length up front (64KB is far more than 100 small events need).
const declaredLen = Number(req.headers.get('content-length') ?? '0')
if (declaredLen > 65536) {
return NextResponse.json({ error: 'Payload too large' }, { status: 413 })
}
let body: unknown
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const parsed = doorWebhookPayloadSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
}
const events = normalizeWebhookEvents(parsed.data.events)
const res = await ingestEvents(admin, conn!.space_id as string, conn!.id as string, events)
return NextResponse.json({ received: true, ...res })
}