diff --git a/DB_SCHEMA_MAP.md b/DB_SCHEMA_MAP.md index 059ba6e..9cf620c 100644 --- a/DB_SCHEMA_MAP.md +++ b/DB_SCHEMA_MAP.md @@ -418,6 +418,8 @@ Default channels created by trigger on space INSERT: `general`, `announcements`, | 051 | `notifications.read_at timestamptz` + partial idx `idx_notifications_unread (space_id, member_id) WHERE read_at IS NULL`. In-app inbox read state on `/me`; unread = `read_at IS NULL`. Mark-read = service-client action scoped to caller's member_id (no RLS change). Email-muted (`skipped`) rows still show in-app | | 052 | HOTFIX: drop `notification_preferences_space_id_fkey`. FKs to BOTH space_members and spaces made the table a PostgREST junction -> `space_members.select('*, spaces(*)')` ambiguous (PGRST201) -> all logged-in users bounced to /signup. One FK left (member_id); space reachable + cascades via space_members. NEVER add a spaces FK to a space_members-referencing table. Guarded by integration/auth-embed.test.ts | | 050 | CHECK `dues_payment_methods_url_https` (`url ~* '^https://'`): the url renders as a member-clickable href and RLS lets admins write directly past the app Zod check, so the DB enforces https (no stored `javascript:`/`http:` XSS). Member-read action also re-filters with `isSafeDuesUrl`. Idempotent DO-block | +| 054 | Door epic P5 (universal API-call UI builder). New perm `apicall.invoke` (Access) seeded in `seed_default_role_permissions` (CREATE OR REPLACE) + backfilled for board. `api_buttons` (label, button_group, sort_order, method GET/POST/PUT/PATCH/DELETE, base_url, pinned_host, url_template, headers jsonb, body_template, auth_mode/auth_param, secret_ref -> vault, required_permission default apicall.invoke, confirm, is_enabled). CRUD = door.manage; per-button required_permission gates invoke (via service-client action). `api_call_log` (append-only press audit, redacted; SELECT door.manage; service-client-only writes). `touch_updated_at` on api_buttons | +| 053 | Door epic P4 (inbound ingest), additive cols. `door_access_log.dedupe_key text` + partial-unique `idx_door_access_log_dedupe (connection_id, dedupe_key) WHERE dedupe_key IS NOT NULL` = per-event idempotency (re-poll/webhook-retry safe via ON CONFLICT DO NOTHING; action rows leave it NULL). `door_connections.inbound_enabled bool default false` (gates webhook endpoint) + `door_connections.inbound_secret_ref -> secrets` (inbound bearer secret, vault; distinct from outbound `secret_ref`). 2 FKs to secrets but surrogate PK = not a junction (safe re 052); nothing embeds secrets. No new permission code; service-client writers only | --- @@ -448,9 +450,11 @@ Default channels created by trigger on space INSERT: `general`, `announcements`, | `equipment` | space_id, name, description, location, status (available/maintenance/retired), required_certification_id -> certifications, asset_tag, is_active | Tool/equipment registry. SELECT = `equipment.manage` (all) or member (`is_active`); writes = `equipment.manage` | | `equipment_reservations` | equipment_id, space_id, member_id, starts_at, ends_at, status (reserved/cancelled/completed), notes | Time-window reservation. SELECT = `equipment.manage` (all) or member (own); UPDATE = `equipment.manage`; NO INSERT/DELETE policy (reserve/cancel via validated service-client action; no-overlap + required-cert enforced there) | | `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_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, inbound_enabled, inbound_secret_ref -> secrets | Door controller integration. CRUD = `door.manage`. Outbound password + inbound webhook secret both live in the encrypted secrets vault, never here | +| `door_access_log` | space_id, connection_id, actor_member_id, target_member_id, action, success, detail (redacted), dedupe_key (per-event idempotency, partial-unique w/ connection_id), occurred_at | Append-only door audit (app actions + ingested poll/webhook events). 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) | +| `api_buttons` | space_id, label, button_group, sort_order, method, base_url, pinned_host (SSRF pin), url_template, headers jsonb, body_template, auth_mode/auth_param, secret_ref -> secrets, required_permission, confirm, is_enabled | API-call builder button defs (door epic P5). CRUD = `door.manage`; each button's `required_permission` (default `apicall.invoke`) gates pressing it via the service-client invoke action. Secret in the vault, never here. Fired through the shared hardened egress (host pin) | +| `api_call_log` | space_id, button_id -> api_buttons, actor_member_id, action, success, detail (redacted), occurred_at | Append-only press audit. SELECT = `door.manage`; NO write policy (validated service-client invoker only; immutable) | | `space_visits` | space_id, member_id, checked_in_at, checked_out_at, is_host, check_in_note, check_out_note | Presence/attendance. Open (checked_out_at NULL) = present; partial UNIQUE one-open-per-member. SELECT = any space member; NO write policy (self-resolved service-client actions only; immutable). Org-wide attendance view = all members | | `member_billing` | space_id, member_id (UNIQUE per space), stripe_customer_id, stripe_subscription_id, subscription_status, current_period_end | Stripe dues link. SELECT = admin/board/treasurer; NO client write policy (webhook/service-client only). Member self-view via a validated action | | `stripe_webhook_events` | event_id PK, space_id, type, received_at | Webhook idempotency ledger (dedupe on Stripe's stable event id). Service-client only; no RLS policy | diff --git a/__tests__/api-call-logic.test.ts b/__tests__/api-call-logic.test.ts new file mode 100644 index 0000000..0e219d1 --- /dev/null +++ b/__tests__/api-call-logic.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest' +import { buildApiRequest } from '@/lib/api-call-logic' + +describe('buildApiRequest', () => { + it('injects a query secret with the right separator', () => { + const a = buildApiRequest({ baseUrl: 'https://h/x', method: 'GET', authMode: 'query', authParam: 'key', secret: 's3cret' }) + expect(a.url).toBe('https://h/x?key=s3cret') + const b = buildApiRequest({ baseUrl: 'https://h/x?a=1', method: 'GET', authMode: 'query', authParam: 'key', secret: 's3cret' }) + expect(b.url).toBe('https://h/x?a=1&key=s3cret') + }) + + it('url-encodes the query secret', () => { + const r = buildApiRequest({ baseUrl: 'https://h/x', method: 'GET', authMode: 'query', authParam: 'k', secret: 'a b&c' }) + expect(r.url).toBe('https://h/x?k=a%20b%26c') + }) + + it('injects a bearer secret as an Authorization header', () => { + const r = buildApiRequest({ baseUrl: 'https://h', method: 'POST', authMode: 'bearer', secret: 'tok' }) + expect(r.headers['authorization']).toBe('Bearer tok') + expect(r.url).toBe('https://h') + }) + + it('injects a custom-header secret under the named (lower-cased) header', () => { + const r = buildApiRequest({ baseUrl: 'https://h', method: 'POST', authMode: 'header', authParam: 'X-Api-Key', secret: 'k' }) + expect(r.headers['x-api-key']).toBe('k') + }) + + it('injects nothing for auth_mode none, or when secret is absent', () => { + const none = buildApiRequest({ baseUrl: 'https://h', method: 'GET', authMode: 'none', secret: 'ignored-no-param' }) + expect(none.url).toBe('https://h') + expect(Object.keys(none.headers)).not.toContain('authorization') + const noSecret = buildApiRequest({ baseUrl: 'https://h', method: 'GET', authMode: 'bearer', secret: null }) + expect(noSecret.headers['authorization']).toBeUndefined() + }) + + it('drops a caller-supplied host header (executor forces the pinned host)', () => { + const r = buildApiRequest({ baseUrl: 'https://h', method: 'POST', authMode: 'none', headers: { Host: 'evil.example', 'X-Other': 'ok' } }) + expect(r.headers['host']).toBeUndefined() + expect(r.headers['x-other']).toBe('ok') + }) + + it('appends url_template to the base url', () => { + const r = buildApiRequest({ baseUrl: 'https://h', urlTemplate: '/toggle?id=5', method: 'GET', authMode: 'none' }) + expect(r.url).toBe('https://h/toggle?id=5') + }) + + it('carries a body only for body-bearing verbs', () => { + expect(buildApiRequest({ baseUrl: 'https://h', method: 'GET', authMode: 'none', body: '{"a":1}' }).body).toBeNull() + expect(buildApiRequest({ baseUrl: 'https://h', method: 'POST', authMode: 'none', body: '{"a":1}' }).body).toBe('{"a":1}') + expect(buildApiRequest({ baseUrl: 'https://h', method: 'delete', authMode: 'none', body: 'x' }).body).toBe('x') + }) + + it('uppercases the method', () => { + expect(buildApiRequest({ baseUrl: 'https://h', method: 'post', authMode: 'none' }).method).toBe('POST') + }) +}) diff --git a/__tests__/door-log-logic.test.ts b/__tests__/door-log-logic.test.ts new file mode 100644 index 0000000..4db1766 Binary files /dev/null and b/__tests__/door-log-logic.test.ts differ diff --git a/app/(app)/door/buttons/api-buttons-client.tsx b/app/(app)/door/buttons/api-buttons-client.tsx new file mode 100644 index 0000000..38f50ba --- /dev/null +++ b/app/(app)/door/buttons/api-buttons-client.tsx @@ -0,0 +1,276 @@ +'use client' + +import { useState } from 'react' +import { toast } from 'sonner' +import { Zap, Plus } from 'lucide-react' +import { PageHeader, PageTitle } from '@/components/ui/page-title' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' +import { useConfirm } from '@/components/ui/confirm' +import { createApiButton, updateApiButton, deleteApiButton } from '@/lib/actions' + +type Btn = { + id: string + label: string + button_group: string + sort_order: number + method: string + base_url: string + pinned_host: string + url_template: string | null + headers: Record | null + body_template: string | null + auth_mode: string + auth_param: string | null + secret_ref: string | null + required_permission: string + confirm: boolean + is_enabled: boolean +} +type LogRow = { id: string; action: string; success: boolean; detail: string | null; occurred_at: string } +type Secret = { id: string; title: string } +type Perm = { code: string; label: string } + +const input = 'w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary' +const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] +const AUTH_MODES = ['none', 'query', 'header', 'bearer'] + +function emptyDraft() { + return { + label: '', button_group: 'General', method: 'POST', base_url: '', pinned_host: '', + url_template: '', headersText: '', body_template: '', + auth_mode: 'none', auth_param: '', secret_ref: '', + required_permission: 'apicall.invoke', confirm: true, + } +} + +// Parse "Key: Value" lines into a header map (blank lines ignored). +function parseHeaders(text: string): Record { + const out: Record = {} + for (const line of text.split('\n')) { + const i = line.indexOf(':') + if (i <= 0) continue + const k = line.slice(0, i).trim() + const v = line.slice(i + 1).trim() + if (k) out[k] = v + } + return out +} + +export function ApiButtonsClient({ + initial, + secrets, + log, + permissions, +}: { + initial: unknown[] + secrets: Secret[] + log: unknown[] + permissions: Perm[] +}) { + const confirm = useConfirm() + const [buttons, setButtons] = useState(initial as Btn[]) + const [showNew, setShowNew] = useState(false) + const [d, setD] = useState(emptyDraft()) + const [busy, setBusy] = useState(false) + const rows = log as LogRow[] + + function doorTemplate() { + setShowNew(true) + setD({ + ...emptyDraft(), + label: 'Open door', button_group: 'Door', method: 'GET', + url_template: '?o1', auth_mode: 'query', auth_param: 'e', + required_permission: 'door.operate', confirm: true, + }) + toast.message('Door template loaded', { description: 'Set the base URL, pinned host, and password secret, then create.' }) + } + + async function onCreate(e: React.FormEvent) { + e.preventDefault() + if (!d.label.trim() || !d.base_url.trim() || !d.pinned_host.trim()) { + return toast.error('Label, base URL and pinned host are required') + } + const headers = parseHeaders(d.headersText) + setBusy(true) + const res = await createApiButton({ + label: d.label.trim(), + button_group: d.button_group.trim() || 'General', + method: d.method, + base_url: d.base_url.trim(), + pinned_host: d.pinned_host.trim(), + url_template: d.url_template.trim() || null, + headers, + body_template: d.body_template.trim() || null, + auth_mode: d.auth_mode, + auth_param: d.auth_param.trim() || null, + secret_ref: d.secret_ref || null, + required_permission: d.required_permission, + confirm: d.confirm, + }) + setBusy(false) + if ('error' in res && res.error) return toast.error(res.error) + const id = (res as { data: { id: string } }).data.id + setButtons(prev => [ + { + id, label: d.label.trim(), button_group: d.button_group.trim() || 'General', sort_order: 0, + method: d.method, base_url: d.base_url.trim(), pinned_host: d.pinned_host.trim(), + url_template: d.url_template.trim() || null, headers, body_template: d.body_template.trim() || null, + auth_mode: d.auth_mode, auth_param: d.auth_param.trim() || null, secret_ref: d.secret_ref || null, + required_permission: d.required_permission, confirm: d.confirm, is_enabled: true, + }, + ...prev, + ]) + setD(emptyDraft()) + setShowNew(false) + toast.success('Button created') + } + + async function onToggleEnabled(b: Btn) { + const res = await updateApiButton({ buttonId: b.id, is_enabled: !b.is_enabled }) + if ('error' in res && res.error) return toast.error(res.error) + setButtons(prev => prev.map(x => (x.id === b.id ? { ...x, is_enabled: !x.is_enabled } : x))) + } + + async function onDelete(b: Btn) { + const ok = await confirm({ + title: 'Delete button', + description: `"${b.label}" will be removed. Existing API-call log entries are kept.`, + confirmText: 'Delete', + destructive: true, + }) + if (!ok) return + const res = await deleteApiButton({ buttonId: b.id }) + if ('error' in res && res.error) return toast.error(res.error) + setButtons(prev => prev.filter(x => x.id !== b.id)) + toast.success('Deleted') + } + + const permLabel = (code: string) => permissions.find(p => p.code === code)?.label ?? code + + return ( + <> + + API buttons +
+ + +
+
+ +
+

+ A button fires a configured HTTP request when an authorized member presses it. The + request runs through the same hardened egress as door control: the server only ever + calls the exact pinned host (any public or private host you set, except + cloud-metadata/link-local), never follows redirects, caps time and response size, and + records every press in the API-call log with secrets redacted. The secret is read from + your encrypted Secrets vault and injected server-side, never stored here. Each button is + gated by a permission you choose; a member sees only the buttons they may press. +

+ + {showNew && ( +
+
+ setD({ ...d, label: e.target.value })} /> + setD({ ...d, button_group: e.target.value })} /> + + + setD({ ...d, base_url: e.target.value })} /> + setD({ ...d, pinned_host: e.target.value })} /> + setD({ ...d, url_template: e.target.value })} /> + + {(d.auth_mode === 'query' || d.auth_mode === 'header') && ( + setD({ ...d, auth_param: e.target.value })} /> + )} + {d.auth_mode !== 'none' && ( + + )} +
+