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.
Add lib/tenant.ts as the single source of truth for multi- vs single-tenant
deployment. Gate space creation (server + signup UI), add open-join to the one
space, hide the marketing shell, and route brand name + base URL through config.
Also commit the _migrations_applied table into the canonical schema so a fresh
instance is reproducible from schema.sql alone.
- lib/tenant.ts: resolveTenantConfig (pure) + tenantConfig() + appBaseUrl()
- lib/auth-actions.ts: createSpace refused when !allowSpaceCreation; joinSpace
open-join resolves the configured/sole space with no invite code
- proxy.ts: marketing paths redirect to /login when showMarketing is off
- app/signup/page.tsx: single-tenant hides create/join chooser, brand=siteName
- app/layout.tsx + 4 link builders: brand/base-URL via config (no hackerspace.sh literal)
- scripts/schema.sql: public._migrations_applied tracking table
- .env.example: documented single-tenant + first-admin bootstrap vars
- package.json: pnpm setup
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.
The new /docs section is static, public help content, but proxy.ts (the auth
middleware) redirects any non-allowlisted path to /login, so unauthenticated
visitors were bounced off /docs to the login page. Add /docs to PUBLIC_ROUTES
(covers /docs and every /docs/<category>/<slug>); screenshots under /docs-media
already bypass the middleware via the image matcher.
Verified locally with no session: /docs and doc pages return 200, while
/dashboard and /members still redirect to /login.
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)
A new public /docs area covering the whole platform, organized by Diátaxis:
tutorials (learning), how-to guides (tasks), reference (facts), and explanation
(the why). 29 pages, each grounded in the actual routes, actions, schema, and
UI — verified against the code and corrected for accuracy (e.g. admin-only vs
admin+board gating, where the permissions matrix actually lives, how Stripe
reconciliation matches).
Implementation:
- lib/docs/nav.ts is the single source of truth (categories + pages), driving
the sidebar, static params, and prev/next.
- Content authored as content/docs/<category>/<slug>.md, read at build time
(the routes are statically generated), rendered by <DocsMarkdown> — reuses the
existing react-markdown/GFM stack, no new dependencies.
- (docs) route group with its own theme (docs.css) matching the public site's
dark / monospace / lime aesthetic; sidebar with active-state, breadcrumb,
prev/next, and a Diátaxis home. Public — signed-in users can read it too.
- "Docs" link added to the landing nav.
typecheck 0 · lint 0 errors · build ok (all /docs pages prerendered)
The homepage hard-redirected any authenticated visitor straight to /dashboard,
so clicking the wordmark/home (or opening / while signed in) never showed the
public site — and would block the public docs section too. Drop the redirect and
make the nav, hero, and closing CTAs auth-aware: signed-in visitors get a
"Dashboard" button; signed-out visitors get "Log in" / "Get started".
45-commit audit/cleanup/refactor + SoC pass, plus follow-ups: type regeneration, L3 onboarding-race atomic RPC (migration 055), and DB-backed integration+e2e CI. All gates green. See docs/HANDOFF-audit-cleanup.md.
The "should load X page" specs asserted the URL stays on the protected route,
but an unauthenticated run — as in CI, which starts a fresh dev server with no
session — correctly redirects to /login. They passed locally only by racing an
already-warm reused server. Accept either the target route or /login, matching
the tolerance the sibling assertions in this file already use.
Confirmed with the full e2e suite (60 tests, chromium) under CI conditions
(fresh server, single worker, JWT keys).
Adds an integration-e2e job to ci.yml alongside the fast hermetic check job. It
brings up the Supabase CLI stack, applies the canonical scripts/schema.sql +
seed.sql, then runs the integration suite (psql-driven shipped-SQL: RPCs / RLS /
triggers / constraints) and the Playwright e2e suite (chromium) against the app
wired to that stack (env sourced from `supabase status`).
The integration suite self-skips when no DB is reachable, so a missing DB could
pass silently. Two guards prevent that: an explicit `select 1` reachability
check, and a post-run assertion that fails the job when the suite executed 0
tests (parsed from the vitest json reporter).
Auto-seed is disabled for the ephemeral CI checkout (seed.sql needs tables that
schema.sql creates outside the migration path); schema + seed are applied
explicitly. Local dev is unaffected.
Validated locally: integration suite (58 tests) and both e2e specs (53 tests,
chromium) pass against the real local stack.
markOnboardingStepDone read space_members.onboarding_progress, appended the step
id in JS, and wrote the whole jsonb object back — a lost-update race where two
near-simultaneous completions (double-submit, or two steps ticked quickly) both
read the same base array and the second write clobbered the first, silently
dropping a step.
Replace it with mark_onboarding_step_done (migration 055): a single atomic
UPDATE that dedup-appends the step id into onboarding_progress.completed_step_ids
under the row lock — no update can be lost (READ COMMITTED re-evaluates the jsonb
expression against the latest tuple via EvalPlanQual), other keys are preserved,
and re-appending an existing id is a no-op. Ownership-guarded by auth.uid() so a
caller can only complete steps on their own membership row; the no-JWT service
path is trusted and scoped by p_member_id (mirrors class_signup_tx et al.).
integration/onboarding-progress.test.ts covers dedup/idempotency, key
preservation, the auth.uid ownership gate, and the point of the fix: 12
concurrent completions all survive.
Migration 055 folded into schema.sql; types/database.ts regenerated.
typecheck 0 · lint 0 errors · 645 unit · 58 integration · build ok
Regenerate types/database.ts from a local Supabase built from the canonical
scripts/schema.sql. The running dev DB was stale (missing the 'stripe'
payment_platform value, members_with_permission, spaces.host_requires_card,
payments.link_status, dues_payment_methods.space_id), so it was rebuilt from
schema.sql first.
With real relationship metadata the 11 `as unknown as … // TODO(types)` join
casts are no longer needed:
- me portal, session-attendance, member-certifications, equipment-reservations:
narrow the action result with `'data' in res` instead of a blanket cast.
- stripe / door embeds read the now-typed relation directly.
- completeSession + listEquipmentReservations route the embedded-select query
through the fully-typed createClient() rather than the wide ServerSupabase gate
client (the pattern documented in auth-helpers.ts); equipment disambiguates its
two space_members FKs with an explicit !fkey hint.
Collapse the hand-maintained governance rows/enums in types/domain/governance.ts
into Tables<>/Enums<> aliases.
Tighten duesPaymentMethodSchema.platform to the DUES_LINK_PLATFORMS literal union
(the old `as unknown as [string,...]` erased it), which the now-correct
payment_platform enum requires.
The hand-added class_signup_tx / class_cancel_tx RPC entries are now generated
natively.
typecheck 0 · lint 0 errors · 645 tests · build ok
updateAreaLead used .single(), which errors (raw PostgREST message) rather than
returning null when the id isn't in the caller's space. Switch to .maybeSingle()
so the existing 'Area lead not found' guard is reached, matching deleteAreaLead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
claimInviteUse returned null both when the cap was truly reached and when it
lost the compare-and-swap 5× under load, so a legitimate joiner on a busy
high-cap invite got a false 'invite has reached its use cap'. Return a
discriminated result; map contention to a distinct 'try again' message.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The invites/onboarding/areas/roles/tiers admin panels moved to /customize, but
their actions still revalidated the old /settings route, so the /customize
Router Cache went stale after a write. Point them at /customize (keeping the
other cross-page revalidations).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Real mock-client tests for createAreaLead/updateAreaLead/deleteAreaLead (admin
gate + space scope), sendMessage (identity from session not input; foreign
channel rejected), and payments member-in-space rejection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
logCashPayment and linkPaymentToMember accepted a uuid member_id without
checking it belongs to the caller's space (the equipment/class paths were
already hardened for exactly this). Look up the member scoped to the caller's
space and reject a foreign id before writing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The chat composer inserted comms_messages straight from the browser with
CLIENT-SUPPLIED user_id/display_name/handle/space_id — a member could forge
another person's name/handle. Add a sendMessage server action that derives
identity from the session, verifies the channel belongs to the caller's space,
validates content, and logs the send. The optimistic insert + realtime
subscription are preserved, so latency is unchanged. loadMessages stays on the
browser client (the realtime subscription needs it) with a comment noting it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ops UI created/updated/deleted area_leads directly from the browser (the
delete had no admin gate or space scope). Add createAreaLead/updateAreaLead/
deleteAreaLead (admin-gated, space-scoped, validated, audited) and wire the
modal + delete handler to them, dropping the direct browser writes. The
distinct area_code-keyed upsertAreaLead interface is left untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
- Delete orphaned components (theme-provider, task-claim-button), the unused
roleDisplayName helper, the fully-dead cleanupRateLimits, the 6 never-imported
validation schemas (signIn/signUp/createSpace/joinSpace — a replaced auth flow;
proposalStatuses; updateAreaLeadRoleSchema).
- Migrate all @/lib/types importers to the canonical @/types/domain and delete
the compatibility shim.
tsc 0, 635 tests green, lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ops 'Add Secret' modal wrote the secret value straight to the DB from the
browser in PLAINTEXT — despite the 'Stored encrypted at rest' label — because
the AES key is server-only. Route it through the createSecret server action,
which encrypts the value (AES-256-GCM), enforces the admin/board gate, validates
input, and logs the activity. Existing plaintext rows (encryption_version 0)
still reveal correctly; new secrets are encrypted when a key is configured.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
663-line members-client.tsx → a 255-line orchestrator + members/components/
{member-form-dialog,members-table,members-toolbar}.tsx + types.ts. The add/edit
form fields (previously defined INSIDE render, which remounted and lost input
focus every keystroke) are now a stable top-level component in member-form-dialog
— fixing that footgun. tsc 0, build ok.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
663-line settings-client.tsx → a 79-line orchestrator (activeTab + shared save
status) + settings/panels/{space,integrations,dues,webhooks}-panel.tsx each
owning its own form state + types.ts. Server-action calls and fields preserved
verbatim. tsc 0, build ok.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
956-line ops-client.tsx → a 230-line orchestrator + ops/panels/{kb,secrets,
area-leads}-panel.tsx (kb+processes share kb-panel via a variant prop) +
ops/components/{kb-modal,secret-modal,area-lead-modal,secret-row,kb-entry-row}.tsx
+ types.ts, following the customize/ exemplar. Behavior preserved (direct writes
kept as-is for now — the secrets one is fixed in the next commit). tsc 0, build ok.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lib/types.ts (aliases + governance shapes + auth re-exports) split into
types/domain/{index,governance,reexports}.ts, making the generated/hand-written
seam explicit (generated = types/database.ts, hand-written = types/domain/*).
lib/types.ts is now a re-export shim so all 11 importers are untouched. Fixed
the stale 'not yet in types/database.ts' comment — those tables/enums are now
generated; de-duplicating the governance rows into Tables<>/Enums<> is a noted
follow-up. tsc 0, 635 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1141-line lib/validations.ts → lib/validations/<feature>.ts + primitives.ts +
an index.ts barrel. '@/lib/validations' now resolves to the barrel, so all 40
importers are untouched. Every one of the 143 exports is preserved verbatim
(verified by symbol-diff); schemas moved unchanged. tsc 0, 635 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Banner documenting the regenerate command and that hand-written domain types
belong in types/domain/*, not here. No code change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Runs on every PR and on main after merge, fully separate from deploy.yml so it
can never affect deploys. Catches type/lint/test regressions that previously
had no CI at all. Integration + e2e (which need a DB service) are a follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These gate every server action but had no direct tests. Add cases for: no user,
no active membership, active member; role allow/deny; the require_approval
unverified gate; and late members keeping their role.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A non-empty invalid transaction_date ('N/A') passed presence-only validation,
then new Date(str).toISOString() threw mid-import: nothing imported, no toast,
and setImporting(false) never ran, sticking the button on 'Importing...'.
Extracted parsePaymentRow to lib/import-logic.ts with a date guard, and wrapped
runImport so the importing flag always clears.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
reserveEquipment used a manager-supplied memberId directly, so a booking could
be stamped for a member id from another space. Mirror signUpForClass: look up
the target by (id, space_id=caller) and reject if absent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
createProposal/openProposal/withdrawProposal/castVote gated only with
requireMember, which admits status='unverified' — so a member pending approval
in a require_approval space could propose and cast counted votes before vetting.
Add the isPrivilegeEligible gate (same as requireMemberWithRole) to all four.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
appealIncident was gated by requireMember but the incident UPDATE is RLS-
restricted to admin/board, so a reporter's appeal inserted a draft proposal,
updated 0 incident rows, and returned success — an orphan proposal. Gate with
ADMIN_ROLES to match the write, and assert the link UPDATE affected a row
(.select), rolling back the draft proposal if it did not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
updateInvite ran the canAssignInviteRole guard only when the payload included
'role', so a board member could re-enable/extend an exhausted admin-granting
invite (is_enabled/max_uses/expires_at) and distribute admin access they cannot
mint. Load the existing invite and require canAssignInviteRole against its
effective role whenever any privilege-affecting field is (re-)armed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
logCashPayment and linkPaymentToMember unconditionally set last_paid_at to the
(possibly backdated) transaction_date and forced payment_status='current', so
logging/linking a historical payment moved a member's dues state backward.
New pure resolveDuesAdvance() keeps the later of existing/incoming date and only
marks 'current' when the payment actually advances; both sites read the current
date first. Identical in the common (newest-payment) case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(a) Reset h/mi/e after each event so a later event with no H:M:E of its own no
longer inherits the previous event's time-of-day (fabricated audit timestamp +
dedupeKey). (b) Track consumed high-half token indices so one g/d token can no
longer be paired with two adjacent grants (wrong reconstructed card numbers).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
isBlockedDoorIp blocked the dotted IPv4-mapped form (::ffff:169.254.169.254)
but the hex form ::ffff:a9fe:a9fe (=169.254.169.254 IMDS) / ::ffff:7f00:1
(=127.0.0.1) fell through the IPv6 branch and returned false. Decode the hex
IPv4-mapped form to dotted IPv4 and run the existing blocked-range checks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tsc --noEmit reports 0 errors (was 586, all masked). next build now
type-checks on every build and prod deploy, so a type regression fails CI
instead of shipping. 649 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>