diff --git a/app/(app)/dashboard/door-self-entry.tsx b/app/(app)/dashboard/door-self-entry.tsx new file mode 100644 index 0000000..6ed28aa --- /dev/null +++ b/app/(app)/dashboard/door-self-entry.tsx @@ -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(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 ( +
+ {doors.map(d => ( +
+ {d.name} + +
+ ))} +
+ ) +} diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index 7f64da0..454f727 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -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 @@ -20,6 +22,9 @@ function IcoPay({ className }: { className?: string }) { function IcoPlus({ className }: { className?: string }) { return } +function IcoDoor({ className }: { className?: string }) { + return +} 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 (
@@ -321,6 +329,15 @@ export default async function DashboardPage() {
+ {selfEntryDoors.length > 0 && ( +
+

+ Door access +

+ +
+ )} +

Payment Alerts

diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 5dc344d..2434c31 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2581424..f1c46d7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. --- diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 4ebc8e5..353a7d4 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -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**. diff --git a/lib/actions/door.ts b/lib/actions/door.ts index fe7b044..10d00e8 100644 --- a/lib/actions/door.ts +++ b/lib/actions/door.ts @@ -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 | 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 } +}