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
+6 -2
View File
@@ -418,6 +418,8 @@ Default channels created by trigger on space INSERT: `general`, `announcements`,
| 051 | `notifications.read_at timestamptz` + partial idx `idx_notifications_unread (space_id, member_id) WHERE read_at IS NULL`. In-app inbox read state on `/me`; unread = `read_at IS NULL`. Mark-read = service-client action scoped to caller's member_id (no RLS change). Email-muted (`skipped`) rows still show in-app |
| 052 | HOTFIX: drop `notification_preferences_space_id_fkey`. FKs to BOTH space_members and spaces made the table a PostgREST junction -> `space_members.select('*, spaces(*)')` ambiguous (PGRST201) -> all logged-in users bounced to /signup. One FK left (member_id); space reachable + cascades via space_members. NEVER add a spaces FK to a space_members-referencing table. Guarded by integration/auth-embed.test.ts |
| 050 | CHECK `dues_payment_methods_url_https` (`url ~* '^https://'`): the url renders as a member-clickable href and RLS lets admins write directly past the app Zod check, so the DB enforces https (no stored `javascript:`/`http:` XSS). Member-read action also re-filters with `isSafeDuesUrl`. Idempotent DO-block |
| 054 | Door epic P5 (universal API-call UI builder). New perm `apicall.invoke` (Access) seeded in `seed_default_role_permissions` (CREATE OR REPLACE) + backfilled for board. `api_buttons` (label, button_group, sort_order, method GET/POST/PUT/PATCH/DELETE, base_url, pinned_host, url_template, headers jsonb, body_template, auth_mode/auth_param, secret_ref -> vault, required_permission default apicall.invoke, confirm, is_enabled). CRUD = door.manage; per-button required_permission gates invoke (via service-client action). `api_call_log` (append-only press audit, redacted; SELECT door.manage; service-client-only writes). `touch_updated_at` on api_buttons |
| 053 | Door epic P4 (inbound ingest), additive cols. `door_access_log.dedupe_key text` + partial-unique `idx_door_access_log_dedupe (connection_id, dedupe_key) WHERE dedupe_key IS NOT NULL` = per-event idempotency (re-poll/webhook-retry safe via ON CONFLICT DO NOTHING; action rows leave it NULL). `door_connections.inbound_enabled bool default false` (gates webhook endpoint) + `door_connections.inbound_secret_ref -> secrets` (inbound bearer secret, vault; distinct from outbound `secret_ref`). 2 FKs to secrets but surrogate PK = not a junction (safe re 052); nothing embeds secrets. No new permission code; service-client writers only |
---
@@ -448,9 +450,11 @@ Default channels created by trigger on space INSERT: `general`, `announcements`,
| `equipment` | space_id, name, description, location, status (available/maintenance/retired), required_certification_id -> certifications, asset_tag, is_active | Tool/equipment registry. SELECT = `equipment.manage` (all) or member (`is_active`); writes = `equipment.manage` |
| `equipment_reservations` | equipment_id, space_id, member_id, starts_at, ends_at, status (reserved/cancelled/completed), notes | Time-window reservation. SELECT = `equipment.manage` (all) or member (own); UPDATE = `equipment.manage`; NO INSERT/DELETE policy (reserve/cancel via validated service-client action; no-overlap + required-cert enforced there) |
| `member_cards` | space_id, member_id, card_uid (credential), card_type rfid/nfc, label, is_active | RFID/NFC card association. SELECT/writes = `door.manage` only; member self-view is masked (server action, never raw UID) |
| `door_connections` | space_id, name, adapter (native_heatsync/generic_http), base_url, pinned_host (SSRF pin), auth_mode, secret_ref -> secrets, verbs jsonb, allow_member_self_entry, is_enabled | Door controller integration. CRUD = `door.manage`. Password lives in the encrypted secrets vault, never here |
| `door_access_log` | space_id, connection_id, actor_member_id, target_member_id, action, success, detail (redacted), occurred_at | Append-only door audit. SELECT = `door.manage`/`door.operate`; NO write policy (validated service-client executor only; immutable) |
| `door_connections` | space_id, name, adapter (native_heatsync/generic_http), base_url, pinned_host (SSRF pin), auth_mode, secret_ref -> secrets, verbs jsonb, allow_member_self_entry, is_enabled, inbound_enabled, inbound_secret_ref -> secrets | Door controller integration. CRUD = `door.manage`. Outbound password + inbound webhook secret both live in the encrypted secrets vault, never here |
| `door_access_log` | space_id, connection_id, actor_member_id, target_member_id, action, success, detail (redacted), dedupe_key (per-event idempotency, partial-unique w/ connection_id), occurred_at | Append-only door audit (app actions + ingested poll/webhook events). SELECT = `door.manage`/`door.operate`; NO write policy (validated service-client executor only; immutable) |
| `door_card_slots` | space_id, connection_id -> door_connections, card_id -> member_cards, slot int, created_by | Per-connection integer-slot allocation map. UNIQUE(connection_id,slot) + UNIQUE(connection_id,card_id). SELECT = `door.manage`/`door.operate`; NO write policy (service-client executor only, in lockstep with the controller) |
| `api_buttons` | space_id, label, button_group, sort_order, method, base_url, pinned_host (SSRF pin), url_template, headers jsonb, body_template, auth_mode/auth_param, secret_ref -> secrets, required_permission, confirm, is_enabled | API-call builder button defs (door epic P5). CRUD = `door.manage`; each button's `required_permission` (default `apicall.invoke`) gates pressing it via the service-client invoke action. Secret in the vault, never here. Fired through the shared hardened egress (host pin) |
| `api_call_log` | space_id, button_id -> api_buttons, actor_member_id, action, success, detail (redacted), occurred_at | Append-only press audit. SELECT = `door.manage`; NO write policy (validated service-client invoker only; immutable) |
| `space_visits` | space_id, member_id, checked_in_at, checked_out_at, is_host, check_in_note, check_out_note | Presence/attendance. Open (checked_out_at NULL) = present; partial UNIQUE one-open-per-member. SELECT = any space member; NO write policy (self-resolved service-client actions only; immutable). Org-wide attendance view = all members |
| `member_billing` | space_id, member_id (UNIQUE per space), stripe_customer_id, stripe_subscription_id, subscription_status, current_period_end | Stripe dues link. SELECT = admin/board/treasurer; NO client write policy (webhook/service-client only). Member self-view via a validated action |
| `stripe_webhook_events` | event_id PK, space_id, type, received_at | Webhook idempotency ledger (dedupe on Stripe's stable event id). Service-client only; no RLS policy |
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest'
import { buildApiRequest } from '@/lib/api-call-logic'
describe('buildApiRequest', () => {
it('injects a query secret with the right separator', () => {
const a = buildApiRequest({ baseUrl: 'https://h/x', method: 'GET', authMode: 'query', authParam: 'key', secret: 's3cret' })
expect(a.url).toBe('https://h/x?key=s3cret')
const b = buildApiRequest({ baseUrl: 'https://h/x?a=1', method: 'GET', authMode: 'query', authParam: 'key', secret: 's3cret' })
expect(b.url).toBe('https://h/x?a=1&key=s3cret')
})
it('url-encodes the query secret', () => {
const r = buildApiRequest({ baseUrl: 'https://h/x', method: 'GET', authMode: 'query', authParam: 'k', secret: 'a b&c' })
expect(r.url).toBe('https://h/x?k=a%20b%26c')
})
it('injects a bearer secret as an Authorization header', () => {
const r = buildApiRequest({ baseUrl: 'https://h', method: 'POST', authMode: 'bearer', secret: 'tok' })
expect(r.headers['authorization']).toBe('Bearer tok')
expect(r.url).toBe('https://h')
})
it('injects a custom-header secret under the named (lower-cased) header', () => {
const r = buildApiRequest({ baseUrl: 'https://h', method: 'POST', authMode: 'header', authParam: 'X-Api-Key', secret: 'k' })
expect(r.headers['x-api-key']).toBe('k')
})
it('injects nothing for auth_mode none, or when secret is absent', () => {
const none = buildApiRequest({ baseUrl: 'https://h', method: 'GET', authMode: 'none', secret: 'ignored-no-param' })
expect(none.url).toBe('https://h')
expect(Object.keys(none.headers)).not.toContain('authorization')
const noSecret = buildApiRequest({ baseUrl: 'https://h', method: 'GET', authMode: 'bearer', secret: null })
expect(noSecret.headers['authorization']).toBeUndefined()
})
it('drops a caller-supplied host header (executor forces the pinned host)', () => {
const r = buildApiRequest({ baseUrl: 'https://h', method: 'POST', authMode: 'none', headers: { Host: 'evil.example', 'X-Other': 'ok' } })
expect(r.headers['host']).toBeUndefined()
expect(r.headers['x-other']).toBe('ok')
})
it('appends url_template to the base url', () => {
const r = buildApiRequest({ baseUrl: 'https://h', urlTemplate: '/toggle?id=5', method: 'GET', authMode: 'none' })
expect(r.url).toBe('https://h/toggle?id=5')
})
it('carries a body only for body-bearing verbs', () => {
expect(buildApiRequest({ baseUrl: 'https://h', method: 'GET', authMode: 'none', body: '{"a":1}' }).body).toBeNull()
expect(buildApiRequest({ baseUrl: 'https://h', method: 'POST', authMode: 'none', body: '{"a":1}' }).body).toBe('{"a":1}')
expect(buildApiRequest({ baseUrl: 'https://h', method: 'delete', authMode: 'none', body: 'x' }).body).toBe('x')
})
it('uppercases the method', () => {
expect(buildApiRequest({ baseUrl: 'https://h', method: 'post', authMode: 'none' }).method).toBe('POST')
})
})
Binary file not shown.
@@ -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 })
}
+2 -1
View File
@@ -15,7 +15,7 @@ import {
MessageSquare, Users, CreditCard, BookUser, Download, LogOut,
Vote, ShieldAlert, ScrollText, LineChart, UserSearch,
MessagesSquare, SlidersHorizontal, ClipboardList, FileText, Award, BadgeCheck, GraduationCap, Hammer,
ChevronDown, DoorClosed, CalendarCheck,
ChevronDown, DoorClosed, CalendarCheck, Zap,
} from 'lucide-react'
interface NavLinkProps {
@@ -230,6 +230,7 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa
{canClasses && <NavLink href="/classes/manage" label="Manage classes" icon={GraduationCap} active={isActive('/classes/manage')} onClick={onNav} />}
{canEquip && <NavLink href="/equipment/manage" label="Manage equipment" icon={Hammer} active={isActive('/equipment/manage')} onClick={onNav} />}
{canDoor && <NavLink href="/door/manage" label="Door access" icon={DoorClosed} active={isActive('/door/manage')} onClick={onNav} />}
{canDoor && <NavLink href="/door/buttons" label="API buttons" icon={Zap} active={isActive('/door/buttons')} onClick={onNav} />}
{isAdmin && <NavLink href="/import" label="Import / Sync" icon={Download} active={isActive('/import')} onClick={onNav} />}
{isAdmin && <NavLink href="/settings" label="Settings" icon={Settings2} active={isActive('/settings')} onClick={onNav} />}
</NavSection>
+10
View File
@@ -939,6 +939,16 @@ Phase 3 live actions (all `door.operate`, rate-limited, every attempt writes one
Member self-entry (no permission code; any active member): `selfEntry({connectionId})` triggers a momentary OPEN only, allowed only when the connection is `is_enabled` AND `allow_member_self_entry` AND the caller has at least one active `member_card` on file (the locked eligibility rule: a `door_card_slots` row is NOT required). Membership and cards are resolved server-side, so a member can only ever open a door for themselves; never unlock/lock/grant/revoke, never anonymous. Strict per-member rate limit (5/min), one redacted `door_access_log` row `action='self_entry'` (`target_member_id` = self), through the same hardened executor. `listSelfEntryDoors()` returns the enabled self-entry connections for the caller's space, but only when the caller has an active card (otherwise empty), so the surface is hidden for ineligible members. `listMyDoorActivity()` returns the caller's own `door_access_log` rows (actor or target; service client after `requireMember`; `detail` is already secret-redacted at write time). Surfaced as a "Door access" panel on the dashboard and on the member `/doors` page (self-entry + masked own cards via `getMyCards` + recent personal activity).
Phase 4 inbound access-log ingest (migration 053): real entry/denied events are pulled INTO `door_access_log` and matched to a member, via two transports sharing one ingest core (`lib/door/ingest.ts`, service-client writer; the audit log stays no-client-write). Config is folded into `updateDoorConnection`/`createDoorConnection`, which gained `inbound_enabled` (gates both transports, off by default) and `inbound_secret_ref` (a distinct vault secret from the outbound `secret_ref`; the create/update path verifies every referenced secret belongs to the caller's space). Both transports resolve a presented card to a member by numeric hex/decimal equality (`cardUidsEquivalent`) and dedupe-insert: `door_access_log.dedupe_key` + the partial-unique `(connection_id, dedupe_key)` index make a re-poll / webhook retry a no-op; action rows leave it NULL.
- `POST /api/cron/door-ingest` (nodejs, force-dynamic; constant-time `CRON_SECRET` bearer, 503 if unset / 401 on mismatch; `proxy.ts`-whitelisted). Polls each `is_enabled` + `inbound_enabled` + `adapter='native_heatsync'` connection's `?z` log through the hardened executor (`fullBody`, 32KB cap), parses it with the pure firmware-characterized `lib/door-log-logic.ts` (`parseHeatSyncLog`: `<pre>` ring-buffer dump, G+g/D+d card-number reconstruction via the 32767 divisor, best-effort H:M:E, raw R/r reads intentionally skipped), and ingests. Bounded to 50 connections/run. Returns `{ ok, polled, inserted, resolved, failed }`. Best-effort by nature (the HeatSync ring buffer has no per-entry sequence id); idempotent across overlapping/missed minutes.
- `POST /api/door/inbound/[connection]` (nodejs, force-dynamic; session-exempt, `proxy.ts`-whitelists the `/api/door/inbound` prefix). Authenticated by the connection's per-connection bearer secret (`inbound_secret_ref` from the vault, constant-time compared; the same generic 401 for not-found / inbound-disabled / wrong-secret so it is not an existence oracle). Body-size guarded (413 over 64KB), per-connection rate-limited (120/min), Zod-validated (`doorWebhookPayloadSchema`: `{ events: [{ id, card_uid?|card_number?, result?, occurred_at? }] }`, ≤100 events; `id` is the dedupe token). `normalizeWebhookEvents` → ingest core. A supplied `occurred_at` is clamped to not-in-the-future (60s skew) so a relay cannot reorder the operator's log. Returns `{ received, inserted, resolved, skipped }`. This is the reliable inbound transport for any (incl. generic/non-HeatSync) controller.
### Universal API-call buttons: `api-buttons.ts` (migration 054, door epic P5)
Admin-defined buttons that fire a configured HTTP call through the same hardened egress as door control. Managing buttons reuses `door.manage` (the catalog scopes it to "buttons"); pressing a button is gated by its own `required_permission` (default the new generic `apicall.invoke`; a door-flavored button can require `door.operate`). One new permission code `apicall.invoke` (group Access), additive + backfilled for board like the door.* codes.
- Manage (all `door.manage`, RLS-gated): `createApiButton`/`updateApiButton`/`deleteApiButton`/`listApiButtons`. A button stores label, button_group, sort_order, method (GET/POST/PUT/PATCH/DELETE), base_url (http(s)), pinned_host (SSRF pin), url_template, headers (jsonb), body_template, auth_mode (none/query/header/bearer) + auth_param, secret_ref (vault; the create/update path verifies it belongs to the space), required_permission (validated against the catalog), confirm, is_enabled. `listApiCallLog()` is the press audit (`door.manage`).
- Member-facing (any member, gated per button): `listInvokableButtons()` is a service-client read (members have no RLS read on the defs) returning ONLY presentational fields (id/label/group/confirm) for enabled buttons whose `required_permission` the caller holds. `invokeApiButton({ buttonId })` is rate-limited first (30/min), loads the def service-side scoped to the member's space, checks the per-row permission (denials audited), decrypts the secret server-side and fires via `callApi` (full verbs + headers + body; the secret is injected per auth_mode and never returned), writing one redacted `api_call_log` row. Returns a generic error on failure (the redacted detail goes only to the log).
- Egress: `lib/door/executor.ts` now shares one internal `egress` (SSRF host pin, resolve-once-connect-by-IP, no redirects, time/body caps, secret redaction). `callDoor` (GET) is unchanged; `callApi` adds method/headers/body with the host header forced to the pin. Pure request assembly + secret placement is unit-tested in `lib/api-call-logic.ts` (`buildApiRequest`). The metadata/link-local block is absolute even for an admin-configured button. Builder UI at `/door/buttons` (door-template preset); members press from `/doors`.
### Presence & attendance: `presence.ts` (migration 038)
No permission code; any active member. All writes are self-only — the member is resolved server-side, never taken from input — and go through validated service-client actions (`space_visits` has no client write policy). Pure logic in `lib/presence-logic.ts` (`presenceStatus`, `hostEligibility`, `summarizePresence`) is unit-tested.
- `checkIn({asHost?, note?})` — opens a visit. Enforces one open visit per member (partial unique index); if an existing open visit is stale (`presenceStatus` past `PRESENCE_MAX_OPEN_HOURS`, 18h) it is auto-closed first (handles forgotten check-outs without a cron), and a fresh existing open visit is rejected ("already checked in"). `asHost` runs `hostEligibility`: if `spaces.host_requires_card` (default true) the caller must have an active `member_card` on file (count checked via the service client); a space may flip the toggle off to let anyone self-mark host. Writes an `activity_log` row.
+44 -2
View File
@@ -616,8 +616,50 @@ row; surfaced as a "Door access" panel on the dashboard and on the
member `/doors` page (self-entry + masked own cards via `getMyCards` +
the member's own recent activity via `listMyDoorActivity`, a service-client
read after `requireMember` filtered to that member), hidden entirely for
ineligible members. Phases 4-5 add inbound log ingest and the
universal API-call UI builder. No anonymous path. The executor is the
ineligible members. Phase 4 (053) adds inbound access-log ingest: real
entry/denied events are pulled INTO `door_access_log` and matched to a
member, via two transports that share one ingest core
(`lib/door/ingest.ts`). Poll: `POST /api/cron/door-ingest` (constant-time
`CRON_SECRET`, `proxy.ts`-whitelisted, once-a-minute crontab) reads each
`inbound_enabled` native-HeatSync connection's `?z` log through the same
hardened executor (`fullBody`, capped) and parses it with the pure,
firmware-characterized `lib/door-log-logic.ts` (`<pre>` ring-buffer dump,
G+g/D+d card-number reconstruction with the 32767 divisor, best-effort
H:M:E). Webhook: `POST /api/door/inbound/[connection]` (also
`proxy.ts`-whitelisted, session-exempt) accepts pushed event JSON from any
controller/relay, authenticated by a per-connection bearer secret
(`inbound_secret_ref`, a distinct vault secret from the outbound password,
constant-time compared); body-size guarded, rate-limited, Zod-validated
(≤100 events). Both resolve a presented card to a member via numeric
hex/decimal equality (`cardUidsEquivalent`) and dedupe-insert through the
service client: `door_access_log.dedupe_key` + the partial-unique
`(connection_id, dedupe_key)` index make a re-poll or webhook retry a
no-op, while NULL-keyed action rows stay unconstrained. The poll is
inherently best-effort (the HeatSync ring buffer has no per-entry sequence
id); the webhook (explicit event ids) is the reliable transport. The audit
log stays no-client-write/immutable; a webhook-supplied `occurred_at` is
clamped to not-in-the-future so a relay cannot reorder the operator's log.
Phase 5 (054) is the universal API-call UI builder: admins (door.manage,
which the catalog already scopes to "buttons") define `api_buttons` (label,
group, method GET/POST/PUT/PATCH/DELETE, base_url, pinned_host, url_template,
headers, body_template, auth_mode/auth_param, vault `secret_ref`, confirm,
and a per-button `required_permission`). A member presses only buttons whose
`required_permission` they hold (one new generic code `apicall.invoke`, the
default; a door-flavored button can require `door.operate`). `listInvokable
Buttons` is a service-client read returning only presentational fields (never
url/headers/secret); `invokeApiButton` is rate-limited-first, loads the
definition service-side scoped to the member's space, checks the per-row
permission (denials audited), decrypts the secret server-side and fires
through `callApi`. The egress is now shared: the SSRF+resolve+connect-by-IP+
no-redirect+caps+redact core is factored into one internal `egress`, with
`callDoor` (GET, query secret) and `callApi` (full verbs + headers + body +
secret injected per auth_mode, host header forced to the pin) on top; pure
request assembly + secret placement is unit-tested in `lib/api-call-logic.ts`
(`buildApiRequest`). The metadata/link-local block is absolute even for an
admin-configured button. `api_call_log` is the append-only, redacted,
service-client-only press audit (SELECT = door.manage). The builder lives at
`/door/buttons` (with a door-template preset); members press from `/doors`.
No anonymous path. The executor is the
single hardened egress: it resolves the controller host once via
`dns.lookup`, rejects if any resolved IP is loopback / unspecified /
link-local / metadata (IPv4-mapped IPv6 normalized; RFC1918 / LAN / ULA
+2
View File
@@ -733,6 +733,8 @@ migration is mirrored as a section in it. Tables/columns added:
| 051 | `notifications.read_at timestamptz` (+ partial index `idx_notifications_unread` on `(space_id, member_id) WHERE read_at IS NULL`): the outbox doubles as the member's in-app inbox on `/me`; `read_at IS NULL` = unread. The mark-read action is a validated service-client write scoped to the caller's own `member_id` (no RLS change; notifications stays no-client-write). The in-app inbox is the always-on channel: a notification whose email was muted (`skipped`) still appears here. No new permission code |
| 052 | HOTFIX (production outage): drop `notification_preferences_space_id_fkey`. The table (048) had FKs to BOTH `space_members` and `spaces`, which PostgREST reads as a junction, making the auth layout's `space_members.select('*, spaces(*)')` embed ambiguous (PGRST201) -- membership resolved to null and every logged-in user was bounced to `/signup`. Dropping the redundant `space_id -> spaces` FK leaves one FK so the table is no longer a junction; space is still reachable via `member_id -> space_members`, and space deletion still cascades through it. **Never add a `spaces` FK to a `space_members`-referencing table** (reintroduces the junction). Guarded by `integration/auth-embed.test.ts`. No new table/column |
| 050 | `dues_payment_methods.url` gains a CHECK constraint (`dues_payment_methods_url_https`, `url ~* '^https://'`). The url is rendered to members as a clickable href; RLS lets an admin/board write the row directly via PostgREST, bypassing the app Zod check, so the DB enforces https as the source of truth (a `javascript:`/`http:` value can never be stored). The member-read action also re-validates with `isSafeDuesUrl` (defense-in-depth). Idempotent DO-block add-if-missing. No new table/column/policy |
| 054 | Door epic Phase 5 (universal API-call UI builder). New permission code `apicall.invoke` (group Access), seeded into the `seed_default_role_permissions` trigger fn (CREATE OR REPLACE) + backfilled for `board` on existing spaces (admin holds it implicitly), additive exactly like the door.* codes in 034. `api_buttons` (per-space button defs: label, button_group, sort_order, method GET/POST/PUT/PATCH/DELETE, base_url https?, pinned_host SSRF pin, url_template, headers jsonb, body_template, auth_mode/auth_param, secret_ref -> vault, required_permission default `apicall.invoke`, confirm, is_enabled). All CRUD = `door.manage` (the catalog already scopes door.manage to "buttons"); each button's `required_permission` gates pressing it (enforced by the service-client invoke action, not RLS, so members never need RLS read on the defs). `api_call_log` (append-only press audit, secrets redacted; SELECT = `door.manage`; NO client write policy, service-client invoker only, immutable). secret_ref is the only `secrets` FK so no junction shape. `touch_updated_at` on api_buttons |
| 053 | Door epic Phase 4 (inbound access-log ingest). Additive columns only. `door_access_log.dedupe_key text` + partial-unique `idx_door_access_log_dedupe (connection_id, dedupe_key) WHERE dedupe_key IS NOT NULL`: a per-event idempotency token so a re-poll of the same `?z` ring-buffer slot or a webhook retry cannot double-insert (insert path uses ON CONFLICT DO NOTHING); existing action rows leave it NULL and are unaffected. `door_connections.inbound_enabled boolean default false` (opt-in, gates the per-connection webhook endpoint) + `door_connections.inbound_secret_ref uuid -> secrets` (INBOUND bearer secret in the AES vault, distinct from `secret_ref` the outbound door password; loaded + constant-time-compared server-side, never returned). PGRST: door_connections now has two FKs to `secrets`, but its surrogate-`id` PK means it is NOT a junction shape (the 048 outage class), nothing embeds `secrets` via PostgREST, and the auth embed is untouched. No new permission code; cron + webhook writers are the validated service client (door_access_log stays no-client-write/immutable) |
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.
+10
View File
@@ -64,6 +64,16 @@ Transactional email is an outbox drained by `POST /api/cron/notifications`. Add
It hits the app locally (bypassing Caddy), self-throttles under Resend's rate limit, and is idempotent, so a missed or overlapping minute is harmless. Migration `041_notifications.sql` is applied automatically by `deploy.sh` like every other `scripts/0*.sql`; no manual database step.
### Door inbound-log poll cron (optional, door epic Phase 4)
If a space runs a native-HeatSync controller and turns on inbound ingest (per connection, on `/door/manage`), the once-a-minute poll pulls its `?z` log into the access log and matches each card to a member. It reuses the same `CRON_SECRET`; add a second crontab entry:
```
* * * * * curl -fsS -m 30 -X POST http://127.0.0.1:3000/api/cron/door-ingest -H "Authorization: Bearer $CRON_SECRET" >/dev/null 2>&1
```
No new env var. The route returns 503 if `CRON_SECRET` is unset and 401 on mismatch, polls only connections with `inbound_enabled` + `adapter='native_heatsync'`, and is idempotent (overlapping or missed minutes are harmless; events dedupe on `(connection_id, dedupe_key)`). Generic (non-HeatSync) controllers do NOT use this poll; they push to the per-connection webhook instead (`POST /api/door/inbound/[connection]`, authenticated by the connection's bearer secret from the vault — no env var, no crontab; the webhook URL is shown on `/door/manage` when inbound is enabled). Migration `053_door_inbound_ingest.sql` applies automatically with the rest.
### Member email change (Supabase Auth config — required)
The self-serve "change my login email" feature (`/me` Profile tab) needs Supabase project configuration that is NOT in code. Until this is set, requesting an email change will send a link that does not land correctly.
+37
View File
@@ -28,6 +28,43 @@ Append-only. Newest entries on top. Keep each entry to one screen.
---
## 2026-05-20 (pass 64): Door epic Phase 5 (universal API-call UI builder) (LOCAL, NOT deployed)
Branch `main`. Completes the door epic. Design-first via AskUserQuestion; the user chose **full verbs** (GET/POST/PUT/PATCH/DELETE) + **per-button required permission**. Suite 624 unit (was 615; +9 buildApiRequest tests) / 50 integration (was 44; +6 api-buttons) / build green / lint clean.
**Shipped (local):**
- **Migration 054**: new permission code `apicall.invoke` (group Access) seeded into `seed_default_role_permissions` (CREATE OR REPLACE) + backfilled for board on existing spaces (additive, like door.* in 034). `api_buttons` (label, button_group, sort_order, method, base_url, pinned_host, url_template, headers jsonb, body_template, auth_mode/auth_param, secret_ref -> vault, required_permission default apicall.invoke, confirm, is_enabled) + `api_call_log` (append-only press audit, redacted, service-client-only). All button CRUD = `door.manage` (the catalog already scopes door.manage to "buttons"); per-button required_permission gates pressing (enforced by the invoke action, members have no RLS read on defs). schema.sql + types + DATABASE_SCHEMA + DB_SCHEMA_MAP + catalog same-change.
- **Shared egress**: refactored `lib/door/executor.ts` so the SSRF+resolve+connect-by-IP+no-redirect+caps+redact core is one internal `egress`; `callDoor` (GET) is behavior-identical (guarded by the existing door tests), `callApi` adds full verbs + headers + body + per-auth_mode secret injection, host header forced to the pin. Pure request assembly in `lib/api-call-logic.ts` (`buildApiRequest`) + 9 unit tests.
- **Actions** `lib/actions/api-buttons.ts`: manage CRUD + `listApiCallLog` (door.manage); `listInvokableButtons` (curated, presentational-only) + `invokeApiButton` (rate-limit-first, per-row permission check, denials audited, secret server-side, redacted audit). Zod schemas (method enum, https? url, header count/length caps, required_permission refined against the catalog).
- **UI**: builder `/door/buttons` (grouped list, create form, door-template preset, secret + required_permission pickers, enable/delete, the call log) + sidebar "API buttons" link (door.manage). Member invoke: an "Actions" section on `/doors` (grouped buttons, confirm dialog).
- **Audit**: SSRF egress reused (host pin + metadata block absolute even for admin config; member never supplies url/headers/body, only a buttonId); tenant-isolated; secret space-scoped + never returned + redacted; all rendering escaped JSX; single secrets FK (PGRST safe, auth-embed green). **Fix:** moved the invoke rate-limit before any DB work so a member can't flood denial-audit rows.
**Deploy state.** NOT deployed; **batched with the pass-63 (P4) work** awaiting one deploy authorization. Migrations 053 + 054 applied to the LOCAL stack only. No new env var or cron for P5 (all in-app). Inert until an admin creates a button.
**Open / next.** Door epic is feature-complete (P1-P5). v1 limits (documented, not bugs): the builder supports create/enable/delete (full field edit = delete+recreate); buttons use static url/headers/body (no per-press placeholder substitution). Browser click-through of `/door/buttons` + the `/doors` Actions section still pending (auth-gated). Back to the main backlog after this: owner-gated spine validation, observability, etc.
---
## 2026-05-20 (pass 63): Door epic Phase 4 (inbound access-log ingest) (LOCAL, NOT deployed)
Branch `main`. Took backlog item 6. Design-first via AskUserQuestion; the user chose BOTH transports. Built poll + webhook sharing one ingest core. Suite 612 unit (was 594; +18 parser tests) / 44 integration (was 40; +4 door-ingest) / build green / lint clean.
**Web-verified first.** Refetched `zyphlar/Open_Access_Control_Ethernet.ino` and characterized the `?z` wire format (recorded in memory `integration-api-facts`): `<pre>`-wrapped `K: N` stream from a fixed 40-slot ring (no per-entry id), card number split across G+g/D+d with divisor 32767, emitted decimal vs hex-stored uid, H:M:E only at verbosity <2. Conclusion: poll is best-effort, webhook (explicit ids) is the reliable path.
**Shipped (local):**
- **Migration 053** (additive only): `door_access_log.dedupe_key` + partial-unique `(connection_id, dedupe_key)` (re-poll / webhook-retry no-op; action rows stay NULL/unconstrained); `door_connections.inbound_enabled` (opt-in, off) + `inbound_secret_ref -> secrets` (inbound bearer secret, distinct from the outbound password). schema.sql + types + DATABASE_SCHEMA + DB_SCHEMA_MAP same-change. PGRST: 2 secrets FKs but surrogate PK = not a junction (safe re 052); auth-embed test still green.
- **Pure parser** `lib/door-log-logic.ts` (`parseHeatSyncLog`, `normalizeWebhookEvents`, `cardUidsEquivalent` hex/decimal match) + 18 unit tests.
- **Ingest core** `lib/door/ingest.ts` (resolve card->member, dedupe-insert via service client). Extracted the shared `resolveDoorSecret` to `lib/door/secret.ts` (door.ts now imports it). Executor gained an additive `fullBody` option (32KB cap; existing callers unchanged at 4096/500).
- **Poll** `POST /api/cron/door-ingest` (CRON_SECRET, proxy-whitelisted, native_heatsync + inbound_enabled only, ≤50 conns/run). **Webhook** `POST /api/door/inbound/[connection]` (proxy-whitelisted prefix, per-connection bearer constant-time, generic 401 non-oracle, 64KB body guard, 120/min, Zod ≤100 events). Both feed the ingest core.
- **Admin UI**: per-connection "Inbound" panel on `/door/manage` (secret picker, on/off toggle that requires a secret first, copyable webhook URL + payload hint, "inbound on" badge).
- **Audit hardening** (3 passes): body-size guard before JSON parse; `occurred_at` clamped not-in-future so a relay can't reorder the log. **Fresh-pass real bug fixed:** the `?z` body is untrusted plaintext HTTP, and `Number('9'*400)===Infinity` then `BigInt(Infinity)` THROWS, which would 500 the whole poll and starve other connections; capped the parser value at 15 digits (firmware is int32) + bounded the G/g pairing window + wrapped each connection in the cron loop in try/catch. Verified: executor change behavior-identical for existing callers; inbound secret space-scoped on write + read; tenant isolation from the looked-up connection; ingested `detail` renders as escaped JSX (no XSS); audit log stays immutable from the client (integration-tested). Accepted trade-offs (documented): decrypt-flood needs an unguessable uuid; `resolved` count is mild info to the already-trusted relay; the content-length guard is best-effort behind the platform body limit.
**Deploy state.** NOT deployed; awaiting the per-request go (ASK-before-deploy). Migration 053 is applied to the LOCAL stack only. No new env var (reuses CRON_SECRET); the door-ingest crontab line is documented in DEPLOYMENT but optional (only spaces that enable inbound need it). Inert until an admin turns on `inbound_enabled` for a connection.
**Open / next.** Door epic **Phase 5** (universal API-call UI builder, `api_buttons`) is the remaining door work; it needs its own design pass (HTTP-method scope + per-button vs fixed invoke permission forks) and extends the executor to method+headers+body. Browser click-through of the `/door/manage` inbound panel still pending (auth-gated; can't curl). Minor deferred: a far-future-date relay can't reorder but a near-now spoof is accepted (trusted relay); `resolved` count is mild info to the trusted relay.
---
## 2026-05-20 (pass 62): Diversified surface audit + fixes (DEPLOYED)
Branch `main`. Audited surfaces I had NOT recently touched, via three parallel independent reviews: (1) anonymous/public + all `app/api/**` route handlers, (2) XSS / unsafe rendering across the whole app, (3) authorization + tenant-isolation in legacy server actions (tasks/projects/members/kb/secrets/contacts/comms/classes/equipment/door/presence/certs/invites/roles/permissions/onboarding/forms/governance). **No P0 on any surface.** Suite 594 / build green.
+107
View File
@@ -0,0 +1,107 @@
// Door epic Phase 5 (migration 054) against real Postgres. Proves the RLS the
// unit tests cannot: api_buttons CRUD is door.manage-only (members have no read,
// since a button's url/headers/secret_ref are operator config), api_call_log is
// immutable from the client (service-only writes), tenant isolation holds, and
// the new apicall.invoke permission is seeded/backfilled for board.
import { describe, it, expect, afterAll } from 'vitest'
import { dbReady, execService, execAsUser, rowsAsUser, rows, seedSpace, seedMember, dropSpace, uuid } from './db'
const d = describe.skipIf(!dbReady)
function seedButton(spaceId: string, requiredPerm = 'apicall.invoke'): string {
const id = uuid()
execService(
`insert into api_buttons (id, space_id, label, base_url, pinned_host, required_permission)
values ('${id}','${spaceId}','itest btn','https://device.lan/','device.lan','${requiredPerm}');`,
)
return id
}
d('api buttons (054)', () => {
const spaces: string[] = []
function space(): string {
const { spaceId } = seedSpace()
spaces.push(spaceId)
return spaceId
}
afterAll(() => spaces.forEach(dropSpace))
it('seeds apicall.invoke for board on a new space', () => {
const sid = space()
const board = seedMember(sid, { role: 'board' })
const has = rows<{ ok: boolean }>(
`select public.user_has_permission('${board.userId}','${sid}','apicall.invoke') as ok`,
)
expect(has[0].ok).toBe(true)
})
it('door.manage can insert + read buttons; a plain member cannot read', () => {
const sid = space()
const admin = seedMember(sid, { role: 'admin' })
const member = seedMember(sid, { role: 'member' })
const ins = execAsUser(
admin.userId,
`insert into api_buttons (space_id, label, base_url, pinned_host) values ('${sid}','front door','https://d.lan/','d.lan');`,
)
expect(ins.ok).toBe(true)
const adminSees = rowsAsUser(admin.userId, `select id from api_buttons where space_id='${sid}'`)
expect(adminSees.length).toBe(1)
// Members have no RLS read on definitions (they get a curated list via the
// service-client action instead).
const memberSees = rowsAsUser(member.userId, `select id from api_buttons where space_id='${sid}'`)
expect(memberSees).toHaveLength(0)
})
it('a plain member cannot insert a button; an admin cannot write into another space', () => {
const sidA = space()
const adminA = seedMember(sidA, { role: 'admin' })
const member = seedMember(sidA, { role: 'member' })
const sidB = space()
const memberWrite = execAsUser(
member.userId,
`insert into api_buttons (space_id, label, base_url, pinned_host) values ('${sidA}','x','https://d/','d');`,
)
expect(memberWrite.ok).toBe(false)
const crossWrite = execAsUser(
adminA.userId,
`insert into api_buttons (space_id, label, base_url, pinned_host) values ('${sidB}','x','https://d/','d');`,
)
expect(crossWrite.ok).toBe(false)
})
it('keeps api_call_log immutable from the client (no INSERT policy)', () => {
const sid = space()
const admin = seedMember(sid, { role: 'admin' })
const btn = seedButton(sid)
const r = execAsUser(
admin.userId,
`insert into api_call_log (space_id, button_id, action, success) values ('${sid}','${btn}','invoke',true);`,
)
expect(r.ok).toBe(false)
})
it('lets door.manage read the api_call_log; a plain member cannot', () => {
const sid = space()
const admin = seedMember(sid, { role: 'admin' })
const member = seedMember(sid, { role: 'member' })
const btn = seedButton(sid)
execService(
`insert into api_call_log (space_id, button_id, action, success) values ('${sid}','${btn}','invoke',true);`,
)
expect(rowsAsUser(admin.userId, `select id from api_call_log where space_id='${sid}'`).length).toBe(1)
expect(rowsAsUser(member.userId, `select id from api_call_log where space_id='${sid}'`)).toHaveLength(0)
})
it('enforces the method CHECK constraint', () => {
const sid = space()
const bad = execService(
`insert into api_buttons (space_id, label, base_url, pinned_host, method) values ('${sid}','x','https://d/','d','TRACE');`,
)
expect(bad.ok).toBe(false)
})
})
+107
View File
@@ -0,0 +1,107 @@
// Door epic Phase 4 (migration 053) against real Postgres. Proves the inbound
// ingest invariants the unit tests cannot: the partial-unique dedup index, the
// fact that action rows (NULL dedupe_key) stay unconstrained, that
// door_access_log remains immutable from the client (service-only writes), and
// that inbound config is door.manage-gated through RLS. Same role-simulation
// harness as the other integration tests.
import { describe, it, expect, afterAll } from 'vitest'
import { dbReady, execService, execAsUser, rowsAsUser, seedSpace, seedMember, dropSpace, uuid } from './db'
const d = describe.skipIf(!dbReady)
function seedConnection(spaceId: string): string {
const id = uuid()
execService(
`insert into door_connections (id, space_id, name, adapter, base_url, pinned_host)
values ('${id}','${spaceId}','itest door','native_heatsync','http://10.0.0.9/','10.0.0.9');`,
)
return id
}
d('door inbound ingest (053)', () => {
const spaces: string[] = []
function space(): string {
const { spaceId } = seedSpace()
spaces.push(spaceId)
return spaceId
}
afterAll(() => spaces.forEach(dropSpace))
it('dedupes ingested rows on (connection_id, dedupe_key)', () => {
const sid = space()
const conn = seedConnection(sid)
const first = execService(
`insert into door_access_log (space_id, connection_id, action, success, dedupe_key)
values ('${sid}','${conn}','entry',true,'hs:5:G:12345:');`,
)
expect(first.ok).toBe(true)
// Same connection + same dedupe_key: the partial unique index rejects it,
// which is what makes a re-poll / webhook retry a safe no-op.
const dup = execService(
`insert into door_access_log (space_id, connection_id, action, success, dedupe_key)
values ('${sid}','${conn}','entry',true,'hs:5:G:12345:');`,
)
expect(dup.ok).toBe(false)
})
it('leaves action rows (NULL dedupe_key) unconstrained', () => {
const sid = space()
const conn = seedConnection(sid)
// Two app-action rows with NULL dedupe_key on the same connection must both
// insert (NULLs are distinct in the unique index), so existing grant/open/
// self_entry auditing is unaffected by the dedup index.
const a = execService(
`insert into door_access_log (space_id, connection_id, action, success) values ('${sid}','${conn}','open',true);`,
)
const b = execService(
`insert into door_access_log (space_id, connection_id, action, success) values ('${sid}','${conn}','open',true);`,
)
expect(a.ok && b.ok).toBe(true)
})
it('keeps door_access_log immutable from the client (no INSERT policy)', () => {
const sid = space()
const conn = seedConnection(sid)
const admin = seedMember(sid, { role: 'admin' })
// Even an admin (door.manage via the role shortcut) cannot write the audit
// log directly; only the service-client ingest paths insert.
const r = execAsUser(
admin.userId,
`insert into door_access_log (space_id, connection_id, action, success, dedupe_key)
values ('${sid}','${conn}','entry',true,'wh:forged');`,
)
expect(r.ok).toBe(false)
})
it('lets door.manage read the log and configure inbound; a plain member cannot', () => {
const sid = space()
const adminUser = seedMember(sid, { role: 'admin' })
const member = seedMember(sid, { role: 'member' })
const conn = seedConnection(sid)
execService(
`insert into door_access_log (space_id, connection_id, action, success, dedupe_key)
values ('${sid}','${conn}','entry',true,'hs:1:G:7:');`,
)
const adminSees = rowsAsUser<{ action: string }>(
adminUser.userId,
`select action from door_access_log where space_id='${sid}'`,
)
expect(adminSees.length).toBeGreaterThanOrEqual(1)
const memberSees = rowsAsUser(member.userId, `select action from door_access_log where space_id='${sid}'`)
expect(memberSees).toHaveLength(0)
// door.manage (admin shortcut) can flip inbound_enabled; a plain member's
// UPDATE is filtered out by RLS (zero rows affected, no error raised).
const adminUpd = execAsUser(
adminUser.userId,
`update door_connections set inbound_enabled=true where id='${conn}';`,
)
expect(adminUpd.ok).toBe(true)
expect(rowsAsUser<{ inbound_enabled: boolean }>(adminUser.userId, `select inbound_enabled from door_connections where id='${conn}'`)[0].inbound_enabled).toBe(true)
execAsUser(member.userId, `update door_connections set inbound_enabled=false where id='${conn}';`)
expect(rowsAsUser<{ inbound_enabled: boolean }>(adminUser.userId, `select inbound_enabled from door_connections where id='${conn}'`)[0].inbound_enabled).toBe(true)
})
})
+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({
+9
View File
@@ -30,6 +30,15 @@ const PUBLIC_ROUTES = [
// Scoped to the exact route so a future /api/cron/* without its own
// secret is not exposed.
'/api/cron/notifications',
// Door inbound-log poll, also crontab-driven; enforces its own CRON_SECRET.
// Scoped to the exact route.
'/api/cron/door-ingest',
// Per-connection inbound door-event webhook. Called by a controller/relay
// with no session; each request is authenticated by the connection's bearer
// secret in the route handler. The [connection] segment varies, so this is a
// prefix match (every /api/door/inbound/* path is session-exempt and must
// self-authenticate).
'/api/door/inbound',
// Public form / waiver fill page. Only /f/[slug] lives here and must be
// reachable anonymously (the page serves only published public forms and
// submitForm enforces visibility). The /forms* management routes do NOT
+55
View File
@@ -0,0 +1,55 @@
-- =============================================================================
-- 053: Door inbound access-log ingest (Door epic, phase 4)
-- =============================================================================
-- Phase 4 pulls real entry/denied events FROM a controller INTO door_access_log,
-- via two transports that share one ingest core:
-- * poll: a CRON_SECRET-guarded route reads each enabled connection's log
-- verb (HeatSync ?z) through the existing hardened executor and parses it;
-- * webhook: a per-connection public endpoint accepts normalized event JSON
-- pushed by the controller or a relay, authenticated by a per-connection
-- bearer secret kept in the AES-256-GCM vault.
--
-- This migration is ADDITIVE ONLY (no table re-create, no policy weakening):
--
-- door_access_log.dedupe_key
-- A per-event idempotency token so a re-poll of the same ring-buffer slot
-- or a webhook retry cannot double-insert. Partial-unique on
-- (connection_id, dedupe_key) WHERE dedupe_key IS NOT NULL: existing
-- action rows (grant/revoke/open/self_entry) leave it NULL and are
-- unaffected; only ingested rows set it. The insert path uses ON CONFLICT
-- DO NOTHING against this index.
--
-- door_connections.inbound_enabled (default false; opt-in, like
-- allow_member_self_entry) gates the webhook endpoint for a connection.
-- door_connections.inbound_secret_ref references the SAME secrets vault as
-- secret_ref but holds the INBOUND webhook secret (a distinct credential
-- from the outbound door password). It is loaded + compared server-side
-- only and never returned to the browser.
--
-- PGRST embed note (the migration-048 outage class): door_connections now has
-- two FKs to public.secrets (secret_ref + inbound_secret_ref). This does NOT
-- create the ambiguous-junction shape that broke the auth embed -- that only
-- happens when a table's PRIMARY KEY covers both FK columns; door_connections
-- has a surrogate `id` PK. Nothing embeds secrets through PostgREST anyway
-- (secrets are read service-side by id), and the auth path
-- (space_members <-> spaces) is untouched. Safe.
--
-- No new permission codes: door.manage configures inbound; the cron + webhook
-- writers are the validated service client (door_access_log already has no
-- client INSERT policy and stays immutable from the client).
--
-- Idempotent: ADD COLUMN IF NOT EXISTS, CREATE INDEX IF NOT EXISTS.
-- =============================================================================
ALTER TABLE public.door_access_log
ADD COLUMN IF NOT EXISTS dedupe_key text;
CREATE UNIQUE INDEX IF NOT EXISTS idx_door_access_log_dedupe
ON public.door_access_log (connection_id, dedupe_key)
WHERE dedupe_key IS NOT NULL;
ALTER TABLE public.door_connections
ADD COLUMN IF NOT EXISTS inbound_enabled boolean NOT NULL DEFAULT false;
ALTER TABLE public.door_connections
ADD COLUMN IF NOT EXISTS inbound_secret_ref uuid REFERENCES public.secrets(id) ON DELETE SET NULL;
+127
View File
@@ -0,0 +1,127 @@
-- =============================================================================
-- 054: Universal API-call UI builder (Door epic, phase 5)
-- =============================================================================
-- Admins (door.manage, which the catalog already scopes to "door integrations,
-- buttons, and member cards") define named buttons that, when pressed by an
-- authorized member, fire a configured HTTP call through the SAME hardened
-- egress as the door executor (per-button pinned_host SSRF pin, always-block
-- metadata/link-local, resolve-once-then-connect-by-IP, no redirects, time/body
-- caps, secret from the AES vault injected server-side, redacted audit).
--
-- Authorization (the user-chosen model): managing buttons = door.manage; each
-- button carries its own required_permission (any catalog code, default the new
-- generic apicall.invoke) and a member may press only buttons whose permission
-- they hold. One new permission code, additive + backfilled exactly like the
-- door.* codes in 034: apicall.invoke (group Access). Manage stays door.manage.
--
-- api_buttons per-space button definition. The secret (if any) is NOT
-- stored here: secret_ref -> the AES-256-GCM secrets vault,
-- decrypted server-side only. RLS: all CRUD = door.manage
-- (members get a curated list via a service-client action that
-- filters by each button's required_permission).
-- api_call_log append-only audit of every press (who/what/result), secrets
-- redacted before write. SELECT = door.manage; NO client write
-- policy (validated service-client invoker only; immutable).
--
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS, DROP POLICY IF EXISTS then
-- CREATE, CREATE OR REPLACE the seed function, ON CONFLICT DO NOTHING backfill.
-- =============================================================================
-- 1) New permission code seeded for new spaces (CREATE OR REPLACE the seeder).
CREATE OR REPLACE FUNCTION public.seed_default_role_permissions()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO public.space_role_permissions (space_id, subject, permission)
SELECT NEW.id, d.subject, d.permission
FROM (VALUES
('board','ops.kb.read'),('board','ops.kb.write'),('board','ops.process.read'),
('board','ops.process.write'),('board','ops.secrets.read'),('board','ops.secrets.write'),
('board','ops.arealeads.manage'),('board','members.manage'),('board','payments.manage'),
('board','governance.manage'),('board','forum.moderate'),('board','forms.manage'),
('board','certifications.manage'),('board','certifications.grant'),
('board','classes.manage'),('board','classes.instruct'),
('board','equipment.manage'),
('board','door.manage'),('board','door.operate'),('board','apicall.invoke'),
('board','customize.manage'),('board','settings.manage'),
('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
('member','ops.kb.read'),('member','ops.process.read'),
('associate','ops.kb.read')
) AS d(subject, permission)
ON CONFLICT (space_id, subject, permission) DO NOTHING;
RETURN NEW;
END;
$$;
-- 2) Backfill existing spaces (board gets apicall.invoke; admin holds it
-- implicitly and is never stored).
INSERT INTO public.space_role_permissions (space_id, subject, permission)
SELECT id, 'board', 'apicall.invoke' FROM public.spaces
ON CONFLICT (space_id, subject, permission) DO NOTHING;
-- 3) Tables.
CREATE TABLE IF NOT EXISTS public.api_buttons (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
label text NOT NULL CHECK (char_length(label) BETWEEN 1 AND 120),
button_group text NOT NULL DEFAULT 'General' CHECK (char_length(button_group) BETWEEN 1 AND 60),
sort_order integer NOT NULL DEFAULT 0,
method text NOT NULL DEFAULT 'POST'
CHECK (method IN ('GET','POST','PUT','PATCH','DELETE')),
base_url text NOT NULL CHECK (base_url ~ '^https?://'),
pinned_host text NOT NULL CHECK (char_length(pinned_host) BETWEEN 1 AND 255),
url_template text,
headers jsonb NOT NULL DEFAULT '{}',
body_template text,
auth_mode text NOT NULL DEFAULT 'none'
CHECK (auth_mode IN ('none','query','header','bearer')),
auth_param text,
secret_ref uuid REFERENCES public.secrets(id) ON DELETE SET NULL,
required_permission text NOT NULL DEFAULT 'apicall.invoke'
CHECK (char_length(required_permission) BETWEEN 1 AND 60),
confirm boolean NOT NULL DEFAULT true,
is_enabled boolean NOT NULL DEFAULT true,
created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_api_buttons_space ON public.api_buttons (space_id, button_group, sort_order);
CREATE TABLE IF NOT EXISTS public.api_call_log (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
button_id uuid REFERENCES public.api_buttons(id) ON DELETE SET NULL,
actor_member_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
action text NOT NULL,
success boolean NOT NULL DEFAULT false,
detail text,
occurred_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_api_call_log_space ON public.api_call_log (space_id, occurred_at DESC);
ALTER TABLE public.api_buttons ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.api_call_log ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS api_buttons_select ON public.api_buttons;
DROP POLICY IF EXISTS api_buttons_insert ON public.api_buttons;
DROP POLICY IF EXISTS api_buttons_update ON public.api_buttons;
DROP POLICY IF EXISTS api_buttons_delete ON public.api_buttons;
CREATE POLICY api_buttons_select ON public.api_buttons FOR SELECT
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
CREATE POLICY api_buttons_insert ON public.api_buttons FOR INSERT
WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
CREATE POLICY api_buttons_update ON public.api_buttons FOR UPDATE
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
CREATE POLICY api_buttons_delete ON public.api_buttons FOR DELETE
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
DROP POLICY IF EXISTS api_call_log_select ON public.api_call_log;
CREATE POLICY api_call_log_select ON public.api_call_log FOR SELECT
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
-- No INSERT/UPDATE/DELETE policy: the validated service-client invoker is the
-- only writer, and the audit trail is immutable.
DROP TRIGGER IF EXISTS trg_api_buttons_touch ON public.api_buttons;
CREATE TRIGGER trg_api_buttons_touch
BEFORE UPDATE ON public.api_buttons
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
+87 -1
View File
@@ -1877,7 +1877,7 @@ BEGIN
('board','certifications.manage'),('board','certifications.grant'),
('board','classes.manage'),('board','classes.instruct'),
('board','equipment.manage'),
('board','door.manage'),('board','door.operate'),
('board','door.manage'),('board','door.operate'),('board','apicall.invoke'),
('board','customize.manage'),('board','settings.manage'),
('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
('member','ops.kb.read'),('member','ops.process.read'),
@@ -2551,6 +2551,11 @@ CREATE TABLE IF NOT EXISTS public.door_connections (
verbs jsonb NOT NULL DEFAULT '{}',
allow_member_self_entry boolean NOT NULL DEFAULT false,
is_enabled boolean NOT NULL DEFAULT true,
-- Inbound access-log ingest (053). inbound_enabled gates the per-connection
-- webhook endpoint (opt-in). inbound_secret_ref is the INBOUND bearer secret
-- in the AES vault, distinct from secret_ref (the outbound door password).
inbound_enabled boolean NOT NULL DEFAULT false,
inbound_secret_ref uuid REFERENCES public.secrets(id) ON DELETE SET NULL,
created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
@@ -2566,9 +2571,15 @@ CREATE TABLE IF NOT EXISTS public.door_access_log (
action text NOT NULL,
success boolean NOT NULL DEFAULT false,
detail text,
-- Per-event idempotency token for inbound ingest (053). NULL for app-issued
-- action rows; set for ingested poll/webhook events. Partial-unique below.
dedupe_key text,
occurred_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_door_access_log_space ON public.door_access_log (space_id, occurred_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_door_access_log_dedupe
ON public.door_access_log (connection_id, dedupe_key)
WHERE dedupe_key IS NOT NULL;
ALTER TABLE public.door_connections ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.door_access_log ENABLE ROW LEVEL SECURITY;
@@ -2642,6 +2653,81 @@ CREATE TRIGGER trg_door_card_slots_touch
BEFORE UPDATE ON public.door_card_slots
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
-- ---------------------------------------------------------------------------
-- Universal API-call UI builder (Door epic phase 5; migration 054).
-- api_buttons: per-space button definitions fired through the same hardened
-- egress as the door executor (per-button pinned_host SSRF pin; secret in
-- the AES vault via secret_ref, never stored here). All CRUD = door.manage;
-- each button's required_permission (default apicall.invoke) gates pressing
-- it (enforced by the service-client invoke action, not RLS).
-- api_call_log: append-only, secrets-redacted audit. SELECT = door.manage;
-- NO client write policy (validated service-client invoker only; immutable).
-- New permission code apicall.invoke (group Access) is seeded above.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.api_buttons (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
label text NOT NULL CHECK (char_length(label) BETWEEN 1 AND 120),
button_group text NOT NULL DEFAULT 'General' CHECK (char_length(button_group) BETWEEN 1 AND 60),
sort_order integer NOT NULL DEFAULT 0,
method text NOT NULL DEFAULT 'POST'
CHECK (method IN ('GET','POST','PUT','PATCH','DELETE')),
base_url text NOT NULL CHECK (base_url ~ '^https?://'),
pinned_host text NOT NULL CHECK (char_length(pinned_host) BETWEEN 1 AND 255),
url_template text,
headers jsonb NOT NULL DEFAULT '{}',
body_template text,
auth_mode text NOT NULL DEFAULT 'none'
CHECK (auth_mode IN ('none','query','header','bearer')),
auth_param text,
secret_ref uuid REFERENCES public.secrets(id) ON DELETE SET NULL,
required_permission text NOT NULL DEFAULT 'apicall.invoke'
CHECK (char_length(required_permission) BETWEEN 1 AND 60),
confirm boolean NOT NULL DEFAULT true,
is_enabled boolean NOT NULL DEFAULT true,
created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_api_buttons_space ON public.api_buttons (space_id, button_group, sort_order);
CREATE TABLE IF NOT EXISTS public.api_call_log (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
button_id uuid REFERENCES public.api_buttons(id) ON DELETE SET NULL,
actor_member_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
action text NOT NULL,
success boolean NOT NULL DEFAULT false,
detail text,
occurred_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_api_call_log_space ON public.api_call_log (space_id, occurred_at DESC);
ALTER TABLE public.api_buttons ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.api_call_log ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS api_buttons_select ON public.api_buttons;
DROP POLICY IF EXISTS api_buttons_insert ON public.api_buttons;
DROP POLICY IF EXISTS api_buttons_update ON public.api_buttons;
DROP POLICY IF EXISTS api_buttons_delete ON public.api_buttons;
CREATE POLICY api_buttons_select ON public.api_buttons FOR SELECT
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
CREATE POLICY api_buttons_insert ON public.api_buttons FOR INSERT
WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
CREATE POLICY api_buttons_update ON public.api_buttons FOR UPDATE
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
CREATE POLICY api_buttons_delete ON public.api_buttons FOR DELETE
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
DROP POLICY IF EXISTS api_call_log_select ON public.api_call_log;
CREATE POLICY api_call_log_select ON public.api_call_log FOR SELECT
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
DROP TRIGGER IF EXISTS trg_api_buttons_touch ON public.api_buttons;
CREATE TRIGGER trg_api_buttons_touch
BEFORE UPDATE ON public.api_buttons
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
-- =============================================================================
-- 24. Presence & attendance (check-in / check-out / hosting)
+18 -6
View File
@@ -155,15 +155,15 @@ export type Database = {
Relationships: []
}
door_connections: {
Row: { id: string; space_id: string; name: string; adapter: string; base_url: string; pinned_host: string; auth_mode: string; auth_param: string | null; secret_ref: string | null; verbs: Json; allow_member_self_entry: boolean; is_enabled: boolean; created_by: string | null; created_at: string; updated_at: string }
Insert: { id?: string; space_id: string; name: string; adapter?: string; base_url: string; pinned_host: string; auth_mode?: string; auth_param?: string | null; secret_ref?: string | null; verbs?: Json; allow_member_self_entry?: boolean; is_enabled?: boolean; created_by?: string | null; created_at?: string; updated_at?: string }
Update: { id?: string; space_id?: string; name?: string; adapter?: string; base_url?: string; pinned_host?: string; auth_mode?: string; auth_param?: string | null; secret_ref?: string | null; verbs?: Json; allow_member_self_entry?: boolean; is_enabled?: boolean; created_by?: string | null; created_at?: string; updated_at?: string }
Row: { id: string; space_id: string; name: string; adapter: string; base_url: string; pinned_host: string; auth_mode: string; auth_param: string | null; secret_ref: string | null; verbs: Json; allow_member_self_entry: boolean; is_enabled: boolean; inbound_enabled: boolean; inbound_secret_ref: string | null; created_by: string | null; created_at: string; updated_at: string }
Insert: { id?: string; space_id: string; name: string; adapter?: string; base_url: string; pinned_host: string; auth_mode?: string; auth_param?: string | null; secret_ref?: string | null; verbs?: Json; allow_member_self_entry?: boolean; is_enabled?: boolean; inbound_enabled?: boolean; inbound_secret_ref?: string | null; created_by?: string | null; created_at?: string; updated_at?: string }
Update: { id?: string; space_id?: string; name?: string; adapter?: string; base_url?: string; pinned_host?: string; auth_mode?: string; auth_param?: string | null; secret_ref?: string | null; verbs?: Json; allow_member_self_entry?: boolean; is_enabled?: boolean; inbound_enabled?: boolean; inbound_secret_ref?: string | null; created_by?: string | null; created_at?: string; updated_at?: string }
Relationships: []
}
door_access_log: {
Row: { id: string; space_id: string; connection_id: string | null; actor_member_id: string | null; target_member_id: string | null; action: string; success: boolean; detail: string | null; occurred_at: string }
Insert: { id?: string; space_id: string; connection_id?: string | null; actor_member_id?: string | null; target_member_id?: string | null; action: string; success?: boolean; detail?: string | null; occurred_at?: string }
Update: { id?: string; space_id?: string; connection_id?: string | null; actor_member_id?: string | null; target_member_id?: string | null; action?: string; success?: boolean; detail?: string | null; occurred_at?: string }
Row: { id: string; space_id: string; connection_id: string | null; actor_member_id: string | null; target_member_id: string | null; action: string; success: boolean; detail: string | null; dedupe_key: string | null; occurred_at: string }
Insert: { id?: string; space_id: string; connection_id?: string | null; actor_member_id?: string | null; target_member_id?: string | null; action: string; success?: boolean; detail?: string | null; dedupe_key?: string | null; occurred_at?: string }
Update: { id?: string; space_id?: string; connection_id?: string | null; actor_member_id?: string | null; target_member_id?: string | null; action?: string; success?: boolean; detail?: string | null; dedupe_key?: string | null; occurred_at?: string }
Relationships: []
}
door_card_slots: {
@@ -172,6 +172,18 @@ export type Database = {
Update: { id?: string; space_id?: string; connection_id?: string; card_id?: string; slot?: number; created_by?: string | null; created_at?: string; updated_at?: string }
Relationships: []
}
api_buttons: {
Row: { id: string; space_id: string; label: string; button_group: string; sort_order: number; method: string; base_url: string; pinned_host: string; url_template: string | null; headers: Json; body_template: string | null; auth_mode: string; auth_param: string | null; secret_ref: string | null; required_permission: string; confirm: boolean; is_enabled: boolean; created_by: string | null; created_at: string; updated_at: string }
Insert: { id?: string; space_id: string; label: string; button_group?: string; sort_order?: number; method?: string; base_url: string; pinned_host: string; url_template?: string | null; headers?: Json; body_template?: string | null; auth_mode?: string; auth_param?: string | null; secret_ref?: string | null; required_permission?: string; confirm?: boolean; is_enabled?: boolean; created_by?: string | null; created_at?: string; updated_at?: string }
Update: { id?: string; space_id?: string; label?: string; button_group?: string; sort_order?: number; method?: string; base_url?: string; pinned_host?: string; url_template?: string | null; headers?: Json; body_template?: string | null; auth_mode?: string; auth_param?: string | null; secret_ref?: string | null; required_permission?: string; confirm?: boolean; is_enabled?: boolean; created_by?: string | null; created_at?: string; updated_at?: string }
Relationships: []
}
api_call_log: {
Row: { id: string; space_id: string; button_id: string | null; actor_member_id: string | null; action: string; success: boolean; detail: string | null; occurred_at: string }
Insert: { id?: string; space_id: string; button_id?: string | null; actor_member_id?: string | null; action: string; success?: boolean; detail?: string | null; occurred_at?: string }
Update: { id?: string; space_id?: string; button_id?: string | null; actor_member_id?: string | null; action?: string; success?: boolean; detail?: string | null; occurred_at?: string }
Relationships: []
}
space_visits: {
Row: { id: string; space_id: string; member_id: string; checked_in_at: string; checked_out_at: string | null; is_host: boolean; check_in_note: string | null; check_out_note: string | null; created_at: string }
Insert: { id?: string; space_id: string; member_id: string; checked_in_at?: string; checked_out_at?: string | null; is_host?: boolean; check_in_note?: string | null; check_out_note?: string | null; created_at?: string }