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