Files
hackerspace.sh/lib/actions/door/operate.ts
T
Moheeb Zara b2b463491a refactor(types): regenerate database types from canonical schema; drop TODO(types) join casts
Regenerate types/database.ts from a local Supabase built from the canonical
scripts/schema.sql. The running dev DB was stale (missing the 'stripe'
payment_platform value, members_with_permission, spaces.host_requires_card,
payments.link_status, dues_payment_methods.space_id), so it was rebuilt from
schema.sql first.

With real relationship metadata the 11 `as unknown as … // TODO(types)` join
casts are no longer needed:
- me portal, session-attendance, member-certifications, equipment-reservations:
  narrow the action result with `'data' in res` instead of a blanket cast.
- stripe / door embeds read the now-typed relation directly.
- completeSession + listEquipmentReservations route the embedded-select query
  through the fully-typed createClient() rather than the wide ServerSupabase gate
  client (the pattern documented in auth-helpers.ts); equipment disambiguates its
  two space_members FKs with an explicit !fkey hint.

Collapse the hand-maintained governance rows/enums in types/domain/governance.ts
into Tables<>/Enums<> aliases.

Tighten duesPaymentMethodSchema.platform to the DUES_LINK_PLATFORMS literal union
(the old `as unknown as [string,...]` erased it), which the now-correct
payment_platform enum requires.

The hand-added class_signup_tx / class_cancel_tx RPC entries are now generated
natively.

typecheck 0 · lint 0 errors · 645 tests · build ok
2026-08-04 22:21:29 -07:00

285 lines
11 KiB
TypeScript

'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<string, number>()
for (const s of slots ?? []) slotByCard.set(s.card_id as string, s.slot as number)
return {
data: (cards ?? []).map(c => {
const sm = c.space_members
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.' }
}