diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 9e3780b..5dc344d 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -929,6 +929,8 @@ Registry management is gated by `equipment.manage`; reserving needs only members - `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). -### Door epic: `member-cards.ts` (migration 034) + `door.ts` (migration 035) +### Door epic: `member-cards.ts` (migration 034) + `door.ts` (migrations 035-036) 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. +`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. + +Phase 3 live actions (all `door.operate`, rate-limited, every attempt writes one redacted `door_access_log` row, service client after the permission check since operators have no RLS read on `door_connections`/`member_cards`): `grantCard({connectionId, cardId, permissionMask?})` allocates the card's per-connection integer slot via the pure lowest-free allocator (`door_card_slots`; idempotent re-grant, `slot_exhausted` when full), reserves it in the DB first, calls the controller, and rolls the reservation back if the call fails. `revokeCard({connectionId, cardId})` is idempotent (no slot assigned means already revoked) and frees the slot only on confirmed controller success so the app's map never diverges from the device. `doorControl({connectionId, verb})` with `verb` of `open`/`unlock`/`lock` (HeatSync `open` is a momentary `o1`; generic controllers need the matching verb template) touches no slot. The per-connection member self-entry action is the next, separately reviewed step. diff --git a/lib/actions/door.ts b/lib/actions/door.ts index ffe174d..146222b 100644 --- a/lib/actions/door.ts +++ b/lib/actions/door.ts @@ -14,14 +14,21 @@ import { createDoorConnectionSchema, updateDoorConnectionSchema, doorConnectionIdSchema, + doorGrantSchema, + doorRevokeSchema, + doorControlSchema, } from '@/lib/validations' import { encodeHeatSyncControl, + encodeHeatSyncGrant, + encodeHeatSyncRevoke, applyTemplate, redactDoorSecrets, } from '@/lib/door-logic' +import { pickLowestFreeSlot, slotCapacity } from '@/lib/door-slots-logic' import { callDoor } from '@/lib/door/executor' import { decryptSecret } from '@/lib/secrets/crypto' +import { checkRateLimit } from '@/lib/security' type Gate = | { ok: true; supabase: ServerSupabase; member: Member } @@ -308,3 +315,334 @@ export async function listDoorAccessLog() { 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 + 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, 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 + }, +) { + 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), + }) +} + +// 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 }) + + 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: result.reason } + } + + 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 }) + + 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: result.reason } + } + + 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 } } +} + +// 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 }) + + 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: result.reason } +} diff --git a/lib/validations.ts b/lib/validations.ts index 1cef09a..615cf36 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -946,6 +946,25 @@ export const doorConnectionIdSchema = z.object({ connectionId: z.string().uuid('Invalid connection ID'), }) +// ─── Door live actions (door.operate) ──────────────────────────────────────── + +export const doorGrantSchema = z.object({ + connectionId: z.string().uuid('Invalid connection ID'), + cardId: z.string().uuid('Invalid card ID'), + // HeatSync permission mask; defaults to 1 (basic access). + permissionMask: z.number().int().min(0).max(255).optional().default(1), +}) + +export const doorRevokeSchema = z.object({ + connectionId: z.string().uuid('Invalid connection ID'), + cardId: z.string().uuid('Invalid card ID'), +}) + +export const doorControlSchema = z.object({ + connectionId: z.string().uuid('Invalid connection ID'), + verb: z.enum(['open', 'unlock', 'lock']), +}) + // ─── Generic ID schemas ────────────────────────────────────────────────────── export const uuidSchema = z.string().uuid('Invalid ID format')