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:
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user