Skip to content

Move Chambers from Supabase to Neon (#136) - #143

Merged
pataniaeli merged 11 commits into
devfrom
feat/issue-136-neon-migration
Sep 19, 2026
Merged

pataniaeli merged 11 commits into
devfrom
feat/issue-136-neon-migration

Conversation

@pataniaeli

Copy link
Copy Markdown
Collaborator

Closes #136

Moves Chambers' database and login off Supabase. Target cutover: September 19. Plan and status: https://claude.ai/artifact/PHfVdZYHYDvja9dats7j4o

What changes

  • Login: Supabase Auth → self-hosted Better Auth. Better Auth's user model is public.users, so ids are unchanged. Supabase's bcrypt hashes are imported and verified with bcrypt, so nobody resets a password. This was proven by signing in with a real password on the rehearsal branch. Public sign-up is off: accounts come only from invites and the signup-code flow (lib/auth-admin.ts). Password reset emails go through Resend. A deactivated account can't start a session.
  • Sessions: two days of inactivity, extended at most hourly while in use, matching the dashboard's idle sign-out. Sessions are checked against the database on every request, so revoking or deactivating someone takes effect on their next request.
  • Queries: the Neon Data API, unchanged. It speaks PostgREST, like Supabase did, so the 309 queries didn't change. lib/db/data-api.ts replaces every Supabase client (54 service-role clients, 53 createClient() routes, the homepage and FAQ clients). The server signs a ten-minute token as chambers_server, a role with full access to app tables and none to auth_*.
  • Row-level security is no longer access control (decided on Migrate Chambers to Neon #136). Every route already checks access in code before querying. RLS stays on with no policies except chambers_server's, so any other role sees nothing.
  • The browser never queries the database. The login card, onboarding and pages that read roles now use API routes or the shell identity.
  • Removed: @supabase/supabase-js, @supabase/ssr, lib/supabase/, proxy.ts, and the realtime-js stub.

Tested on the rehearsal branch (production untouched)

  • Data API checks: all 11 pass. They cover nested selects, nested-table filters (the My Rooms shape), aliased joins, .or(), counts and a write. Requests with no token, or an ordinary user's token, are refused.
  • Reads: a signed-in click-through of every tab made 47 API calls, all 200.
  • Saves, with email disabled:
    • Room request: submit, then Ops Review → Awaiting CSC → Denied
    • Revision request, through the same statuses
    • SGA Space booking: create, edit, cancel
    • Weekly SGA Space series: create, cancel
    • The one-time and weekly booking editors, through the real UI
    • Account flows: invite, resend invite, wrong password refused, password reset request, deactivation
  • Build: typecheck passes. next build passes, including with no database or auth variables, as in CI.

Schema and scripts: db/neon/, scripts/neon/

  • Schema files:
    • 0001_baseline.sql: the production schema, generated from the live catalog, with foreign keys to auth.users repointed at users
    • 0002_better_auth.sql: Better Auth's tables
    • 0003_data_api_server_role.sql: the chambers_server role
    • after-data-api.sql: run on each branch once its Data API is enabled
  • Cutover scripts:
    • cutover.mjs: one command with guards (endpoint must match, Data API must be enabled, wipes only with --reset-target)
    • copy-data.mjs: one transaction, every row count checked
    • compare-counts.mjs: finds rows written to Supabase after the copy
    • test-data-api.mjs
  • Runbook: db/neon/README.md

Vercel variables (Production)

Set these before merging to main. The generated secrets are handed over separately, not in this PR.

Variable Value
DATABASE_URL Neon prod pooled URL
NEON_DATA_API_URL Neon prod Data API URL
DATA_API_PRIVATE_JWK production private key
BETTER_AUTH_SECRET generated
BETTER_AUTH_URL https://chambers.northeasternsga.com

Remove after cutover: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY. Nothing in the app reads them any more.

Things to know

  • Schema changes now need a schema-cache refresh in the Neon console, per branch. Neon's Data API ignores PostgREST's reload notification.
  • Neon only trusts one key per environment. Production's Data API should use data-api-jwks-production.json, which holds only the production key.
  • A blank RESEND_API_KEY crashes every route, sign-in included, because the Resend client throws at startup. That was always true, but login now loads it too. Production has the key. Hardening this is a follow-up.
  • The first deploy signs everyone out once. Sessions aren't copied; passwords are.

🤖 Generated with Claude Code

pataniaeli and others added 9 commits September 17, 2026 11:42
- db/neon/0001_baseline.sql: production public schema from the live catalog,
  FKs to auth.users repointed at public.users, no RLS policies or Supabase
  helper functions, RLS left enabled as default deny.
- db/neon/0002_better_auth.sql: Better Auth mapped onto public.users, plus
  auth_sessions, auth_accounts and auth_verifications.
- scripts/neon/copy-data.mjs: copies every table in FK order in one
  transaction, imports Supabase logins with their bcrypt hashes, verifies
  row counts.
- lib/better-auth.ts, lib/auth-client.ts, lib/auth-admin.ts and
  /api/auth/[...all]: Better Auth with bcrypt passwords, sign-up disabled,
  password reset through Resend, deactivated users refused a session.
- lib/auth.ts, lib/authorization.ts, lib/shell-identity.ts read the Better
  Auth session and the users row through a pg pool.
- Client pages read roles from the shell identity instead of token metadata.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Login flows now run on Better Auth: sign-in, password reset (through
Resend), the signup code, onboarding, admin invites and resends,
deactivation and session revocation. The browser no longer queries the
database -- the login and onboarding pages ask three new API routes
instead -- and roles are read from the users row rather than copied into a
token.

Sessions last two days of inactivity, extended at most hourly while in
use, matching the dashboard's existing idle sign-out.

Row-level security is no longer part of access control (decided on #136).
Supabase cannot identify a Better Auth user, so the server clients use the
service role, and every route relies on the checks it already made in
application code before querying. The Neon schema keeps RLS enabled with
no policies, so anything other than the app's own role is denied by
default.

Also: proxy.ts and the browser Supabase client are gone, AuthedUser moved
to lib/auth-types.ts so browser-shared modules do not import server code,
the warm cron warms the Postgres pool, and .env.example documents
DATABASE_URL, BETTER_AUTH_SECRET and BETTER_AUTH_URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y-schema script (#136)

The login import passed one parameter as both the uuid user_id and the text
account_id, which Postgres refuses. apply-schema.mjs applies db/neon/*.sql
to an empty database in one transaction.

Rehearsed against the new Neon project: schema applied (34 tables), dry run
and real copy both passed with every row count matching and 37 logins
imported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…136)

The Data API switches to the Postgres role named in the request token. The
server will sign short-lived tokens as chambers_server with a private key
only it holds; public/data-api-jwks.json is the matching public key for
Neon to verify them. chambers_server gets full access to the app tables
through one permissive policy each, and none to the auth_* tables.

Adds jose and @supabase/postgrest-js as direct dependencies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…#136)

Rehearsed on a Neon branch: nested selects, a nested-table filter, aliased
FK embeds, .or(), head counts, single(), and a restored write all work
through the Data API with a server-signed chambers_server token. Requests
with no token or an ordinary authenticated token get nothing, and the
auth_* tables stay closed.

after-data-api.sql grants chambers_server to authenticator, which only
exists once the Data API is enabled. The runbook now covers enabling it,
registering the key, and refreshing the schema cache, which Neon does not do
on its own after DDL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… app (#136)

Every Supabase client -- the 54 service-role admin clients, the 53 routes
using lib/supabase/server, the homepage's anon client and the FAQ page --
is now lib/db/data-api.ts: a PostgREST client pointed at the Neon Data API
that signs its own ten-minute chambers_server token per request. The
queries themselves are unchanged; the Data API speaks the same protocol.

@supabase/supabase-js and @supabase/ssr are uninstalled, lib/supabase and
the realtime-js stub (and its bundler alias) are deleted. next.config gains
an empty turbopack block, which Next 16 requires beside next-pwa's webpack
config once no other turbopack option is set.

Checked: typecheck, production build (whose prerender queries Neon), and
the homepage and FAQ reading live data from the rehearsal branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… a database (#136)

- scripts/neon/cutover.mjs loads a Neon branch from Supabase in one command:
  refuses unless --target-endpoint matches the URL and the Data API is
  enabled, drops existing tables only with --reset-target, then applies the
  schema, copies data and logins, and grants the Data API role. --check
  reports the plan without writing.
- scripts/neon/compare-counts.mjs, run after the deploy, lists tables with
  rows Supabase gained after the copy.
- public/data-api-jwks-production.json holds only the production key, so
  production's Data API never trusts the dev and rehearsal key.
- The database client reports a missing NEON_DATA_API_URL as a query error
  instead of throwing on first use, the FAQ falls back to role titles, and
  Better Auth gets a placeholder secret during next build only. Together
  these let CI build with no database or auth configuration; CI no longer
  passes the Supabase secrets.
- Runbook and .env.example updated for the Data API and the Sept 19 cutover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pataniaeli pataniaeli added enhancement New feature or request massive Beyond a normal issue size labels Sep 18, 2026
@vercel

vercel Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
chambers Ready Ready Preview Sep 19, 2026 2:13pm UTC

pataniaeli and others added 2 commits September 19, 2026 10:07
Brings in #126 meeting time, #127 series space change, #120 audit detail,
#139 dismissable cancellations, #142 and #147. The tabling route's
admin client and the new lib/audit.ts type now use the Neon Data API
client like everything else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
)

scripts/neon/generate-baseline.mjs rebuilds db/neon/0001_baseline.sql from
Supabase's live catalog, read-only, with the same rules as the hand-built
version: auth.users foreign keys repointed at public.users, no policies or
Supabase helper functions, RLS enabled as default deny.

Regenerated today to pick up the migrations that reached production since
Sept 17: audit_logs target/target_date/action/changes and their checks,
the Dismissed cancellation status, and audit_logs_booking_created_idx.
Nothing else differs from the previous hand-built file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pataniaeli
pataniaeli merged commit 033733f into dev Sep 19, 2026
4 checks passed
@pataniaeli pataniaeli mentioned this pull request Sep 19, 2026
@pataniaeli
pataniaeli deleted the feat/issue-136-neon-migration branch September 19, 2026 19:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request massive Beyond a normal issue size

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant