diff --git a/CHANGELOG.md b/CHANGELOG.md index ce8ebee..535d056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project are documented here. Format follows [Keep a ## [Unreleased] ### Added +- **Door epic, complete (P1-P5; migrations 034-036, 053-054).** Member access cards (UID treated as a credential, masked self-view); per-space door connections (native HeatSync or generic HTTP) behind one SSRF-guarded egress (host-pinned, DNS-rebind-safe, no redirects, time/body caps, secret-redacted audit); live grant/revoke/open with a per-connection card-slot allocator; member self-entry. **Inbound access-log ingest** (053): a `CRON_SECRET`-guarded HeatSync `?z` poll plus a per-connection bearer-authenticated webhook, both matching the presented card to a member (hex-uid model) and deduping on `(connection_id, dedupe_key)`. **Universal API-call button builder** (054): admins define permission-gated buttons (any HTTP verb + headers + body + vault secret, default `apicall.invoke`) fired through the same egress, with a door-control preset; members press from `/doors`, every press audited in `api_call_log`. Inert until an admin configures it. - **Customizable permissions, per-item Ops ACLs, area-lead roles** (migration 023, additive and backward compatible): - Customize -> Permissions: a role x permission grid. Admin holds everything implicitly and cannot be locked out. Grants per built-in/custom role. - Per-item access control on Ops secrets (and KB/processes via the same infra): pick which roles, multiple, can access an item. Empty = existing default visibility. Secrets/KB SELECT policies were rewritten as `existing-rule OR acl-match`, so nothing changes until an admin sets an ACL. diff --git a/README.md b/README.md index b9bf70d..38f7e56 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ A multi-tenant operating system for hackerspaces, makerspaces, and member-run sh - **Certifications and instructors.** Certification types with optional validity periods; an Instructor capability awards and revokes them; expiry and revocation tracked; members see their own record. - **Classes.** Class offerings with scheduled sessions, member signup with waitlists, attendance, and an optional certification granted on completion. - **Equipment.** Tool and equipment registry with time-window reservations, no-overlap enforcement, and an optional required certification per item. -- **Access control.** Member access cards (the card UID is treated as a credential), a configurable per-space door integration (native HeatSync controller or generic HTTP) behind an SSRF-guarded executor, and an immutable, secret-redacted access log. +- **Access control.** Member access cards (the card UID is treated as a credential), a configurable per-space door integration (native HeatSync controller or generic HTTP) behind an SSRF-guarded executor, live grant/revoke/open and member self-entry, inbound access-log ingest (a HeatSync `?z` poll plus a per-connection authenticated webhook, each card matched back to a member), and an immutable, secret-redacted access log. A universal **API-call button builder** generalizes this: admins define permission-gated buttons (any HTTP verb, headers, body, vault-stored secret) that fire through the same hardened egress, with a door-control preset. - **Onboarding and invites.** A configurable member onboarding flow; multi-use, expiring, role-granting invite links. - **Payments and financials.** Per-member payment ledger, integration credentials per space, exports, monthly financial summary. **Stripe recurring dues** are a first-class integration: each space configures its own Stripe account (not Connect), members pay through hosted Checkout and self-serve cancel/update through the hosted Billing Portal (no card data on our servers), and a per-space signed webhook keeps `member_billing` and member status (`current` / `late`) in sync, never auto-downgrading past `late`. - **Transactional email and notifications.** An idempotent notifications outbox writes dues-lifecycle emails (renewal receipt, payment failed, lapse) from the Stripe webhook; a small dispatcher cron drains them through Resend with a fair per-space queue and per-attempt idempotency. Adding other domains (bookings, forms) reuses the same outbox. @@ -120,7 +120,7 @@ pnpm test:e2e # playwright end-to-end ## Project status -The system is feature-functional and serving production traffic at `hackerspace.sh`. See [docs/AUDIT.md](./docs/AUDIT.md) for the latest audit and the open issues list. +The system is feature-functional and serving production traffic at `hackerspace.sh`. The running session log, deploy state, and rolling security audits live in [docs/HANDOFF.md](./docs/HANDOFF.md) (newest on top). [docs/AUDIT.md](./docs/AUDIT.md) is a deeper full-codebase audit snapshot from 2026-05-14 (historical context). ## Contributing diff --git a/app/(app)/comms/comms-client.tsx b/app/(app)/comms/comms-client.tsx index 304bddc..8ee964c 100644 --- a/app/(app)/comms/comms-client.tsx +++ b/app/(app)/comms/comms-client.tsx @@ -215,7 +215,7 @@ export default function CommsClient({ member, space, channels }: Props) { }`} > - {channel.name} + {channel.name} {channel.unread_count > 0 && ( {channel.unread_count} @@ -240,7 +240,7 @@ export default function CommsClient({ member, space, channels }: Props) { }`} > - {channel.name} + {channel.name} ))} @@ -262,7 +262,7 @@ export default function CommsClient({ member, space, channels }: Props) { - {channel.name} + {channel.name} ))} diff --git a/app/(app)/dashboard/door-self-entry.tsx b/app/(app)/dashboard/door-self-entry.tsx index 6ed28aa..e9b7e20 100644 --- a/app/(app)/dashboard/door-self-entry.tsx +++ b/app/(app)/dashboard/door-self-entry.tsx @@ -31,8 +31,8 @@ export function DoorSelfEntry({ doors }: { doors: Door[] }) {
{doors.map(d => (
- {d.name} -
diff --git a/app/(app)/dashboard/presence-panel.tsx b/app/(app)/dashboard/presence-panel.tsx index a4215b3..81eb1b1 100644 --- a/app/(app)/dashboard/presence-panel.tsx +++ b/app/(app)/dashboard/presence-panel.tsx @@ -65,7 +65,7 @@ export function PresencePanel({ present }: { present: Present[] }) {
    {present.map(p => (
  • - + {p.name}{p.isMe ? ' (you)' : ''} {p.note && {p.note}} diff --git a/app/api/cron/door-ingest/route.ts b/app/api/cron/door-ingest/route.ts index ed444a0..fa55e14 100644 --- a/app/api/cron/door-ingest/route.ts +++ b/app/api/cron/door-ingest/route.ts @@ -52,29 +52,39 @@ export async function POST(req: NextRequest) { .limit(MAX_CONNECTIONS) if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - let polled = 0 + // Poll connections concurrently: each connection is independent, and the + // executor's per-call timeout means a sequential loop would take the SUM of + // all timeouts (a slow/unreachable fleet could run minutes and overlap the + // next tick). allSettled bounds a tick to roughly the slowest single poll; + // each connection is isolated so one misbehaving controller (untrusted, + // plaintext HTTP) cannot 500 the run or starve the rest. MAX_CONNECTIONS + // caps the fan-out. + const list = (conns ?? []) as unknown as IngestConn[] + const settled = await Promise.allSettled( + list.map(async c => { + const poll = await pollConnectionLog(admin, c) + if (!poll.ok) { + console.error(`[door-ingest] connection ${c.id}: ${poll.detail}`) + return { failed: true, inserted: 0, resolved: 0 } + } + const res = await ingestEvents(admin, c.space_id, c.id, poll.events) + return { failed: false, inserted: res.inserted, resolved: res.resolved } + }), + ) + let inserted = 0 let resolved = 0 let failed = 0 - for (const c of (conns ?? []) as unknown as IngestConn[]) { - polled++ - // Isolate each connection: a single misbehaving controller (the body is - // untrusted, plaintext HTTP) must not 500 the whole run and starve the rest. - try { - const poll = await pollConnectionLog(admin, c) - if (!poll.ok) { - failed++ - console.error(`[door-ingest] connection ${c.id}: ${poll.detail}`) - continue - } - const res = await ingestEvents(admin, c.space_id, c.id, poll.events) - inserted += res.inserted - resolved += res.resolved - } catch (e) { + for (const r of settled) { + if (r.status === 'rejected') { failed++ - console.error(`[door-ingest] connection ${c.id} threw:`, e instanceof Error ? e.message : e) + console.error('[door-ingest] connection threw:', r.reason instanceof Error ? r.reason.message : r.reason) + continue } + if (r.value.failed) failed++ + inserted += r.value.inserted + resolved += r.value.resolved } - return NextResponse.json({ ok: true, polled, inserted, resolved, failed }) + return NextResponse.json({ ok: true, polled: list.length, inserted, resolved, failed }) } diff --git a/components/forms/form-builder.tsx b/components/forms/form-builder.tsx index 6cd3e48..fabfa84 100644 --- a/components/forms/form-builder.tsx +++ b/components/forms/form-builder.tsx @@ -237,7 +237,7 @@ export function FormBuilder({ initial }: { initial: BuilderForm }) { {isEdit && initial.status === 'published' && visibility !== 'members' && (
    - {publicUrl} + {publicUrl}