From 398541915794fcfc85b26d26b4f6cabbfdcc234c Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 21:06:21 -0700 Subject: [PATCH] feat(door): P3-A door_card_slots per-connection slot allocation map Migration 036 + schema.sql Section 23: per-connection integer-slot allocation table for controllers that key cards by slot (HeatSync 0-200). UNIQUE(connection_id,slot) lets the DB arbitrate concurrent grants; UNIQUE(connection_id,card_id) makes re-grant idempotent. RLS additive, default-deny: SELECT = door.manage/operate, no client write policy (service-client executor only). Types + DATABASE_SCHEMA + DB_SCHEMA_MAP. --- DB_SCHEMA_MAP.md | 4 ++- docs/DATABASE_SCHEMA.md | 1 + scripts/036_door_card_slots.sql | 59 +++++++++++++++++++++++++++++++++ scripts/schema.sql | 44 ++++++++++++++++++++++++ types/database.ts | 6 ++++ 5 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 scripts/036_door_card_slots.sql diff --git a/DB_SCHEMA_MAP.md b/DB_SCHEMA_MAP.md index 1356854..00d8903 100644 --- a/DB_SCHEMA_MAP.md +++ b/DB_SCHEMA_MAP.md @@ -401,10 +401,11 @@ Default channels created by trigger on space INSERT: `general`, `announcements`, | 033 | `equipment`, `equipment_reservations` + `equipment.manage` permission. Additive default-deny RLS; reserve/cancel via validated service-client action (no INSERT/DELETE policy) enforcing status + no-overlap + required-cert with manager override | | 034 | `member_cards` + `door.manage` / `door.operate` permissions. Card UID is a credential: door.manage-only RLS, no member SELECT; masked (count+last4) self-view via server action. Door epic phase 1, no controller calls | | 035 | `door_connections`, `door_access_log` (Door epic P2). Password via secrets vault (secret_ref), `pinned_host` SSRF pin, hardened executor. Connections CRUD = door.manage; log SELECT = door.manage/operate, service-client-only writes | +| 036 | `door_card_slots` (Door epic P3). Per-connection integer-slot allocation map (HeatSync keys cards by slot 0-200). UNIQUE(connection_id,slot) + UNIQUE(connection_id,card_id); lowest-free policy in pure logic. SELECT = door.manage/operate, service-client-only writes | --- -## Tables added by migrations 016-035 (quick map) +## Tables added by migrations 016-036 (quick map) | Table | Key columns | Purpose | |-------|-------------|---------| @@ -433,6 +434,7 @@ Default channels created by trigger on space INSERT: `general`, `announcements`, | `member_cards` | space_id, member_id, card_uid (credential), card_type rfid/nfc, label, is_active | RFID/NFC card association. SELECT/writes = `door.manage` only; member self-view is masked (server action, never raw UID) | | `door_connections` | space_id, name, adapter (native_heatsync/generic_http), base_url, pinned_host (SSRF pin), auth_mode, secret_ref -> secrets, verbs jsonb, allow_member_self_entry, is_enabled | Door controller integration. CRUD = `door.manage`. Password lives in the encrypted secrets vault, never here | | `door_access_log` | space_id, connection_id, actor_member_id, target_member_id, action, success, detail (redacted), occurred_at | Append-only door audit. SELECT = `door.manage`/`door.operate`; NO write policy (validated service-client executor only; immutable) | +| `door_card_slots` | space_id, connection_id -> door_connections, card_id -> member_cards, slot int, created_by | Per-connection integer-slot allocation map. UNIQUE(connection_id,slot) + UNIQUE(connection_id,card_id). SELECT = `door.manage`/`door.operate`; NO write policy (service-client executor only, in lockstep with the controller) | Column additions: `space_members.tier_id`, `onboarding_completed_at`, `onboarding_progress`; `secrets.encrypted_value`, `encryption_version`; `knowledge_base.render_markdown`, `is_meeting_minutes`, `meeting_date`. New enum `comment_entity_type`. diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 2e638a0..74a4753 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -715,6 +715,7 @@ migration is mirrored as a section in it. Tables/columns added: | 033 | `equipment` (registry; status available/maintenance/retired; optional `required_certification_id`; `is_active` archive) + `equipment_reservations` (member time-window reservation; denormalized space_id; no DB overlap constraint). RLS additive + default-deny: equipment SELECT = `equipment.manage` (all) or member (`is_active`), reservations SELECT = `equipment.manage` (all) or member (own), UPDATE = `equipment.manage`, NO INSERT/DELETE policy (reserve/cancel via validated service-client action enforcing status + no-overlap + required-cert, manager override). New `equipment.manage` permission seeded to board + backfilled. No anonymous path | | 034 | `member_cards` (RFID/NFC card UID -> member; the UID is a credential -- `door.manage`-only RLS, no member SELECT policy; masked self-view via a server action). New `door.manage` + `door.operate` permissions (group Access; the whole Door epic uses them) seeded to board + backfilled. No controller calls in this phase, no anonymous path | | 035 | `door_connections` (per-space controller integration; shared password NOT stored -- `secret_ref` -> AES-256-GCM `secrets` vault, decrypted server-side only; `pinned_host` SSRF pin; adapter native_heatsync|generic_http; `allow_member_self_entry` opt-in) + `door_access_log` (append-only, secrets redacted). RLS additive + default-deny: door_connections CRUD = `door.manage`; door_access_log SELECT = `door.manage`/`door.operate`, NO write policy (service-client executor only; immutable). No new permission codes (door.* from 034). No anonymous path | +| 036 | `door_card_slots` (per-connection integer-slot allocation map for controllers that key cards by slot, e.g. HeatSync 0-200). `UNIQUE (connection_id, slot)` lets the DB arbitrate concurrent grants; `UNIQUE (connection_id, card_id)` makes re-grant idempotent. Allocation policy (lowest-free, range bounds) is pure unit-tested logic, not SQL, so it stays adapter-generic. RLS additive + default-deny: SELECT = `door.manage`/`door.operate`, NO write policy (validated service-client executor only, in lockstep with the controller). No new permission codes. No anonymous path | New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`. diff --git a/scripts/036_door_card_slots.sql b/scripts/036_door_card_slots.sql new file mode 100644 index 0000000..afb44ac --- /dev/null +++ b/scripts/036_door_card_slots.sql @@ -0,0 +1,59 @@ +-- ============================================================================= +-- 036: Door card slot allocation (Door epic, phase 3) +-- ============================================================================= +-- A door controller may key cards by an integer "slot" (the verified +-- HeatSync/23b firmware uses slots 0-200: grant ?m..., revoke ?r). +-- The slot space is PER CONNECTION, not global: the same member_card pushed to +-- two controllers can occupy different slots on each. door_card_slots is the +-- platform's allocation map. +-- +-- * One row = one card occupying one slot on one connection. +-- * UNIQUE (connection_id, slot) -> the DB arbitrates concurrent grants +-- racing onto the same slot (the action retries on conflict). +-- * UNIQUE (connection_id, card_id) -> a card holds at most one slot per +-- controller, so re-granting is idempotent (no double-spend). +-- * The allocation policy (lowest free slot, range bounds) lives in pure +-- unit-tested logic (lib/door-slots-logic.ts), not in SQL, so it stays +-- adapter-generic (HeatSync passes 0-200; another slot controller can +-- pass its own range). +-- +-- No new permission codes (door.manage / door.operate from migration 034). +-- RLS (additive, default-deny; the guarded surface): +-- * SELECT = door.manage OR door.operate (operators need to see/allocate). +-- * NO INSERT/UPDATE/DELETE policy: only the validated service-client +-- executor writes here, in lockstep with the controller call. +-- No anonymous path. +-- +-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS, DROP POLICY IF EXISTS then +-- CREATE. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS public.door_card_slots ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE, + connection_id uuid NOT NULL REFERENCES public.door_connections(id) ON DELETE CASCADE, + card_id uuid NOT NULL REFERENCES public.member_cards(id) ON DELETE CASCADE, + slot integer NOT NULL CHECK (slot >= 0), + created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_door_card_slots_slot ON public.door_card_slots (connection_id, slot); +CREATE UNIQUE INDEX IF NOT EXISTS idx_door_card_slots_card ON public.door_card_slots (connection_id, card_id); +CREATE INDEX IF NOT EXISTS idx_door_card_slots_space ON public.door_card_slots (space_id); + +ALTER TABLE public.door_card_slots ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS door_card_slots_select ON public.door_card_slots; +CREATE POLICY door_card_slots_select ON public.door_card_slots FOR SELECT + USING ( + public.user_has_permission(auth.uid(), space_id, 'door.manage') + OR public.user_has_permission(auth.uid(), space_id, 'door.operate') + ); +-- No INSERT/UPDATE/DELETE policy: the validated service-client executor is the +-- only writer, kept in lockstep with the physical controller. + +DROP TRIGGER IF EXISTS trg_door_card_slots_touch ON public.door_card_slots; +CREATE TRIGGER trg_door_card_slots_touch + BEFORE UPDATE ON public.door_card_slots + FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at(); diff --git a/scripts/schema.sql b/scripts/schema.sql index bad51e8..38e5fc3 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -2453,3 +2453,47 @@ DROP TRIGGER IF EXISTS trg_door_connections_touch ON public.door_connections; CREATE TRIGGER trg_door_connections_touch BEFORE UPDATE ON public.door_connections FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at(); + + +-- ============================================================================= +-- 23. Door card slot allocation (Door epic, phase 3) +-- (equivalent to scripts/036_door_card_slots.sql). +-- +-- A controller may key cards by integer slot (HeatSync/23b: 0-200). The +-- slot space is PER CONNECTION; door_card_slots is the allocation map. +-- UNIQUE (connection_id, slot) lets the DB arbitrate concurrent grants; +-- UNIQUE (connection_id, card_id) makes re-granting idempotent. The +-- lowest-free-slot policy + range bounds live in pure unit-tested logic +-- (lib/door-slots-logic.ts), not SQL, so it stays adapter-generic. RLS +-- additive/default-deny: SELECT = door.manage OR door.operate; NO client +-- write policy (validated service-client executor only, in lockstep with +-- the controller). No new permission codes. No anonymous path. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS public.door_card_slots ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE, + connection_id uuid NOT NULL REFERENCES public.door_connections(id) ON DELETE CASCADE, + card_id uuid NOT NULL REFERENCES public.member_cards(id) ON DELETE CASCADE, + slot integer NOT NULL CHECK (slot >= 0), + created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_door_card_slots_slot ON public.door_card_slots (connection_id, slot); +CREATE UNIQUE INDEX IF NOT EXISTS idx_door_card_slots_card ON public.door_card_slots (connection_id, card_id); +CREATE INDEX IF NOT EXISTS idx_door_card_slots_space ON public.door_card_slots (space_id); + +ALTER TABLE public.door_card_slots ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS door_card_slots_select ON public.door_card_slots; +CREATE POLICY door_card_slots_select ON public.door_card_slots FOR SELECT + USING ( + public.user_has_permission(auth.uid(), space_id, 'door.manage') + OR public.user_has_permission(auth.uid(), space_id, 'door.operate') + ); + +DROP TRIGGER IF EXISTS trg_door_card_slots_touch ON public.door_card_slots; +CREATE TRIGGER trg_door_card_slots_touch + BEFORE UPDATE ON public.door_card_slots + FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at(); diff --git a/types/database.ts b/types/database.ts index 413af6c..be70d69 100644 --- a/types/database.ts +++ b/types/database.ts @@ -166,6 +166,12 @@ export type Database = { Update: { id?: string; space_id?: string; connection_id?: string | null; actor_member_id?: string | null; target_member_id?: string | null; action?: string; success?: boolean; detail?: string | null; occurred_at?: string } Relationships: [] } + door_card_slots: { + Row: { id: string; space_id: string; connection_id: string; card_id: string; slot: number; created_by: string | null; created_at: string; updated_at: string } + Insert: { id?: string; space_id: string; connection_id: string; card_id: string; slot: number; created_by?: string | null; created_at?: string; updated_at?: string } + Update: { id?: string; space_id?: string; connection_id?: string; card_id?: string; slot?: number; created_by?: string | null; created_at?: string; updated_at?: string } + Relationships: [] + } activity_log: { Row: { action: string