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
+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>