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:
+4
-1
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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[]) : []}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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 ?? [] }
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user