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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user