Quick audit before merge found the docs promise 'pnpm setup create-admin --force'
resets an existing admin's password, but the CLI only reused the user without
resetting (an admin-lockout footgun). Implement it via auth.admin.updateUserById,
guarded by --force and only when a password is supplied. Update docs/HANDOFF.md
with the single-tenant + security-fix entry (newest on top).
Tier 2 productization for single-tenant instances, plus fixes from an
adversarial review pass.
New:
- scripts/setup.mjs: 'pnpm setup' CLI (doctor/provision/create-admin/all),
idempotent, creates the first admin via the Supabase Admin API (email_confirm),
flag > env > .env.local precedence, masked interactive prompts, --yes/--force.
- scripts/apply-migrations.sh + deploy/deploy.sh: reproducible, idempotent CD
glue (previously only on the prod droplet), tracked via public._migrations_applied.
- docker-compose.full.yml + .env.full.example: one-command app + self-hosted
Supabase bundle (compose config validated).
- docs/SINGLE_TENANT.md (deploy + CLI guide) and docs/CUSTOMIZE.md (the
documented lib/tenant.ts config pattern for white-labeling); README pointer.
- __tests__/tenant.test.ts: 19 unit tests for resolveTenantConfig + isMarketingPath.
Security (found by review, pre-existing):
- Drop the stale handle_space_signup() auth trigger, which derived member role
and space from client-controlled raw_user_meta_data and could self-insert an
admin. Membership is created only by the vetted server actions.
Migration scripts/056_drop_stale_signup_trigger.sql + folded into schema.sql.
Fixes:
- setup.mjs masked-password prompt hung (callback passed to readline/promises
question); use the awaited form.
- notifications-enqueue.test: base-URL default is now localhost (platform-domain
fallback removed), not hackerspace.sh.
- docs accuracy (logo/BrandMark, apply-migrations does not restart PostgREST, --yes).
Gates: tsc 0, lint 0 errors, 664 unit tests, build ok.
Records that the /docs section and homepage fix are merged (PR #8) and live,
the proxy.ts PUBLIC_ROUTES hotfix that made /docs reachable without a session,
and the authoring/middleware gotchas for future public route groups.
Finalizes the docs section after an adversarial audit (code review + per-page
fact-check + coherence critic) and two content requests.
- Accuracy: fixed 10 invented cross-link slugs, the admin-vs-board gating claims
the verifiers caught (permissions matrix, /settings, invites, onboarding,
connect-payments, proposal withdraw, Stripe reconciliation, member fields,
door.operate), and 2 issues in the new pages (project area-at-creation, forum
thread-delete role).
- Coverage: added 5 how-to pages so every module is documented (recurring Stripe
dues, tasks & chores, projects, comms & forum, ops knowledge base + secrets
vault). 34 pages total.
- Screenshots: 12 captioned app screenshots under public/docs-media/, embedded
as figures. Each tutorial now has 2-3 step screenshots.
- Style: removed all em-dashes/en-dashes/ellipses from the docs (natural ASCII
punctuation).
- Handoff (docs/HANDOFF-audit-cleanup.md) updated with the current state.
typecheck 0 · lint 0 errors · build ok (all 34 /docs pages prerendered)
ARCHITECTURE.md documents the layered structure, the server-action contract +
space-scope invariant, feature-folder conventions, and an add-a-feature guide.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The installed supabase-js (2.99, 5 generics) is incompatible with
@supabase/[email protected] (calls it with 3), collapsing the server client's
schema to `never` — the root cause of ~450 of the 586 hidden type errors
(next.config typescript.ignoreBuildErrors masks them all).
- lib/supabase/server.ts: annotate the factory as SupabaseClient<Database>
and bridge the ssr/js impedance mismatch at the one documented boundary;
type the setAll cookie params.
- lib/supabase/proxy.ts, proxy.ts: type the setAll cookie params.
- lib/auth-helpers.ts: parseInput now infers its result from the schema via
z.infer (was a fragile ZodSchema<T> that widened outputs to {}).
- lib/validations.ts: annotate flexibleDateTime's output type.
- types/database.ts: add class_signup_tx / class_cancel_tx RPC signatures
(called but absent; pending full regeneration).
- tsconfig.json: target ES2020 (door-log-logic uses BigInt literals).
- vitest.setup.ts: import beforeAll/afterAll instead of relying on globals.
No runtime behavior change; 639 tests still green. ignoreBuildErrors stays
on until the remaining errors reach zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
docs/SPINE_VALIDATION.md: a precise owner runbook + checklist that provisions
the shipped-but-inert spine (Resend, CRON_SECRET + both crontabs, per-space
Stripe test mode + Billing Portal, the Supabase change-email template) and
proves each path end to end (dues cycle, notifications, prefs/inbox, /me,
door). Grounded in the actual config and route fail-closed codes; linked from
the README docs table.
integration/permission-escalation.test.ts: lock the additive permission
model's core guard, previously untested -- only admin/board may write
space_role_permissions, so a plain member cannot grant themselves a
capability, and the WITH CHECK pins the space.
No app code or migration; suite 626 unit / 54 integration green.
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.
The inbound-ingest matcher read a stored card_uid as both decimal and hex,
which mis-attributed all-digit uids (a stored "16" is hex 0x16 = 22, but it
wrongly matched a decimal poll of card 16) and was conceptually wrong.
HeatSync stores the uid as hex (the grant encoder requires it) and the ?z
log / webhooks report the decimal card number, so cardMatchesEvent now
matches a reported decimal when hexInt(storedUid) equals it, and matches a
webhook card_uid by exact string. This removes the false positive while
keeping every legit match. Attribution is audit-only (never an access
decision) and inert until a space enables ingest; no migration.
Also records the pass-63/64 door-epic deploy state and the pass-65 audit in
the handoff log.
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.
Diversified audit across public/API, XSS/rendering, and legacy authz
surfaces (three independent reviews). No P0. Fixes:
- Onboarding payment_url (P1 stored XSS): rendered into an href with no
protocol check; an admin could store javascript:. Sanitize at the sink
with sanitizeUrl (http(s) only). Same class as the dues-url fix.
- forum.addComment (P2): only forum_thread targets were space-verified;
proposal/incident/policy entity_ids were not, so a member could attach
a comment to another space's entity. Verify the target exists in the
caller's space (RLS-scoped) for every entity type before insert.
- joinSpace (P2): add a per-user rate limit (10/hr) for parity with
createSpace/signIn, bounding invite-code guessing.
Deep audit of the recent change set (passes 56-60):
- Remove the generic Integrations-tab Stripe card. saveIntegration
overwrites integrations.config wholesale, so configuring Stripe there
clobbered the dues config (mode/prices/grace_days) that the Dues tab
manages, and couldn't actually set up dues. Stripe is now configured in
one place (the Dues tab). PayPal/Zeffy/Venmo sync cards unchanged.
- deleteDuesPaymentMethod validates platform against DUES_LINK_PLATFORMS
instead of passing a raw client string into the enum column.
- markNotificationsRead uses a strict uuid regex for the id filter.
PGRST201 class characterized + confirmed contained: PostgREST treats a
2-FK table as an ambiguous junction only when its PK covers both FKs; the
only such table now is space_member_custom_roles (a legitimate M2M).
Guarded by integration/auth-embed.test.ts.
The Stripe recurring-dues panel and the external payment-links panel were
rendered below the settings tab bar (outside SettingsClient), so they
floated under every tab. Add a Dues tab (Space / Integrations / Dues /
Webhooks) and render both panels there as standard tab cards. UI only.
In-app notification center (Phase 5, migration 051):
- notifications.read_at + partial unread index. /me Notifications is now
an inbox: unread styling, click to expand the message + mark read,
"mark all as read", unread count. markNotificationsRead is a service-
client write scoped to the caller's own member_id. Always-on channel:
email-muted ('skipped') notifications still appear in-app.
Consolidate /profile into /me:
- Delete app/(app)/profile/*; /me is the single member page. Port the
conflict-of-interest privileged-role nudge + last-disclosed date into
/me's profile editor. Remove the "My profile" sidebar + command-palette
entries; rename the /me nav label to "My membership". Repoint the
recruitment empty-state link to /me.
Theme realign:
- Revert the rounded-lg cards introduced in the prior /me visual pass to
rounded (the app norm, matching the former /profile). Route /me section
headers through the SectionTitle primitive.
Regression guard:
- integration/auth-embed.test.ts runs the auth layout's
space_members.select('*, spaces(*)') embed through PostgREST and fails
on PGRST201, so a future junction table cannot silently break auth again.
The url renders to members as a clickable href. https was enforced only
in the admin Zod action, but RLS lets an admin/board write the row
directly via PostgREST, bypassing Zod, with no DB constraint -- a
malicious admin could store a javascript: URL and XSS members who click.
- Migration 050: CHECK (url ~* '^https://') so the DB is the source of
truth; integration-tested that the service path cannot store
javascript:/http:.
- Member-read action filters with isSafeDuesUrl (defense-in-depth).
- dues_payment_methods RLS: member read, admin-only write, cross-space
denial, 046 unverified-admin status gate (4 cases).
- notification dispatcher: a muted category becomes 'skipped' while
billing always sends; no pref row sends (default on). First real
exercise of the skip path (cron is gated off in prod).
- Fix integration/db.ts rowsAsUser: the SELECT must be the last psql -c
statement or its output is swallowed (it was never used before).
- Dues card: hide "No active dues subscription" for spaces without
Stripe, where it read as confusing.
Notification preferences (migration 048):
- Per-member opt-out of muteable categories (bookings, classes, forms,
admin alerts); billing is always-on. Opt-out model, default on.
- Dispatcher reads prefs and marks muted outbox rows 'skipped' instead
of sending; pure isMuted logic, fail-open on a prefs lookup error.
- notification_preferences table (default-deny RLS); /me toggles and
self-view via validated service-client actions.
Alternate dues payment methods (migration 049):
- Admin sets external pay-here URLs per platform (PayPal/Zeffy/Venmo)
in /settings; members see active ones on /me and pay off-platform;
the treasurer reconciles manually (platform pre-tagged). https-only.
- dues_payment_methods table (SELECT space member, write admin/board).
/me visual pass:
- Restore the chip/preset profile editor (skills/interests/willing-to/
affiliations); promote ChipInput and presets to shared modules.
- Gate the Stripe pay-dues button on whether the space has Stripe
configured; show external links or a contact-admin note otherwise.
Adds a session-closeout subsection capturing the three-audit-pass
discipline that this phase set as a new high-bar (each repeat audit
on the "clean" change set found a real, deploy-blocking issue: the
RPC enumeration vector, the best-effort gap in helpers, the phishing
vector in user-controlled fields exposed by form_submission_admin).
Refreshes the remaining-work list to the post-Phase 4 priority. The
owner-gated end-to-end validation is still the single biggest gap.
Member preferences + in-app inbox are now the natural next product
work (Phase 5; preferences also doubles as the volume governor for
Phase 4 fan-outs). user_has_permission EXECUTE-TO-PUBLIC pre-existing
exposure recorded as a separate triage item with the constraint
written down (RLS policies depend on anon/authenticated EXECUTE, so
the lockdown can't just be copied from migration 047).
Marks the Phase 4 entry DEPLOYED. Deploy log confirms migration 047
applied (applying 047_members_with_permission.sql, REVOKE, GRANT).
Smoke clean: public 200, gated 307, Stripe webhook 400, cron 503 (no
regressions; cron-503 is the expected fails-safe with CRON_SECRET
unset). The new event types are wired but inert at the transport
layer until owner provisions Resend and the cron secret + crontab.
Renames the P4f entry to "first-audit fixes", adds a P4g entry for the
third-audit URL-strip defense, and bumps the test-count line to 572
(40 new cases). The commit list at the top of the section now ends at
09f293e.
DATABASE_SCHEMA: the migration 047 row now records the REVOKE/GRANT
lockdown, with one-line rationale for why it differs from
user_has_permission (RLS policies depend on anon/authenticated being
able to call user_has_permission, so locking it would break policy
evaluation; the new function has no such constraint).
HANDOFF: replaces "Next-most-leverage" with "Highest-priority" in my
pass-56 backlog header. "Leverage" is on the CLAUDE.md style-bans list.
Records the ultrathink audit and the P4f fixes (RPC lockdown + best-
effort enqueue wraps). Also flags pre-existing user_has_permission
EXECUTE TO PUBLIC for separate triage: narrower than the 047 case
(per-uid boolean, only useful within spaces the caller is in), and
locking it down requires careful work because RLS policies depend on
anon/authenticated being able to call it. Out of Phase 4 scope.
Updates the test-count line to 567 (35 new cases) covering the new
helper best-effort no-throw paths.
Adds an ARCHITECTURE section "Notification breadth: bookings, classes,
forms" describing each new event type, the triggering action, the
recipient rule, and the dedupe-key scheme. Updates Known Limitations #5
to reflect that booking + class + form notifications are no longer "not
wired yet."
API_REFERENCE: extends the Transactional notifications section with the
shared enqueue helper, every new event type, its trigger and recipient,
and the new members_with_permission RPC used for the forms.manage
fan-out.
DATABASE_SCHEMA + DB_SCHEMA_MAP: adds the migration 047 row and the new
helper function to the helper-functions list.
HANDOFF: pass-56 entry covers the four locked design forks, the four
sub-phase commits, test growth (532 -> 565, +33 cases), state (local,
inert post-deploy until owner provisions Resend + CRON_SECRET), the
known fan-out volume tradeoff, and the remaining backlog.
Root-cause fix for 'correctness depended on post-deploy manual audits'.
Separate psql-driven suite (pnpm test:integration, own vitest config,
integration/ outside __tests__ so the hermetic pnpm test gate is
untouched; self-skips without a DB). 18 tests run against real Postgres:
- 045 class_signup_tx/class_cancel_tx: RETURNS TABLE->array shape the
actions destructure, capacity/waitlist/dedupe, and the per-session
advisory lock under TWO concurrent connections (D11 proven, never
over-enrolls).
- 042 equipment exclusion constraint (overlap 23P01; cancelled/adjacent
ok). 044 self-change trigger (role + payment cols blocked; service
bypass). 043 members_update WITH CHECK (cross-space move blocked).
Disproves the pass-52 flagged risk (signup rpc-shape). Non-runtime;
default pnpm test still 532/26 hermetic; build clean.
Deferred deeper-audit P1. signUpForClass (count->decide->insert) and
cancelMySignup (cancel->recount->promote) were non-atomic: concurrent
signups at the capacity boundary over-enrolled and concurrent cancels
double-promoted waitlisters (dynamic capacity can't be a DB constraint).
Migration 045 adds class_signup_tx/class_cancel_tx (SECURITY DEFINER,
pg_advisory_xact_lock per session) doing the decision+write atomically;
actions call the RPCs and keep all pre-checks. Removed the now-unused
effectiveCapacity/computeSignupStatus/pickPromotion imports +
isUniqueViolation (pure rules still live + tested in classes-logic).
528 tests; build clean.