From 39bcdd967758b459d4056c45f53f9b3e55d694d1 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Tue, 4 Aug 2026 06:01:02 -0700 Subject: [PATCH] refactor(actions): split door.ts into a folder (slice 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 813-line lib/actions/door.ts → lib/actions/door/{connections,operate,self-entry}.ts ('use server') + _guards.ts + _audit.ts + index.ts barrel. All 14 actions preserved verbatim; lib/door/ (executor/ingest/secret) untouched. tsc 0, tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/actions/door.ts | 813 -------------------------------- lib/actions/door/_audit.ts | 112 +++++ lib/actions/door/_guards.ts | 40 ++ lib/actions/door/connections.ts | 265 +++++++++++ lib/actions/door/index.ts | 13 + lib/actions/door/operate.ts | 285 +++++++++++ lib/actions/door/self-entry.ts | 145 ++++++ 7 files changed, 860 insertions(+), 813 deletions(-) delete mode 100644 lib/actions/door.ts create mode 100644 lib/actions/door/_audit.ts create mode 100644 lib/actions/door/_guards.ts create mode 100644 lib/actions/door/connections.ts create mode 100644 lib/actions/door/index.ts create mode 100644 lib/actions/door/operate.ts create mode 100644 lib/actions/door/self-entry.ts diff --git a/lib/actions/door.ts b/lib/actions/door.ts deleted file mode 100644 index c57944d..0000000 --- a/lib/actions/door.ts +++ /dev/null @@ -1,813 +0,0 @@ -'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, - doorGrantSchema, - doorRevokeSchema, - doorControlSchema, -} from '@/lib/validations' -import { - encodeHeatSyncControl, - encodeHeatSyncGrant, - encodeHeatSyncRevoke, - applyTemplate, - redactDoorSecrets, - last4, -} from '@/lib/door-logic' -import { pickLowestFreeSlot, slotCapacity } from '@/lib/door-slots-logic' -import { callDoor } from '@/lib/door/executor' -import { resolveDoorSecret } from '@/lib/door/secret' -import { checkRateLimit } from '@/lib/security' - -type Gate = - | { ok: true; supabase: ServerSupabase; member: Member } - | { ok: false; error: string } - -async function requireDoorManager(): Promise { - 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 } -} - -// Connection door password is loaded + decrypted via the shared -// resolveDoorSecret (lib/door/secret.ts); service client, never returned to -// the browser. Aliased here to keep the existing call sites unchanged. -const resolveSecret = resolveDoorSecret - -function isUniqueViolation(message: string): boolean { - return /duplicate key value|already exists|unique constraint/i.test(message) -} - -// ─── 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 - - const refs = [c.secret_ref, c.inbound_secret_ref].filter(Boolean) as string[] - if (refs.length > 0) { - const admin = createAdminClient() - for (const ref of refs) { - const { data: sec } = await admin - .from('secrets') - .select('id') - .eq('id', ref) - .eq('space_id', member.space_id) - .maybeSingle() - if (!sec) return { error: 'The referenced secret was not found in this space.' } - } - } - - const { data, error } = await supabase - .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, - inbound_enabled: c.inbound_enabled, - inbound_secret_ref: c.inbound_secret_ref ?? null, - 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 - - const refs = [u.secret_ref, u.inbound_secret_ref].filter(Boolean) as string[] - if (refs.length > 0) { - const admin = createAdminClient() - for (const ref of refs) { - const { data: sec } = await admin - .from('secrets') - .select('id') - .eq('id', ref) - .eq('space_id', member.space_id) - .maybeSingle() - if (!sec) return { error: 'The referenced secret was not found in this space.' } - } - } - - const patch: Record = {} - for (const k of [ - 'name', 'adapter', 'base_url', 'pinned_host', 'auth_mode', - 'auth_param', 'secret_ref', 'verbs', 'allow_member_self_entry', 'is_enabled', - 'inbound_enabled', 'inbound_secret_ref', - ] as const) { - if (u[k] !== undefined) patch[k] = u[k] === undefined ? null : u[k] ?? null - } - 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, inbound_enabled, inbound_secret_ref, 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, auth_param, 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 | null)?.status - if (!tmpl) return { error: 'This connection has no "status" verb template configured.' } - query = applyTemplate(tmpl, { pw: password ?? '' }) - } - - const authParam = (conn.auth_param as string | null) ?? null - const result = await callDoor({ - url: (conn.base_url as string) + query, - pinnedHost: conn.pinned_host as string, - password, - authParam, - }) - - const detail = result.ok - ? redactDoorSecrets(`status ${result.status}: ${result.snippet}`.slice(0, 1000), password, authParam) - : redactDoorSecrets(`failed: ${result.reason}`.slice(0, 1000), password, authParam) - - 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: 'The door controller call failed. Check the door audit log for the redacted detail.' } -} - -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 ?? [] } -} - -// ─── Live actions (door.operate) ───────────────────────────────────────────── -// These reach the physical controller. door.operate holders deliberately do -// NOT have RLS SELECT on door_connections / member_cards (the UID is a -// credential), so every read here goes through the service client AFTER the -// operator permission is verified, always scoped by space_id. Every attempt -// writes one redacted door_access_log row; the executor is the single egress. - -async function requireDoorOperator(): Promise { - 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.operate', - }) - if (error) return { ok: false, error: error.message } - if (!allowed) return { ok: false, error: 'You do not have permission to operate the door' } - return { ok: true, supabase, member } -} - -type ConnRow = { - id: string - adapter: string - base_url: string - pinned_host: string - auth_param: string | null - secret_ref: string | null - verbs: Record | null - is_enabled: boolean -} - -async function loadEnabledConnection( - admin: ReturnType, - spaceId: string, - connectionId: string, -): Promise<{ ok: true; conn: ConnRow } | { ok: false; error: string }> { - const { data } = await admin - .from('door_connections') - .select('id, adapter, base_url, pinned_host, auth_param, secret_ref, verbs, is_enabled') - .eq('id', connectionId) - .eq('space_id', spaceId) - .maybeSingle() - if (!data) return { ok: false, error: 'Connection not found' } - if (!data.is_enabled) return { ok: false, error: 'This connection is disabled.' } - return { ok: true, conn: data as unknown as ConnRow } -} - -async function auditDoor( - admin: ReturnType, - row: { - spaceId: string - connectionId: string - actorMemberId: string - targetMemberId?: string | null - action: string - success: boolean - detail: string - password?: string | null - authParam?: string | null - }, -) { - await admin.from('door_access_log').insert({ - space_id: row.spaceId, - connection_id: row.connectionId, - actor_member_id: row.actorMemberId, - target_member_id: row.targetMemberId ?? null, - action: row.action, - success: row.success, - detail: redactDoorSecrets(row.detail.slice(0, 1000), row.password, row.authParam), - }) -} - -// Reserve (or reuse) the card's slot on this connection. The DB unique -// constraints arbitrate concurrency: (connection_id, card_id) makes re-grant -// idempotent; (connection_id, slot) makes a slot race fail loudly so we retry. -async function reserveSlot( - admin: ReturnType, - spaceId: string, - connectionId: string, - cardId: string, - createdBy: string, -): Promise< - | { ok: true; slot: number; created: boolean } - | { ok: false; error: string } -> { - for (let attempt = 0; attempt < 3; attempt++) { - const { data: existing } = await admin - .from('door_card_slots') - .select('slot') - .eq('connection_id', connectionId) - .eq('card_id', cardId) - .maybeSingle() - if (existing) return { ok: true, slot: existing.slot as number, created: false } - - const { data: used } = await admin - .from('door_card_slots') - .select('slot') - .eq('connection_id', connectionId) - const pick = pickLowestFreeSlot((used ?? []).map(r => r.slot as number)) - if (!pick.ok) { - const cap = slotCapacity() - return { ok: false, error: `This controller is full (${cap}/${cap} card slots used). Revoke an unused card before granting another.` } - } - - const { error: insErr } = await admin.from('door_card_slots').insert({ - space_id: spaceId, - connection_id: connectionId, - card_id: cardId, - slot: pick.slot, - created_by: createdBy, - }) - if (!insErr) return { ok: true, slot: pick.slot, created: true } - // Unique violation = a concurrent grant took this slot or this card. - // Loop: re-read (the card row may now exist → reuse it; else re-pick). - if (!isUniqueViolation(insErr.message)) return { ok: false, error: insErr.message } - } - return { ok: false, error: 'Could not allocate a free slot (contention). Try again.' } -} - -export async function grantCard(input: unknown) { - const gate = await requireDoorOperator() - if (!gate.ok) return { error: gate.error } - const { member } = gate - - const v = parseInput(doorGrantSchema, input) - if (!v.ok) return { error: v.error } - const { connectionId, cardId, permissionMask } = v.data - - const rl = checkRateLimit(`door-grant:${member.space_id}:${member.id}`, 30, 60_000) - if (!rl.allowed) return { error: 'Too many door changes. Slow down and try again shortly.' } - - const admin = createAdminClient() - const cx = await loadEnabledConnection(admin, member.space_id, connectionId) - if (!cx.ok) return { error: cx.error } - const conn = cx.conn - - const { data: card } = await admin - .from('member_cards') - .select('id, member_id, card_uid, is_active') - .eq('id', cardId) - .eq('space_id', member.space_id) - .maybeSingle() - if (!card) return { error: 'Card not found in this space' } - if (!card.is_active) return { error: 'That card is inactive. Reactivate it before granting access.' } - const targetMemberId = (card.member_id as string) ?? null - - const password = await resolveSecret(admin, member.space_id, conn.secret_ref) - - const reserved = await reserveSlot(admin, member.space_id, connectionId, cardId, member.id) - if (!reserved.ok) { - await auditDoor(admin, { - spaceId: member.space_id, connectionId, actorMemberId: member.id, - targetMemberId, action: 'grant', success: false, - detail: `failed: ${reserved.error}`, password, - }) - return { error: reserved.error } - } - - let query: string - if (conn.adapter === 'native_heatsync') { - const enc = encodeHeatSyncGrant({ - slot: reserved.slot, - permissionMask, - tagHex: card.card_uid as string, - password: password ?? '', - }) - if (!enc.ok) { - if (reserved.created) await admin.from('door_card_slots').delete().eq('connection_id', connectionId).eq('card_id', cardId) - return { error: enc.reason } - } - query = enc.query - } else { - const tmpl = conn.verbs?.grant - if (!tmpl) { - if (reserved.created) await admin.from('door_card_slots').delete().eq('connection_id', connectionId).eq('card_id', cardId) - return { error: 'This connection has no "grant" verb template configured.' } - } - query = applyTemplate(tmpl, { - slot: reserved.slot, tag: card.card_uid as string, perm: permissionMask, pw: password ?? '', - }) - } - - const result = await callDoor({ url: conn.base_url + query, pinnedHost: conn.pinned_host, password, authParam: conn.auth_param }) - - if (!result.ok) { - // Roll back the reservation so slots do not leak on a failed write. - if (reserved.created) { - await admin.from('door_card_slots').delete().eq('connection_id', connectionId).eq('card_id', cardId) - } - await auditDoor(admin, { - spaceId: member.space_id, connectionId, actorMemberId: member.id, - targetMemberId, action: 'grant', success: false, - detail: `failed: ${result.reason}`, password, - }) - return { error: 'The door controller call failed. Check the door audit log for the redacted detail.' } - } - - await auditDoor(admin, { - spaceId: member.space_id, connectionId, actorMemberId: member.id, - targetMemberId, action: 'grant', success: true, - detail: `slot ${reserved.slot} · status ${result.status}: ${result.snippet}`, password, - }) - revalidatePath('/door/manage') - return { data: { ok: true, slot: reserved.slot } } -} - -export async function revokeCard(input: unknown) { - const gate = await requireDoorOperator() - if (!gate.ok) return { error: gate.error } - const { member } = gate - - const v = parseInput(doorRevokeSchema, input) - if (!v.ok) return { error: v.error } - const { connectionId, cardId } = v.data - - const rl = checkRateLimit(`door-revoke:${member.space_id}:${member.id}`, 30, 60_000) - if (!rl.allowed) return { error: 'Too many door changes. Slow down and try again shortly.' } - - const admin = createAdminClient() - const cx = await loadEnabledConnection(admin, member.space_id, connectionId) - if (!cx.ok) return { error: cx.error } - const conn = cx.conn - - const { data: card } = await admin - .from('member_cards') - .select('id, member_id') - .eq('id', cardId) - .eq('space_id', member.space_id) - .maybeSingle() - const targetMemberId = (card?.member_id as string) ?? null - - const { data: slotRow } = await admin - .from('door_card_slots') - .select('slot') - .eq('connection_id', connectionId) - .eq('card_id', cardId) - .maybeSingle() - - const password = await resolveSecret(admin, member.space_id, conn.secret_ref) - - // Idempotent: nothing assigned means it is already revoked. - if (!slotRow) { - await auditDoor(admin, { - spaceId: member.space_id, connectionId, actorMemberId: member.id, - targetMemberId, action: 'revoke', success: true, - detail: 'no slot assigned (already revoked)', password, - }) - revalidatePath('/door/manage') - return { data: { ok: true, alreadyRevoked: true } } - } - const slot = slotRow.slot as number - - let query: string - if (conn.adapter === 'native_heatsync') { - const enc = encodeHeatSyncRevoke(slot, password ?? '') - if (!enc.ok) return { error: enc.reason } - query = enc.query - } else { - const tmpl = conn.verbs?.revoke - if (!tmpl) return { error: 'This connection has no "revoke" verb template configured.' } - query = applyTemplate(tmpl, { slot, pw: password ?? '' }) - } - - const result = await callDoor({ url: conn.base_url + query, pinnedHost: conn.pinned_host, password, authParam: conn.auth_param }) - - if (!result.ok) { - // Keep the slot row so the app's map stays in sync with the device's - // belief; the operator can retry. - await auditDoor(admin, { - spaceId: member.space_id, connectionId, actorMemberId: member.id, - targetMemberId, action: 'revoke', success: false, - detail: `failed (slot ${slot} kept): ${result.reason}`, password, - }) - return { error: 'The door controller call failed. Check the door audit log for the redacted detail.' } - } - - await admin.from('door_card_slots').delete().eq('connection_id', connectionId).eq('card_id', cardId) - await auditDoor(admin, { - spaceId: member.space_id, connectionId, actorMemberId: member.id, - targetMemberId, action: 'revoke', success: true, - detail: `slot ${slot} freed · status ${result.status}: ${result.snippet}`, password, - }) - revalidatePath('/door/manage') - return { data: { ok: true } } -} - -// Active cards in the space plus the slot (if any) each holds on this -// connection, for the operator grant/revoke UI. door.operate; service client -// (operators have no RLS read on member_cards). The raw UID is never returned -// -- only the masked last4, like the member self-view. -export async function listDoorCards(input: unknown) { - const gate = await requireDoorOperator() - if (!gate.ok) return { error: gate.error } - const { member } = gate - - const v = parseInput(doorConnectionIdSchema, input) - if (!v.ok) return { error: v.error } - - const admin = createAdminClient() - const { data: cards, error } = await admin - .from('member_cards') - .select('id, card_uid, label, member_id, space_members!member_cards_member_id_fkey(display_name)') - .eq('space_id', member.space_id) - .eq('is_active', true) - .order('created_at', { ascending: false }) - if (error) return { error: error.message } - - const { data: slots } = await admin - .from('door_card_slots') - .select('card_id, slot') - .eq('connection_id', v.data.connectionId) - const slotByCard = new Map() - for (const s of slots ?? []) slotByCard.set(s.card_id as string, s.slot as number) - - return { - data: (cards ?? []).map(c => { - // TODO(types): remove after regenerating types/database.ts (missing FK relationship metadata) - const sm = c.space_members as unknown as { display_name: string | null } | { display_name: string | null }[] | null - const name = Array.isArray(sm) ? sm[0]?.display_name : sm?.display_name - return { - cardId: c.id as string, - memberName: name ?? 'Unknown member', - label: (c.label as string | null) ?? null, - last4: last4(c.card_uid as string), - slot: slotByCard.has(c.id as string) ? (slotByCard.get(c.id as string) as number) : null, - } - }), - } -} - -// Open / unlock / lock. No slot involvement. HeatSync 'open' = momentary o1. -// Generic controllers must have the matching verb template configured. -export async function doorControl(input: unknown) { - const gate = await requireDoorOperator() - if (!gate.ok) return { error: gate.error } - const { member } = gate - - const v = parseInput(doorControlSchema, input) - if (!v.ok) return { error: v.error } - const { connectionId, verb } = v.data - - const rl = checkRateLimit(`door-control:${member.space_id}:${member.id}`, 20, 60_000) - if (!rl.allowed) return { error: 'Too many door commands. Slow down and try again shortly.' } - - const admin = createAdminClient() - const cx = await loadEnabledConnection(admin, member.space_id, connectionId) - if (!cx.ok) return { error: cx.error } - const conn = cx.conn - - const password = await resolveSecret(admin, member.space_id, conn.secret_ref) - - let query: string - if (conn.adapter === 'native_heatsync') { - const enc = encodeHeatSyncControl(verb === 'open' ? 'open1' : verb, password ?? '') - if (!enc.ok) return { error: enc.reason } - query = enc.query - } else { - const tmpl = conn.verbs?.[verb] - if (!tmpl) return { error: `This connection has no "${verb}" verb template configured.` } - query = applyTemplate(tmpl, { pw: password ?? '' }) - } - - const result = await callDoor({ url: conn.base_url + query, pinnedHost: conn.pinned_host, password, authParam: conn.auth_param }) - - await auditDoor(admin, { - spaceId: member.space_id, connectionId, actorMemberId: member.id, - action: verb, success: result.ok, - detail: result.ok ? `status ${result.status}: ${result.snippet}` : `failed: ${result.reason}`, - password, - }) - - revalidatePath('/door/manage') - return result.ok ? { data: { ok: true, status: result.status } } : { error: 'The door controller call failed. Check the door audit log for the redacted detail.' } -} - -// ─── Member self-entry ("buzz me in") ──────────────────────────────────────── -// ELEVATED RISK, opt-in per connection. Any active member with at least one -// active card on file (the locked eligibility rule: a door_card_slots row is -// NOT required) may trigger a momentary OPEN on a connection that has -// allow_member_self_entry on. Never unlock/lock/grant/revoke; never anonymous; -// the member can only ever open the door for themselves (membership + cards -// resolved server-side, nothing identifying taken from the client). Strict -// per-member rate limit; every attempt writes one redacted door_access_log -// row through the single hardened executor. - -// Connections in the caller's space that are enabled AND opted into member -// self-entry, returned ONLY when the caller has an active card on file (so -// the dashboard surface is hidden entirely for ineligible members). Members -// have no RLS read on door_connections/member_cards, so this is a service- -// client read after requireMember. -export async function listSelfEntryDoors() { - const supabase = await createClient() - const auth = await requireMember(supabase) - if (!auth.ok) return { error: auth.error } - const { member } = auth - - const admin = createAdminClient() - const { count: cardCount } = await admin - .from('member_cards') - .select('id', { count: 'exact', head: true }) - .eq('space_id', member.space_id) - .eq('member_id', member.id) - .eq('is_active', true) - if (!cardCount || cardCount < 1) return { data: [] } - - const { data: conns, error } = await admin - .from('door_connections') - .select('id, name') - .eq('space_id', member.space_id) - .eq('is_enabled', true) - .eq('allow_member_self_entry', true) - .order('name', { ascending: true }) - if (error) return { error: error.message } - return { data: (conns ?? []).map(c => ({ id: c.id as string, name: c.name as string })) } -} - -export async function selfEntry(input: unknown) { - const supabase = await createClient() - const auth = await requireMember(supabase) - if (!auth.ok) return { error: auth.error } - const { member } = auth - - const v = parseInput(doorConnectionIdSchema, input) - if (!v.ok) return { error: v.error } - - const rl = checkRateLimit(`door-self:${member.space_id}:${member.id}`, 5, 60_000) - if (!rl.allowed) return { error: 'Too many entry attempts. Wait a moment and try again.' } - - const admin = createAdminClient() - - const { data: conn } = await admin - .from('door_connections') - .select('id, adapter, base_url, pinned_host, auth_param, secret_ref, verbs, is_enabled, allow_member_self_entry') - .eq('id', v.data.connectionId) - .eq('space_id', member.space_id) - .maybeSingle() - if (!conn || !conn.is_enabled || !conn.allow_member_self_entry) { - return { error: 'Self-entry is not available for that door.' } - } - - // Locked rule: any active card on file is enough (no door_card_slots row). - const { count: cardCount } = await admin - .from('member_cards') - .select('id', { count: 'exact', head: true }) - .eq('space_id', member.space_id) - .eq('member_id', member.id) - .eq('is_active', true) - if (!cardCount || cardCount < 1) { - await auditDoor(admin, { - spaceId: member.space_id, connectionId: conn.id as string, actorMemberId: member.id, - targetMemberId: member.id, action: 'self_entry', success: false, - detail: 'denied: no active card on file', - }) - return { error: 'You have no active access card on file. Ask a door manager to add one.' } - } - - 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('open1', password ?? '') - if (!enc.ok) return { error: enc.reason } - query = enc.query - } else { - const tmpl = (conn.verbs as Record | null)?.open - if (!tmpl) return { error: 'This door has no "open" command configured.' } - query = applyTemplate(tmpl, { pw: password ?? '' }) - } - - const result = await callDoor({ - url: (conn.base_url as string) + query, - pinnedHost: conn.pinned_host as string, - password, - authParam: (conn.auth_param as string | null) ?? null, - }) - - await auditDoor(admin, { - spaceId: member.space_id, connectionId: conn.id as string, actorMemberId: member.id, - targetMemberId: member.id, action: 'self_entry', success: result.ok, - detail: result.ok ? `status ${result.status}: ${result.snippet}` : `failed: ${result.reason}`, - password, - }) - - revalidatePath('/dashboard') - return result.ok ? { data: { ok: true } } : { error: 'The door controller call failed. Check the door audit log for the redacted detail.' } -} - -// The signed-in member's OWN door activity (rows where they were the actor or -// the target). door_access_log has no member-facing RLS SELECT, so this uses -// the service client after requireMember and filters to this member only. The -// detail column is already secret-redacted at write time. -export async function listMyDoorActivity() { - const supabase = await createClient() - const auth = await requireMember(supabase) - if (!auth.ok) return { error: auth.error } - const { member } = auth - - const admin = createAdminClient() - const { data, error } = await admin - .from('door_access_log') - .select('id, action, success, detail, occurred_at') - .eq('space_id', member.space_id) - .or(`actor_member_id.eq.${member.id},target_member_id.eq.${member.id}`) - .order('occurred_at', { ascending: false }) - .limit(50) - if (error) return { error: error.message } - return { data: data ?? [] } -} diff --git a/lib/actions/door/_audit.ts b/lib/actions/door/_audit.ts new file mode 100644 index 0000000..c988b6d --- /dev/null +++ b/lib/actions/door/_audit.ts @@ -0,0 +1,112 @@ +import { createAdminClient } from '@/lib/supabase/admin' +import { redactDoorSecrets } from '@/lib/door-logic' +import { pickLowestFreeSlot, slotCapacity } from '@/lib/door-slots-logic' +import { resolveDoorSecret } from '@/lib/door/secret' + +// Connection door password is loaded + decrypted via the shared +// resolveDoorSecret (lib/door/secret.ts); service client, never returned to +// the browser. Aliased here to keep the existing call sites unchanged. +export const resolveSecret = resolveDoorSecret + +export function isUniqueViolation(message: string): boolean { + return /duplicate key value|already exists|unique constraint/i.test(message) +} + +type ConnRow = { + id: string + adapter: string + base_url: string + pinned_host: string + auth_param: string | null + secret_ref: string | null + verbs: Record | null + is_enabled: boolean +} + +export async function loadEnabledConnection( + admin: ReturnType, + spaceId: string, + connectionId: string, +): Promise<{ ok: true; conn: ConnRow } | { ok: false; error: string }> { + const { data } = await admin + .from('door_connections') + .select('id, adapter, base_url, pinned_host, auth_param, secret_ref, verbs, is_enabled') + .eq('id', connectionId) + .eq('space_id', spaceId) + .maybeSingle() + if (!data) return { ok: false, error: 'Connection not found' } + if (!data.is_enabled) return { ok: false, error: 'This connection is disabled.' } + return { ok: true, conn: data as unknown as ConnRow } +} + +export async function auditDoor( + admin: ReturnType, + row: { + spaceId: string + connectionId: string + actorMemberId: string + targetMemberId?: string | null + action: string + success: boolean + detail: string + password?: string | null + authParam?: string | null + }, +) { + await admin.from('door_access_log').insert({ + space_id: row.spaceId, + connection_id: row.connectionId, + actor_member_id: row.actorMemberId, + target_member_id: row.targetMemberId ?? null, + action: row.action, + success: row.success, + detail: redactDoorSecrets(row.detail.slice(0, 1000), row.password, row.authParam), + }) +} + +// Reserve (or reuse) the card's slot on this connection. The DB unique +// constraints arbitrate concurrency: (connection_id, card_id) makes re-grant +// idempotent; (connection_id, slot) makes a slot race fail loudly so we retry. +export async function reserveSlot( + admin: ReturnType, + spaceId: string, + connectionId: string, + cardId: string, + createdBy: string, +): Promise< + | { ok: true; slot: number; created: boolean } + | { ok: false; error: string } +> { + for (let attempt = 0; attempt < 3; attempt++) { + const { data: existing } = await admin + .from('door_card_slots') + .select('slot') + .eq('connection_id', connectionId) + .eq('card_id', cardId) + .maybeSingle() + if (existing) return { ok: true, slot: existing.slot as number, created: false } + + const { data: used } = await admin + .from('door_card_slots') + .select('slot') + .eq('connection_id', connectionId) + const pick = pickLowestFreeSlot((used ?? []).map(r => r.slot as number)) + if (!pick.ok) { + const cap = slotCapacity() + return { ok: false, error: `This controller is full (${cap}/${cap} card slots used). Revoke an unused card before granting another.` } + } + + const { error: insErr } = await admin.from('door_card_slots').insert({ + space_id: spaceId, + connection_id: connectionId, + card_id: cardId, + slot: pick.slot, + created_by: createdBy, + }) + if (!insErr) return { ok: true, slot: pick.slot, created: true } + // Unique violation = a concurrent grant took this slot or this card. + // Loop: re-read (the card row may now exist → reuse it; else re-pick). + if (!isUniqueViolation(insErr.message)) return { ok: false, error: insErr.message } + } + return { ok: false, error: 'Could not allocate a free slot (contention). Try again.' } +} diff --git a/lib/actions/door/_guards.ts b/lib/actions/door/_guards.ts new file mode 100644 index 0000000..3092671 --- /dev/null +++ b/lib/actions/door/_guards.ts @@ -0,0 +1,40 @@ +import { createClient } from '@/lib/supabase/server' +import { + requireMember, + type Member, + type ServerSupabase, +} from '@/lib/auth-helpers' + +export type Gate = + | { ok: true; supabase: ServerSupabase; member: Member } + | { ok: false; error: string } + +export async function requireDoorManager(): Promise { + 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 } +} + +export async function requireDoorOperator(): Promise { + 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.operate', + }) + if (error) return { ok: false, error: error.message } + if (!allowed) return { ok: false, error: 'You do not have permission to operate the door' } + return { ok: true, supabase, member } +} diff --git a/lib/actions/door/connections.ts b/lib/actions/door/connections.ts new file mode 100644 index 0000000..dc26f7c --- /dev/null +++ b/lib/actions/door/connections.ts @@ -0,0 +1,265 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { createClient } from '@/lib/supabase/server' +import { createAdminClient } from '@/lib/supabase/admin' +import { + requireMember, + logActivity, + parseInput, +} 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 { requireDoorManager } from './_guards' +import { resolveSecret, isUniqueViolation } from './_audit' + +// ─── 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 + + const refs = [c.secret_ref, c.inbound_secret_ref].filter(Boolean) as string[] + if (refs.length > 0) { + const admin = createAdminClient() + for (const ref of refs) { + const { data: sec } = await admin + .from('secrets') + .select('id') + .eq('id', ref) + .eq('space_id', member.space_id) + .maybeSingle() + if (!sec) return { error: 'The referenced secret was not found in this space.' } + } + } + + const { data, error } = await supabase + .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, + inbound_enabled: c.inbound_enabled, + inbound_secret_ref: c.inbound_secret_ref ?? null, + 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 + + const refs = [u.secret_ref, u.inbound_secret_ref].filter(Boolean) as string[] + if (refs.length > 0) { + const admin = createAdminClient() + for (const ref of refs) { + const { data: sec } = await admin + .from('secrets') + .select('id') + .eq('id', ref) + .eq('space_id', member.space_id) + .maybeSingle() + if (!sec) return { error: 'The referenced secret was not found in this space.' } + } + } + + const patch: Record = {} + for (const k of [ + 'name', 'adapter', 'base_url', 'pinned_host', 'auth_mode', + 'auth_param', 'secret_ref', 'verbs', 'allow_member_self_entry', 'is_enabled', + 'inbound_enabled', 'inbound_secret_ref', + ] as const) { + if (u[k] !== undefined) patch[k] = u[k] === undefined ? null : u[k] ?? null + } + 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, inbound_enabled, inbound_secret_ref, 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, auth_param, 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 | null)?.status + if (!tmpl) return { error: 'This connection has no "status" verb template configured.' } + query = applyTemplate(tmpl, { pw: password ?? '' }) + } + + const authParam = (conn.auth_param as string | null) ?? null + const result = await callDoor({ + url: (conn.base_url as string) + query, + pinnedHost: conn.pinned_host as string, + password, + authParam, + }) + + const detail = result.ok + ? redactDoorSecrets(`status ${result.status}: ${result.snippet}`.slice(0, 1000), password, authParam) + : redactDoorSecrets(`failed: ${result.reason}`.slice(0, 1000), password, authParam) + + 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: 'The door controller call failed. Check the door audit log for the redacted detail.' } +} + +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 ?? [] } +} diff --git a/lib/actions/door/index.ts b/lib/actions/door/index.ts new file mode 100644 index 0000000..55a4d2f --- /dev/null +++ b/lib/actions/door/index.ts @@ -0,0 +1,13 @@ +/** + * Barrel re-export for the door server actions. + * + * Split from the original single `door.ts` into section files. This barrel + * keeps `@/lib/actions/door` resolving to the same set of exports. + * + * Do NOT add `'use server'` here. The directive lives on the section files + * (connections/operate/self-entry); re-exports preserve the marking. + */ + +export * from './connections' +export * from './operate' +export * from './self-entry' diff --git a/lib/actions/door/operate.ts b/lib/actions/door/operate.ts new file mode 100644 index 0000000..8279389 --- /dev/null +++ b/lib/actions/door/operate.ts @@ -0,0 +1,285 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { createAdminClient } from '@/lib/supabase/admin' +import { parseInput } from '@/lib/auth-helpers' +import { + doorGrantSchema, + doorRevokeSchema, + doorControlSchema, + doorConnectionIdSchema, +} from '@/lib/validations' +import { + encodeHeatSyncControl, + encodeHeatSyncGrant, + encodeHeatSyncRevoke, + applyTemplate, + last4, +} from '@/lib/door-logic' +import { callDoor } from '@/lib/door/executor' +import { checkRateLimit } from '@/lib/security' +import { requireDoorOperator } from './_guards' +import { resolveSecret, loadEnabledConnection, auditDoor, reserveSlot } from './_audit' + +// ─── Live actions (door.operate) ───────────────────────────────────────────── +// These reach the physical controller. door.operate holders deliberately do +// NOT have RLS SELECT on door_connections / member_cards (the UID is a +// credential), so every read here goes through the service client AFTER the +// operator permission is verified, always scoped by space_id. Every attempt +// writes one redacted door_access_log row; the executor is the single egress. + +export async function grantCard(input: unknown) { + const gate = await requireDoorOperator() + if (!gate.ok) return { error: gate.error } + const { member } = gate + + const v = parseInput(doorGrantSchema, input) + if (!v.ok) return { error: v.error } + const { connectionId, cardId, permissionMask } = v.data + + const rl = checkRateLimit(`door-grant:${member.space_id}:${member.id}`, 30, 60_000) + if (!rl.allowed) return { error: 'Too many door changes. Slow down and try again shortly.' } + + const admin = createAdminClient() + const cx = await loadEnabledConnection(admin, member.space_id, connectionId) + if (!cx.ok) return { error: cx.error } + const conn = cx.conn + + const { data: card } = await admin + .from('member_cards') + .select('id, member_id, card_uid, is_active') + .eq('id', cardId) + .eq('space_id', member.space_id) + .maybeSingle() + if (!card) return { error: 'Card not found in this space' } + if (!card.is_active) return { error: 'That card is inactive. Reactivate it before granting access.' } + const targetMemberId = (card.member_id as string) ?? null + + const password = await resolveSecret(admin, member.space_id, conn.secret_ref) + + const reserved = await reserveSlot(admin, member.space_id, connectionId, cardId, member.id) + if (!reserved.ok) { + await auditDoor(admin, { + spaceId: member.space_id, connectionId, actorMemberId: member.id, + targetMemberId, action: 'grant', success: false, + detail: `failed: ${reserved.error}`, password, + }) + return { error: reserved.error } + } + + let query: string + if (conn.adapter === 'native_heatsync') { + const enc = encodeHeatSyncGrant({ + slot: reserved.slot, + permissionMask, + tagHex: card.card_uid as string, + password: password ?? '', + }) + if (!enc.ok) { + if (reserved.created) await admin.from('door_card_slots').delete().eq('connection_id', connectionId).eq('card_id', cardId) + return { error: enc.reason } + } + query = enc.query + } else { + const tmpl = conn.verbs?.grant + if (!tmpl) { + if (reserved.created) await admin.from('door_card_slots').delete().eq('connection_id', connectionId).eq('card_id', cardId) + return { error: 'This connection has no "grant" verb template configured.' } + } + query = applyTemplate(tmpl, { + slot: reserved.slot, tag: card.card_uid as string, perm: permissionMask, pw: password ?? '', + }) + } + + const result = await callDoor({ url: conn.base_url + query, pinnedHost: conn.pinned_host, password, authParam: conn.auth_param }) + + if (!result.ok) { + // Roll back the reservation so slots do not leak on a failed write. + if (reserved.created) { + await admin.from('door_card_slots').delete().eq('connection_id', connectionId).eq('card_id', cardId) + } + await auditDoor(admin, { + spaceId: member.space_id, connectionId, actorMemberId: member.id, + targetMemberId, action: 'grant', success: false, + detail: `failed: ${result.reason}`, password, + }) + return { error: 'The door controller call failed. Check the door audit log for the redacted detail.' } + } + + await auditDoor(admin, { + spaceId: member.space_id, connectionId, actorMemberId: member.id, + targetMemberId, action: 'grant', success: true, + detail: `slot ${reserved.slot} · status ${result.status}: ${result.snippet}`, password, + }) + revalidatePath('/door/manage') + return { data: { ok: true, slot: reserved.slot } } +} + +export async function revokeCard(input: unknown) { + const gate = await requireDoorOperator() + if (!gate.ok) return { error: gate.error } + const { member } = gate + + const v = parseInput(doorRevokeSchema, input) + if (!v.ok) return { error: v.error } + const { connectionId, cardId } = v.data + + const rl = checkRateLimit(`door-revoke:${member.space_id}:${member.id}`, 30, 60_000) + if (!rl.allowed) return { error: 'Too many door changes. Slow down and try again shortly.' } + + const admin = createAdminClient() + const cx = await loadEnabledConnection(admin, member.space_id, connectionId) + if (!cx.ok) return { error: cx.error } + const conn = cx.conn + + const { data: card } = await admin + .from('member_cards') + .select('id, member_id') + .eq('id', cardId) + .eq('space_id', member.space_id) + .maybeSingle() + const targetMemberId = (card?.member_id as string) ?? null + + const { data: slotRow } = await admin + .from('door_card_slots') + .select('slot') + .eq('connection_id', connectionId) + .eq('card_id', cardId) + .maybeSingle() + + const password = await resolveSecret(admin, member.space_id, conn.secret_ref) + + // Idempotent: nothing assigned means it is already revoked. + if (!slotRow) { + await auditDoor(admin, { + spaceId: member.space_id, connectionId, actorMemberId: member.id, + targetMemberId, action: 'revoke', success: true, + detail: 'no slot assigned (already revoked)', password, + }) + revalidatePath('/door/manage') + return { data: { ok: true, alreadyRevoked: true } } + } + const slot = slotRow.slot as number + + let query: string + if (conn.adapter === 'native_heatsync') { + const enc = encodeHeatSyncRevoke(slot, password ?? '') + if (!enc.ok) return { error: enc.reason } + query = enc.query + } else { + const tmpl = conn.verbs?.revoke + if (!tmpl) return { error: 'This connection has no "revoke" verb template configured.' } + query = applyTemplate(tmpl, { slot, pw: password ?? '' }) + } + + const result = await callDoor({ url: conn.base_url + query, pinnedHost: conn.pinned_host, password, authParam: conn.auth_param }) + + if (!result.ok) { + // Keep the slot row so the app's map stays in sync with the device's + // belief; the operator can retry. + await auditDoor(admin, { + spaceId: member.space_id, connectionId, actorMemberId: member.id, + targetMemberId, action: 'revoke', success: false, + detail: `failed (slot ${slot} kept): ${result.reason}`, password, + }) + return { error: 'The door controller call failed. Check the door audit log for the redacted detail.' } + } + + await admin.from('door_card_slots').delete().eq('connection_id', connectionId).eq('card_id', cardId) + await auditDoor(admin, { + spaceId: member.space_id, connectionId, actorMemberId: member.id, + targetMemberId, action: 'revoke', success: true, + detail: `slot ${slot} freed · status ${result.status}: ${result.snippet}`, password, + }) + revalidatePath('/door/manage') + return { data: { ok: true } } +} + +// Active cards in the space plus the slot (if any) each holds on this +// connection, for the operator grant/revoke UI. door.operate; service client +// (operators have no RLS read on member_cards). The raw UID is never returned +// -- only the masked last4, like the member self-view. +export async function listDoorCards(input: unknown) { + const gate = await requireDoorOperator() + if (!gate.ok) return { error: gate.error } + const { member } = gate + + const v = parseInput(doorConnectionIdSchema, input) + if (!v.ok) return { error: v.error } + + const admin = createAdminClient() + const { data: cards, error } = await admin + .from('member_cards') + .select('id, card_uid, label, member_id, space_members!member_cards_member_id_fkey(display_name)') + .eq('space_id', member.space_id) + .eq('is_active', true) + .order('created_at', { ascending: false }) + if (error) return { error: error.message } + + const { data: slots } = await admin + .from('door_card_slots') + .select('card_id, slot') + .eq('connection_id', v.data.connectionId) + const slotByCard = new Map() + for (const s of slots ?? []) slotByCard.set(s.card_id as string, s.slot as number) + + return { + data: (cards ?? []).map(c => { + // TODO(types): remove after regenerating types/database.ts (missing FK relationship metadata) + const sm = c.space_members as unknown as { display_name: string | null } | { display_name: string | null }[] | null + const name = Array.isArray(sm) ? sm[0]?.display_name : sm?.display_name + return { + cardId: c.id as string, + memberName: name ?? 'Unknown member', + label: (c.label as string | null) ?? null, + last4: last4(c.card_uid as string), + slot: slotByCard.has(c.id as string) ? (slotByCard.get(c.id as string) as number) : null, + } + }), + } +} + +// Open / unlock / lock. No slot involvement. HeatSync 'open' = momentary o1. +// Generic controllers must have the matching verb template configured. +export async function doorControl(input: unknown) { + const gate = await requireDoorOperator() + if (!gate.ok) return { error: gate.error } + const { member } = gate + + const v = parseInput(doorControlSchema, input) + if (!v.ok) return { error: v.error } + const { connectionId, verb } = v.data + + const rl = checkRateLimit(`door-control:${member.space_id}:${member.id}`, 20, 60_000) + if (!rl.allowed) return { error: 'Too many door commands. Slow down and try again shortly.' } + + const admin = createAdminClient() + const cx = await loadEnabledConnection(admin, member.space_id, connectionId) + if (!cx.ok) return { error: cx.error } + const conn = cx.conn + + const password = await resolveSecret(admin, member.space_id, conn.secret_ref) + + let query: string + if (conn.adapter === 'native_heatsync') { + const enc = encodeHeatSyncControl(verb === 'open' ? 'open1' : verb, password ?? '') + if (!enc.ok) return { error: enc.reason } + query = enc.query + } else { + const tmpl = conn.verbs?.[verb] + if (!tmpl) return { error: `This connection has no "${verb}" verb template configured.` } + query = applyTemplate(tmpl, { pw: password ?? '' }) + } + + const result = await callDoor({ url: conn.base_url + query, pinnedHost: conn.pinned_host, password, authParam: conn.auth_param }) + + await auditDoor(admin, { + spaceId: member.space_id, connectionId, actorMemberId: member.id, + action: verb, success: result.ok, + detail: result.ok ? `status ${result.status}: ${result.snippet}` : `failed: ${result.reason}`, + password, + }) + + revalidatePath('/door/manage') + return result.ok ? { data: { ok: true, status: result.status } } : { error: 'The door controller call failed. Check the door audit log for the redacted detail.' } +} diff --git a/lib/actions/door/self-entry.ts b/lib/actions/door/self-entry.ts new file mode 100644 index 0000000..a8f824e --- /dev/null +++ b/lib/actions/door/self-entry.ts @@ -0,0 +1,145 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { createClient } from '@/lib/supabase/server' +import { createAdminClient } from '@/lib/supabase/admin' +import { requireMember, parseInput } from '@/lib/auth-helpers' +import { doorConnectionIdSchema } from '@/lib/validations' +import { encodeHeatSyncControl, applyTemplate } from '@/lib/door-logic' +import { callDoor } from '@/lib/door/executor' +import { checkRateLimit } from '@/lib/security' +import { resolveSecret, auditDoor } from './_audit' + +// ─── Member self-entry ("buzz me in") ──────────────────────────────────────── +// ELEVATED RISK, opt-in per connection. Any active member with at least one +// active card on file (the locked eligibility rule: a door_card_slots row is +// NOT required) may trigger a momentary OPEN on a connection that has +// allow_member_self_entry on. Never unlock/lock/grant/revoke; never anonymous; +// the member can only ever open the door for themselves (membership + cards +// resolved server-side, nothing identifying taken from the client). Strict +// per-member rate limit; every attempt writes one redacted door_access_log +// row through the single hardened executor. + +// Connections in the caller's space that are enabled AND opted into member +// self-entry, returned ONLY when the caller has an active card on file (so +// the dashboard surface is hidden entirely for ineligible members). Members +// have no RLS read on door_connections/member_cards, so this is a service- +// client read after requireMember. +export async function listSelfEntryDoors() { + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const admin = createAdminClient() + const { count: cardCount } = await admin + .from('member_cards') + .select('id', { count: 'exact', head: true }) + .eq('space_id', member.space_id) + .eq('member_id', member.id) + .eq('is_active', true) + if (!cardCount || cardCount < 1) return { data: [] } + + const { data: conns, error } = await admin + .from('door_connections') + .select('id, name') + .eq('space_id', member.space_id) + .eq('is_enabled', true) + .eq('allow_member_self_entry', true) + .order('name', { ascending: true }) + if (error) return { error: error.message } + return { data: (conns ?? []).map(c => ({ id: c.id as string, name: c.name as string })) } +} + +export async function selfEntry(input: unknown) { + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const v = parseInput(doorConnectionIdSchema, input) + if (!v.ok) return { error: v.error } + + const rl = checkRateLimit(`door-self:${member.space_id}:${member.id}`, 5, 60_000) + if (!rl.allowed) return { error: 'Too many entry attempts. Wait a moment and try again.' } + + const admin = createAdminClient() + + const { data: conn } = await admin + .from('door_connections') + .select('id, adapter, base_url, pinned_host, auth_param, secret_ref, verbs, is_enabled, allow_member_self_entry') + .eq('id', v.data.connectionId) + .eq('space_id', member.space_id) + .maybeSingle() + if (!conn || !conn.is_enabled || !conn.allow_member_self_entry) { + return { error: 'Self-entry is not available for that door.' } + } + + // Locked rule: any active card on file is enough (no door_card_slots row). + const { count: cardCount } = await admin + .from('member_cards') + .select('id', { count: 'exact', head: true }) + .eq('space_id', member.space_id) + .eq('member_id', member.id) + .eq('is_active', true) + if (!cardCount || cardCount < 1) { + await auditDoor(admin, { + spaceId: member.space_id, connectionId: conn.id as string, actorMemberId: member.id, + targetMemberId: member.id, action: 'self_entry', success: false, + detail: 'denied: no active card on file', + }) + return { error: 'You have no active access card on file. Ask a door manager to add one.' } + } + + 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('open1', password ?? '') + if (!enc.ok) return { error: enc.reason } + query = enc.query + } else { + const tmpl = (conn.verbs as Record | null)?.open + if (!tmpl) return { error: 'This door has no "open" command configured.' } + query = applyTemplate(tmpl, { pw: password ?? '' }) + } + + const result = await callDoor({ + url: (conn.base_url as string) + query, + pinnedHost: conn.pinned_host as string, + password, + authParam: (conn.auth_param as string | null) ?? null, + }) + + await auditDoor(admin, { + spaceId: member.space_id, connectionId: conn.id as string, actorMemberId: member.id, + targetMemberId: member.id, action: 'self_entry', success: result.ok, + detail: result.ok ? `status ${result.status}: ${result.snippet}` : `failed: ${result.reason}`, + password, + }) + + revalidatePath('/dashboard') + return result.ok ? { data: { ok: true } } : { error: 'The door controller call failed. Check the door audit log for the redacted detail.' } +} + +// The signed-in member's OWN door activity (rows where they were the actor or +// the target). door_access_log has no member-facing RLS SELECT, so this uses +// the service client after requireMember and filters to this member only. The +// detail column is already secret-redacted at write time. +export async function listMyDoorActivity() { + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const admin = createAdminClient() + const { data, error } = await admin + .from('door_access_log') + .select('id, action, success, detail, occurred_at') + .eq('space_id', member.space_id) + .or(`actor_member_id.eq.${member.id},target_member_id.eq.${member.id}`) + .order('occurred_at', { ascending: false }) + .limit(50) + if (error) return { error: error.message } + return { data: data ?? [] } +}