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
- 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) <[email protected]>
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) <[email protected]>
- 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.
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.
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.
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.
- 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.
A ⌘K palette with no visible entry point is itself a discoverability +
a11y gap (keyboard/SR users can't find it). Added a labeled 'Jump to…'
button (aria-label spells out the shortcut) that dispatches a
'commandpalette:open' event the palette now also listens for. Verified
the other top a11y items are already satisfied here (sonner Toaster
provides the aria-live region; sidebar icon buttons are aria-labeled;
skip-link + drawer focus mgmt already present).
New CommandPalette over the existing unused cmdk primitive, mounted in
the (app) layout. Grouped nav destinations mirroring the sidebar; admin/
manage entries gated by the same isAdmin + navPerms the layout already
resolves (so it never offers a route the guard would bounce). Opens on
Cmd/Ctrl-K, type-to-filter, Enter to jump.
listMemberSubmissions (forms.manage, RLS-honoring, metadata only) + a
FORMS column/button -> MemberFormsDialog listing the member's
submissions (title, kind, version, date) with a link to the audited
forms results surface. Gated by forms.manage (matches existing
submission-read RLS; no new exposure).
Audit follow-up (UI P1). The page guards authorize feature management via
user_has_permission, but the Admin nav links were shown by role only:
a member with a delegated forms/certifications/classes/equipment/door
*.manage permission had no link (orphaned), and an admin lacking the
permission saw a link that redirected. The layout now resolves those five
permissions and the sidebar shows each link on (isAdmin || permission);
Customize/Import/Settings stay admin-only. Also: aria-label on the
forum delete button; comment the intentional Classes/Equipment
nav-active exact-match.
Dashboard right column gets a 'Who's here' panel: present members with
Host badges + your own Check in / Check in as host / Check out with an
optional note (router.refresh after). New /attendance page (all members)
lists recent visits with status + duration + notes. /me gains a 'My
recent visits' section. Sidebar 'Attendance' link under People.
The group holds Classes, Equipment, Doors and Forms -- only Classes is
about learning; the rest are facility access and resources. Section id
kept as 'learn' so members' saved collapse state is preserved.
New listMyDoorActivity (service client after requireMember; rows where
the caller is actor or target; detail already redacted at write). /doors
composes the existing self-entry control, the masked own cards
(getMyCards, last4 only), and the member's own recent door activity;
empty-state when none. Sidebar 'Doors' link added under Learn.
New EquipmentReservations component (listEquipmentReservations,
equipment.manage-gated) behind a per-item 'Reservations' toggle: shows
who reserved + time range + status, with a manager Cancel. Mirrors the
classes SessionAttendance pattern.
Migration 035 adds door_connections (per-space controller integration:
secret_ref -> AES-256-GCM secrets vault, never a plaintext password;
pinned_host SSRF pin; native_heatsync|generic_http adapter;
allow_member_self_entry opt-in for phase 3) and door_access_log
(append-only, secrets redacted, service-client-only writes; immutable).
RLS additive/default-deny: connections CRUD = door.manage; log SELECT =
door.manage/operate.
Safety core is pure and heavily unit-tested (door-logic, suite 409):
validateDoorTarget (request host must equal the pinned host;
metadata/link-local always blocked; http(s) only), the
firmware-verified HeatSync encoders with fixed-width zero-padding
(slot 3 / perm 3 / tag 8 / 4-char password), generic template
substitution, and redactDoorSecrets. lib/door/executor.ts is the single
hardened egress: re-validates the pin, refuses redirects, caps time and
body, redacts before any audit write. The door password is read from the
secrets vault and decrypted server-side only, never returned or logged.
/door/manage (door.manage) configures connections, picks a vault secret,
and runs a safe status-only test. Sidebar Admin link added; "Learn"
moved before "People" per request. Docs updated.
Sidebar is now grouped into collapsible sections (Workspace,
Governance, People, Finance, Learn, Account, Admin). Finance splits
payments/financials into their own category; Learn groups
classes/equipment/forms. Each section header toggles open/closed and the
state persists per category in localStorage (defaults open so nothing is
hidden on first visit). Admin section stays admin/board-gated.
Migration 034 adds member_cards (RFID/NFC UID -> member). The card UID
is treated as a credential: door.manage-only RLS with no member SELECT
policy; the member's own "My access cards" view goes through a service
-client action that returns only count + last4 (never the raw UID).
New door.manage / door.operate permissions (group Access), seeded to
board and backfilled, introduced now for the whole Door epic. Pure
door-logic masking is unit-tested. A per-member "Cards" panel on
/members is gated to door.manage (independent of admin). No controller
calls and no anonymous path in this phase. Mirrored in schema.sql; types
and schema docs updated.
/equipment lists active equipment for any member with status and the
required-certification gate shown up front (Reserve is disabled when the
item is unavailable or the member is not certified). Reserving opens an
inline start/end/notes form; the action enforces eligibility and
no-overlap server-side. Sidebar gets a member-visible "Equipment" link.
/equipment/manage (guarded by equipment.manage) lists equipment with
inline create/edit, status (available/maintenance/retired), an optional
required-certification picker, asset tag, archive/restore and delete
(refused once reservations exist). Sidebar gets a "Manage equipment"
link in the Admin block. Member browse/reserve is the next phase.
/classes lists upcoming sessions for any member with spots-left,
sign-up / join-waitlist and cancel (waitlist auto-promotes on cancel via
the action). Holders of classes.instruct/manage get an inline attendees
panel to mark attendance and complete the session; completion issues the
class's certification through the normal grant path and the UI reports
when certificates were skipped (no certifications.grant). Sidebar gets a
member-visible "Classes" link.
/classes/manage (guarded by classes.manage) lists classes with inline
create/edit, archive/restore, delete (refused once sessions exist), an
optional grants-certification picker and a generic payment link, plus
per-class session scheduling with cancel/delete. Sidebar gets a "Manage
classes" link in the Admin block. The member-facing catalog/signup is
the next phase.
KB/process content was shown via a bare unsanitized ReactMarkdown in the
ops view dialog (the /ops/[id] route is the editor, not a view). Route
it through the shared SafeMarkdown instead. New dependency-free
lib/markdown-anchors.ts (heading-text flatten, GitHub-ish slug,
per-render dedupe, in-doc href test; unit-tested) is wired into both
SafeMarkdown and MarkdownBody so headings get stable ids and a
[link](#heading) resolves. In-document links no longer force a new tab,
which previously broke the anchor jump for policies/proposals too.
rehype-slug was not added (no offline install); the helper is equivalent
and tested.
/me shows the signed-in member their own certifications (status badges
from the shared logic) and a read-only view of their effective
permissions. Sidebar gets a "My access" link. ARCHITECTURE documents the
module; HANDOFF pass-21 records the UX fixes (deployed) and the
code-complete-but-local certifications module.
A "Certs" column appears for anyone holding certifications.grant (the
Instructor capability), independent of admin/board, so a non-admin
instructor can reach it. The button opens a self-contained dialog that
loads the member's grants and the active cert catalog, and supports
award (with optional note + expiry override), soft revoke, and renew.
Status badges come from the shared certifications-logic. The members
server page resolves certifications.grant via the RPC and passes it in.
/certifications (guarded by certifications.manage) lists cert types with
inline create/edit, archive/restore (is_active), and delete (refused
server-side once granted). Sidebar gets a Certifications link in the
Admin block next to Forms & waivers. Awarding to members is a separate
permission handled from the Members page (next phase).
- shared components/forms/copy-link-button.tsx (one control, no drift):
copies the absolute /f/<space>/<slug> URL, check-mark + toast feedback
- admin /forms list: Copy link button on every published public form
- member /my-forms list: shows the /f/<space>/<slug> path and a Copy
button for public forms; row restructured so the button is not nested
in the row link
- members-only forms show no public link (correct: they have none)
337 tests; build verified. Local commit (deploy on your word).
- builder: draft/published/closed status banners; creating a form now
lands on its edit page so Publish is immediately visible; a
members-only form shows a "no public link" note instead of a dead URL
- member-facing forms: /my-forms list + /my-forms/[id] fill
(MemberFillClient), "Forms" sidebar item for all members. Fixes the
long-standing gap where members-only forms had no fill surface
- starter templates: lib/form-templates.ts + a picker on /forms/new
(Liability waiver, Membership application, Event RSVP, Photo release,
Blank) that seeds builder state (no table)
- forms-client relabels members-only forms (was showing a misleading
/f/<space>/<slug> that always 404s for members-only)
- docs corrected per the audit pass: DATABASE_SCHEMA / DB_SCHEMA_MAP /
ARCHITECTURE / API_REFERENCE now reflect migrations 014-029, per-space
slug, /f/[space]/[slug], getPublicForm({space,slug}), invite roles;
HANDOFF pass-19 added with residual gaps
337 tests; build verified (Compiled successfully, exit 0). No deploy.
- migration 028: drop global UNIQUE(forms.slug), add
UNIQUE(space_id, slug); schema.sql mirrored. Form slugs are now
unique per space (fixes cross-tenant slug squatting)
- getPublicForm now takes { space, slug }: resolves the space by its
slug then the form by space_id + slug (maybeSingle)
- route moved app/f/[slug] -> app/f/[space]/[slug]; the public client
submits by formId (slug is no longer globally unambiguous) and the
sign-in link round-trips the full /f/space/slug path
- forms-guard returns the space slug; the builder + forms list show
the full /f/<space>/<slug> share URL; new/edit pass it through
- middleware /f allowlist already covers /f/<space>/<slug>
- getPublicFormSchema + tests updated (333 pass)
build verified (Compiled successfully, exit 0; new route present). No deploy.
The forms security/correctness logic was buried in DB-coupled server
actions and the builder component, so it had no real tests (the suite
is pure-logic only; nothing exercises Supabase). Extract the pure
parts behaviour-unchanged into lib/forms-logic.ts and test them for
real:
- csvCell (results-export escaping), parseClientIp (e-sign IP capture
filtering), shouldBumpFormVersion (waiver non-blocking re-sign),
evaluateRequiredFormStep (onboarding required/auto-satisfy/fail-open),
slugify + deriveFieldKeys (builder key derivation + dedupe)
- forms.ts / onboarding.ts / form-builder.tsx now consume these; the
local copies are removed (no behaviour change — verified by the full
suite staying green)
- __tests__/forms.test.ts expanded 16 -> 62: every validateAnswers
type/branch (type coercion, length caps, required, unknown-key
stripping, first-error ordering), parseFormSchema, every new Zod
schema incl. formSlug boundaries, and all extracted helpers
333/333 tests; build verified (Compiled successfully, exit 0). No deploy.
- components/forms/form-renderer.tsx: one controlled renderer for all 8
field types, shared by the builder preview and the public page
- components/forms/form-builder.tsx: easy builder — label-only field
creation (keys auto-derived and de-duped at save), one-click type
buttons, up/down reordering, inline options editor, live preview;
edit mode adds publish/close/draft/delete and copy-public-link
- /forms directory, /forms/new, /forms/[id]/edit, /forms/[id]/results
(CSV download). Server-gated by lib/forms-guard.ts (forms.manage via
the same RPC the RLS uses; RLS remains the real boundary)
- public app/f/[slug] (outside the app group): getPublicForm serves
only published public forms — members-only forms are never exposed
there; waiver consent, anon email capture, signed-in gate for
public_auth, thank-you state
- sidebar "Forms & waivers" link; sonner toasts (codebase convention)
- ARCHITECTURE + HANDOFF updated
285/285 tests, build clean, all four routes compiled. No deploy.
- secrets: revealed value gets a copy-to-clipboard with feedback,
auto re-hides after 30s, re-hides on window blur, and the timer is
cleared on unmount (no change to revealSecret or RLS)
- permissions matrix: a view-only notice when the viewer is not admin,
and a per-checkbox aria-label so the grid is usable non-visually
- OpsAclEditor: pills are now aria-pressed toggles with a visible
check when selected and a success toast on save
No server action, RLS, or permission logic changed.
- S6: unlayered :focus-visible ring for form controls and links so
keyboard focus is perceivable (was focus:outline-none + 1px border)
- S5: >=44px touch targets at the named hotspots (dashboard actions,
list-row buttons, ops pills/icons, customize number inputs); dense
rows use a 44px hit area without growing row height
- S8: 28 form fields associated via id/htmlFor; search inputs get
aria-label
- S7: claim/complete/delete/link handlers now surface result.error via
toast and stop instead of failing silently
- new components/ui/confirm.tsx: ConfirmProvider + useConfirm() built
on the existing Radix alert-dialog (focus trap, ESC, aria for free)
- wired once into the app layout
- all 13 native window.confirm() delete/remove guards now use the
styled, accessible confirm with a destructive action button
- one Empty/EmptyHeader/EmptyMedia/EmptyTitle/EmptyDescription
component replaces ~18 ad-hoc empty states
- projects gains a real empty state (was four bare columns) with a
create CTA wired to the existing handler
- governance/ops/customize/comms/comments empty states unified;
existing CTAs and conditions preserved (presentational only)
- new components/ui/page-title.tsx (PageHeader/PageTitle/SectionTitle)
- every page heading routed through PageTitle (one source of truth
for the page-title style; identical render)
- members restricted-directory screen now uses the standard
PageHeader bar instead of an off-style mono heading
- Ops nav now uses Wrench (was Settings2, collided with Settings)
- aria-current on active nav link, aria-label on primary nav
- mobile drawer: role=dialog/aria-modal, Escape to close, body
scroll lock, focus moves into drawer and back to the menu button
- larger touch targets on the menu open/close buttons
- sidebar logo uses the shared BrandMark instead of an ad-hoc {hs}
- skip-to-content link added to the app layout
Full-application audit (5 parallel read-only audits). Two agent
"blockers" were false alarms and verified as already-correct. Real
findings fixed:
- Self-escalation (BLOCKER): migration 024 + schema.sql section 16
extend prevent_member_self_role_change to also block a
non-privileged member changing their own tier_id (self tier
upgrade) and onboarding_completed_at (skipping required
code-of-conduct via a raw PostgREST call). finishOnboarding and
skipOnboarding now set onboarding_completed_at through the service
client AFTER their server-side required-steps check, so the
legitimate path is unaffected.
- Cross-tenant write scoping: added .eq('space_id', member.space_id)
to updateForumThread, deleteForumThread, editComment, deleteComment,
deleteChannel, renameChannel (RLS-only -> defence-in-depth).
- generateInviteCode: crypto.getRandomValues, widened to 8 chars
(was Math.random, ~21 bits, enumerable under the join rate limit).
- SafeMarkdown: dropped iframe and the global style attribute
(no unsandboxed embeds / UI-redress); markdown + safe HTML stay.
- /settings: server-side admin redirect before fetching space and
integration config (was client-hidden only).
- app/(app)/layout.tsx: export const dynamic = 'force-dynamic'.
- Bounded queries: loadComments .limit(500), tasks .limit(2000).
Backlog from the audit is tracked in HANDOFF (import Zod schemas,
types/database.ts + docs regen, email lowercasing, unauthorized-
handling consistency, KB-modal ACL hookup).
271/271 tests pass. Build clean. Migrations 023 + 024 idempotent,
apply via deploy.sh on next deploy.
Migration 023 (folded into schema.sql section 15). ADDITIVE and
backward compatible: with no permission/ACL rows the app behaves
exactly as before.
Schema:
- space_role_permissions (space_id, subject, permission); ops_acl
(space_id, entity_type secret|kb|process|area_lead, entity_id,
role). Full RLS, writes admin/board.
- user_effective_roles(uid,sid): built-in role + custom-role slugs +
area_lead:<id> for areas the member leads. SECURITY DEFINER.
- user_has_permission(uid,sid,perm): admin implicit-all else a grant
via an effective role. SECURITY DEFINER.
- secrets/knowledge_base SELECT rewritten as
`exact existing rule OR ops_acl match` (only widens; zero change
with no ACL rows). Seed + backfill default role permissions.
Area-lead roles reuse the existing area_leads table rather than
duplicating the concept: a row is a role, lead_id NULL = Vacant,
assigning sets lead_id, the member then holds area_lead:<id>.
App:
- lib/permissions-catalog.ts (codes + per-role defaults + guardrails),
Zod schemas, lib/actions/permissions.ts (setRolePermissions,
setOpsAcl, createAreaLeadRole, assign/unassign/delete).
- Customize gains Permissions (role x permission matrix, admin shown
implicit-all/locked) and Area leads (create/Vacant/assign) panels,
modular under customize/panels/. Reusable
components/ops/ops-acl-editor.tsx wired into the Secrets list.
Audit-found latent fixes:
- revealSecret errors on encrypted-but-no-ciphertext corruption
instead of silently returning the stale plaintext column.
- upsertAreaLead rejects null/empty area_code (NULLs are distinct in
unique constraints; allowed duplicate rows).
types/database.ts: added the two new tables. 271/271 tests pass,
build clean. KB-modal ACL wiring + members-row shortcut deferred and
tracked in HANDOFF.
The animated product mini-previews are back, recovered from before
the redesign into components/landing/mini-previews.tsx ('use client',
own module for separation). They render framed as product screenshots
(window chrome + bordered surface in the landing palette) in a
two-column hero, so they read as the real app sitting inside the
editorial marketing page. Hidden below 900px.
266/266 tests pass. Build clean. No migration.
Landing:
- Rebuilt app/(landing)/page.tsx from a 539-line client monolith to a
~150-line server component in the /resources editorial style.
- New scoped app/(landing)/landing.css mirrors the resources palette
(#0c0c0c / lime #d4ff00 / #252525), IBM Plex Mono + Libre
Baskerville loaded in (landing)/layout.tsx, faint grid overlay.
- Removed the animated Mini* previews and inline Ico* set (clashed
with the editorial look). Split into components/landing/icons.tsx
and components/landing/resource-showcase.tsx for separation.
- Resource showcase: one row of six square tiles with the real
project SVG logos linking to atlas/space-after-dark/proposal-duel/
zine/learner/governance. Responsive 6 -> 3 -> 2.
- BrandMark accepts an optional style prop.
Fix:
- KB search crashed the Ops page. filteredLeads referenced
l.member_name (the column is lead_handle), so undefined.toLowerCase()
threw on every keystroke since the filter memos recompute on shared
search state across tabs. All Ops filter predicates are now
null-safe via a shared has() helper; filteredSecrets no longer
assumes a non-null title.
Docs:
- docs/PERMISSIONS_DESIGN.md: concrete plan for customizable
permissions, per-item Ops ACLs, and area-lead roles. That work is
security-sensitive (RLS rewrites on secrets/knowledge_base) and is
scoped as its own next pass, not bundled here.
266/266 tests pass. Production build clean. No migration this pass.
IA refactor and the custom-roles editor pass 11 left as data-only.
Customize hub:
- New /customize (admin/board) with a left section rail: Roles,
Membership tiers, Areas, Invite codes, Onboarding.
- One focused module per panel under app/(app)/customize/panels/
plus a shared card and types file. The client shell is ~75 lines;
each panel is self-contained (smart separation of concerns).
- Settings slimmed to Space, Integrations, Webhooks. Removed the
Roles/Tiers/Invites/Onboarding tabs and the Areas block from
settings-client.tsx (~700 lines) and the matching fetches from
settings/page.tsx. Dead props/state/types removed.
- Sidebar: added a Customize NavLink (admin/board).
Roles editor:
- Rename/recolor the five built-in roles (upsertRoleLabel) and full
CRUD on custom org roles. Adds the read path the audit flagged as
missing: lib/role-labels.ts (getRoleLabelMap/roleDisplayName) is
consumed in (app)/layout.tsx so the sidebar shows the renamed role.
Logo:
- Reverted the brand mark to the terminal >_ glyph
(components/brand-mark.tsx) on the landing nav/footer, onboarding
header, and favicon (public/logo.svg). Removed the dead local
IcoTerminal. AtlasLogo stays only on the resources project page.
Invite links:
- InvitesPanel has Copy code and Copy link (/signup?invite=CODE).
signup reads ?invite= on mount, prefills the code, preselects join.
Deferred and tracked in HANDOFF: types/database.ts sync, renamed-role
display on the members table, splitting the still-large
settings-client.tsx, custom-role assignment UI.
266/266 tests pass. Production build clean; /customize, /onboarding,
slimmed /settings all in the route table. No migration this pass.
A new member who joins via invite code now walks through an
admin-defined onboarding sequence before reaching the app.
Schema (migration 022, folded into schema.sql Section 14):
- space_onboarding_steps: step_key, step_type
(welcome|code_of_conduct|profile|payment|content), title, body,
config jsonb, is_enabled, is_required, is_system, sort_order,
unique per (space, step_key). Full RLS: members read, admin/board
write, admin delete non-system.
- space_members.onboarding_completed_at + onboarding_progress jsonb.
- Trigger seeds four default steps per space (welcome, code of
conduct [required], profile, dues). Backfill seeds existing spaces
and marks all existing members complete so the new gate never
traps them.
Flow:
- /onboarding (top-level route, outside the (app) group so no
redirect loop). Progress bar, step counter, back/continue, per-type
rendering. Profile step calls updateMyProfile; payment step is a
non-blocking nudge with optional link and "Remind me later".
- (app)/layout.tsx redirects to /onboarding while
onboarding_completed_at is null.
- createSpace marks the founder complete: founders configure the
space, they do not run the member flow (matches admin-vs-end-user
persona research).
- finishOnboarding re-checks required steps server-side so a tampered
client cannot skip a required code-of-conduct acknowledgement.
skipOnboarding only works when nothing is required.
Admin config:
- Settings -> Onboarding tab: reorder, edit title/body/payment link,
toggle enabled/required, add custom content steps, delete
non-system steps.
Safety:
- components/safe-markdown.tsx renders admin-authored markdown and a
sanitized subset of HTML (rehype-raw + rehype-sanitize). Added
those two deps.
Also:
- bio added to updateMyProfileSchema (members can self-set it; not
blocked by the migration-015 trigger).
- docs/UX_AND_PERSONAS.md: 4 personas and prioritized UX audit
findings. Onboarding was finding #1; 2-6 deferred and tracked.
266/266 unit tests pass. Production build clean, /onboarding in the
route table. Migration 022 applies idempotently on the next deploy.