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:
+6
-2
@@ -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 |
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { requireDoorManagerPage } from '@/lib/door-guard'
|
||||
import { listApiButtons, listSecretTitles, listApiCallLog } from '@/lib/actions'
|
||||
import { PERMISSIONS } from '@/lib/permissions-catalog'
|
||||
import { ApiButtonsClient } from './api-buttons-client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function ApiButtonsPage() {
|
||||
await requireDoorManagerPage()
|
||||
|
||||
const [buttons, secrets, log] = await Promise.all([
|
||||
listApiButtons(),
|
||||
listSecretTitles(),
|
||||
listApiCallLog(),
|
||||
])
|
||||
|
||||
return (
|
||||
<ApiButtonsClient
|
||||
initial={'data' in buttons ? (buttons.data as unknown[]) : []}
|
||||
secrets={'data' in secrets ? (secrets.data as { id: string; title: string }[]) : []}
|
||||
log={'data' in log ? (log.data as unknown[]) : []}
|
||||
permissions={PERMISSIONS.map(p => ({ code: p.code, label: p.label }))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -32,6 +32,8 @@ type Conn = {
|
||||
verbs: Record<string, string> | null
|
||||
allow_member_self_entry: boolean
|
||||
is_enabled: boolean
|
||||
inbound_enabled: boolean
|
||||
inbound_secret_ref: string | null
|
||||
}
|
||||
type LogRow = { id: string; action: string; success: boolean; detail: string | null; occurred_at: string }
|
||||
type DoorCard = { cardId: string; memberName: string; label: string | null; last4: string; slot: number | null }
|
||||
@@ -65,6 +67,7 @@ export function DoorManageClient({
|
||||
const [cardsFor, setCardsFor] = useState<string | null>(null)
|
||||
const [cards, setCards] = useState<DoorCard[]>([])
|
||||
const [cardsBusy, setCardsBusy] = useState(false)
|
||||
const [inboundFor, setInboundFor] = useState<string | null>(null)
|
||||
const rows = log as LogRow[]
|
||||
|
||||
async function onControl(c: Conn, verb: 'open' | 'unlock' | 'lock') {
|
||||
@@ -161,6 +164,7 @@ export function DoorManageClient({
|
||||
pinned_host: d.pinned_host.trim(), auth_mode: d.secret_ref ? 'query' : 'none',
|
||||
auth_param: d.adapter === 'native_heatsync' ? 'e' : null, secret_ref: d.secret_ref || null,
|
||||
verbs: buildVerbs(d), allow_member_self_entry: d.allow_member_self_entry, is_enabled: true,
|
||||
inbound_enabled: false, inbound_secret_ref: null,
|
||||
},
|
||||
...prev,
|
||||
])
|
||||
@@ -191,6 +195,36 @@ export function DoorManageClient({
|
||||
setConns(prev => prev.map(x => (x.id === c.id ? { ...x, allow_member_self_entry: next } : x)))
|
||||
}
|
||||
|
||||
async function onSetInboundSecret(c: Conn, ref: string) {
|
||||
const res = await updateDoorConnection({ connectionId: c.id, inbound_secret_ref: ref || null })
|
||||
if ('error' in res && res.error) return toast.error(res.error)
|
||||
setConns(prev => prev.map(x => (x.id === c.id ? { ...x, inbound_secret_ref: ref || null } : x)))
|
||||
}
|
||||
|
||||
async function onToggleInbound(c: Conn) {
|
||||
const next = !c.inbound_enabled
|
||||
if (next && !c.inbound_secret_ref) {
|
||||
return toast.error('Choose a webhook secret first; it authenticates inbound events.')
|
||||
}
|
||||
const res = await updateDoorConnection({ connectionId: c.id, inbound_enabled: next })
|
||||
if ('error' in res && res.error) return toast.error(res.error)
|
||||
setConns(prev => prev.map(x => (x.id === c.id ? { ...x, inbound_enabled: next } : x)))
|
||||
}
|
||||
|
||||
function webhookUrl(id: string): string {
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : ''
|
||||
return `${origin}/api/door/inbound/${id}`
|
||||
}
|
||||
|
||||
async function copyWebhookUrl(id: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(webhookUrl(id))
|
||||
toast.success('Webhook URL copied')
|
||||
} catch {
|
||||
toast.error('Could not copy. Select and copy the URL manually.')
|
||||
}
|
||||
}
|
||||
|
||||
async function onTest(c: Conn) {
|
||||
setBusy(true)
|
||||
const res = await testDoorConnection({ connectionId: c.id })
|
||||
@@ -332,6 +366,7 @@ export function DoorManageClient({
|
||||
<Badge variant="outline">{c.adapter === 'native_heatsync' ? 'HeatSync' : 'Generic'}</Badge>
|
||||
{!c.is_enabled && <Badge variant="outline">Disabled</Badge>}
|
||||
{c.allow_member_self_entry && <span className="font-mono text-[10px] text-amber-600">self-entry on</span>}
|
||||
{c.inbound_enabled && <span className="font-mono text-[10px] text-primary">inbound on</span>}
|
||||
</div>
|
||||
<p className="font-mono text-[10px] text-muted-foreground mt-1">
|
||||
{c.base_url} · pinned {c.pinned_host} · auth {c.secret_ref ? 'secret' : 'none'}
|
||||
@@ -343,6 +378,9 @@ export function DoorManageClient({
|
||||
<Button size="sm" variant="outline" onClick={() => onToggleSelfEntry(c)}>
|
||||
{c.allow_member_self_entry ? 'Self-entry off' : 'Self-entry on'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setInboundFor(v => (v === c.id ? null : c.id))}>
|
||||
{inboundFor === c.id ? 'Hide inbound' : 'Inbound'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onDelete(c)}>Delete</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -359,6 +397,45 @@ export function DoorManageClient({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inboundFor === c.id && (
|
||||
<div className="rounded border border-border bg-background p-3 space-y-3">
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p className="font-sans">
|
||||
Inbound ingest pulls real entry/denied events into the access log and
|
||||
matches each presented card to a member. Two transports, one switch:
|
||||
the once-a-minute poll reads a HeatSync controller’s log, and the
|
||||
webhook accepts events POSTed by any controller or relay. Turn it on per
|
||||
connection after choosing a webhook secret.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<select
|
||||
className={input}
|
||||
value={c.inbound_secret_ref ?? ''}
|
||||
onChange={e => onSetInboundSecret(c, e.target.value)}
|
||||
>
|
||||
<option value="">No webhook secret</option>
|
||||
{secrets.map(s => <option key={s.id} value={s.id}>Secret: {s.title}</option>)}
|
||||
</select>
|
||||
<Button size="sm" variant="outline" onClick={() => onToggleInbound(c)}>
|
||||
{c.inbound_enabled ? 'Turn inbound off' : 'Turn inbound on'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded border border-border bg-card p-2 space-y-2">
|
||||
<p className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Webhook URL</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="font-mono text-[10px] text-foreground break-all min-w-0">{webhookUrl(c.id)}</code>
|
||||
<Button size="sm" variant="outline" className="shrink-0" onClick={() => copyWebhookUrl(c.id)}>Copy</Button>
|
||||
</div>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">
|
||||
POST with header <code>Authorization: Bearer <your webhook secret></code> and a JSON
|
||||
body <code>{'{ "events": [{ "id": "<stable-id>", "card_uid": "<hex>", "result": "granted" }] }'}</code>.
|
||||
The <code>id</code> dedupes retries; <code>card_uid</code> or <code>card_number</code> identifies the card.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canOperate && cardsFor === c.id && (
|
||||
<div className="rounded border border-border bg-background p-3">
|
||||
{cardsBusy && cards.length === 0 ? (
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useConfirm } from '@/components/ui/confirm'
|
||||
import { invokeApiButton } from '@/lib/actions'
|
||||
|
||||
type InvokeButton = { id: string; label: string; group: string; confirm: boolean }
|
||||
|
||||
export function ApiButtonsInvoke({ buttons }: { buttons: InvokeButton[] }) {
|
||||
const confirm = useConfirm()
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
|
||||
const groups = new Map<string, InvokeButton[]>()
|
||||
for (const b of buttons) {
|
||||
if (!groups.has(b.group)) groups.set(b.group, [])
|
||||
groups.get(b.group)!.push(b)
|
||||
}
|
||||
|
||||
async function press(b: InvokeButton) {
|
||||
if (b.confirm) {
|
||||
const ok = await confirm({
|
||||
title: `${b.label}?`,
|
||||
description: 'This fires a configured action. Continue?',
|
||||
confirmText: b.label,
|
||||
})
|
||||
if (!ok) return
|
||||
}
|
||||
setBusy(b.id)
|
||||
const res = await invokeApiButton({ buttonId: b.id })
|
||||
setBusy(null)
|
||||
if ('error' in res && res.error) return toast.error(res.error)
|
||||
toast.success(`${b.label} sent`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from(groups.entries()).map(([group, items]) => (
|
||||
<div key={group}>
|
||||
<p className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground/70 mb-2">{group}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map(b => (
|
||||
<Button key={b.id} size="sm" disabled={busy === b.id} onClick={() => press(b)}>
|
||||
{b.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import { PageHeader, PageTitle } from '@/components/ui/page-title'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
|
||||
import { DoorClosed } from 'lucide-react'
|
||||
import { listSelfEntryDoors, getMyCards, listMyDoorActivity } from '@/lib/actions'
|
||||
import { listSelfEntryDoors, getMyCards, listMyDoorActivity, listInvokableButtons } from '@/lib/actions'
|
||||
import { DoorSelfEntry } from '../dashboard/door-self-entry'
|
||||
import { ApiButtonsInvoke } from './api-buttons-invoke'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -27,16 +28,18 @@ export default async function DoorsPage() {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const [doorsRes, cardsRes, activityRes] = await Promise.all([
|
||||
const [doorsRes, cardsRes, activityRes, buttonsRes] = await Promise.all([
|
||||
listSelfEntryDoors(),
|
||||
getMyCards(),
|
||||
listMyDoorActivity(),
|
||||
listInvokableButtons(),
|
||||
])
|
||||
const doors = ('data' in doorsRes ? doorsRes.data : []) as { id: string; name: string }[]
|
||||
const cards = ('data' in cardsRes ? cardsRes.data : []) as Card[]
|
||||
const activity = ('data' in activityRes ? activityRes.data : []) as Activity[]
|
||||
const buttons = ('data' in buttonsRes ? buttonsRes.data : []) as { id: string; label: string; group: string; confirm: boolean }[]
|
||||
|
||||
const nothing = doors.length === 0 && cards.length === 0 && activity.length === 0
|
||||
const nothing = doors.length === 0 && cards.length === 0 && activity.length === 0 && buttons.length === 0
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -69,6 +72,16 @@ export default async function DoorsPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{buttons.length > 0 && (
|
||||
<section>
|
||||
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-3">Actions</h2>
|
||||
<ApiButtonsInvoke buttons={buttons} />
|
||||
<p className="font-mono text-[10px] text-muted-foreground mt-2">
|
||||
Each action fires a configured request. Every press is logged.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{cards.length > 0 && (
|
||||
<section>
|
||||
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-3">My access cards</h2>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Door inbound-log poll. Hit once a minute by the droplet's crontab (see
|
||||
// docs/DEPLOYMENT.md) to pull access events from each native-HeatSync
|
||||
// connection that has opted into inbound ingest. Unauthenticated by session
|
||||
// (cron has none); proxy.ts whitelists this exact path and trust is the
|
||||
// CRON_SECRET shared secret, compared in constant time.
|
||||
//
|
||||
// Each connection's `?z` log is read through the SAME hardened executor as the
|
||||
// outbound actions (SSRF host pin, no redirects, time/body caps, secret
|
||||
// redaction). Parsed events are resolved to members and idempotently inserted
|
||||
// (dedupe_key + ON-CONFLICT no-op), so overlapping runs and unchanged re-polls
|
||||
// are safe. Generic controllers ingest via the per-connection webhook instead;
|
||||
// only native_heatsync has a characterized log format to parse.
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { timingSafeEqual } from 'node:crypto'
|
||||
import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { pollConnectionLog, ingestEvents, type IngestConn } from '@/lib/door/ingest'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Bounded work per run: cap the connections polled so a misconfigured fleet
|
||||
// cannot make one cron tick run unbounded. A single hackerspace has one or two
|
||||
// doors, so this is generous.
|
||||
const MAX_CONNECTIONS = 50
|
||||
|
||||
function authorized(req: NextRequest): boolean {
|
||||
const expected = process.env.CRON_SECRET
|
||||
if (!expected) return false
|
||||
const header = req.headers.get('authorization') ?? ''
|
||||
const provided = header.startsWith('Bearer ') ? header.slice(7) : header
|
||||
const a = Buffer.from(provided)
|
||||
const b = Buffer.from(expected)
|
||||
return a.length === b.length && timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!process.env.CRON_SECRET) {
|
||||
return NextResponse.json({ error: 'CRON_SECRET not configured' }, { status: 503 })
|
||||
}
|
||||
if (!authorized(req)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const admin = createAdminClient()
|
||||
const { data: conns, error } = await admin
|
||||
.from('door_connections')
|
||||
.select('id, space_id, adapter, base_url, pinned_host, auth_param, secret_ref, verbs, is_enabled')
|
||||
.eq('is_enabled', true)
|
||||
.eq('inbound_enabled', true)
|
||||
.eq('adapter', 'native_heatsync')
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(MAX_CONNECTIONS)
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
let polled = 0
|
||||
let inserted = 0
|
||||
let resolved = 0
|
||||
let failed = 0
|
||||
for (const c of (conns ?? []) as unknown as IngestConn[]) {
|
||||
polled++
|
||||
// Isolate each connection: a single misbehaving controller (the body is
|
||||
// untrusted, plaintext HTTP) must not 500 the whole run and starve the rest.
|
||||
try {
|
||||
const poll = await pollConnectionLog(admin, c)
|
||||
if (!poll.ok) {
|
||||
failed++
|
||||
console.error(`[door-ingest] connection ${c.id}: ${poll.detail}`)
|
||||
continue
|
||||
}
|
||||
const res = await ingestEvents(admin, c.space_id, c.id, poll.events)
|
||||
inserted += res.inserted
|
||||
resolved += res.resolved
|
||||
} catch (e) {
|
||||
failed++
|
||||
console.error(`[door-ingest] connection ${c.id} threw:`, e instanceof Error ? e.message : e)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, polled, inserted, resolved, failed })
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Per-connection inbound door-event webhook. A controller or a relay (e.g. a
|
||||
// small agent on the space's LAN that reads a non-HeatSync controller, or any
|
||||
// system that can POST) pushes normalized access events here. Unauthenticated
|
||||
// by session (the caller has none); proxy.ts whitelists the /api/door/inbound
|
||||
// prefix and trust is the per-connection bearer secret in the AES vault,
|
||||
// compared in constant time. Every DB write is post-auth.
|
||||
//
|
||||
// This is the reliable inbound transport: each event carries a caller-supplied
|
||||
// stable id, so retries are idempotent (dedupe_key) and there is none of the
|
||||
// ring-buffer ambiguity of the HeatSync `?z` poll. Events are resolved to
|
||||
// members and inserted through the same shared ingest core as the poll.
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { timingSafeEqual } from 'node:crypto'
|
||||
import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { resolveDoorSecret } from '@/lib/door/secret'
|
||||
import { ingestEvents } from '@/lib/door/ingest'
|
||||
import { normalizeWebhookEvents } from '@/lib/door-log-logic'
|
||||
import { doorWebhookPayloadSchema } from '@/lib/validations'
|
||||
import { checkRateLimit } from '@/lib/security'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
function bearerMatches(req: NextRequest, expected: string): boolean {
|
||||
const header = req.headers.get('authorization') ?? ''
|
||||
const provided = header.startsWith('Bearer ') ? header.slice(7) : header
|
||||
const a = Buffer.from(provided)
|
||||
const b = Buffer.from(expected)
|
||||
return a.length === b.length && timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ connection: string }> },
|
||||
) {
|
||||
const { connection: connectionId } = await params
|
||||
// Generic 404 for a malformed id so the endpoint does not double as a
|
||||
// uuid-shape oracle.
|
||||
if (!UUID_RE.test(connectionId)) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const admin = createAdminClient()
|
||||
const { data: conn } = await admin
|
||||
.from('door_connections')
|
||||
.select('id, space_id, inbound_enabled, inbound_secret_ref')
|
||||
.eq('id', connectionId)
|
||||
.maybeSingle()
|
||||
|
||||
// Do not reveal whether the connection exists vs. has inbound disabled vs.
|
||||
// has no secret: an unauthenticated caller gets the same 401 for all of
|
||||
// "can't authenticate this request".
|
||||
const secret = conn && conn.inbound_enabled
|
||||
? await resolveDoorSecret(admin, conn.space_id as string, (conn.inbound_secret_ref as string | null) ?? null)
|
||||
: null
|
||||
if (!secret || !bearerMatches(req, secret)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const rl = checkRateLimit(`door-inbound:${connectionId}`, 120, 60_000)
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json({ error: 'Rate limited' }, { status: 429 })
|
||||
}
|
||||
|
||||
// Bound the body before parsing it into memory. The schema caps at 100
|
||||
// events, but req.json() reads the whole body first, so reject an oversized
|
||||
// declared length up front (64KB is far more than 100 small events need).
|
||||
const declaredLen = Number(req.headers.get('content-length') ?? '0')
|
||||
if (declaredLen > 65536) {
|
||||
return NextResponse.json({ error: 'Payload too large' }, { status: 413 })
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||
}
|
||||
const parsed = doorWebhookPayloadSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
|
||||
}
|
||||
|
||||
const events = normalizeWebhookEvents(parsed.data.events)
|
||||
const res = await ingestEvents(admin, conn!.space_id as string, conn!.id as string, events)
|
||||
return NextResponse.json({ received: true, ...res })
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)` }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user