mobile: fix flex-row text overflow; parallelize door-ingest poll; docs refresh

Mobile responsiveness: several flex rows had a `truncate` child without
`min-w-0`, so a long value (member/door name, channel name, published form
URL) would not shrink and pushed the row past the viewport on a phone. Added
`min-w-0` (with `flex-1`/`shrink-0` where needed) on the dashboard presence +
door-self-entry rows, the comms channel list, and the form-builder URL.

Door inbound poll: `/api/cron/door-ingest` now polls connections with
Promise.allSettled instead of a sequential loop, so one tick is bounded to the
slowest single poll rather than the sum of all per-call timeouts; each
connection stays isolated and the run is idempotent.

Docs: refreshed ARCHITECTURE (door epic marked complete P1-P5), the README
access-control feature bullet + project-status pointer, a historical banner on
the older AUDIT.md, a door-epic CHANGELOG entry, and the rolling HANDOFF audit
log (passes 65-68). No migration; no behavior change for existing users.
This commit is contained in:
Moheeb Zara
2026-05-20 21:55:49 -07:00
parent 0ac69abc8e
commit 32934124c2
10 changed files with 86 additions and 31 deletions
+1
View File
@@ -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.
+2 -2
View File
@@ -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
+3 -3
View File
@@ -215,7 +215,7 @@ export default function CommsClient({ member, space, channels }: Props) {
}`}
>
<Hash className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{channel.name}</span>
<span className="truncate min-w-0">{channel.name}</span>
{channel.unread_count > 0 && (
<span className="ml-auto w-4 h-4 bg-primary text-white text-[9px] font-bold rounded-full flex items-center justify-center">
{channel.unread_count}
@@ -240,7 +240,7 @@ export default function CommsClient({ member, space, channels }: Props) {
}`}
>
<Hash className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{channel.name}</span>
<span className="truncate min-w-0">{channel.name}</span>
</button>
))}
</div>
@@ -262,7 +262,7 @@ export default function CommsClient({ member, space, channels }: Props) {
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="truncate">{channel.name}</span>
<span className="truncate min-w-0">{channel.name}</span>
</button>
))}
</div>
+2 -2
View File
@@ -31,8 +31,8 @@ export function DoorSelfEntry({ doors }: { doors: Door[] }) {
<div className="bg-card rounded border border-border divide-y divide-border">
{doors.map(d => (
<div key={d.id} className="flex items-center justify-between gap-3 px-4 py-3">
<span className="font-sans text-sm text-foreground truncate">{d.name}</span>
<Button size="sm" disabled={busy === d.id} onClick={() => open(d)}>
<span className="flex-1 min-w-0 font-sans text-sm text-foreground truncate">{d.name}</span>
<Button size="sm" className="shrink-0" disabled={busy === d.id} onClick={() => open(d)}>
{busy === d.id ? 'Opening…' : 'Open'}
</Button>
</div>
+1 -1
View File
@@ -65,7 +65,7 @@ export function PresencePanel({ present }: { present: Present[] }) {
<ul className="divide-y divide-border">
{present.map(p => (
<li key={p.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
<span className="font-sans text-sm text-foreground truncate">
<span className="flex-1 min-w-0 font-sans text-sm text-foreground truncate">
{p.name}{p.isMe ? ' (you)' : ''}
{p.note && <span className="font-mono text-[10px] text-muted-foreground ml-2">{p.note}</span>}
</span>
+28 -18
View File
@@ -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 })
}
+1 -1
View File
@@ -237,7 +237,7 @@ export function FormBuilder({ initial }: { initial: BuilderForm }) {
{isEdit && initial.status === 'published' && visibility !== 'members' && (
<div className="flex items-center gap-2 rounded-lg border bg-muted/40 p-3 text-sm">
<span className="truncate font-mono text-xs">{publicUrl}</span>
<span className="flex-1 min-w-0 truncate font-mono text-xs">{publicUrl}</span>
<Button
size="sm"
variant="secondary"
+7 -4
View File
@@ -1,6 +1,6 @@
# Hackerspace.sh - Architecture Documentation
> **Last Updated**: 2026-05-17
> **Last Updated**: 2026-05-21
> **Version**: 1.0.0
> **Status**: In production (serving live traffic)
>
@@ -451,7 +451,10 @@ forms + waivers + onboarding form step + per-space form slug + invite roles,
`030` certifications + Instructor capability, `031` secrets SELECT honors
`ops.secrets.read`, `032` classes + sessions + signups, `033` equipment +
reservations, `034` member access cards + door permissions, `035` door
connections + access log).
connections + access log, `036` door card slots; later migrations through
`054` add classes/equipment/Stripe-dues/notifications and the door epic's
inbound access-log ingest (`053`) + universal API-call buttons (`054`) — see
docs/DATABASE_SCHEMA.md for the full, authoritative migration history).
### Forms & waivers (migrations 026-029; complete Phases 1-5)
@@ -575,9 +578,9 @@ status='reserved'` constraint (migration 042 + `btree_gist`), so two
simultaneous requests for the same window cannot both insert. No
anonymous path.
### Door / access control (migrations 034-036; epic in progress)
### Door / access control (migrations 034-036, 053-054; epic complete, P1-P5)
Phased; P1-P3 built, including member self-entry (reviewed at the locked checkpoint, then built). `member_cards` (034) associates RFID/NFC UIDs to
Phased and complete: P1-P5 built + deployed, 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
+2
View File
@@ -1,5 +1,7 @@
# Codebase Audit
> **Historical snapshot.** This is a full-codebase audit as of 2026-05-14, before the certifications, classes, equipment, Stripe-dues, notifications, member-portal, and door (cards / connections / inbound ingest / API-call buttons) work landed. For audit findings, deploy state, and the running session log AFTER this date, see [HANDOFF.md](./HANDOFF.md) (newest entry on top) — it carries the rolling per-pass security audits.
Date: 2026-05-13 through 2026-05-14 (three passes)
Scope: every file outside `node_modules`, `.next`, and `.git`.
Method: read each source file, cross-check against the live database type definitions in `types/database.ts`, the canonical schema in `scripts/schema.sql`, and the column map in `DB_SCHEMA_MAP.md`.
+39
View File
@@ -28,6 +28,45 @@ Append-only. Newest entries on top. Keep each entry to one screen.
---
## 2026-05-21 (pass 68): App-wide mobile-overflow audit + cron poll parallelized
Branch `main`. Continued the mobile pass app-wide and cleared the tracked cron finding. Build + lint green; 626 unit / 50 integration.
**Audit: systematic mobile horizontal-overflow scan.** Grepped every `truncate` in `app/`+`components/` for the flex anti-pattern (a `truncate` flex child without `min-w-0` will NOT shrink, so a long value pushes the row past the viewport). Triaged the 5 files that use `truncate` but never `min-w-0`: table-cell truncates in `payments`/`import` are fine (`max-w-[…]` + `table-cell`), the payments meta `<p>` is a block child (fine), and `mini-previews` is decorative landing. Two real instances fixed: **comms channel-name spans** (3, the channel sidebar/mobile drawer) -> `truncate min-w-0`; **form-builder published-URL span** -> `flex-1 min-w-0 truncate`. (Pass 67 already fixed the dashboard presence + door-self-entry spans.)
**Cron poll parallelized (clears the pass-66 latent finding).** `/api/cron/door-ingest` now polls connections with `Promise.allSettled` instead of a sequential `for…await`, so a tick is bounded to ~the slowest single poll rather than the SUM of all 6s timeouts (a slow/unreachable fleet could previously run minutes and overlap the next minute's tick). Each connection stays isolated (one bad controller can't 500 the run); `MAX_CONNECTIONS=50` caps the fan-out; dedup keeps it idempotent. Inert until CRON_SECRET is provisioned.
**Deploy.** This pass + the uncommitted pass-66 (docs/README refresh) + pass-67 (dashboard mobile + API-button re-audit) ship together (see the deploy note below this entry once pushed). No migration; no behavior change for existing users (className + cron-internal only).
---
## 2026-05-21 (pass 67): API-buttons/door re-audit (clean) + dashboard mobile fixes
Branch `main`. Re-audited the API-call buttons + door subsystems with a fresh adversarial lens and did a dashboard mobile-responsiveness pass.
**API-buttons + door audit (CLEAN, no code bug).** Re-read the invoke path, executor, and validations. Confirmed: the SSRF pin is enforced on the FINAL assembled URL (`base_url + url_template + query-secret` -> `validateDoorTarget` -> resolve-once-connect-by-IP), so even a `url_template` like `@evil.com` cannot bypass it -- a host mismatch with the per-button pin rejects, and the metadata/link-local block is absolute. `buildApiRequest` drops a caller `Host` header and undici rejects CRLF in header names/values (no request smuggling). Invoke is rate-limited FIRST, loads the def service-side scoped to the member's space, gates on the per-row `required_permission` (denials audited), fails closed on disabled/unknown-permission, decrypts the secret server-side and never returns it; `api_call_log` detail is double-redacted (egress + action). Tenant isolation holds; all UI rendering is escaped JSX; no committed secrets. The pass-65 hex-uid matcher holds.
**Dashboard mobile responsiveness.** The shell is sound (fixed 52px mobile top bar + hamburger drawer; `main` clears it with `pt-[52px] md:pt-0`) and the dashboard grids already collapse (`grid-cols-2 lg:grid-cols-4` stats; `lg:grid-cols-[1fr_280px]` -> single column; 44px touch targets on Quick Task / CLAIM; titles `truncate` with `min-w-0`). Found + fixed TWO real horizontal-overflow bugs: in `dashboard/presence-panel.tsx` and `dashboard/door-self-entry.tsx` the name `<span>` had `truncate` but no `min-w-0`, so in a flex row a long member/door name would NOT shrink and pushed the row past the viewport (horizontal scroll on a phone). Added `flex-1 min-w-0` (+ `shrink-0` on the door Open button). The task-row title was already correct. No other overflow risks found (banner links `flex-wrap`; proposals/incidents titles are block `truncate`; activity/project text wraps). **Could not browser-test (auth-gated); verified by layout inspection + build.** Note (not changed): shared `Button size="sm"` is ~36px tall, under the 44px touch guideline, but that is app-wide and consistent, not a dashboard regression.
**State.** Build + lint green; 626 unit / 50 integration unchanged (className-only + audit). Includes the pass-66 docs (still uncommitted).
---
## 2026-05-21 (pass 66): Ultrathink audit (broad) + docs/README refresh
Branch `main`. A fresh diversified audit (not re-defending the door work) plus the doc/README consolidation it surfaced. No code change this pass.
**Audit (diversified surfaces; findings).**
- **Clean / re-verified.** No committed secrets in the door/api-button files. No hardcoded permission-code list that adding `apicall.invoke` would break (the only consumers are `user_has_permission` calls + the door-template default). The `apicall.invoke` catalog addition + 054 backfill are additive (board + admin-implicit only; member/treasurer/associate unchanged). API-button header injection is bounded: `buildApiRequest` drops a caller `host`, and undici rejects CRLF in header names/values (no request smuggling); admins are trusted for their own buttons and the metadata/link-local block is absolute regardless. The matcher fix (pass 65) holds.
- **Finding 1 (LATENT, inert): poll cron is sequential.** `/api/cron/door-ingest` does `for (const c of conns) await pollConnectionLog(c)` with a 6s per-poll timeout and `MAX_CONNECTIONS=50`, so a fleet of slow/unreachable controllers can make one tick run up to ~300s (the crontab's `curl -m 30` returns, but the Node handler keeps running), and overlapping minute-ticks can pile up. Safe for correctness (idempotent dedup) and a non-issue for the deploying org (1-2 doors), but a real scaling concern. **Recommended fix when this matters: `Promise.allSettled` the polls (bounded) so a tick takes ~max(single poll) not the sum, and/or lower the per-poll timeout.** Inert today (CRON_SECRET unprovisioned). Not changed this pass (docs-focused; would need its own deploy).
- **Finding 2 (DOCS, fixed this pass): stale docs.** ARCHITECTURE still framed the door epic as "P1-P3 built / migrations 034-036; epic in progress"; the README access-control bullet predated P4/P5; and the README pointed at `docs/AUDIT.md` (a 2026-05-14 snapshot) as "the latest audit." Fixed below.
**Docs/README refreshed (same-change).** ARCHITECTURE: door section header + intro now read "migrations 034-036, 053-054; epic complete, P1-P5"; the migration parenthetical points to DATABASE_SCHEMA for the full history; "Last Updated" -> 2026-05-21. README: access-control feature bullet now covers grant/revoke/self-entry + inbound ingest (poll + webhook) + the API-call button builder; the project-status pointer now sends readers to HANDOFF.md for the running log and labels AUDIT.md as a historical 2026-05-14 snapshot. `docs/AUDIT.md` gained a top banner saying the same. CHANGELOG `[Unreleased] / Added` gained a door-epic-complete entry.
**State.** Suite 626 unit / 50 integration / build + lint green. Migrations through 054. Door epic feature-complete + deployed (P1-P5). One latent scaling finding (cron sequential poll) documented, not yet fixed.
---
## 2026-05-20 (pass 65): Door epic P4+P5 DEPLOYED + ultrathink audit
Branch `main`. Shipped the batched door-epic completion and ran a fresh diversified audit.