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:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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’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 <your webhook secret></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 ? (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
Reference in New Issue
Block a user