feat(door): P3-C live grant/revoke/open-lock-unlock actions

door.operate, rate-limited, every attempt writes one redacted
door_access_log row. Operators have no RLS read on door_connections/
member_cards so reads use the service client after the permission check,
space-scoped. grantCard reserves the slot in the DB first (unique
constraints arbitrate concurrency), calls the controller, rolls the
reservation back on failure; idempotent re-grant; slot_exhausted when
full. revokeCard idempotent; frees the slot only on confirmed controller
success so the app map never diverges from the device. doorControl
open/unlock/lock via the single hardened executor. API_REFERENCE.
This commit is contained in:
Moheeb Zara
2026-05-16 21:09:59 -07:00
parent a5e1ed985c
commit e5cedb62e5
3 changed files with 361 additions and 2 deletions
+4 -2
View File
@@ -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. - `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) ### 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). 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.
+338
View File
@@ -14,14 +14,21 @@ import {
createDoorConnectionSchema, createDoorConnectionSchema,
updateDoorConnectionSchema, updateDoorConnectionSchema,
doorConnectionIdSchema, doorConnectionIdSchema,
doorGrantSchema,
doorRevokeSchema,
doorControlSchema,
} from '@/lib/validations' } from '@/lib/validations'
import { import {
encodeHeatSyncControl, encodeHeatSyncControl,
encodeHeatSyncGrant,
encodeHeatSyncRevoke,
applyTemplate, applyTemplate,
redactDoorSecrets, redactDoorSecrets,
} from '@/lib/door-logic' } from '@/lib/door-logic'
import { pickLowestFreeSlot, slotCapacity } from '@/lib/door-slots-logic'
import { callDoor } from '@/lib/door/executor' import { callDoor } from '@/lib/door/executor'
import { decryptSecret } from '@/lib/secrets/crypto' import { decryptSecret } from '@/lib/secrets/crypto'
import { checkRateLimit } from '@/lib/security'
type Gate = type Gate =
| { ok: true; supabase: ServerSupabase; member: Member } | { ok: true; supabase: ServerSupabase; member: Member }
@@ -308,3 +315,334 @@ export async function listDoorAccessLog() {
if (error) return { error: error.message } if (error) return { error: error.message }
return { data: data ?? [] } 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<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.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<string, string> | null
is_enabled: boolean
}
async function loadEnabledConnection(
admin: ReturnType<typeof createAdminClient>,
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<typeof createAdminClient>,
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<typeof createAdminClient>,
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 }
}
+19
View File
@@ -946,6 +946,25 @@ export const doorConnectionIdSchema = z.object({
connectionId: z.string().uuid('Invalid connection ID'), 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 ────────────────────────────────────────────────────── // ─── Generic ID schemas ──────────────────────────────────────────────────────
export const uuidSchema = z.string().uuid('Invalid ID format') export const uuidSchema = z.string().uuid('Invalid ID format')