feat(door): P2 connections + SSRF-safe executor + native adapter + audit

Migration 035 adds door_connections (per-space controller integration:
secret_ref -> AES-256-GCM secrets vault, never a plaintext password;
pinned_host SSRF pin; native_heatsync|generic_http adapter;
allow_member_self_entry opt-in for phase 3) and door_access_log
(append-only, secrets redacted, service-client-only writes; immutable).
RLS additive/default-deny: connections CRUD = door.manage; log SELECT =
door.manage/operate.

Safety core is pure and heavily unit-tested (door-logic, suite 409):
validateDoorTarget (request host must equal the pinned host;
metadata/link-local always blocked; http(s) only), the
firmware-verified HeatSync encoders with fixed-width zero-padding
(slot 3 / perm 3 / tag 8 / 4-char password), generic template
substitution, and redactDoorSecrets. lib/door/executor.ts is the single
hardened egress: re-validates the pin, refuses redirects, caps time and
body, redacts before any audit write. The door password is read from the
secrets vault and decrypted server-side only, never returned or logged.
/door/manage (door.manage) configures connections, picks a vault secret,
and runs a safe status-only test. Sidebar Admin link added; "Learn"
moved before "People" per request. Docs updated.
This commit is contained in:
Moheeb Zara
2026-05-16 20:31:43 -07:00
parent 1cbea138ba
commit 59e9fcbd4f
17 changed files with 1203 additions and 11 deletions
+4 -1
View File
@@ -400,10 +400,11 @@ Default channels created by trigger on space INSERT: `general`, `announcements`,
| 032 | `classes`, `class_sessions`, `class_signups` + `classes.manage` / `classes.instruct` permissions. Additive default-deny RLS; signup/cancel via validated service-client action (no INSERT/DELETE policy); one non-cancelled signup per member+session; optional cert-on-completion via the normal certifications path | | 032 | `classes`, `class_sessions`, `class_signups` + `classes.manage` / `classes.instruct` permissions. Additive default-deny RLS; signup/cancel via validated service-client action (no INSERT/DELETE policy); one non-cancelled signup per member+session; optional cert-on-completion via the normal certifications path |
| 033 | `equipment`, `equipment_reservations` + `equipment.manage` permission. Additive default-deny RLS; reserve/cancel via validated service-client action (no INSERT/DELETE policy) enforcing status + no-overlap + required-cert with manager override | | 033 | `equipment`, `equipment_reservations` + `equipment.manage` permission. Additive default-deny RLS; reserve/cancel via validated service-client action (no INSERT/DELETE policy) enforcing status + no-overlap + required-cert with manager override |
| 034 | `member_cards` + `door.manage` / `door.operate` permissions. Card UID is a credential: door.manage-only RLS, no member SELECT; masked (count+last4) self-view via server action. Door epic phase 1, no controller calls | | 034 | `member_cards` + `door.manage` / `door.operate` permissions. Card UID is a credential: door.manage-only RLS, no member SELECT; masked (count+last4) self-view via server action. Door epic phase 1, no controller calls |
| 035 | `door_connections`, `door_access_log` (Door epic P2). Password via secrets vault (secret_ref), `pinned_host` SSRF pin, hardened executor. Connections CRUD = door.manage; log SELECT = door.manage/operate, service-client-only writes |
--- ---
## Tables added by migrations 016-034 (quick map) ## Tables added by migrations 016-035 (quick map)
| Table | Key columns | Purpose | | Table | Key columns | Purpose |
|-------|-------------|---------| |-------|-------------|---------|
@@ -430,6 +431,8 @@ 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` | 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) | | `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) | | `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) |
Column additions: `space_members.tier_id`, `onboarding_completed_at`, `onboarding_progress`; `secrets.encrypted_value`, `encryption_version`; `knowledge_base.render_markdown`, `is_meeting_minutes`, `meeting_date`. New enum `comment_entity_type`. Column additions: `space_members.tier_id`, `onboarding_completed_at`, `onboarding_progress`; `secrets.encrypted_value`, `encryption_version`; `knowledge_base.render_markdown`, `is_meeting_minutes`, `meeting_date`. New enum `comment_entity_type`.
+93 -1
View File
@@ -1,5 +1,15 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { last4, maskCardUid } from '@/lib/door-logic' import {
last4,
maskCardUid,
isAlwaysBlockedHost,
validateDoorTarget,
encodeHeatSyncGrant,
encodeHeatSyncRevoke,
encodeHeatSyncControl,
applyTemplate,
redactDoorSecrets,
} from '@/lib/door-logic'
describe('last4', () => { describe('last4', () => {
it('returns the last 4 chars, or the whole short value', () => { it('returns the last 4 chars, or the whole short value', () => {
@@ -20,3 +30,85 @@ describe('maskCardUid', () => {
expect(maskCardUid('')).toBe('••••') expect(maskCardUid('')).toBe('••••')
}) })
}) })
describe('isAlwaysBlockedHost', () => {
it('blocks cloud metadata and link-local', () => {
expect(isAlwaysBlockedHost('169.254.169.254')).toBe(true)
expect(isAlwaysBlockedHost('169.254.1.2')).toBe(true)
expect(isAlwaysBlockedHost('metadata.google.internal')).toBe(true)
expect(isAlwaysBlockedHost('fe80::1')).toBe(true)
expect(isAlwaysBlockedHost('0.0.0.0')).toBe(true)
})
it('allows ordinary LAN hosts', () => {
expect(isAlwaysBlockedHost('192.168.1.50')).toBe(false)
expect(isAlwaysBlockedHost('10.0.0.5')).toBe(false)
expect(isAlwaysBlockedHost('door.local')).toBe(false)
})
})
describe('validateDoorTarget', () => {
it('allows a LAN target that matches the pin', () => {
const r = validateDoorTarget('http://192.168.1.50/?o1&e=1234', '192.168.1.50')
expect(r.ok).toBe(true)
})
it('rejects a host that does not match the pin (anti-redirect/SSRF)', () => {
const r = validateDoorTarget('http://evil.example.com/x', '192.168.1.50')
expect(r.ok).toBe(false)
})
it('rejects metadata even if someone pins it', () => {
const r = validateDoorTarget('http://169.254.169.254/latest/meta-data/', '169.254.169.254')
expect(r.ok).toBe(false)
})
it('rejects non-http protocols', () => {
expect(validateDoorTarget('file:///etc/passwd', 'x').ok).toBe(false)
expect(validateDoorTarget('gopher://192.168.1.50', '192.168.1.50').ok).toBe(false)
})
it('pin may be entered with scheme/port/path and still matches host', () => {
const r = validateDoorTarget('http://192.168.1.50:8080/?9&e=1234', 'http://192.168.1.50:8080/door')
expect(r.ok).toBe(true)
})
})
describe('encodeHeatSyncGrant', () => {
it('zero-pads slot/perm to 3 and tag to 8 (firmware byte offsets)', () => {
const r = encodeHeatSyncGrant({ slot: 7, permissionMask: 1, tagHex: 'abcd', password: '1234' })
expect(r).toEqual({ ok: true, query: '?m007&p001&t0000abcd&e=1234' })
})
it('rejects out-of-range slot/perm and bad tag', () => {
expect(encodeHeatSyncGrant({ slot: 999, permissionMask: 1, tagHex: 'ab', password: '1' }).ok).toBe(false)
expect(encodeHeatSyncGrant({ slot: 1, permissionMask: 500, tagHex: 'ab', password: '1' }).ok).toBe(false)
expect(encodeHeatSyncGrant({ slot: 1, permissionMask: 1, tagHex: 'xyz', password: '1' }).ok).toBe(false)
expect(encodeHeatSyncGrant({ slot: 1, permissionMask: 1, tagHex: 'ab', password: '' }).ok).toBe(false)
})
})
describe('encodeHeatSyncRevoke / control', () => {
it('revoke zero-pads the slot and appends auth', () => {
expect(encodeHeatSyncRevoke(12, 'pass')).toEqual({ ok: true, query: '?r012&e=pass' })
})
it('control maps verbs and appends auth', () => {
expect(encodeHeatSyncControl('open1', 'pw')).toEqual({ ok: true, query: '?o1&e=pw' })
expect(encodeHeatSyncControl('status', 'pw')).toEqual({ ok: true, query: '?9&e=pw' })
expect(encodeHeatSyncControl('nope', 'pw').ok).toBe(false)
})
})
describe('applyTemplate', () => {
it('substitutes and URL-encodes known vars, leaves unknowns', () => {
expect(applyTemplate('?card={tag}&slot={slot}', { tag: 'a b', slot: 5 })).toBe('?card=a%20b&slot=5')
expect(applyTemplate('?x={missing}', {})).toBe('?x={missing}')
})
})
describe('redactDoorSecrets', () => {
it('scrubs the literal password and e= query value', () => {
expect(redactDoorSecrets('GET http://x/?m007&t00ab&e=1234 -> 200', '1234'))
.toBe('GET http://x/?m007&t00ab&e=<redacted> -> 200')
})
it('scrubs auth-ish params even without the password known', () => {
expect(redactDoorSecrets('?pw=hunter2&token=abc')).toBe('?pw=<redacted>&token=<redacted>')
})
it('leaves unrelated text intact', () => {
expect(redactDoorSecrets('status 200 ok', null)).toBe('status 200 ok')
})
})
@@ -0,0 +1,260 @@
'use client'
import { useState } from 'react'
import { toast } from 'sonner'
import { DoorClosed, 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 {
createDoorConnection,
updateDoorConnection,
deleteDoorConnection,
testDoorConnection,
} from '@/lib/actions'
type Conn = {
id: string
name: string
adapter: string
base_url: string
pinned_host: string
auth_mode: string
auth_param: string | null
secret_ref: string | null
verbs: Record<string, string> | null
allow_member_self_entry: boolean
is_enabled: boolean
}
type LogRow = { id: string; action: string; success: boolean; detail: string | null; occurred_at: 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'
function emptyDraft() {
return {
name: '', adapter: 'native_heatsync', base_url: '', pinned_host: '',
secret_ref: '', allow_member_self_entry: false,
grant: '', revoke: '', open: '', status: '',
}
}
export function DoorManageClient({
initial,
secrets,
log,
}: {
initial: unknown[]
secrets: { id: string; title: string }[]
log: unknown[]
}) {
const confirm = useConfirm()
const [conns, setConns] = useState<Conn[]>(initial as Conn[])
const [showNew, setShowNew] = useState(false)
const [d, setD] = useState(emptyDraft())
const [busy, setBusy] = useState(false)
const rows = log as LogRow[]
function buildVerbs(dr: typeof d) {
if (dr.adapter === 'native_heatsync') return {}
const v: Record<string, string> = {}
if (dr.grant.trim()) v.grant = dr.grant.trim()
if (dr.revoke.trim()) v.revoke = dr.revoke.trim()
if (dr.open.trim()) v.open = dr.open.trim()
if (dr.status.trim()) v.status = dr.status.trim()
return v
}
async function onCreate(e: React.FormEvent) {
e.preventDefault()
if (!d.name.trim() || !d.base_url.trim() || !d.pinned_host.trim()) {
return toast.error('Name, base URL and pinned host are required')
}
setBusy(true)
const res = await createDoorConnection({
name: d.name.trim(),
adapter: d.adapter,
base_url: d.base_url.trim(),
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,
})
setBusy(false)
if ('error' in res && res.error) return toast.error(res.error)
const id = (res as { data: { id: string } }).data.id
setConns(prev => [
{
id, name: d.name.trim(), adapter: d.adapter, base_url: d.base_url.trim(),
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,
},
...prev,
])
setD(emptyDraft())
setShowNew(false)
toast.success('Connection created')
}
async function onToggleEnabled(c: Conn) {
const res = await updateDoorConnection({ connectionId: c.id, is_enabled: !c.is_enabled })
if ('error' in res && res.error) return toast.error(res.error)
setConns(prev => prev.map(x => (x.id === c.id ? { ...x, is_enabled: !x.is_enabled } : x)))
}
async function onToggleSelfEntry(c: Conn) {
const next = !c.allow_member_self_entry
if (next) {
const ok = await confirm({
title: 'Allow member self-entry?',
description: 'Any active member will be able to trigger this door themselves. This is an elevated physical-security risk. Continue?',
confirmText: 'Enable self-entry',
destructive: true,
})
if (!ok) return
}
const res = await updateDoorConnection({ connectionId: c.id, allow_member_self_entry: next })
if ('error' in res && res.error) return toast.error(res.error)
setConns(prev => prev.map(x => (x.id === c.id ? { ...x, allow_member_self_entry: next } : x)))
}
async function onTest(c: Conn) {
setBusy(true)
const res = await testDoorConnection({ connectionId: c.id })
setBusy(false)
if ('error' in res && res.error) return toast.error(`Test failed: ${res.error}`)
toast.success(`Reachable (HTTP ${(res as { data: { status: number } }).data.status})`)
}
async function onDelete(c: Conn) {
const ok = await confirm({
title: 'Delete connection',
description: `"${c.name}" will be removed. Existing access-log entries are kept.`,
confirmText: 'Delete',
destructive: true,
})
if (!ok) return
const res = await deleteDoorConnection({ connectionId: c.id })
if ('error' in res && res.error) return toast.error(res.error)
setConns(prev => prev.filter(x => x.id !== c.id))
toast.success('Deleted')
}
return (
<>
<PageHeader>
<PageTitle>Door access</PageTitle>
<Button size="sm" onClick={() => setShowNew(v => !v)}>
<Plus className="size-4" /> {showNew ? 'Cancel' : 'New connection'}
</Button>
</PageHeader>
<div className="p-4 md:p-6 space-y-4">
<p className="font-sans text-sm text-muted-foreground max-w-2xl">
A connection targets a door controller on your LAN. The shared password is read
from your encrypted Secrets vault, never stored here. The server only ever calls
the exact pinned host, never follows redirects, and records every action in the
access log with secrets redacted.
</p>
{showNew && (
<form onSubmit={onCreate} className="rounded-lg border border-border bg-card p-4 space-y-3">
<input className={input} placeholder="Name (e.g. Front door)" maxLength={200}
value={d.name} onChange={e => setD({ ...d, name: e.target.value })} />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<select className={input} value={d.adapter} onChange={e => setD({ ...d, adapter: e.target.value })}>
<option value="native_heatsync">Native HeatSync controller</option>
<option value="generic_http">Generic HTTP</option>
</select>
<select className={input} value={d.secret_ref} onChange={e => setD({ ...d, secret_ref: e.target.value })}>
<option value="">No password (auth: none)</option>
{secrets.map(s => <option key={s.id} value={s.id}>Password: {s.title}</option>)}
</select>
<input className={input} placeholder="Base URL (http://192.168.1.50/)"
value={d.base_url} onChange={e => setD({ ...d, base_url: e.target.value })} />
<input className={input} placeholder="Pinned host (192.168.1.50)"
value={d.pinned_host} onChange={e => setD({ ...d, pinned_host: e.target.value })} />
</div>
{d.adapter === 'generic_http' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input className={input} placeholder="grant template (?m{slot}&t{tag}&e={pw})"
value={d.grant} onChange={e => setD({ ...d, grant: e.target.value })} />
<input className={input} placeholder="revoke template (?r{slot}&e={pw})"
value={d.revoke} onChange={e => setD({ ...d, revoke: e.target.value })} />
<input className={input} placeholder="open template (?o1&e={pw})"
value={d.open} onChange={e => setD({ ...d, open: e.target.value })} />
<input className={input} placeholder="status template (?9&e={pw})"
value={d.status} onChange={e => setD({ ...d, status: e.target.value })} />
</div>
)}
<label className="flex items-center gap-2 font-mono text-[11px] text-muted-foreground">
<input type="checkbox" checked={d.allow_member_self_entry}
onChange={e => setD({ ...d, allow_member_self_entry: e.target.checked })} />
Allow member self-entry (elevated risk; off by default)
</label>
<Button type="submit" size="sm" disabled={busy}>Create connection</Button>
</form>
)}
{conns.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon"><DoorClosed /></EmptyMedia>
<EmptyTitle>No door connections</EmptyTitle>
<EmptyDescription>Add a controller connection to manage physical access.</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="divide-y rounded-lg border border-border">
{conns.map(c => (
<div key={c.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">{c.name}</span>
<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>}
</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'}
</p>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" variant="outline" disabled={busy} onClick={() => onTest(c)}>Test</Button>
<Button size="sm" variant="outline" onClick={() => onToggleEnabled(c)}>{c.is_enabled ? 'Disable' : 'Enable'}</Button>
<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={() => onDelete(c)}>Delete</Button>
</div>
</div>
))}
</div>
)}
<section>
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-2 mt-6">Access log</h2>
{rows.length === 0 ? (
<p className="font-mono text-[10px] text-muted-foreground">No door activity 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>
</>
)
}
+23
View File
@@ -0,0 +1,23 @@
import { requireDoorManagerPage } from '@/lib/door-guard'
import { listDoorConnections, listSecretTitles, listDoorAccessLog } from '@/lib/actions'
import { DoorManageClient } from './door-manage-client'
export const dynamic = 'force-dynamic'
export default async function DoorManagePage() {
await requireDoorManagerPage()
const [conns, secrets, log] = await Promise.all([
listDoorConnections(),
listSecretTitles(),
listDoorAccessLog(),
])
return (
<DoorManageClient
initial={'data' in conns ? (conns.data as unknown[]) : []}
secrets={'data' in secrets ? (secrets.data as { id: string; title: string }[]) : []}
log={'data' in log ? (log.data as unknown[]) : []}
/>
)
}
+8 -7
View File
@@ -15,7 +15,7 @@ import {
MessageSquare, Users, CreditCard, BookUser, Download, LogOut, MessageSquare, Users, CreditCard, BookUser, Download, LogOut,
Vote, ShieldAlert, ScrollText, LineChart, UserCircle, UserSearch, Vote, ShieldAlert, ScrollText, LineChart, UserCircle, UserSearch,
MessagesSquare, SlidersHorizontal, ClipboardList, FileText, Award, BadgeCheck, GraduationCap, Hammer, MessagesSquare, SlidersHorizontal, ClipboardList, FileText, Award, BadgeCheck, GraduationCap, Hammer,
ChevronDown, ChevronDown, DoorClosed,
} from 'lucide-react' } from 'lucide-react'
interface NavLinkProps { interface NavLinkProps {
@@ -173,6 +173,12 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa
<NavLink href="/policies" label="Policies" icon={ScrollText} active={isActive('/policies')} onClick={onNav} /> <NavLink href="/policies" label="Policies" icon={ScrollText} active={isActive('/policies')} onClick={onNav} />
</NavSection> </NavSection>
<NavSection id="learn" title="Learn">
<NavLink href="/classes" label="Classes" icon={GraduationCap} active={pathname === '/classes' || pathname.startsWith('/classes?')} onClick={onNav} />
<NavLink href="/equipment" label="Equipment" icon={Hammer} active={pathname === '/equipment' || pathname.startsWith('/equipment?')} onClick={onNav} />
<NavLink href="/my-forms" label="Forms" icon={FileText} active={isActive('/my-forms')} onClick={onNav} />
</NavSection>
<NavSection id="people" title="People"> <NavSection id="people" title="People">
<NavLink href="/members" label="Members" icon={Users} active={isActive('/members')} onClick={onNav} /> <NavLink href="/members" label="Members" icon={Users} active={isActive('/members')} onClick={onNav} />
<NavLink href="/contacts" label="Contacts" icon={BookUser} active={isActive('/contacts')} onClick={onNav} /> <NavLink href="/contacts" label="Contacts" icon={BookUser} active={isActive('/contacts')} onClick={onNav} />
@@ -186,12 +192,6 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa
<NavLink href="/financials" label="Financials" icon={LineChart} active={isActive('/financials')} onClick={onNav} /> <NavLink href="/financials" label="Financials" icon={LineChart} active={isActive('/financials')} onClick={onNav} />
</NavSection> </NavSection>
<NavSection id="learn" title="Learn">
<NavLink href="/classes" label="Classes" icon={GraduationCap} active={pathname === '/classes' || pathname.startsWith('/classes?')} onClick={onNav} />
<NavLink href="/equipment" label="Equipment" icon={Hammer} active={pathname === '/equipment' || pathname.startsWith('/equipment?')} onClick={onNav} />
<NavLink href="/my-forms" label="Forms" icon={FileText} active={isActive('/my-forms')} onClick={onNav} />
</NavSection>
<NavSection id="account" title="Account"> <NavSection id="account" title="Account">
<NavLink href="/profile" label="My profile" icon={UserCircle} active={isActive('/profile')} onClick={onNav} /> <NavLink href="/profile" label="My profile" icon={UserCircle} active={isActive('/profile')} onClick={onNav} />
<NavLink href="/me" label="My access" icon={BadgeCheck} active={isActive('/me')} onClick={onNav} /> <NavLink href="/me" label="My access" icon={BadgeCheck} active={isActive('/me')} onClick={onNav} />
@@ -204,6 +204,7 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa
<NavLink href="/certifications" label="Certifications" icon={Award} active={isActive('/certifications')} onClick={onNav} /> <NavLink href="/certifications" label="Certifications" icon={Award} active={isActive('/certifications')} onClick={onNav} />
<NavLink href="/classes/manage" label="Manage classes" icon={GraduationCap} active={isActive('/classes/manage')} onClick={onNav} /> <NavLink href="/classes/manage" label="Manage classes" icon={GraduationCap} active={isActive('/classes/manage')} onClick={onNav} />
<NavLink href="/equipment/manage" label="Manage equipment" icon={Hammer} active={isActive('/equipment/manage')} onClick={onNav} /> <NavLink href="/equipment/manage" label="Manage equipment" icon={Hammer} active={isActive('/equipment/manage')} onClick={onNav} />
<NavLink href="/door/manage" label="Door access" icon={DoorClosed} active={isActive('/door/manage')} onClick={onNav} />
<NavLink href="/import" label="Import / Sync" icon={Download} active={isActive('/import')} onClick={onNav} /> <NavLink href="/import" label="Import / Sync" icon={Download} active={isActive('/import')} onClick={onNav} />
<NavLink href="/settings" label="Settings" icon={Settings2} active={isActive('/settings')} onClick={onNav} /> <NavLink href="/settings" label="Settings" icon={Settings2} active={isActive('/settings')} onClick={onNav} />
</NavSection> </NavSection>
+4
View File
@@ -928,3 +928,7 @@ Registry management is gated by `equipment.manage`; reserving needs only members
- `reserveEquipment(input)` / `cancelReservation(input)` — any member. Validated service-client writes (`equipment_reservations` has no INSERT/DELETE policy): `reserveEquipment` runs `reservationEligibility` (status/active, future window, `hasConflict` no-overlap, required-cert) — a holder of `equipment.manage` gets the override and may pass `memberId` to book on someone's behalf; `cancelReservation` allows the owner or an `equipment.manage` holder. - `reserveEquipment(input)` / `cancelReservation(input)` — any member. Validated service-client writes (`equipment_reservations` has no INSERT/DELETE policy): `reserveEquipment` runs `reservationEligibility` (status/active, future window, `hasConflict` no-overlap, required-cert) — a holder of `equipment.manage` gets the override and may pass `memberId` to book on someone's behalf; `cancelReservation` allows the owner or an `equipment.manage` holder.
- `listEquipmentReservations(input)` — `equipment.manage`; reservations for one item (joined member) for the schedule view. - `listEquipmentReservations(input)` — `equipment.manage`; reservations for one item (joined member) for the schedule view.
- `getMyReservations()` — the signed-in member's own reservations (joined equipment). No params; RLS restricts to own rows (the `/me` view). - `getMyReservations()` — the signed-in member's own reservations (joined equipment). No params; RLS restricts to own rows (the `/me` view).
### Door epic: `member-cards.ts` (migration 034) + `door.ts` (migration 035)
Card UID is a credential. `member-cards.ts`: `addMemberCard`/`updateMemberCard`/`deleteMemberCard`/`listMemberCards` are `door.manage` (full UID); `getMyCards()` is the member's own MASKED view (service client, returns only count + last4, never the raw UID — `member_cards` has no member RLS SELECT).
`door.ts` (all `door.manage` except the read log): `createDoorConnection`/`updateDoorConnection`/`deleteDoorConnection`/`listDoorConnections`; `listSecretTitles()` (titles only, to pick a vault secret for the password). `testDoorConnection(input)` runs only the `status` verb through the hardened executor and writes one redacted audit row. `listDoorAccessLog()` is `door.manage` OR `door.operate`. Security model: the shared password is read from the AES-256-GCM secrets vault server-side and embedded in the request per the verified firmware (auth is a query param); `lib/door/executor.ts` is the single egress — it enforces `validateDoorTarget` (request host must equal the connection's pinned host; metadata/link-local always blocked; http(s) only), refuses redirects, caps time/body, and `redactDoorSecrets` scrubs the password before anything is logged. Native HeatSync verbs are encoded with the verified fixed-width zero-padding (slot 3 / perm 3 / tag 8 / `&e=` password); generic connections use admin `{slot}/{tag}/{pw}` templates. Live grant/revoke/open and the per-connection member self-entry are phase 3.
+24 -1
View File
@@ -421,7 +421,8 @@ customizable permissions + Ops ACLs, self-change hardening, `026`-`029` custom
forms + waivers + onboarding form step + per-space form slug + invite roles, forms + waivers + onboarding form step + per-space form slug + invite roles,
`030` certifications + Instructor capability, `031` secrets SELECT honors `030` certifications + Instructor capability, `031` secrets SELECT honors
`ops.secrets.read`, `032` classes + sessions + signups, `033` equipment + `ops.secrets.read`, `032` classes + sessions + signups, `033` equipment +
reservations). reservations, `034` member access cards + door permissions, `035` door
connections + access log).
### Forms & waivers (migrations 026-029; complete Phases 1-5) ### Forms & waivers (migrations 026-029; complete Phases 1-5)
@@ -510,6 +511,28 @@ rule, and the required-certification gate (checked against the normal
`member_certifications` data; an `equipment.manage` holder gets the `member_certifications` data; an `equipment.manage` holder gets the
override and may book on a member's behalf). No anonymous path. override and may book on a member's behalf). No anonymous path.
### Door / access control (migrations 034-035; epic in progress)
Phased; P1-P2 built. `member_cards` (034) associates RFID/NFC UIDs to
members; the UID is a credential (`door.manage`-only RLS, no member SELECT;
masked count+last4 self-view via a service-client action). `door.manage` /
`door.operate` permissions (group Access). `door_connections` (035) is a
per-space controller integration: the shared password is NOT stored on the
row — `secret_ref` points at the existing AES-256-GCM `secrets` vault and is
decrypted server-side only. `pinned_host` is the SSRF pin. Pure logic in
`lib/door-logic.ts` (SSRF `validateDoorTarget`, always-blocked metadata/
link-local, native HeatSync encoders with the firmware-verified fixed-width
zero-padding, generic template substitution, secret redaction) is heavily
unit-tested. `lib/door/executor.ts` is the single hardened egress: it
re-validates the target against the pin, refuses redirects, caps time and
body, and redacts secrets before any audit write. `door_access_log` is an
append-only, secrets-redacted audit with no client write policy (validated
service-client executor only). `/door/manage` (door.manage) configures
connections, picks a vault secret, and runs a safe `status`-only test.
Phase 3 adds live grant/revoke/open and the per-connection member
self-entry (opt-in, elevated risk, off by default); phases 4-5 add inbound
log ingest and the universal API-call UI builder. No anonymous path.
--- ---
## 13. Known Limitations ## 13. Known Limitations
+2 -1
View File
@@ -686,7 +686,7 @@ CREATE INDEX idx_activity_space ON activity_log(space_id, created_at);
--- ---
## Migrations 014-034 (additions since the 13-table baseline) ## Migrations 014-035 (additions since the 13-table baseline)
`scripts/schema.sql` is the canonical idempotent schema; each numbered `scripts/schema.sql` is the canonical idempotent schema; each numbered
migration is mirrored as a section in it. Tables/columns added: migration is mirrored as a section in it. Tables/columns added:
@@ -714,6 +714,7 @@ migration is mirrored as a section in it. Tables/columns added:
| 032 | `classes`, `class_sessions`, `class_signups` (class offerings, scheduled sessions, member signups; optional `payment_link` and `grants_certification_id`; partial unique = one non-cancelled signup per member+session). RLS additive + default-deny: classes SELECT = `classes.manage` (all) or member (`is_active`), class_sessions SELECT = any space member, class_signups SELECT = manage/instruct (all) or member (own), UPDATE = `classes.instruct`, NO INSERT/DELETE policy (signup/cancel via a validated service-client action enforcing capacity/waitlist/dedupe). New `classes.manage` + `classes.instruct` permissions seeded to board + backfilled. Cert-on-completion uses the normal certifications path (still needs `certifications.grant`). No anonymous path | | 032 | `classes`, `class_sessions`, `class_signups` (class offerings, scheduled sessions, member signups; optional `payment_link` and `grants_certification_id`; partial unique = one non-cancelled signup per member+session). RLS additive + default-deny: classes SELECT = `classes.manage` (all) or member (`is_active`), class_sessions SELECT = any space member, class_signups SELECT = manage/instruct (all) or member (own), UPDATE = `classes.instruct`, NO INSERT/DELETE policy (signup/cancel via a validated service-client action enforcing capacity/waitlist/dedupe). New `classes.manage` + `classes.instruct` permissions seeded to board + backfilled. Cert-on-completion uses the normal certifications path (still needs `certifications.grant`). No anonymous path |
| 033 | `equipment` (registry; status available/maintenance/retired; optional `required_certification_id`; `is_active` archive) + `equipment_reservations` (member time-window reservation; denormalized space_id; no DB overlap constraint). RLS additive + default-deny: equipment SELECT = `equipment.manage` (all) or member (`is_active`), reservations SELECT = `equipment.manage` (all) or member (own), UPDATE = `equipment.manage`, NO INSERT/DELETE policy (reserve/cancel via validated service-client action enforcing status + no-overlap + required-cert, manager override). New `equipment.manage` permission seeded to board + backfilled. No anonymous path | | 033 | `equipment` (registry; status available/maintenance/retired; optional `required_certification_id`; `is_active` archive) + `equipment_reservations` (member time-window reservation; denormalized space_id; no DB overlap constraint). RLS additive + default-deny: equipment SELECT = `equipment.manage` (all) or member (`is_active`), reservations SELECT = `equipment.manage` (all) or member (own), UPDATE = `equipment.manage`, NO INSERT/DELETE policy (reserve/cancel via validated service-client action enforcing status + no-overlap + required-cert, manager override). New `equipment.manage` permission seeded to board + backfilled. No anonymous path |
| 034 | `member_cards` (RFID/NFC card UID -> member; the UID is a credential -- `door.manage`-only RLS, no member SELECT policy; masked self-view via a server action). New `door.manage` + `door.operate` permissions (group Access; the whole Door epic uses them) seeded to board + backfilled. No controller calls in this phase, no anonymous path | | 034 | `member_cards` (RFID/NFC card UID -> member; the UID is a credential -- `door.manage`-only RLS, no member SELECT policy; masked self-view via a server action). New `door.manage` + `door.operate` permissions (group Access; the whole Door epic uses them) seeded to board + backfilled. No controller calls in this phase, no anonymous path |
| 035 | `door_connections` (per-space controller integration; shared password NOT stored -- `secret_ref` -> AES-256-GCM `secrets` vault, decrypted server-side only; `pinned_host` SSRF pin; adapter native_heatsync|generic_http; `allow_member_self_entry` opt-in) + `door_access_log` (append-only, secrets redacted). RLS additive + default-deny: door_connections CRUD = `door.manage`; door_access_log SELECT = `door.manage`/`door.operate`, NO write policy (service-client executor only; immutable). No new permission codes (door.* from 034). No anonymous path |
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`. New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.
+310
View File
@@ -0,0 +1,310 @@
'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 {
createDoorConnectionSchema,
updateDoorConnectionSchema,
doorConnectionIdSchema,
} from '@/lib/validations'
import {
encodeHeatSyncControl,
applyTemplate,
redactDoorSecrets,
} from '@/lib/door-logic'
import { callDoor } from '@/lib/door/executor'
import { decryptSecret } from '@/lib/secrets/crypto'
type Gate =
| { ok: true; supabase: ServerSupabase; member: Member }
| { ok: false; error: string }
async function requireDoorManager(): 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 door connections' }
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
}
function isUniqueViolation(message: string): boolean {
return /duplicate key value|already exists|unique constraint/i.test(message)
}
// ─── Connection CRUD (door.manage) ───────────────────────────────────────────
export async function createDoorConnection(input: unknown) {
const gate = await requireDoorManager()
if (!gate.ok) return { error: gate.error }
const { supabase, member } = gate
const v = parseInput(createDoorConnectionSchema, 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('door_connections')
.insert({
space_id: member.space_id,
name: c.name,
adapter: c.adapter,
base_url: c.base_url,
pinned_host: c.pinned_host,
auth_mode: c.auth_mode,
auth_param: c.auth_param ?? null,
secret_ref: c.secret_ref ?? null,
verbs: c.verbs,
allow_member_self_entry: c.allow_member_self_entry,
is_enabled: c.is_enabled,
created_by: member.id,
})
.select('id')
.single()
if (error) {
if (isUniqueViolation(error.message)) return { error: 'A connection with that name already exists.' }
return { error: error.message }
}
await logActivity(supabase, member, 'created', 'door_connection', data.id as string, c.name)
revalidatePath('/door/manage')
return { data: { id: data.id as string } }
}
export async function updateDoorConnection(input: unknown) {
const gate = await requireDoorManager()
if (!gate.ok) return { error: gate.error }
const { supabase, member } = gate
const v = parseInput(updateDoorConnectionSchema, 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 [
'name', 'adapter', 'base_url', 'pinned_host', 'auth_mode',
'auth_param', 'secret_ref', 'verbs', 'allow_member_self_entry', 'is_enabled',
] as const) {
if (u[k] !== undefined) patch[k] = u[k] === undefined ? null : u[k] ?? null
}
if (Object.keys(patch).length === 0) return { data: { id: u.connectionId } }
const { error } = await supabase
.from('door_connections')
.update(patch)
.eq('id', u.connectionId)
.eq('space_id', member.space_id)
if (error) return { error: error.message }
await logActivity(supabase, member, 'updated', 'door_connection', u.connectionId)
revalidatePath('/door/manage')
return { data: { id: u.connectionId } }
}
export async function deleteDoorConnection(input: unknown) {
const gate = await requireDoorManager()
if (!gate.ok) return { error: gate.error }
const { supabase, member } = gate
const v = parseInput(doorConnectionIdSchema, input)
if (!v.ok) return { error: v.error }
const { error } = await supabase
.from('door_connections')
.delete()
.eq('id', v.data.connectionId)
.eq('space_id', member.space_id)
if (error) return { error: error.message }
await logActivity(supabase, member, 'deleted', 'door_connection', v.data.connectionId)
revalidatePath('/door/manage')
return { data: { id: v.data.connectionId } }
}
export async function listDoorConnections() {
const gate = await requireDoorManager()
if (!gate.ok) return { error: gate.error }
const { supabase, member } = gate
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')
.eq('space_id', member.space_id)
.order('name', { ascending: true })
if (error) return { error: error.message }
return { data: data ?? [] }
}
// Available secrets (titles only) so the admin can pick one for a connection
// without the values ever being exposed here.
export async function listSecretTitles() {
const gate = await requireDoorManager()
if (!gate.ok) return { error: gate.error }
const { member } = gate
const admin = createAdminClient()
const { data, error } = await admin
.from('secrets')
.select('id, title')
.eq('space_id', member.space_id)
.order('title', { ascending: true })
if (error) return { error: error.message }
return { data: (data ?? []).map(s => ({ id: s.id as string, title: s.title as string })) }
}
// Safe connectivity check: runs the 'status' verb only. Never opens a door.
// Writes one redacted audit row.
export async function testDoorConnection(input: unknown) {
const gate = await requireDoorManager()
if (!gate.ok) return { error: gate.error }
const { supabase, member } = gate
const v = parseInput(doorConnectionIdSchema, input)
if (!v.ok) return { error: v.error }
const { data: conn } = await supabase
.from('door_connections')
.select('id, space_id, adapter, base_url, pinned_host, secret_ref, verbs, is_enabled')
.eq('id', v.data.connectionId)
.eq('space_id', member.space_id)
.maybeSingle()
if (!conn) return { error: 'Connection not found' }
if (!conn.is_enabled) return { error: 'This connection is disabled.' }
const admin = createAdminClient()
const password = await resolveSecret(admin, member.space_id, conn.secret_ref as string | null)
let query: string
if (conn.adapter === 'native_heatsync') {
const enc = encodeHeatSyncControl('status', password ?? '')
if (!enc.ok) return { error: enc.reason }
query = enc.query
} else {
const tmpl = (conn.verbs as Record<string, string> | null)?.status
if (!tmpl) return { error: 'This connection has no "status" verb template configured.' }
query = applyTemplate(tmpl, { pw: password ?? '' })
}
const result = await callDoor({
url: (conn.base_url as string) + query,
pinnedHost: conn.pinned_host as string,
password,
})
const detail = result.ok
? redactDoorSecrets(`status ${result.status}: ${result.snippet}`.slice(0, 1000), password)
: redactDoorSecrets(`failed: ${result.reason}`.slice(0, 1000), password)
await admin.from('door_access_log').insert({
space_id: member.space_id,
connection_id: conn.id,
actor_member_id: member.id,
action: 'test',
success: result.ok,
detail,
})
revalidatePath('/door/manage')
return result.ok
? { data: { ok: true, status: result.status } }
: { error: result.reason }
}
export async function listDoorAccessLog() {
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { data: canManage } = await supabase.rpc('user_has_permission', {
uid: member.user_id as string,
sid: member.space_id,
perm: 'door.manage',
})
let permitted = !!canManage
if (!permitted) {
const { data: canOp } = await supabase.rpc('user_has_permission', {
uid: member.user_id as string,
sid: member.space_id,
perm: 'door.operate',
})
permitted = !!canOp
}
if (!permitted) return { error: 'You do not have permission to view the access log' }
const { data, error } = await supabase
.from('door_access_log')
.select('id, connection_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 ?? [] }
}
+3
View File
@@ -59,3 +59,6 @@ export * from './equipment'
// Member access cards (Door epic) // Member access cards (Door epic)
export * from './member-cards' export * from './member-cards'
// Door connections + executor (Door epic)
export * from './door'
+31
View File
@@ -0,0 +1,31 @@
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
// Server-component guard for the /door/manage surface. Mirrors the other
// module guards: resolve the member, check door.manage via the same RPC the
// RLS uses, redirect non-managers before any data is fetched. The RLS
// independently enforces this.
export async function requireDoorManagerPage() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: member } = await supabase
.from('space_members')
.select('id, space_id, role, user_id, display_name')
.eq('user_id', user.id)
.in('status', ['current', 'unverified', 'late'])
.single()
if (!member) redirect('/login')
const { data: allowed } = await supabase.rpc('user_has_permission', {
uid: user.id,
sid: member.space_id,
perm: 'door.manage',
})
if (!allowed) redirect('/dashboard')
return { supabase, member }
}
+144
View File
@@ -12,3 +12,147 @@ export function maskCardUid(uid: string): string {
if (uid.length <= 4) return '•'.repeat(uid.length || 4) if (uid.length <= 4) return '•'.repeat(uid.length || 4)
return '•'.repeat(uid.length - 4) + uid.slice(-4) return '•'.repeat(uid.length - 4) + uid.slice(-4)
} }
// ─── SSRF guard (the dangerous part) ─────────────────────────────────────────
// A door connection deliberately targets a LAN device, so private ranges are
// allowed — but ONLY the exact host the admin pinned, and never the cloud
// metadata / link-local range. The executor additionally forbids redirects
// and caps response size/time; this pure function decides if a target URL is
// allowed to be called at all.
// Always-blocked, regardless of pin: IMDS / link-local and unspecified.
export function isAlwaysBlockedHost(host: string): boolean {
const h = host.toLowerCase().replace(/^\[|\]$/g, '')
if (h === '169.254.169.254' || h === 'metadata' || h === 'metadata.google.internal') return true
if (h.startsWith('169.254.')) return true // IPv4 link-local (includes IMDS)
if (h === '0.0.0.0' || h === '::' || h === '0:0:0:0:0:0:0:0') return true
if (h === 'fd00:ec2::254') return true // AWS IPv6 IMDS
if (h.startsWith('fe80:')) return true // IPv6 link-local
return false
}
export type DoorTarget =
| { ok: true; url: string; host: string }
| { ok: false; reason: string }
// Validate a fully-formed request URL against the connection's pinned host.
// pinnedHost is exactly what the admin entered (hostname or IP, optional
// :port is compared on the host only). The request host must equal the pin
// (case-insensitive) and must not be an always-blocked address.
export function validateDoorTarget(rawUrl: string, pinnedHost: string): DoorTarget {
let u: URL
try {
u = new URL(rawUrl)
} catch {
return { ok: false, reason: 'The request URL is not a valid URL.' }
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
return { ok: false, reason: 'Only http and https are allowed.' }
}
const host = u.hostname.toLowerCase()
const pin = pinnedHost.trim().toLowerCase().replace(/^https?:\/\//, '').split('/')[0].split(':')[0]
if (!pin) return { ok: false, reason: 'This connection has no pinned host.' }
if (isAlwaysBlockedHost(host)) {
return { ok: false, reason: 'That host is blocked (link-local / metadata).' }
}
if (host !== pin) {
return { ok: false, reason: `Request host "${host}" does not match the pinned host "${pin}".` }
}
return { ok: true, url: u.toString(), host }
}
// ─── HeatSync native adapter encoders ────────────────────────────────────────
// Firmware (zyphlar/Open_Access_Control_Ethernet.ino, re-verified 2026-05-17)
// parses FIXED-WIDTH ZERO-PADDED query params by byte offset: user slot = 3
// digits, perm mask = 3 digits, tag = 8 hex chars, password = the shared
// secret. Auth is satisfied when "?e=" or "&e=" is present, so every
// privileged verb appends "&e=<pw>". Getting the widths wrong addresses the
// WRONG slot — so these are pure and unit-tested.
function pad(n: number, width: number): string {
return String(n).padStart(width, '0')
}
export type EncodeResult = { ok: true; query: string } | { ok: false; reason: string }
function checkSlot(slot: number): string | null {
if (!Number.isInteger(slot) || slot < 0 || slot > 200) return 'Card slot must be an integer 0-200.'
return null
}
export function encodeHeatSyncGrant(input: {
slot: number
permissionMask: number
tagHex: string
password: string
}): EncodeResult {
const slotErr = checkSlot(input.slot)
if (slotErr) return { ok: false, reason: slotErr }
if (!Number.isInteger(input.permissionMask) || input.permissionMask < 0 || input.permissionMask > 255) {
return { ok: false, reason: 'Permission mask must be an integer 0-255.' }
}
if (!/^[0-9a-fA-F]{1,8}$/.test(input.tagHex)) {
return { ok: false, reason: 'Tag must be 1-8 hexadecimal characters.' }
}
if (input.password.length < 1 || input.password.length > 16) {
return { ok: false, reason: 'Door password is missing or implausible.' }
}
const tag = input.tagHex.toLowerCase().padStart(8, '0')
return {
ok: true,
query: `?m${pad(input.slot, 3)}&p${pad(input.permissionMask, 3)}&t${tag}&e=${input.password}`,
}
}
export function encodeHeatSyncRevoke(slot: number, password: string): EncodeResult {
const slotErr = checkSlot(slot)
if (slotErr) return { ok: false, reason: slotErr }
if (!password) return { ok: false, reason: 'Door password is missing.' }
return { ok: true, query: `?r${pad(slot, 3)}&e=${password}` }
}
// verb: 'open1' | 'open2' | 'unlock' | 'lock' | 'arm' | 'disarm' | 'status' | 'log'
export function encodeHeatSyncControl(verb: string, password: string): EncodeResult {
if (!password) return { ok: false, reason: 'Door password is missing.' }
const map: Record<string, string> = {
open1: '?o1',
open2: '?o2',
unlock: '?u',
lock: '?l',
disarm: '?1',
arm: '?2',
status: '?9',
log: '?z',
}
const base = map[verb]
if (!base) return { ok: false, reason: `Unknown door verb "${verb}".` }
return { ok: true, query: `${base}&e=${password}` }
}
// ─── Generic adapter template substitution ───────────────────────────────────
// A generic connection stores a path/query template per verb with {slot},
// {tag}, {perm}, {door}, {pw} placeholders. Substitution is literal and the
// values are URL-encoded; unknown placeholders are left untouched so a
// misconfig fails loudly rather than silently calling something unintended.
export function applyTemplate(
template: string,
vars: Record<string, string | number>,
): string {
return template.replace(/\{(\w+)\}/g, (m, key) =>
key in vars ? encodeURIComponent(String(vars[key])) : m,
)
}
// Before anything (URL, response snippet) is written to the audit log, scrub
// the shared door password: any literal occurrence, and any e=/&e= query
// value (the firmware auth param), become "<redacted>". The password is a
// credential and must never land in door_access_log.
export function redactDoorSecrets(text: string, password?: string | null): string {
let out = text
if (password && password.length > 0) {
out = out.split(password).join('<redacted>')
}
out = out.replace(/([?&](?:e|pw|password|api[_-]?key|token)=)[^&\s"']*/gi, '$1<redacted>')
return out
}
+68
View File
@@ -0,0 +1,68 @@
// 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.
import { validateDoorTarget, redactDoorSecrets } from '@/lib/door-logic'
export type DoorCallResult =
| { ok: true; status: number; snippet: string }
| { ok: false; reason: string; status?: number }
const MAX_BODY = 4096
const SNIPPET = 500
export async function callDoor(opts: {
url: string
pinnedHost: string
password?: string | null
timeoutMs?: number
}): Promise<DoorCallResult> {
const guard = validateDoorTarget(opts.url, opts.pinnedHost)
if (!guard.ok) return { ok: false, reason: guard.reason }
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 6000)
try {
const res = await fetch(guard.url, {
method: 'GET',
// Never chase a redirect: a 3xx could bounce us off the pinned host.
redirect: 'manual',
signal: controller.signal,
headers: { accept: 'text/plain, text/html' },
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).',
status: res.status || undefined,
}
}
let received = ''
const reader = res.body?.getReader()
if (reader) {
const dec = new TextDecoder()
while (received.length < MAX_BODY) {
const { done, value } = await reader.read()
if (done) break
received += dec.decode(value, { stream: true })
}
try {
await reader.cancel()
} catch {
/* ignore */
}
}
const snippet = redactDoorSecrets(received.slice(0, SNIPPET), opts.password)
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) }
} finally {
clearTimeout(timer)
}
}
+43
View File
@@ -903,6 +903,49 @@ export const listMemberCardsSchema = z.object({
memberId: z.string().uuid('Invalid member ID'), memberId: z.string().uuid('Invalid member ID'),
}) })
// ─── Door connections (Door epic P2) ─────────────────────────────────────────
const httpUrl = z
.string()
.trim()
.max(2000)
.regex(/^https?:\/\/.+/i, 'Must be an http(s) URL')
const doorAdapter = z.enum(['native_heatsync', 'generic_http'])
const doorAuthMode = z.enum(['none', 'query', 'header', 'bearer'])
export const createDoorConnectionSchema = z.object({
name: z.string().min(1, 'Name is required').max(200),
adapter: doorAdapter.optional().default('generic_http'),
base_url: httpUrl,
pinned_host: z.string().min(1, 'A pinned host is required').max(255),
auth_mode: doorAuthMode.optional().default('none'),
auth_param: z.string().max(120).optional().nullable(),
secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(),
// generic adapter per-verb templates (validated as a flat string map)
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),
})
export const updateDoorConnectionSchema = z.object({
connectionId: z.string().uuid('Invalid connection ID'),
name: z.string().min(1).max(200).optional(),
adapter: doorAdapter.optional(),
base_url: httpUrl.optional(),
pinned_host: z.string().min(1).max(255).optional(),
auth_mode: doorAuthMode.optional(),
auth_param: z.string().max(120).optional().nullable(),
secret_ref: z.string().uuid('Invalid secret reference').optional().nullable(),
verbs: z.record(z.string().max(2000)).optional(),
allow_member_self_entry: z.boolean().optional(),
is_enabled: z.boolean().optional(),
})
export const doorConnectionIdSchema = z.object({
connectionId: z.string().uuid('Invalid connection ID'),
})
// ─── Generic ID schemas ────────────────────────────────────────────────────── // ─── Generic ID schemas ──────────────────────────────────────────────────────
export const uuidSchema = z.string().uuid('Invalid ID format') export const uuidSchema = z.string().uuid('Invalid ID format')
+95
View File
@@ -0,0 +1,95 @@
-- =============================================================================
-- 035: Door connections + access log (Door epic, phase 2)
-- =============================================================================
-- door_connections is a per-space configured integration to a physical door
-- controller. SECURITY-CRITICAL:
--
-- * The shared door password is NOT stored here. secret_ref references a
-- row in the existing AES-256-GCM `secrets` vault; the executor loads and
-- decrypts it server-side only and never returns/logs it.
-- * pinned_host is the SSRF pin: the executor will only ever call that exact
-- host (no redirects, size/time caps). A door is LAN-only, so private
-- ranges are allowed -- but only the pinned host, never metadata.
-- * adapter is 'native_heatsync' (verified query-string firmware) or
-- 'generic_http' (admin-supplied per-verb templates).
-- * allow_member_self_entry opts a connection into the member "buzz me in"
-- action (phase 3). Off by default; elevated risk, per-connection only.
--
-- door_access_log is an append-only audit of every door action (who, what,
-- result), with secrets redacted before write. No client write policy: only
-- the validated service-client executor inserts rows.
--
-- No new permission codes: door.manage / door.operate were added in 034.
-- RLS (additive, default-deny; the guarded surface):
-- * door_connections: all CRUD = door.manage.
-- * door_access_log SELECT = door.manage OR door.operate; NO
-- INSERT/UPDATE/DELETE policy (service-client executor only; immutable).
-- No anonymous path.
--
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS, DROP POLICY IF EXISTS then
-- CREATE.
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.door_connections (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 200),
adapter text NOT NULL DEFAULT 'generic_http'
CHECK (adapter IN ('native_heatsync','generic_http')),
base_url text NOT NULL CHECK (base_url ~ '^https?://'),
pinned_host text NOT NULL CHECK (char_length(pinned_host) BETWEEN 1 AND 255),
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,
verbs jsonb NOT NULL DEFAULT '{}',
allow_member_self_entry boolean NOT NULL DEFAULT false,
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_door_connections_space ON public.door_connections (space_id);
CREATE TABLE IF NOT EXISTS public.door_access_log (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
connection_id uuid REFERENCES public.door_connections(id) ON DELETE SET NULL,
actor_member_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
target_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_door_access_log_space ON public.door_access_log (space_id, occurred_at DESC);
ALTER TABLE public.door_connections ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.door_access_log ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS door_connections_select ON public.door_connections;
DROP POLICY IF EXISTS door_connections_insert ON public.door_connections;
DROP POLICY IF EXISTS door_connections_update ON public.door_connections;
DROP POLICY IF EXISTS door_connections_delete ON public.door_connections;
CREATE POLICY door_connections_select ON public.door_connections FOR SELECT
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
CREATE POLICY door_connections_insert ON public.door_connections FOR INSERT
WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
CREATE POLICY door_connections_update ON public.door_connections FOR UPDATE
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
CREATE POLICY door_connections_delete ON public.door_connections FOR DELETE
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
DROP POLICY IF EXISTS door_access_log_select ON public.door_access_log;
CREATE POLICY door_access_log_select ON public.door_access_log FOR SELECT
USING (
public.user_has_permission(auth.uid(), space_id, 'door.manage')
OR public.user_has_permission(auth.uid(), space_id, 'door.operate')
);
-- No INSERT/UPDATE/DELETE policy: the validated service-client executor is
-- the only writer, and the audit trail is immutable.
DROP TRIGGER IF EXISTS trg_door_connections_touch ON public.door_connections;
CREATE TRIGGER trg_door_connections_touch
BEFORE UPDATE ON public.door_connections
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
+79
View File
@@ -2374,3 +2374,82 @@ ON CONFLICT (space_id, subject, permission) DO NOTHING;
INSERT INTO public.space_role_permissions (space_id, subject, permission) INSERT INTO public.space_role_permissions (space_id, subject, permission)
SELECT id, 'board', 'door.operate' FROM public.spaces SELECT id, 'board', 'door.operate' FROM public.spaces
ON CONFLICT (space_id, subject, permission) DO NOTHING; ON CONFLICT (space_id, subject, permission) DO NOTHING;
-- =============================================================================
-- 22. Door connections + access log (Door epic, phase 2)
-- (equivalent to scripts/035_door_connections.sql).
--
-- door_connections: per-space controller integration. The shared door
-- password is NOT here -- secret_ref -> AES-256-GCM `secrets` vault,
-- decrypted server-side only. pinned_host is the SSRF pin (executor only
-- ever calls that exact host, no redirects, size/time caps). adapter
-- native_heatsync|generic_http; allow_member_self_entry opt-in (phase 3).
-- door_access_log: append-only, secrets redacted, service-client-only
-- writes (no client write policy; immutable audit). RLS additive/
-- default-deny: door_connections CRUD = door.manage; door_access_log
-- SELECT = door.manage OR door.operate. No anonymous path. No new
-- permission codes (door.manage/door.operate from migration 034).
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.door_connections (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 200),
adapter text NOT NULL DEFAULT 'generic_http'
CHECK (adapter IN ('native_heatsync','generic_http')),
base_url text NOT NULL CHECK (base_url ~ '^https?://'),
pinned_host text NOT NULL CHECK (char_length(pinned_host) BETWEEN 1 AND 255),
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,
verbs jsonb NOT NULL DEFAULT '{}',
allow_member_self_entry boolean NOT NULL DEFAULT false,
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_door_connections_space ON public.door_connections (space_id);
CREATE TABLE IF NOT EXISTS public.door_access_log (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
connection_id uuid REFERENCES public.door_connections(id) ON DELETE SET NULL,
actor_member_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
target_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_door_access_log_space ON public.door_access_log (space_id, occurred_at DESC);
ALTER TABLE public.door_connections ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.door_access_log ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS door_connections_select ON public.door_connections;
DROP POLICY IF EXISTS door_connections_insert ON public.door_connections;
DROP POLICY IF EXISTS door_connections_update ON public.door_connections;
DROP POLICY IF EXISTS door_connections_delete ON public.door_connections;
CREATE POLICY door_connections_select ON public.door_connections FOR SELECT
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
CREATE POLICY door_connections_insert ON public.door_connections FOR INSERT
WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
CREATE POLICY door_connections_update ON public.door_connections FOR UPDATE
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
CREATE POLICY door_connections_delete ON public.door_connections FOR DELETE
USING (public.user_has_permission(auth.uid(), space_id, 'door.manage'));
DROP POLICY IF EXISTS door_access_log_select ON public.door_access_log;
CREATE POLICY door_access_log_select ON public.door_access_log FOR SELECT
USING (
public.user_has_permission(auth.uid(), space_id, 'door.manage')
OR public.user_has_permission(auth.uid(), space_id, 'door.operate')
);
DROP TRIGGER IF EXISTS trg_door_connections_touch ON public.door_connections;
CREATE TRIGGER trg_door_connections_touch
BEFORE UPDATE ON public.door_connections
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
+12
View File
@@ -154,6 +154,18 @@ export type Database = {
Update: { id?: string; space_id?: string; member_id?: string; card_uid?: string; card_type?: string; label?: string | null; is_active?: boolean; created_by?: string | null; created_at?: string; updated_at?: string } Update: { id?: string; space_id?: string; member_id?: string; card_uid?: string; card_type?: string; label?: string | null; is_active?: boolean; created_by?: string | null; created_at?: string; updated_at?: string }
Relationships: [] 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 }
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 }
Relationships: []
}
activity_log: { activity_log: {
Row: { Row: {
action: string action: string