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.