Door epic phases 4-5: inbound access-log ingest + universal API-call buttons

Phase 4 (migration 053): pull entry/denied events into door_access_log and
match each card to a member, via two transports sharing one ingest core.
Poll: a CRON_SECRET-guarded /api/cron/door-ingest reads each opted-in
native-HeatSync connection's ?z log through the existing hardened executor
and parses it (firmware-characterized, best-effort ring buffer). Webhook:
/api/door/inbound/[connection] accepts pushed event JSON authenticated by a
per-connection bearer secret from the vault. Both dedupe on
(connection_id, dedupe_key) so re-polls and retries are no-ops. Adds an
inbound config panel to /door/manage.

Phase 5 (migration 054): admins define api_buttons that fire a configured
HTTP call (full verbs + headers + body) when an authorized member presses
them, through the same hardened egress (now shared between callDoor and the
new callApi). Each button carries a required_permission (default the new
apicall.invoke code); members see only buttons they may press. Builder at
/door/buttons with a door-template preset; members press from /doors. Every
press is recorded in api_call_log with secrets redacted.

Both subsystems are inert until an admin enables inbound on a connection or
creates a button. 624 unit + 50 integration tests; migrations applied
top-to-bottom in schema.sql; types and docs updated.
This commit is contained in:
Moheeb Zara
2026-05-20 20:45:44 -07:00
parent 6a892cbad5
commit 7ec90b0f1e
33 changed files with 2277 additions and 95 deletions
+10
View File
@@ -939,6 +939,16 @@ Phase 3 live actions (all `door.operate`, rate-limited, every attempt writes one
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. `listMyDoorActivity()` returns the caller's own `door_access_log` rows (actor or target; service client after `requireMember`; `detail` is already secret-redacted at write time). Surfaced as a "Door access" panel on the dashboard and on the member `/doors` page (self-entry + masked own cards via `getMyCards` + recent personal activity).
Phase 4 inbound access-log ingest (migration 053): real entry/denied events are pulled INTO `door_access_log` and matched to a member, via two transports sharing one ingest core (`lib/door/ingest.ts`, service-client writer; the audit log stays no-client-write). Config is folded into `updateDoorConnection`/`createDoorConnection`, which gained `inbound_enabled` (gates both transports, off by default) and `inbound_secret_ref` (a distinct vault secret from the outbound `secret_ref`; the create/update path verifies every referenced secret belongs to the caller's space). Both transports resolve a presented card to a member by numeric hex/decimal equality (`cardUidsEquivalent`) and dedupe-insert: `door_access_log.dedupe_key` + the partial-unique `(connection_id, dedupe_key)` index make a re-poll / webhook retry a no-op; action rows leave it NULL.
- `POST /api/cron/door-ingest` (nodejs, force-dynamic; constant-time `CRON_SECRET` bearer, 503 if unset / 401 on mismatch; `proxy.ts`-whitelisted). Polls each `is_enabled` + `inbound_enabled` + `adapter='native_heatsync'` connection's `?z` log through the hardened executor (`fullBody`, 32KB cap), parses it with the pure firmware-characterized `lib/door-log-logic.ts` (`parseHeatSyncLog`: `<pre>` ring-buffer dump, G+g/D+d card-number reconstruction via the 32767 divisor, best-effort H:M:E, raw R/r reads intentionally skipped), and ingests. Bounded to 50 connections/run. Returns `{ ok, polled, inserted, resolved, failed }`. Best-effort by nature (the HeatSync ring buffer has no per-entry sequence id); idempotent across overlapping/missed minutes.
- `POST /api/door/inbound/[connection]` (nodejs, force-dynamic; session-exempt, `proxy.ts`-whitelists the `/api/door/inbound` prefix). Authenticated by the connection's per-connection bearer secret (`inbound_secret_ref` from the vault, constant-time compared; the same generic 401 for not-found / inbound-disabled / wrong-secret so it is not an existence oracle). Body-size guarded (413 over 64KB), per-connection rate-limited (120/min), Zod-validated (`doorWebhookPayloadSchema`: `{ events: [{ id, card_uid?|card_number?, result?, occurred_at? }] }`, ≤100 events; `id` is the dedupe token). `normalizeWebhookEvents` → ingest core. A supplied `occurred_at` is clamped to not-in-the-future (60s skew) so a relay cannot reorder the operator's log. Returns `{ received, inserted, resolved, skipped }`. This is the reliable inbound transport for any (incl. generic/non-HeatSync) controller.
### Universal API-call buttons: `api-buttons.ts` (migration 054, door epic P5)
Admin-defined buttons that fire a configured HTTP call through the same hardened egress as door control. Managing buttons reuses `door.manage` (the catalog scopes it to "buttons"); pressing a button is gated by its own `required_permission` (default the new generic `apicall.invoke`; a door-flavored button can require `door.operate`). One new permission code `apicall.invoke` (group Access), additive + backfilled for board like the door.* codes.
- Manage (all `door.manage`, RLS-gated): `createApiButton`/`updateApiButton`/`deleteApiButton`/`listApiButtons`. A button stores label, button_group, sort_order, method (GET/POST/PUT/PATCH/DELETE), base_url (http(s)), pinned_host (SSRF pin), url_template, headers (jsonb), body_template, auth_mode (none/query/header/bearer) + auth_param, secret_ref (vault; the create/update path verifies it belongs to the space), required_permission (validated against the catalog), confirm, is_enabled. `listApiCallLog()` is the press audit (`door.manage`).
- Member-facing (any member, gated per button): `listInvokableButtons()` is a service-client read (members have no RLS read on the defs) returning ONLY presentational fields (id/label/group/confirm) for enabled buttons whose `required_permission` the caller holds. `invokeApiButton({ buttonId })` is rate-limited first (30/min), loads the def service-side scoped to the member's space, checks the per-row permission (denials audited), decrypts the secret server-side and fires via `callApi` (full verbs + headers + body; the secret is injected per auth_mode and never returned), writing one redacted `api_call_log` row. Returns a generic error on failure (the redacted detail goes only to the log).
- Egress: `lib/door/executor.ts` now shares one internal `egress` (SSRF host pin, resolve-once-connect-by-IP, no redirects, time/body caps, secret redaction). `callDoor` (GET) is unchanged; `callApi` adds method/headers/body with the host header forced to the pin. Pure request assembly + secret placement is unit-tested in `lib/api-call-logic.ts` (`buildApiRequest`). The metadata/link-local block is absolute even for an admin-configured button. Builder UI at `/door/buttons` (door-template preset); members press from `/doors`.
### Presence & attendance: `presence.ts` (migration 038)
No permission code; any active member. All writes are self-only — the member is resolved server-side, never taken from input — and go through validated service-client actions (`space_visits` has no client write policy). Pure logic in `lib/presence-logic.ts` (`presenceStatus`, `hostEligibility`, `summarizePresence`) is unit-tested.
- `checkIn({asHost?, note?})` — opens a visit. Enforces one open visit per member (partial unique index); if an existing open visit is stale (`presenceStatus` past `PRESENCE_MAX_OPEN_HOURS`, 18h) it is auto-closed first (handles forgotten check-outs without a cron), and a fresh existing open visit is rejected ("already checked in"). `asHost` runs `hostEligibility`: if `spaces.host_requires_card` (default true) the caller must have an active `member_card` on file (count checked via the service client); a space may flip the toggle off to let anyone self-mark host. Writes an `activity_log` row.