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
+42
View File
@@ -0,0 +1,42 @@
'use client'
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { useConfirm } from '@/components/ui/confirm'
import { selfEntry } from '@/lib/actions'
type Door = { id: string; name: string }
export function DoorSelfEntry({ doors }: { doors: Door[] }) {
const confirm = useConfirm()
const [busy, setBusy] = useState<string | null>(null)
async function open(d: Door) {
const ok = await confirm({
title: `Open ${d.name}?`,
description:
'This sends a live command that physically opens the door. Only do this if you are at the door and authorized to enter.',
confirmText: 'Open the door',
})
if (!ok) return
setBusy(d.id)
const res = await selfEntry({ connectionId: d.id })
setBusy(null)
if ('error' in res && res.error) return toast.error(res.error)
toast.success(`${d.name} opened`)
}
return (
<div className="bg-card rounded border border-border divide-y divide-border">
{doors.map(d => (
<div key={d.id} className="flex items-center justify-between gap-3 px-4 py-3">
<span className="font-sans text-sm text-foreground truncate">{d.name}</span>
<Button size="sm" disabled={busy === d.id} onClick={() => open(d)}>
{busy === d.id ? 'Opening…' : 'Open'}
</Button>
</div>
))}
</div>
)
}
+17
View File
@@ -4,6 +4,8 @@ import { redirect } from 'next/navigation'
import type { Tables } from '@/types/database'
import { PageTitle } from '@/components/ui/page-title'
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
import { listSelfEntryDoors } from '@/lib/actions'
import { DoorSelfEntry } from './door-self-entry'
function IcoUsers({ className }: { className?: string }) {
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg>
@@ -20,6 +22,9 @@ function IcoPay({ className }: { className?: string }) {
function IcoPlus({ className }: { className?: string }) {
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
}
function IcoDoor({ className }: { className?: string }) {
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 21h18M6 21V5a2 2 0 012-2h8a2 2 0 012 2v16M14 12h.01" /></svg>
}
export default async function DashboardPage() {
const supabase = await createClient()
@@ -153,6 +158,9 @@ export default async function DashboardPage() {
)
}
const selfEntryRes = await listSelfEntryDoors()
const selfEntryDoors = ('data' in selfEntryRes ? selfEntryRes.data : []) as { id: string; name: string }[]
return (
<div className="min-h-screen bg-background">
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center justify-between">
@@ -321,6 +329,15 @@ export default async function DashboardPage() {
</div>
<div className="space-y-6">
{selfEntryDoors.length > 0 && (
<div>
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3 flex items-center gap-1.5">
<IcoDoor className="w-3 h-3" /> Door access
</p>
<DoorSelfEntry doors={selfEntryDoors} />
</div>
)}
<div>
<div className="flex items-center justify-between mb-3">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Payment Alerts</p>
+3 -1
View File
@@ -933,4 +933,6 @@ Registry management is gated by `equipment.manage`; reserving needs only members
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.
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.
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.
Member self-entry (no permission code; any active member): `selfEntry({connectionId})` triggers a momentary OPEN only, allowed only when the connection is `is_enabled` AND `allow_member_self_entry` AND the caller has at least one active `member_card` on file (the locked eligibility rule: a `door_card_slots` row is NOT required). Membership and cards are resolved server-side, so a member can only ever open a door for themselves; never unlock/lock/grant/revoke, never anonymous. Strict per-member rate limit (5/min), one redacted `door_access_log` row `action='self_entry'` (`target_member_id` = self), through the same hardened executor. `listSelfEntryDoors()` returns the enabled self-entry connections for the caller's space, but only when the caller has an active card (otherwise empty), so the surface is hidden for ineligible members. Surfaced as a "Door access" panel on the dashboard.
+10 -5
View File
@@ -513,7 +513,7 @@ override and may book on a member's behalf). No anonymous path.
### Door / access control (migrations 034-036; epic in progress)
Phased; P1-P3 built (member self-entry deferred to a separately reviewed step). `member_cards` (034) associates RFID/NFC UIDs to
Phased; P1-P3 built, including member self-entry (reviewed at the locked checkpoint, then built). `member_cards` (034) associates RFID/NFC UIDs to
members; the UID is a credential (`door.manage`-only RLS, no member SELECT;
masked count+last4 self-view via a service-client action). `door.manage` /
`door.operate` permissions (group Access). `door_connections` (035) is a
@@ -542,10 +542,15 @@ the controller, and rolls the reservation back if the call fails;
`revokeCard` is idempotent and frees the slot only on confirmed controller
success so the app map never diverges from the device; `doorControl`
(open/unlock/lock) touches no slot. `/door/manage` surfaces these to
`door.operate` holders. The per-connection member self-entry (opt-in,
elevated risk, off by default) is the next, separately reviewed step;
phases 4-5 add inbound log ingest and the universal API-call UI builder.
No anonymous path.
`door.operate` holders. Member self-entry (`selfEntry`) is built: any
active member with at least one active card on file may trigger a momentary
OPEN on a connection that is enabled and has `allow_member_self_entry` on
(opt-in, off by default; the locked eligibility rule is "any active card",
no `door_card_slots` row required). Membership/cards are resolved
server-side, strict per-member rate limit, one redacted `self_entry` audit
row; surfaced as a "Door access" panel on the dashboard, hidden entirely
for ineligible members. Phases 4-5 add inbound log ingest and the
universal API-call UI builder. No anonymous path.
---
+17
View File
@@ -4,6 +4,23 @@ Append-only. Newest entries on top. Keep each entry to one screen.
---
## 2026-05-17 (pass 28) — Member self-entry BUILT (dashboard surface); LOCAL, awaiting deploy
Branch `main`. Pass-27 (Door P3) is deployed. User then directed: "add door access controls to dashboard if user has card access enabled" = build the locked-design member self-entry, surfaced on the dashboard. No new migration (reuses 035 `allow_member_self_entry` + `member_cards`).
### Built (LOCAL, not deployed)
- `lib/actions/door.ts`: `selfEntry({connectionId})` — `requireMember` (any active member, no permission code), strict rate limit `door-self` 5/min, momentary OPEN only (HeatSync `?o1` / generic `open` template) through the existing hardened executor. Eligibility (locked rule): connection `is_enabled` AND `allow_member_self_entry` AND caller has >=1 active `member_card` (NO `door_card_slots` row required). Membership/cards resolved server-side (client sends only `connectionId`); never unlock/lock/grant/revoke; never anon. One redacted `door_access_log` row `action='self_entry'`, `target_member_id`=self (denials audited too). `listSelfEntryDoors()` returns enabled self-entry connections only if the caller has an active card (else empty → surface hidden).
- Dashboard: `app/(app)/dashboard/door-self-entry.tsx` (client, confirm + toast) + a "Door access" panel in the dashboard right column, rendered only when `listSelfEntryDoors()` is non-empty. First-run dashboard branch unaffected (query runs after that early return).
- Docs: API_REFERENCE + ARCHITECTURE updated; this entry.
### Open / next
- LOCAL & undeployed: self-entry (1 commit, see `git log`). Plus still-unpushed pass-27 deploy-state docs commit `14a2554`. **Awaiting deploy approval** (no migration; app-only). After push: with a self-entry-enabled connection + the member holding an active card, the dashboard "Door access" panel should appear and the confirm button should fire `selfEntry`; verify `self_entry` audit rows are redacted. NOT browser/controller-verified here.
- Gate green at build time: suite 417, `pnpm build` clean.
- Door epic remaining: **P4** inbound access-log ingest (poll `?z`/webhook → resolve card_uid→member); **P5** universal API-call UI builder (`api_buttons`, same SSRF executor, door template).
- Residual/test gap unchanged: SSRF pin is host-string equality (theoretical DNS-rebinding, mitigated); no Supabase mock harness so action orchestration (incl. self-entry eligibility) is not unit-tested; pure slot logic is.
---
## 2026-05-17 (pass 27) — Door P3 DEPLOYED (slot allocator + live actions); self-entry CHECKPOINT next
Branch `main`. Start state was the pass-26 clean checkpoint. Reaffirmed framing: this is a generic multi-space hackerspace platform, HeatSync is one tenant/adapter (memory `platform-not-heatsync-only`). Slot-allocator design presented and APPROVED before building; user decisions locked: slot range **0-200 no reservation**, grant ordering **reserve -> call -> roll back on failure**, **checkpoint before member self-entry: YES**.
+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 }
}