Files
hackerspace.sh/lib/actions/door/self-entry.ts
T
Moheeb ZaraandClaude Opus 4.8 39bcdd9677 refactor(actions): split door.ts into a folder (slice 4)
813-line lib/actions/door.ts → lib/actions/door/{connections,operate,self-entry}.ts
('use server') + _guards.ts + _audit.ts + index.ts barrel. All 14 actions
preserved verbatim; lib/door/ (executor/ingest/secret) untouched. tsc 0, tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 06:01:02 -07:00

146 lines
6.1 KiB
TypeScript

'use server'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { requireMember, parseInput } from '@/lib/auth-helpers'
import { doorConnectionIdSchema } from '@/lib/validations'
import { encodeHeatSyncControl, applyTemplate } from '@/lib/door-logic'
import { callDoor } from '@/lib/door/executor'
import { checkRateLimit } from '@/lib/security'
import { resolveSecret, auditDoor } from './_audit'
// ─── 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, auth_param, 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,
authParam: (conn.auth_param as string | null) ?? null,
})
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: 'The door controller call failed. Check the door audit log for the redacted detail.' }
}
// The signed-in member's OWN door activity (rows where they were the actor or
// the target). door_access_log has no member-facing RLS SELECT, so this uses
// the service client after requireMember and filters to this member only. The
// detail column is already secret-redacted at write time.
export async function listMyDoorActivity() {
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { member } = auth
const admin = createAdminClient()
const { data, error } = await admin
.from('door_access_log')
.select('id, action, success, detail, occurred_at')
.eq('space_id', member.space_id)
.or(`actor_member_id.eq.${member.id},target_member_id.eq.${member.id}`)
.order('occurred_at', { ascending: false })
.limit(50)
if (error) return { error: error.message }
return { data: data ?? [] }
}