feat(door): member self-entry on the dashboard (locked design)

selfEntry({connectionId}): any active member with >=1 active card on file
may trigger a momentary open on a connection that is enabled and has
allow_member_self_entry on (locked rule: any active card, no slot row
required). Membership/cards resolved server-side so a member can only open
for themselves; never unlock/lock/grant/revoke; never anonymous. Strict
5/min per-member rate limit, one redacted self_entry audit row (denials
audited too), through the existing hardened executor. listSelfEntryDoors
returns the enabled self-entry connections only when the caller has an
active card, so the dashboard 'Door access' panel is hidden for
ineligible members. API_REFERENCE + ARCHITECTURE + HANDOFF pass-28.
This commit is contained in:
Moheeb Zara
2026-05-16 21:21:09 -07:00
parent 14a2554d06
commit 6d1c8536cd
6 changed files with 200 additions and 6 deletions
+111
View File
@@ -690,3 +690,114 @@ export async function doorControl(input: unknown) {
revalidatePath('/door/manage')
return result.ok ? { data: { ok: true, status: result.status } } : { error: result.reason }
}
// ─── 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, 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<string, string> | 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,
})
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: result.reason }
}