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>
The old file asserted on string literals it defined itself (e.g.
expect(".eq('space_id', ...)").toContain('space_id')) and never invoked an
action, so it stayed green regardless of the code — false confidence on exactly
the authz/scoping it claimed to cover.
Rewrite drives the real updateMember/removeMember/logCashPayment through a
recording mock Supabase client and asserts: unauthenticated + non-privileged
callers are rejected with no write; admin/treasurer writes are pinned to the
caller's own space_id (the IDOR guard). Removing a role gate or a space_id
scope now fails a test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The candidate-member initials rendered m.display_name.split(' '); display_name
is nullable, so a member with no name crashed the link dialog. Fall back to
email then '?'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
member_directory_visibility is the directory_visibility enum
(board_only|member_count_visible|members_visible|public_members_visible), but
the gate compared against 'board_visible'/'admin_only' — values from other
enums that never match — so every setting except members_visible silently
restricted the directory, including public_members_visible (meant to be the
most open). Map the gate to the real enum values.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Typing into the members search box called .toLowerCase() on display_name/email,
both of which are nullable (e.g. imported members) — a single null row threw a
TypeError and blanked the entire members page. Optional-chain the search match,
and handle nullable values when seeding the edit form and optimistic update.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Redeeming an invite read uses_count, checked the cap in JS, then did a
non-atomic uses_count = uses_count + 1 write AFTER creating the membership.
Two concurrent redemptions of a max_uses=1 invite both passed the check and
both created a member, and the counter under-counted.
Fix: claim a use ATOMICALLY before creating the membership via a
compare-and-swap on uses_count (lib/invite-claim.ts) — the UPDATE only writes
when uses_count still equals the value the cap decision was based on, so of two
racing claims exactly one wins. The loser re-reads and retries while capacity
remains (multi-use invites unaffected). On a failed membership insert the claim
is rolled back so a slot is not burned.
Extracted to lib/invite-claim.ts (out of the 'use server' surface, unit-tested).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- eslint.config.mjs: flat config built on eslint-config-next's flat array
(core-web-vitals + typescript + Next plugin), with build/generated artifacts
ignored and the aggressive react-hooks v6 React-Compiler rules downgraded to
warnings so `pnpm run lint` passes clean (0 errors, 88 warnings of burn-down
debt) instead of failing. Previously `eslint .` failed outright — no config
and no eslint binary were installed.
- Install eslint, @eslint/js, eslint-config-next, typescript-eslint, globals as
devDeps (dev-only; prod Docker build is unaffected).
- package.json: add `typecheck` (tsc --noEmit) and `test:run` scripts.
- app/not-found.tsx: escape apostrophes (react/no-unescaped-entities).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-file type-only fixes surfaced after the Supabase client was correctly
typed. All fixes preserve runtime behavior; no any/ts-ignore/ts-expect-error.
- lib/supabase/client.ts: annotate the browser client as SupabaseClient<Database>
(same ssr/js drift as server.ts; fixes it centrally for all callers).
- Supabase embedded-select joins whose FK metadata is missing from the
generated types are bridged with `as unknown as <target>` + a TODO to remove
after regenerating types/database.ts.
- setState optimistic-update variance and raw <select> enum values cast to the
precise element/union type; .insert payload fields (Json, enums) given the
precise column type via satisfies/field casts.
- Added narrowing guards where a value was legitimately nullable (comms channel,
public form page) without changing the intended path.
The 14 remaining errors are confined to the four files fixed in Phase B (real
bugs + the tautological actions.test rewrite). 639 tests still green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The installed supabase-js (2.99, 5 generics) is incompatible with
@supabase/ssr@0.6.1 (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) <noreply@anthropic.com>
The mark is the actual brand PNG (already transparent) — just trimmed and
downscaled. The prior version wrongly ran a saturation mask that stripped the
white ring and mangled the wrench into a blob.
- Replace the hand-drawn wrench with the actual hack.build mark: isolate the
pink wrench from the brand PNG onto transparent (public/hackbuild-mark.png)
and use it for the talk tile and the Resources card.
- Showcase grid: 7 columns so the seven projects fill one row, and draw the
hairlines from per-tile borders instead of a grey container showing through
gaps — no more grey empty cells on an incomplete row.
- Drop the now-unused HackBuildIcon.
- Landing 'From hackerspace.sh' showcase: add a 'Hacking Your Hackerspace'
tile (07 projects) linking the deck on hack.build, with a new HackBuildIcon
(the brand's pink open-end wrench).
- Point the Atlas tile at https://atlas.hackerspace.sh (now live) instead of
the old internal /atlas route.
- Use the same hack.build wrench for the talk card on the Resources page.
- Resources: 7th project card linking the 'Hacking Your Hackerspace' talk
(hack.build), with a PlayIcon; bump the section count to 07.
- Sidebar: NavLink now supports external targets; add a 'Public Site' section
linking the homepage and the public resources page.
- Login: a back-to-homepage link.
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.
notification_preferences (048) had FKs to BOTH space_members and spaces.
PostgREST read that as a junction, so embedding spaces into space_members
(the auth layout's `select('*, spaces(*)')`) became ambiguous and errored
-- member resolved to null and every logged-in user was bounced to /signup.
Drop the redundant space_id -> spaces FK (space is reachable via
member_id -> space_members; space deletion still cascades through it). With
one FK left the table is no longer a junction and the embed is unambiguous.
Verified against local PostgREST: the embed returns rows again.
(schema.sql also carries the in-progress notifications.read_at column from
uncommitted inbox work; harmless -- prod applies migrations, not schema.sql.)
Edge URLs like https:example.com parse to protocol https: but lack //,
which the migration-050 CHECK rejects -- the admin would see a confusing
save failure instead of a clear validation error. Require the prefix so
the Zod boundary matches the data layer. Fail-closed either way; no
security impact.
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.
Third-pass audit finding. renderShell auto-linkifies any https?:// in
body lines so the manageUrl line becomes a clickable link in the HTML
email. The same regex would happily turn a malicious URL injected into
a user-controlled field (member display_name, equipment / class / form
title, submitter label, space name) into a clickable link.
In Phase 2 dues emails this was self-only: the recipient is the same
member whose display_name appears, so self-phishing is silly. Phase 4
introduces the first email type where one user's display_name lands in
a different user's inbox: form_submission_admin embeds the submitter's
display_name (or, when the form was submitted anonymously and an email
match attributed it to a member, that member's display_name) in an
email read by every forms.manage holder. An authenticated member can
set display_name to "Click https://evil.com for free stuff", submit a
form, and the admin's inbox now has a clickable phishing URL.
body.email is Zod-validated against email format so the typed-email
path is closed, but display_name (z.string().min(1).max(100)) is a
free string. No format restriction will catch a determined attacker.
Mitigation: stripUrls(s) replaces https?://\S+ with [link]. Applied to
every user-controlled input across all four renderers (dues, booking,
class, form) including spaceName, memberName / recipientName, equipment
/ class / form title, location, and submitterLabel. manageUrl, which
buildManageUrl constructs from NEXT_PUBLIC_APP_URL or a Stripe-
authenticated webhook host, stays untouched and remains the only
linkified URL in the rendered email.
5 new tests cover the new attack vector (form_submission_admin) and
defense-in-depth across booking, class, and dues renderers. Suite 572,
build clean.
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.
Two findings from an ultrathink audit of the Phase 4 work.
(1) members_with_permission was callable by any PostgREST client. Postgres
grants EXECUTE on new functions to PUBLIC by default, and this codebase
has no existing REVOKE pattern. The function returns a SET of member_ids
for any (space, permission) pair, so without a lockdown ANY authenticated
caller could enumerate "who has permission X in space Y" for spaces they
are not even members of, learning member_ids and the permission
distribution. user_has_permission has a narrower exposure (per-uid
boolean) and is needed by anon/authenticated for RLS-policy evaluation,
so it stays callable; the new function is locked to service_role since
the only legitimate caller (the form-submission fan-out) goes through
the admin client. Migration 047 + schema.sql now REVOKE EXECUTE FROM
PUBLIC and GRANT TO service_role.
(2) The Phase 4 enqueue blocks in equipment.ts (reserve, cancel) and
classes.ts (signUp, cancelMySignup waitlist promotion, updateSession
session-cancellation fan-out) used the shared helpers, but the helpers
themselves were not internally best-effort. The Stripe webhook's
original enqueueDues wrapped its entire body in try/catch so a thrown
member-lookup error could never bubble into the money path; my refactor
removed that inner wrap and relied on enqueueNotification's
best-effort. resolveMemberContact and getSpaceName could still throw on
a transient supabase network error, which would surface to the action
return value after the underlying mutation already committed: the
client would see an error even though their booking, signup, or
session-cancellation succeeded. Fixed at both layers: the helpers now
swallow throws internally (returning null / empty), and each call site
in equipment/classes is wrapped in try/catch around the lookup chain so
secondary action-specific queries (equipment name+location lookup,
promoted-signup session lookup, session-cancellation signup fan-out)
also cannot leak. forms.submitForm already had the wrap from P4d.
2 new helper tests pin the best-effort behavior; suite 567, build
clean.
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.
submitForm now enqueues two kinds of notifications, gated per the design:
- form_submission_received: ONLY when the submitter is authenticated
(members or public_auth visibility). Recipient is the verified auth
email (user.email), never the typed body.email. Anonymous public
submissions skip the confirmation because confirming to a typed
address is a victim-spam vector.
- form_submission_admin: one row per member who holds forms.manage in
the space (mirrors the forms-guard / forms RLS gate). Dedupe by
(submission, admin) so a replay is a no-op.
Finding all permission holders without N round-trips needs a new SQL
helper. Migration 047 adds members_with_permission(sid, perm): the
inverted, set-returning form of user_has_permission, with the same
current/late status gate (046) and the same admin shortcut +
space_role_permissions / user_effective_roles fallback. Additive,
introduces no new tables or policies; existing callers unchanged.
Mirrored into schema.sql; types/database.ts updated.
renderFormEmail + formDedupeKey live in notifications-logic; 6 new
render/dedupe unit tests cover both types, fallback labels, escaping.
Suite 565, build clean.
signUpForClass enqueues class_signup_registered or class_signup_waitlisted
based on the RPC return shape; recipient is the affected member, so a
manager signing someone else up still emails the target. cancelMySignup
enqueues class_signup_promoted to whoever was bumped from the waitlist
into the session (the promoted_id returned by class_cancel_tx).
updateSession, when status flips to 'cancelled', fans out a
class_session_cancelled email to every still-active signup (one row per
member, dedupe by session + member).
No type for plain self-cancel: cancelMySignup is self-only and the rule
is "only when someone other than the affected member cancelled," so it
has no caller.
Adds renderClassEmail + classDedupeKey to notifications-logic; 9 new
render/dedupe unit tests cover each type's subject + body and the
session-cancelled per-member dedupe shape.
Suite 559, build clean.
reserveEquipment now enqueues a booking_confirmed email to the booked-for
member (always; a manager booking on someone else's behalf still emails
the target). cancelReservation enqueues a booking_cancelled email ONLY
when the actor is not the affected member, so self-cancels stay silent.
Adds renderBookingEmail + bookingDedupeKey to lib/notifications-logic.ts
(extracts a shared renderShell at the same time so the 6 upcoming
templates do not duplicate the HTML wrapper). 6 new render/dedupe unit
tests cover subject lines, range + location lines, fallback copy when
names are missing, and HTML escaping of injected names.
Suite 550, build clean. No schema change (the notifications.type column
is text), no dispatcher change.
Extracts resolveMemberContact, enqueueNotification, getSpaceName, and
buildManageUrl into lib/notifications/enqueue.ts. The Stripe webhook's
enqueueDues now composes them instead of inlining the member lookup +
upsert. Zero behavior change (same recipient resolution, same upsert
shape, same best-effort wrap, same idempotency via (space_id,
dedupe_key)). Prepares P4b-P4d (equipment/classes/forms enqueue) which
would otherwise copy-paste the same plumbing four times.
12 new unit tests cover the helpers: member lookup hit/miss, upsert
shape, recipient skip on missing-@, best-effort no-throw on error or
exception, URL fallbacks. Suite 544, build clean.
Lifts the deliberately-deferred SECURITY DEFINER status-gate. The
deferral's blocking reason ('no way to verify it breaks nothing') is
resolved by the integration harness, so the gap is now closed at the
RLS layer too (defense-in-depth beyond D2's app layer): an unverified/
inactive member can no longer exercise a role or permission via direct
PostgREST. Scoped to the two privilege entrypoints; get_user_space_ids
+ user_effective_roles deliberately untouched so unverified self-reads/
onboarding still work. Migration 046 + schema.sql mirror. New
integration test (5) proves both halves: gap closed AND
current/late/approval/unverified-reads all unbroken. 31 integration
tests green; default pnpm test 532/26 hermetic; build clean.