13 Commits

Author SHA1 Message Date
Tyler c6501b3ecd chore(release): v2.0.0
Deploy to route.crispygoat.com / deploy (push) Successful in 4m18s
2026-06-09 18:14:33 -06:00
Tyler edf3989ef2 fix(deploy): move inline node -e scripts into separate files to fix shell quoting errors
Deploy to route.crispygoat.com / deploy (push) Successful in 4m16s
The inline node -e '...' blocks inside the YAML run: block had shell
quoting conflicts (nested parens, single-quote escapes) that caused
act/local tooling to fail with 'syntax error near unexpected token ('.

Split into two dedicated scripts:
- scripts/preflight-check.js  — neon_auth schema check + 0001_init.sql tracking repair
- scripts/postflight-check.js — admin_users table verification after migrations

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:02:08 -06:00
Tyler 1d4300d505 fix(deploy): make 0001_init.sql re-runnable and repair historical _migrations tracking
Deploy to route.crispygoat.com / deploy (push) Failing after 1m12s
The "Run migrations" job (and thus the whole deploy) was failing on every push after the first successful bootstrap with:

  ✗ 0001_init.sql failed: relation "admin_users" already exists

Root causes:
- 0001_init.sql used plain CREATE TABLE/INDEX/TRIGGER despite the header comment claiming "CREATE TABLE IF NOT EXISTS".
- The _migrations tracking row for 0001_init.sql was never recorded on the prod DATABASE_URL (tracking logic landed after initial apply, or apply happened outside the runner).

Fix (3 layers of defense):
* db/migrations/0001_init.sql: every CREATE TABLE now uses IF NOT EXISTS (63 tables), CREATE INDEX uses IF NOT EXISTS (49), all CREATE TRIGGER wrapped in safe DO $$ IF NOT EXISTS (pg_trigger join check) THEN ... END IF; $$, removed the file's own BEGIN/COMMIT so the runner's transaction is authoritative. RLS drop+create and CREATE OR REPLACE FUNCTION were already safe.
* scripts/migrate.js: added ensureTracked() right after loading the applied set. For 0001/0002, if the core objects (admin_users table, password_hash column) already exist in information_schema but the tracking row is absent, INSERT the filename (ON CONFLICT DO NOTHING) and log "repaired tracking". Then the normal skip path handles it.
* .gitea/workflows/deploy.yml: added an inline node -e pre-repair (same admin_users existence check → force _migrations row) immediately before `npm run migrate:one`. This protects even on an older checkout of the runner. The neon_auth preflight and post-apply "admin_users must exist" hard gate are untouched.
* Minor: cleaned 0002_admin_password.sql tx wrapper for consistency; updated MEMORY.md with the incident + resolution.

Result: deploys (and any server-side `node scripts/migrate.js` recovery runs) on an already-initialized prod DB will repair/skip 0001 cleanly, pass the verification query, and continue to build/deploy. The scp of scripts/ + db/migrations/ in the Deploy step now carries the fixed versions.

See the plan note in deploy.yml and MEMORY.md for background.
2026-06-09 17:55:42 -06:00
Tyler 0db1609c89 fix(deploy): add explicit pre-flight check for neon_auth schema before running migrations (better error than raw 3F000 from 0001_init.sql) 2026-06-09 15:50:53 -06:00
openclaw 91ba7b5c5c fix(deploy): add health/db-schema guard + curl gate; ship migration assets; document prod bootstrap (executed per systematic-debugging plan)
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
2026-06-09 15:45:58 -06:00
openclaw d312783f3a fix: admin auth for prod Neon Auth deployment
Deploy to route.crispygoat.com / deploy (push) Successful in 3m59s
- getAdminUser now properly supports platform_admin with 0 brand links and loads full brand_ids
- createAdminUser action now inserts into admin_user_brands join table
- Admin layout surfaces the signed-in email on Access Denied
- AdminAccessDenied links to /login instead of dead-end /admin
- Main dashboard uses direct pool query instead of dead supabase shim
- Improved provision-admin.ts script for prod bootstrap (loads .env.production too)
2026-06-09 15:30:23 -06:00
openclaw 1af47698a1 debug: comprehensive logging to trace auth
Deploy to route.crispygoat.com / deploy (push) Successful in 3m24s
2026-06-09 15:00:44 -06:00
openclaw 03bd0fbf1f debug: add logging to trace auth issues
Deploy to route.crispygoat.com / deploy (push) Successful in 3m56s
2026-06-09 15:00:09 -06:00
openclaw e28ebf5664 fix(deploy): improve SSH key handling and add debug output
Deploy to route.crispygoat.com / deploy (push) Successful in 3m23s
2026-06-09 14:49:22 -06:00
openclaw 653dce747b fix: select role from admin_users instead of admin_user_brands
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
The membership query in getAdminUser() was selecting role from
adminUserBrands.adminUserId, which is wrong - admin_user_brands has no
role column. Role is stored in admin_users. Also added try/catch
around withPlatformAdmin to prevent DB errors from throwing.
2026-06-09 14:42:43 -06:00
openclaw b46e00fefd fix(deploy): use printf to write env file, fix SSH key setup, clean scp commands
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
2026-06-09 13:47:34 -06:00
openclaw ceb061addf ci: trigger workflow 2026-06-09 13:44:40 -06:00
openclaw 16c8edf7e9 fix(deploy): use scp instead of rsync, remove apt-get step, add SSH test 2026-06-09 13:43:12 -06:00
18 changed files with 1036 additions and 304 deletions
+84 -55
View File
@@ -24,7 +24,10 @@ jobs:
env: env:
DATABASE_URL: ${{ secrets.DATABASE_URL }} DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: | run: |
npm run migrate:one || echo "No migration script or migrations already applied" set -e
node scripts/preflight-check.js
npm run migrate:one
node scripts/postflight-check.js
- name: Build - name: Build
env: env:
@@ -66,9 +69,6 @@ jobs:
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }} MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
run: npm run build run: npm run build
- name: Install rsync
run: sudo apt-get update && sudo apt-get install -y rsync
- name: Deploy - name: Deploy
env: env:
DATABASE_URL: ${{ secrets.DATABASE_URL }} DATABASE_URL: ${{ secrets.DATABASE_URL }}
@@ -112,61 +112,90 @@ jobs:
set -e set -e
APP_DIR=/home/tyler/route-commerce APP_DIR=/home/tyler/route-commerce
# Add server SSH host key if provided # Setup SSH key - write raw (no printf which can corrupt multi-line keys)
if [ -n "$SERVER_SSH_KEY" ]; then mkdir -p ~/.ssh
mkdir -p ~/.ssh echo "$SERVER_SSH_KEY" > ~/.ssh/id_ed25519
echo "$SERVER_SSH_KEY" >> ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan route.crispygoat.com >> ~/.ssh/known_hosts 2>/dev/null # Verify key was written correctly
if ! grep -q "PRIVATE KEY" ~/.ssh/id_ed25519; then
echo "ERROR: SSH key not found or malformed. Check SERVER_SSH_KEY secret."
cat ~/.ssh/id_ed25519 || echo "File is empty"
exit 1
fi fi
ssh-keyscan -H route.crispygoat.com >> ~/.ssh/known_hosts 2>/dev/null || true
# Test SSH connection with verbose output for debugging
echo "Testing SSH connection..."
ssh -o ConnectTimeout=15 -o StrictHostKeyChecking=no -o LogLevel=VERBOSE tyler@route.crispygoat.com "echo 'SSH OK' && hostname" 2>&1 || { echo "SSH FAILED"; exit 1; }
# Create app dir on server
ssh tyler@route.crispygoat.com "mkdir -p $APP_DIR/.next $APP_DIR/public"
# Write production env file # Write production env file
ENV_FILE=$(mktemp)
{ {
printf "DATABASE_URL=%s\n" "$DATABASE_URL" printf 'DATABASE_URL=%s\n' "$DATABASE_URL"
printf "NEXT_PUBLIC_SITE_URL=%s\n" "$NEXT_PUBLIC_SITE_URL" printf 'NEXT_PUBLIC_SITE_URL=%s\n' "$NEXT_PUBLIC_SITE_URL"
printf "NEON_AUTH_BASE_URL=%s\n" "$NEON_AUTH_BASE_URL" printf 'NEON_AUTH_BASE_URL=%s\n' "$NEON_AUTH_BASE_URL"
printf "NEON_AUTH_COOKIE_SECRET=%s\n" "$NEON_AUTH_COOKIE_SECRET" printf 'NEON_AUTH_COOKIE_SECRET=%s\n' "$NEON_AUTH_COOKIE_SECRET"
printf "AUTH_SECRET=%s\n" "$AUTH_SECRET" printf 'AUTH_SECRET=%s\n' "$AUTH_SECRET"
printf "AUTH_URL=%s\n" "$AUTH_URL" printf 'AUTH_URL=%s\n' "$AUTH_URL"
printf "NEXT_PUBLIC_AUTH_URL=%s\n" "$NEXT_PUBLIC_AUTH_URL" printf 'NEXT_PUBLIC_AUTH_URL=%s\n' "$NEXT_PUBLIC_AUTH_URL"
printf "GOOGLE_CLIENT_ID=%s\n" "$GOOGLE_CLIENT_ID" printf 'GOOGLE_CLIENT_ID=%s\n' "$GOOGLE_CLIENT_ID"
printf "GOOGLE_CLIENT_SECRET=%s\n" "$GOOGLE_CLIENT_SECRET" printf 'GOOGLE_CLIENT_SECRET=%s\n' "$GOOGLE_CLIENT_SECRET"
printf "ALLOW_DEV_LOGIN=%s\n" "$ALLOW_DEV_LOGIN" printf 'ALLOW_DEV_LOGIN=%s\n' "$ALLOW_DEV_LOGIN"
printf "ADMIN_ALLOWED_EMAILS=%s\n" "$ADMIN_ALLOWED_EMAILS" printf 'ADMIN_ALLOWED_EMAILS=%s\n' "$ADMIN_ALLOWED_EMAILS"
printf "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=%s\n" "$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY" printf 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=%s\n' "$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY"
printf "STRIPE_SECRET_KEY=%s\n" "$STRIPE_SECRET_KEY" printf 'STRIPE_SECRET_KEY=%s\n' "$STRIPE_SECRET_KEY"
printf "STRIPE_WEBHOOK_SECRET=%s\n" "$STRIPE_WEBHOOK_SECRET" printf 'STRIPE_WEBHOOK_SECRET=%s\n' "$STRIPE_WEBHOOK_SECRET"
printf "STRIPE_PRICE_STARTER=%s\n" "$STRIPE_PRICE_STARTER" printf 'STRIPE_PRICE_STARTER=%s\n' "$STRIPE_PRICE_STARTER"
printf "STRIPE_PRICE_FARM=%s\n" "$STRIPE_PRICE_FARM" printf 'STRIPE_PRICE_FARM=%s\n' "$STRIPE_PRICE_FARM"
printf "STRIPE_PRICE_ENTERPRISE=%s\n" "$STRIPE_PRICE_ENTERPRISE" printf 'STRIPE_PRICE_ENTERPRISE=%s\n' "$STRIPE_PRICE_ENTERPRISE"
printf "STRIPE_PRICE_HARVEST_REACH=%s\n" "$STRIPE_PRICE_HARVEST_REACH" printf 'STRIPE_PRICE_HARVEST_REACH=%s\n' "$STRIPE_PRICE_HARVEST_REACH"
printf "STRIPE_PRICE_WHOLESALE_PORTAL=%s\n" "$STRIPE_PRICE_WHOLESALE_PORTAL" printf 'STRIPE_PRICE_WHOLESALE_PORTAL=%s\n' "$STRIPE_PRICE_WHOLESALE_PORTAL"
printf "STRIPE_PRICE_WATER_LOG=%s\n" "$STRIPE_PRICE_WATER_LOG" printf 'STRIPE_PRICE_WATER_LOG=%s\n' "$STRIPE_PRICE_WATER_LOG"
printf "STRIPE_PRICE_AI_TOOLS=%s\n" "$STRIPE_PRICE_AI_TOOLS" printf 'STRIPE_PRICE_AI_TOOLS=%s\n' "$STRIPE_PRICE_AI_TOOLS"
printf "STRIPE_PRICE_SQUARE_SYNC=%s\n" "$STRIPE_PRICE_SQUARE_SYNC" printf 'STRIPE_PRICE_SQUARE_SYNC=%s\n' "$STRIPE_PRICE_SQUARE_SYNC"
printf "STRIPE_PRICE_SMS_CAMPAIGNS=%s\n" "$STRIPE_PRICE_SMS_CAMPAIGNS" printf 'STRIPE_PRICE_SMS_CAMPAIGNS=%s\n' "$STRIPE_PRICE_SMS_CAMPAIGNS"
printf "RESEND_API_KEY=%s\n" "$RESEND_API_KEY" printf 'RESEND_API_KEY=%s\n' "$RESEND_API_KEY"
printf "FROM_EMAIL=%s\n" "$FROM_EMAIL" printf 'FROM_EMAIL=%s\n' "$FROM_EMAIL"
printf "MINIO_ENDPOINT=%s\n" "$MINIO_ENDPOINT" printf 'MINIO_ENDPOINT=%s\n' "$MINIO_ENDPOINT"
printf "MINIO_REGION=%s\n" "$MINIO_REGION" printf 'MINIO_REGION=%s\n' "$MINIO_REGION"
printf "MINIO_ACCESS_KEY=%s\n" "$MINIO_ACCESS_KEY" printf 'MINIO_ACCESS_KEY=%s\n' "$MINIO_ACCESS_KEY"
printf "MINIO_SECRET_KEY=%s\n" "$MINIO_SECRET_KEY" printf 'MINIO_SECRET_KEY=%s\n' "$MINIO_SECRET_KEY"
printf "MINIO_PUBLIC_URL=%s\n" "$MINIO_PUBLIC_URL" printf 'MINIO_PUBLIC_URL=%s\n' "$MINIO_PUBLIC_URL"
printf "MINIO_BUCKET_PRODUCTS=%s\n" "$MINIO_BUCKET_PRODUCTS" printf 'MINIO_BUCKET_PRODUCTS=%s\n' "$MINIO_BUCKET_PRODUCTS"
printf "MINIO_BUCKET_BRAND_LOGOS=%s\n" "$MINIO_BUCKET_BRAND_LOGOS" printf 'MINIO_BUCKET_BRAND_LOGOS=%s\n' "$MINIO_BUCKET_BRAND_LOGOS"
printf "MINIO_BUCKET_WATER_LOGS=%s\n" "$MINIO_BUCKET_WATER_LOGS" printf 'MINIO_BUCKET_WATER_LOGS=%s\n' "$MINIO_BUCKET_WATER_LOGS"
printf "MINIMAX_API_KEY=%s\n" "$MINIMAX_API_KEY" printf 'MINIMAX_API_KEY=%s\n' "$MINIMAX_API_KEY"
printf "MINIMAX_BASE_URL=%s\n" "$MINIMAX_BASE_URL" printf 'MINIMAX_BASE_URL=%s\n' "$MINIMAX_BASE_URL"
printf "CRON_SECRET=%s\n" "$CRON_SECRET" printf 'CRON_SECRET=%s\n' "$CRON_SECRET"
} > $APP_DIR/.env.production } > "$ENV_FILE"
# Sync build output and required files to server # Upload env file and sync build output
rsync -a --delete .next/ tyler@route.crispygoat.com:$APP_DIR/.next/ echo "Uploading env file..."
rsync -a --delete public/ tyler@route.crispygoat.com:$APP_DIR/public/ scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no "$ENV_FILE" tyler@route.crispygoat.com:$APP_DIR/.env.production
rsync $APP_DIR/package.json tyler@route.crispygoat.com:$APP_DIR/
rsync next.config.ts tyler@route.crispygoat.com:$APP_DIR/ 2>/dev/null || true
# Install production deps and restart on server echo "Copying .next/..."
ssh tyler@route.crispygoat.com "cd $APP_DIR && npm install --omit=dev && pm2 restart route-commerce || pm2 start npm --name route-commerce -- start -- -p 3100 && pm2 save" scp -o ConnectTimeout=30 -o StrictHostKeyChecking=no -r .next tyler@route.crispygoat.com:$APP_DIR/
echo "Copying public/..."
scp -o ConnectTimeout=30 -o StrictHostKeyChecking=no -r public tyler@route.crispygoat.com:$APP_DIR/
echo "Copying package.json..."
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no package.json tyler@route.crispygoat.com:$APP_DIR/
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no next.config.ts tyler@route.crispygoat.com:$APP_DIR/ 2>/dev/null || true
echo "Deployed successfully" # Ship the migration runner + SQL so the server has a recovery path (scripts/ and db/migrations/ were previously omitted from the artifact).
# This allows `node scripts/migrate.js` (after sourcing .env.production) to work directly on the target if needed for bootstrap or emergencies.
# See docs/superpowers/plans/2026-06-prod-db-schema-migration-reliability.md
echo "Ensuring migration directories on server and copying runner + SQL..."
ssh -o ConnectTimeout=15 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "mkdir -p $APP_DIR/scripts $APP_DIR/db"
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no scripts/migrate.js tyler@route.crispygoat.com:$APP_DIR/scripts/migrate.js 2>/dev/null || true
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no -r db/migrations tyler@route.crispygoat.com:$APP_DIR/db/ 2>/dev/null || true
# Install deps and restart on server
echo "Installing deps and restarting PM2..."
ssh -o ConnectTimeout=60 -o StrictHostKeyChecking=no tyler@route.crispygoat.com "cd $APP_DIR && npm install --omit=dev 2>&1 | tail -5 && pm2 restart route-commerce || pm2 start npm --name route-commerce -- start -- -p 3100 && pm2 save && sleep 4 && curl -f -s http://localhost:3100/api/health/db-schema || { echo 'Health check failed after start - schema not applied (see plan)'; exit 1; }"
echo "Deployed successfully"
+27
View File
@@ -101,6 +101,33 @@ The app connects to **Postgres directly** — no Supabase platform, JS client, o
- A single shared `pg` `Pool` is exported from `src/lib/db.ts` (TBD — to be created/confirmed during the migration). Server actions and API routes import it and call `pool.query(...)` against RPC names. - A single shared `pg` `Pool` is exported from `src/lib/db.ts` (TBD — to be created/confirmed during the migration). Server actions and API routes import it and call `pool.query(...)` against RPC names.
- No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — these are being purged from the codebase. - No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — these are being purged from the codebase.
#### First production deploy / new prod DB bootstrap (critical for admin access)
The `admin_users` + `admin_user_brands` tables (and the rest of the schema) come **only** from `db/migrations/0001_init.sql`.
If the prod `DATABASE_URL` has never had the migrations applied, `getAdminUser()` will fail with "relation \"admin_users\" does not exist", the layout will show "Access Denied", and even a signed-in Neon Auth user will be blocked.
**Correct bootstrap sequence (do this from a machine with the full source tree before the first push that exercises /admin):**
1. Ensure the Gitea secret `DATABASE_URL` points at the real prod Neon Postgres (the one with `neon_auth.user` already present).
2. Sign in once at the live prod URL (`/login`) with the email you want as the first `platform_admin`. This creates the row in `neon_auth.user`.
3. From your laptop (or any box with the checkout):
```bash
# Paste the real prod connection string (get it from Gitea secrets or the target's .env.production)
DATABASE_URL="postgresql://...prod-full-string..." node scripts/migrate.js
# Then provision (the script will link to the first brand it finds)
DATABASE_URL="postgresql://...prod-full-string..." \
npx tsx scripts/provision-admin.ts you@real.com platform_admin
```
4. Push to main. The deploy workflow now has a hard gate (see `.gitea/workflows/deploy.yml` "Run migrations" + verification query for `admin_users`) and ships the migrate runner + SQL files, so future deploys and server-side recovery are protected.
See the full root-cause + plan: `docs/superpowers/plans/2026-06-prod-db-schema-migration-reliability.md`
The old `|| echo` masking around `npm run migrate:one` has been removed; a missing critical table will now fail the CI job with a clear message.
#### SECURITY DEFINER RPCs + Brand Scoping #### SECURITY DEFINER RPCs + Brand Scoping
The app uses **PostgreSQL SECURITY DEFINER functions** for all data access. These run with the function owner's privileges and bypass any future RLS. This means: The app uses **PostgreSQL SECURITY DEFINER functions** for all data access. These run with the function owner's privileges and bypass any future RLS. This means:
+29 -1
View File
@@ -2,7 +2,35 @@
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations. This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
**Last updated:** 2026-06-06 (Supabase → Postgres pivot) **Last updated:** 2026-06 (migration reliability + Google sign-in work)
## 2026-06: CI migration failures on re-deploy (0001_init.sql "already exists")
Prod DATABASE_URL already had the schema from the first successful bootstrap.
The deploy workflow runs `npm run migrate:one` on every push (after neon_auth preflight).
`scripts/migrate.js` has `_migrations` tracking + skip, but the row for `0001_init.sql` was never recorded (the tracking logic landed after the initial apply, or an apply happened outside the runner).
`db/migrations/0001_init.sql` header *claimed* "CREATE TABLE IF NOT EXISTS" but the actual statements were plain `CREATE TABLE`, plain `CREATE INDEX`, and unguarded `CREATE TRIGGER`.
Result: every subsequent deploy hit `relation "admin_users" already exists` (and would have hit index/trigger dups too) inside the runner's BEGIN, causing ROLLBACK + failure of the whole "Run migrations" job.
### Fixes applied
- Made `0001_init.sql` truly re-runnable:
- All `CREATE TABLE``CREATE TABLE IF NOT EXISTS`
- All `CREATE INDEX` / `CREATE UNIQUE INDEX``... IF NOT EXISTS`
- Every `CREATE TRIGGER` wrapped in a `DO $$ IF NOT EXISTS (pg_trigger check) THEN CREATE TRIGGER ... END IF; $$` guard
- Removed the file-level `BEGIN; ... COMMIT;` (the runner owns the tx; this also prevents inner-COMMIT from ending the runner tx early).
- `0002_admin_password.sql` had its tx wrapper removed for consistency (its ALTER was already `IF NOT EXISTS`).
- Hardened `scripts/migrate.js`:
- Added `ensureTracked()` repair: for 0001/0002, if the core objects (admin_users table, or the password_hash column) already exist in the target DB but the tracking row is absent, we INSERT the row (ON CONFLICT DO NOTHING) and skip the file. Logs "repaired tracking".
- Hardened `.gitea/workflows/deploy.yml` "Run migrations" step:
- Added an inline pre-repair node snippet (same idea) right before `npm run migrate:one`. This protects even if an older runner is checked out.
- The existing neon_auth preflight + post `admin_users` verification remain as the hard gate.
- Updated header comments and docs in the files.
After this, `npm run migrate` / deploys on an already-initialized DB will log the "repaired" or "already applied" lines for 0001 and proceed cleanly. The shipped `scripts/migrate.js` + `db/migrations/` on the target server also benefit for emergency recovery runs.
See also the plan doc referenced in deploy.yml for the broader reliability work.
--- ---
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -10,12 +10,11 @@
-- `src/lib/auth.ts` — it queries this column and runs `verifyPassword` -- `src/lib/auth.ts` — it queries this column and runs `verifyPassword`
-- (see `src/lib/passwords.ts`) before returning the user. -- (see `src/lib/passwords.ts`) before returning the user.
BEGIN; -- Transaction is managed by the migrate runner (scripts/migrate.js).
-- File kept small and re-runnable via IF NOT EXISTS on the ALTER.
ALTER TABLE users ALTER TABLE users
ADD COLUMN IF NOT EXISTS password_hash TEXT; ADD COLUMN IF NOT EXISTS password_hash TEXT;
-- Update the updated_at trigger tracking — no new triggers needed since -- Update the updated_at trigger tracking — no new triggers needed since
-- `users` already has `set_updated_at` from migration 0001. -- `users` already has `set_updated_at` from migration 0001.
COMMIT;
@@ -0,0 +1,147 @@
# Production DB Schema Migration Reliability Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:systematic-debugging (completed root cause), superpowers:writing-plans (this), superpowers:test-driven-development where code changes have tests, superpowers:verification-before-completion, and superpowers:executing-plans or subagent-driven-development to implement task-by-task. Steps use checkbox syntax.
**Goal:** Ensure that every production deploy (push to main) results in the full schema from `db/migrations/0001_init.sql` (including the `admin_users` and `admin_user_brands` tables required by `getAdminUser()`) being present in the `DATABASE_URL` that the running app connects to via its pool / drizzle client. Eliminate "relation does not exist" errors and the resulting Access Denied screen for properly provisioned Neon Auth users.
**Architecture:**
- Make the CI "Run migrations" step a hard gate (fatal on failure, plus explicit post-migrate verification that critical tables exist).
- Ship the minimal migration runner + SQL files as part of the deploy artifact so the target server has a recovery path.
- Add a lightweight post-deploy / startup verification in the app or deploy script (fail fast with clear message instead of silent 500s on first admin request).
- Keep the "migrate from full source locally" path working for initial prod DB bootstrap and emergencies.
- Do not change the core migration logic or 0001_init.sql in this plan (that would be a separate architectural change if the double-BEGIN wrapping proves fragile).
**Tech Stack:** Gitea Actions (YAML), Node 22 + pg + drizzle on target, Next.js standalone output, pm2 on Ubuntu server, Neon Postgres (with neon_auth schema).
**Root Cause (from systematic-debugging Phase 1):** The prod runtime DB lacked the `admin_users` table because (1) the migration step in `.gitea/workflows/deploy.yml` used `|| echo` making any failure (connection, SQL error in the huge 0001 file, FK to neon_auth.user, tx nesting from the file's BEGIN + script's BEGIN) non-fatal, (2) only `.next/`, `public/`, `package.json` (and optional next.config) are scp'd — `scripts/migrate.js` and `db/migrations/` are never on the server, (3) no verification after "migrate" or at app startup that the tables the admin permission layer depends on actually exist, (4) the `.env.production` written from the same secret as the CI migrate step was used, but the apply didn't happen or was skipped due to _migrations state or partial rollback.
**Evidence Gathered:**
- deploy.yml: Run migrations step, limited scp, .env.production printf, pm2 "npm start".
- scripts/migrate.js: dotenv .env.local + env override, _migrations tracking, per-file client.query(sql) inside script tx, re-throw on error.
- db/migrations/0001_init.sql: explicit CREATE TABLE admin_users (with FK to neon_auth.user), admin_user_brands, brands; file starts with BEGIN;.
- db/client.ts + src/lib/admin-permissions.ts: withPlatformAdmin → drizzle select on adminUsers from schema (the exact query that 42P01s).
- next.config.ts: output: 'standalone' (explains pm2 warning).
- Runtime logs: the repeated "Database query failed" + "relation does not exist", app starts fine.
- Git history: recent deploy "fixes" focused on SSH/env writing, not migration reliability.
**Files to touch (decomposition by responsibility):**
- `.gitea/workflows/deploy.yml` (CI pipeline gates + artifact contents)
- `scripts/migrate.js` (minor hardening if needed for verification hook)
- `src/app/api/health/route.ts` or similar (new, for startup/schema check — or add to existing)
- `CLAUDE.md` + `PRODUCTION_DEPLOYMENT_CHECKLIST.md` (docs)
- Possibly a small `scripts/verify-prod-schema.js` helper
---
### Task 1: Make CI migration step a hard failure + add explicit verification for admin_users
**Files:**
- Modify: `.gitea/workflows/deploy.yml:23-27` (the Run migrations step and surrounding)
- [ ] **Step 1.1:** Replace the non-fatal migration line with a strict block that fails the job if migrate fails or the critical table is missing after.
```yaml
- name: Run migrations
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: |
set -e
echo "=== Running migrations against prod DB ==="
npm run migrate:one
echo "=== Verifying critical schema (admin_users) ==="
node -e '
const {Client} = require("pg");
const c = new Client({connectionString: process.env.DATABASE_URL});
c.connect().then(() => c.query("SELECT 1 FROM admin_users LIMIT 1")).then(() => {
console.log("✓ admin_users table exists");
return c.end();
}).then(() => process.exit(0)).catch(e => {
console.error("✗ admin_users missing or inaccessible:", e.message);
process.exit(1);
});
'
```
- [ ] **Step 1.2:** Run a local simulation or note that the Gitea runner will now fail the whole deploy if the secret DB is missing the table (good — forces the bootstrap to happen before code that depends on it ships).
- [ ] **Step 1.3:** Commit the yml change with message referencing the root cause (missing table in prod due to masked migration).
### Task 2: Ship migration capability in the deploy artifact so server has a recovery path
**Files:**
- Modify: `.gitea/workflows/deploy.yml` in the "Deploy" step (the scp and ssh sections)
- [ ] **Step 2.1:** Add scp for the migration assets (after the existing public/.next scp):
```bash
echo "Copying migration runner and SQL..."
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no -r scripts/migrate.js tyler@...:$APP_DIR/scripts/ || true
scp -o ConnectTimeout=15 -o StrictHostKeyChecking=no -r db/migrations tyler@...:$APP_DIR/db/ || true
```
- [ ] **Step 2.2:** Update the server ssh install/restart line to also ensure the scripts dir has the right perms if needed, but mainly document that now `node scripts/migrate.js` will work on the server with the .env.production.
- [ ] **Step 2.3:** In the same Deploy step, after writing .env.production and before or after the pm2 restart, optionally run the migrate on the server as a belt-and-suspenders (using the just-written .env):
```bash
ssh ... "cd $APP_DIR && source .env.production 2>/dev/null || export \$(grep DATABASE_URL .env.production); node scripts/migrate.js || echo 'migrate on server completed or not needed'"
```
(Keep it non-fatal on server for now; the CI gate is the hard one.)
- [ ] **Step 2.4:** Test the scp paths in a dry-run or note the change.
### Task 3: Add a minimal runtime / startup guard (fail fast with clear message)
**Files:**
- Create: `src/app/api/health/db-schema/route.ts` (or add to an existing health if present)
- Or simpler: in the admin layout or a top level, but a dedicated health is better for PM2/docker.
- [ ] **Step 3.1:** Create a tiny health endpoint that does the same check the CI verification does (SELECT 1 FROM admin_users) using the existing pool or withDb, returns 200 or 503 with message "Schema not applied - run migrations".
- [ ] **Step 3.2:** Wire it so the deploy can curl it after restart as a final gate (in the workflow ssh step).
- [ ] **Step 3.3:** (Optional but recommended per defense-in-depth) Call a similar check early in getAdminUser or the admin layout and log a very loud message + return a better error than generic "does not have admin access" when the table is literally missing.
### Task 4: Update documentation and bootstrap instructions (so humans know the right sequence)
**Files:**
- Modify: `CLAUDE.md` (the Commands and Important File Locations + Gotchas sections)
- Modify or create: `PRODUCTION_DEPLOYMENT_CHECKLIST.md` or a new `docs/PROD_BOOTSTRAP.md`
- [ ] **Step 4.1:** In CLAUDE.md under "Commands" and "Adding a New Brand" / auth section, add a "First production deploy / new prod DB bootstrap" subsection:
1. Ensure the Neon project has neon_auth enabled and the DATABASE_URL secret in Gitea points to it.
2. (Before first code push that depends on admin) Locally or in a throwaway runner: `DATABASE_URL=prod... node scripts/migrate.js`
3. Then `DATABASE_URL=prod... npx tsx scripts/provision-admin.ts you@real.com platform_admin` (after signing in on the prod URL).
4. Push; the CI gate + shipped runner will keep it healthy on future deploys.
5. If you ever see "relation admin_users does not exist" in prod logs, the DB the app is talking to is not the one that had migrate run.
- [ ] **Step 4.2:** Add a note about the `|| echo` anti-pattern that was removed and why the new verification step exists.
- [ ] **Step 4.3:** Mention the standalone vs npm start issue (already in logs) and that the start command on server should eventually be updated to `node .next/standalone/server.js -p 3100` (can be a follow-up task).
### Task 5: Verification before claiming success (use the dedicated skill)
**Files:** (none new, just process)
- [ ] **Step 5.1:** Before merging the plan changes, use `superpowers:verification-before-completion` checklist: the change makes a fresh DB get the table, an "already applied" DB is a no-op, a deploy with missing table now fails the job early with clear output, a manual server migrate works because the files are there, the runtime health returns 200 when table present.
- [ ] **Step 5.2:** After the PR is on a branch, trigger a deploy to a staging or the real prod (with a test DB first if possible), capture the CI log showing the new verification passing, and the app logs showing no more "Database query failed" on /admin.
- [ ] **Step 5.3:** Run the provision script as the final user-visible test; confirm the Access Denied with email message is gone and the platform_admin can see the UI.
- [ ] **Step 5.4:** Document the before/after in the plan or a memory file.
### Task 6: (Stretch / follow-up) Improve the migrate script's resilience for huge init files (if the double tx ever bites again)
**Files:**
- Modify: `scripts/migrate.js`
- [ ] Only if during verification the 0001 apply is flaky: change the per-file execution to not wrap the file's own BEGIN/COMMIT, or use a separate connection, or exec `psql -f` (but keep node/pg for consistency). Add a comment explaining the previous fragility.
**Rollback / emergency:** If a deploy breaks because of this, the server now has the scripts + db/migrations copied, so SSH + `source .env.production; node scripts/migrate.js` is the recovery (exactly what the user was trying to do manually).
**Success criteria:**
- A brand new prod DB + push to main results in a green deploy + working /admin after provision.
- The error "relation \"admin_users\" does not exist" no longer appears in prod pm2 logs for normal admin flows.
- The pipeline fails loudly (with the table name in the error) instead of shipping a broken app that only shows "Access Denied".
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "route-commerce-platform", "name": "route-commerce-platform",
"version": "1.0.0", "version": "2.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0 -p 4000", "dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0 -p 4000",
+39 -6
View File
@@ -1,14 +1,16 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Apply Postgres migrations from `db/migrations/*.sql` in lexical order. * Apply Postgres migrations from `db/migrations/*.sql` in lexical order.
* Wraps the whole thing in a transaction; tracks applied files in * Wraps each new file in a transaction; tracks applied files in
* `_migrations` so re-runs are safe. * `_migrations` so re-runs are safe and idempotent.
*
* The 0001_init.sql (and 0002) are now fully re-runnable (IF NOT EXISTS +
* trigger guards) + this script has repair logic for DBs that were
* initialized before tracking was introduced.
* *
* Usage: * Usage:
* npm run db:migrate * npm run migrate
* * npm run migrate:one # same as above (applies all pending)
* Replaces the old `supabase/push-migrations.js` — that script was
* hardcoded to a Supabase URL. This one reads `DATABASE_URL` directly.
*/ */
require("dotenv").config({ path: ".env.local" }); require("dotenv").config({ path: ".env.local" });
@@ -51,6 +53,37 @@ async function main() {
); );
const appliedSet = new Set(applied.map((r) => r.filename)); const appliedSet = new Set(applied.map((r) => r.filename));
// Repair for historical DBs (applied before _migrations tracking existed,
// or via direct psql / earlier tooling). If the core objects are present
// we record the filename so future deploys (and server-side recovery runs)
// treat 0001/0002 as done without re-executing the large init script.
async function ensureTracked(filename, existenceCheckSql) {
if (appliedSet.has(filename)) return;
try {
const { rows } = await client.query(existenceCheckSql);
if (rows.length > 0) {
await client.query(
`INSERT INTO _migrations (filename) VALUES ($1) ON CONFLICT (filename) DO NOTHING`,
[filename]
);
console.log(`${filename} (objects present in DB; repaired tracking)`);
appliedSet.add(filename);
}
} catch (e) {
// Non-fatal: the check may fail on a brand-new DB or with limited perms.
// We'll let the normal apply path handle it.
}
}
await ensureTracked(
"0001_init.sql",
"SELECT 1 FROM information_schema.tables WHERE table_name = 'admin_users' LIMIT 1"
);
await ensureTracked(
"0002_admin_password.sql",
"SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'password_hash' LIMIT 1"
);
let appliedNow = 0; let appliedNow = 0;
for (const file of files) { for (const file of files) {
if (appliedSet.has(file)) { if (appliedSet.has(file)) {
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env node
/**
* Post-migration verification.
* Called by .gitea/workflows/deploy.yml after npm run migrate:one.
* Confirms critical table admin_users is queryable.
*/
const { Client } = require("pg");
async function main() {
const url = process.env.DATABASE_URL;
if (!url) {
console.error("No DATABASE_URL");
process.exit(1);
}
const c = new Client({ connectionString: url });
await c.connect();
try {
console.log("=== Post-migration verification: critical table admin_users must exist ===");
await c.query("SELECT 1 FROM admin_users LIMIT 1");
console.log("✓ admin_users table exists and is queryable");
process.exit(0);
} catch (e) {
console.error("✗ FATAL: admin_users relation missing after migrate:", e.message);
console.error("The deploy cannot continue. The secret DATABASE_URL must have had db/migrations/0001_init.sql applied successfully.");
process.exit(1);
} finally {
await c.end();
}
}
main();
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* Preflight check + migration repair runner.
* Called by .gitea/workflows/deploy.yml before running migrations.
*
* 1. Verify neon_auth schema exists (prerequisite for 0001_init.sql FKs)
* 2. Ensure 0001_init.sql is tracked in _migrations if admin_users already exists
* (repair for DBs that had 0001_init.sql applied before _migrations was added)
*/
const { Client } = require("pg");
async function main() {
const url = process.env.DATABASE_URL;
if (!url) {
console.error("No DATABASE_URL");
process.exit(1);
}
const c = new Client({ connectionString: url });
await c.connect();
try {
// 1. Pre-flight: check neon_auth schema
console.log("=== Pre-flight: checking for neon_auth schema (required by 0001_init.sql for FKs to neon_auth.user and related RPCs) ===");
const schemaRes = await c.query(
"SELECT 1 FROM information_schema.schemata WHERE schema_name = 'neon_auth'"
);
if (schemaRes.rows.length === 0) {
console.error("✗ FATAL: neon_auth schema does not exist in the target database.");
console.error("Enable Neon Auth on the Neon project/branch that this DATABASE_URL points to first.");
console.error("Run: neonctl neon-auth (or equivalent) against the correct Neon branch.");
console.error("The neon_auth schema is created by Neon Auth and is a prerequisite for 0001_init.sql.");
process.exit(1);
}
console.log("✓ neon_auth schema exists");
// 2. Pre-repair: ensure 0001_init.sql is tracked if admin_users already exists
// This handles DBs where 0001_init.sql was applied before _migrations tracking existed.
console.log("=== Pre-repair: checking _migrations tracking (best-effort) ===");
try {
const tableRes = await c.query(
"SELECT 1 FROM information_schema.tables WHERE table_name = 'admin_users' LIMIT 1"
);
if (tableRes.rows.length > 0) {
await c.query(`
CREATE TABLE IF NOT EXISTS _migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO _migrations (filename)
VALUES ('0001_init.sql')
ON CONFLICT (filename) DO NOTHING;
`);
console.log("✓ 0001_init.sql tracking repaired (admin_users already present)");
}
} catch (e) {
// best-effort
console.log("(pre-repair skipped: " + e.message + ")");
}
console.log("Preflight complete. Proceeding to migrations...");
process.exit(0);
} catch (e) {
console.error("Preflight failed:", e.message);
process.exit(1);
} finally {
await c.end();
}
}
main();
+17 -10
View File
@@ -8,16 +8,23 @@ import pg from "pg";
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
// Load .env.local manually // Load .env.local (or .env.production) manually so the script works in prod bootstrap
const envPath = path.join(process.cwd(), ".env.local"); const envFiles = [".env.local", ".env.production"];
if (fs.existsSync(envPath)) { for (const f of envFiles) {
const envContent = fs.readFileSync(envPath, "utf-8"); const envPath = path.join(process.cwd(), f);
for (const line of envContent.split("\n")) { if (fs.existsSync(envPath)) {
const trimmed = line.trim(); const envContent = fs.readFileSync(envPath, "utf-8");
if (trimmed && !trimmed.startsWith("#")) { for (const line of envContent.split("\n")) {
const [key, ...valueParts] = trimmed.split("="); const trimmed = line.trim();
process.env[key.trim()] = valueParts.join("=").trim(); if (trimmed && !trimmed.startsWith("#")) {
const [key, ...valueParts] = trimmed.split("=");
const k = key.trim();
if (!process.env[k]) {
process.env[k] = valueParts.join("=").trim();
}
}
} }
console.log(`[provision] loaded ${f}`);
} }
} }
@@ -106,7 +113,7 @@ async function main() {
} }
console.log(`\n✅ ${email} is now provisioned as ${role}!`); console.log(`\n✅ ${email} is now provisioned as ${role}!`);
console.log(` They can access the admin at http://localhost:4000/admin`); console.log(` They can access the admin at your production URL /admin (sign in first if needed).`);
} finally { } finally {
await pool.end(); await pool.end();
+19
View File
@@ -211,6 +211,25 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
); );
if (!rows[0]) return { user: null, error: "Insert returned no row" }; if (!rows[0]) return { user: null, error: "Insert returned no row" };
const newAdminId = String(rows[0].id);
// Ensure the admin_user_brands link exists for brand-scoped roles.
// (Platform admins created without a chosen brand may have 0 links and still
// get access via role; getAdminUser allows this.)
if (input.brand_id) {
try {
await query(
`INSERT INTO admin_user_brands (admin_user_id, brand_id)
VALUES ($1, $2)
ON CONFLICT (admin_user_id, brand_id) DO NOTHING`,
[newAdminId, input.brand_id],
);
} catch (linkErr) {
console.error("[createAdminUser] Failed to create admin_user_brands link:", linkErr);
// Non-fatal — the user row exists; a platform admin can link manually.
}
}
await sendWelcomeEmailSafe({ await sendWelcomeEmailSafe({
to: input.email, to: input.email,
name: input.display_name ?? input.email.split("@")[0], name: input.display_name ?? input.email.split("@")[0],
+15 -2
View File
@@ -4,6 +4,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { listBrandsForAdmin } from "@/actions/brands"; import { listBrandsForAdmin } from "@/actions/brands";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import "@/styles/admin-design-system.css"; import "@/styles/admin-design-system.css";
import { ToastProvider } from "@/components/admin/Toast"; import { ToastProvider } from "@/components/admin/Toast";
import { ToastContainer } from "@/components/admin/ToastContainer"; import { ToastContainer } from "@/components/admin/ToastContainer";
@@ -47,13 +48,25 @@ export default async function AdminLayout({ children }: { children: React.ReactN
); );
} }
// Not authenticated // Not authenticated / not provisioned
if (!adminUser) { if (!adminUser) {
// Best-effort: surface the Neon Auth identity so the user (or support) knows
// which account was checked. getAdminUser already logged details.
let attemptedEmail: string | null = null;
try {
const { data: session } = await getSession();
attemptedEmail = session?.user?.email ?? null;
} catch {
// ignore
}
const message = attemptedEmail
? `Your account (${attemptedEmail}) does not have admin access. Contact a platform administrator to be provisioned.`
: "Your account does not have admin access.";
return ( return (
<ToastProviderWrapper> <ToastProviderWrapper>
<AdminSidebar userRole={null} /> <AdminSidebar userRole={null} />
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}> <div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
<AdminAccessDenied message="Your account does not have admin access." /> <AdminAccessDenied message={message} />
</div> </div>
</ToastProviderWrapper> </ToastProviderWrapper>
); );
+10 -9
View File
@@ -1,10 +1,10 @@
import Link from "next/link"; import Link from "next/link";
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { isFeatureEnabled } from "@/lib/feature-flags"; import { isFeatureEnabled } from "@/lib/feature-flags";
import { getBillingOverview } from "@/actions/billing/billing-overview"; import { getBillingOverview } from "@/actions/billing/billing-overview";
import DashboardClient from "@/components/admin/DashboardClient"; import DashboardClient from "@/components/admin/DashboardClient";
import { pool } from "@/lib/db";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
@@ -31,17 +31,18 @@ export default async function AdminPage() {
// so a transient DB/network failure can't crash the whole admin page. // so a transient DB/network failure can't crash the whole admin page.
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null; let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
if (!dashboardBrandId && adminUser?.role === "platform_admin") { if (!dashboardBrandId && adminUser?.role === "platform_admin") {
// Direct pg query (the supabase shim returns empty results).
// This ensures a platform_admin sees real dashboard stats even on first login
// before they have chosen an active brand.
try { try {
const { data: firstBrand } = await supabase const { rows } = await pool.query<{ id: string }>(
.from("brands") `SELECT id FROM brands ORDER BY created_at ASC LIMIT 1`
.select("id") );
.limit(1) if (rows[0]?.id) {
.single(); dashboardBrandId = rows[0].id;
if (firstBrand?.id) {
dashboardBrandId = String(firstBrand.id);
} }
} catch (err) { } catch (err) {
console.error("[admin/page] supabase brands lookup failed:", err); console.error("[admin/page] brands lookup failed:", err);
} }
} }
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { pool } from "@/lib/db";
/**
* Lightweight health check for the critical schema tables required by the
* admin permission system (admin_users).
*
* Used by the deploy workflow as a final post-start gate.
* Returns 200/JSON on success, 503 on missing table or connection error.
*
* This provides the "fail fast with clear message" instead of the previous
* silent "Access Denied" that only appeared on first admin request.
*/
export async function GET() {
try {
// Same check used in the CI verification step and the original error path.
await pool.query("SELECT 1 FROM admin_users LIMIT 1");
return NextResponse.json(
{ status: "ok", message: "admin_users table present" },
{ status: 200 }
);
} catch (err: any) {
return NextResponse.json(
{
status: "error",
message: err?.message ?? "Database schema check failed",
hint: "Run migrations against the DATABASE_URL (see docs/superpowers/plans/2026-06-prod-db-schema-migration-reliability.md)",
},
{ status: 503 }
);
}
}
+8 -2
View File
@@ -31,10 +31,16 @@ export default function AdminAccessDenied({
</h1> </h1>
<p className="mt-2 text-sm text-stone-500">{message}</p> <p className="mt-2 text-sm text-stone-500">{message}</p>
<Link <Link
href="/admin" href="/login"
className="mt-6 inline-flex items-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-medium text-white transition-all shadow-sm" className="mt-6 inline-flex items-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-medium text-white transition-all shadow-sm"
> >
Back to Admin Go to Login
</Link>
<Link
href="/"
className="mt-3 block text-sm text-stone-500 hover:text-stone-700 underline-offset-2 hover:underline"
>
Return to homepage
</Link> </Link>
</div> </div>
</div> </div>
+74 -46
View File
@@ -18,13 +18,15 @@ import type { AdminRole, AdminUser, TenantContext } from "@/lib/admin-permission
* Returns `null` if: * Returns `null` if:
* - No Neon Auth session (caller not signed in) * - No Neon Auth session (caller not signed in)
* - The session email doesn't match any `admin_users.email` * - The session email doesn't match any `admin_users.email`
* - The user has no `admin_user_brands` row (not provisioned yet) * - The user is a brand-scoped role (brand_admin / store_employee) and has no
* rows in `admin_user_brands` (not provisioned for any brand yet)
* *
* Provisioning: an admin must run * Platform admins may be provisioned with zero brand links and still receive
* INSERT INTO admin_users (email, ...) VALUES (...) * access (they see all brands). Brand-scoped admins require >= 1 link row.
* INSERT INTO admin_user_brands (admin_user_id, brand_id, role) VALUES (...) *
* to grant a signed-in user admin access. Until provisioned, the * Provisioning: an admin must ensure rows exist in both tables for the user's
* layout shows "Access Denied" — correct behavior. * email (matched from their Neon Auth session). Until provisioned, the layout
* shows "Access Denied" — correct behavior.
*/ */
export async function getAdminUser(): Promise<AdminUser | null> { export async function getAdminUser(): Promise<AdminUser | null> {
// Check for dev_session cookie in development mode // Check for dev_session cookie in development mode
@@ -39,53 +41,75 @@ export async function getAdminUser(): Promise<AdminUser | null> {
let sessionEmail: string | null = null; let sessionEmail: string | null = null;
try { try {
const { data: session } = await getSession(); const { data: session } = await getSession();
console.log("[admin-permissions] Full session:", JSON.stringify(session));
sessionEmail = session?.user?.email ?? null; sessionEmail = session?.user?.email ?? null;
console.log("[admin-permissions] Session email:", sessionEmail);
} catch (err) { } catch (err) {
console.error("[admin-permissions] getSession() failed:", err); console.error("[admin-permissions] getSession() failed:", err);
return null; return null;
} }
if (!sessionEmail) return null; if (!sessionEmail) {
console.log("[admin-permissions] No session email - returning null");
return null;
}
return await withPlatformAdmin(async (db) => { try {
const userRows = await db return await withPlatformAdmin(async (db) => {
.select() console.log("[admin-permissions] Looking for user with email:", sessionEmail.toLowerCase());
.from(adminUsers) const userRows = await db
.where(eq(adminUsers.email, sessionEmail.toLowerCase())) .select()
.limit(1); .from(adminUsers)
const user = userRows[0]; .where(eq(adminUsers.email, sessionEmail.toLowerCase()))
if (!user) return null; .limit(1);
console.log("[admin-permissions] User rows found:", userRows.length, userRows);
const user = userRows[0];
if (!user) {
console.log("[admin-permissions] User not found in admin_users");
return null;
}
const membershipRows = await db const role = (user.role as AdminRole) || "brand_admin";
.select({
brandId: adminUserBrands.brandId,
brandName: brands.name,
brandSlug: brands.slug,
role: adminUserBrands.adminUserId,
})
.from(adminUserBrands)
.innerJoin(brands, eq(brands.id, adminUserBrands.brandId))
.where(eq(adminUserBrands.adminUserId, user.id))
.limit(1);
if (membershipRows.length === 0) { // Load all brand memberships for this admin (supports multi-brand admins).
// Signed in but not provisioned for any brand. // The redundant adminUsers join was removed; role lives on admin_users.
return null; const membershipRows = await db
} .select({
brandId: adminUserBrands.brandId,
brandName: brands.name,
brandSlug: brands.slug,
})
.from(adminUserBrands)
.innerJoin(brands, eq(brands.id, adminUserBrands.brandId))
.where(eq(adminUserBrands.adminUserId, user.id));
const m = membershipRows[0]; console.log("[admin-permissions] Membership rows found:", membershipRows.length, membershipRows);
const role = user.role as AdminRole;
return buildAdminUser({ // Brand-scoped roles (brand_admin, store_employee) require at least one brand link.
id: user.id, // Platform admins may have zero or more explicit links; they get cross-brand access via role.
email: user.email, if (membershipRows.length === 0 && role !== "platform_admin") {
displayName: user.name, // Signed in but not provisioned for any brand.
brandId: m.brandId, console.log("[admin-permissions] No brand memberships for non-platform user — denying");
brandName: m.brandName, return null;
brandSlug: m.brandSlug, }
role,
active: true, const first = membershipRows[0] ?? null;
return buildAdminUser({
id: user.id,
email: user.email,
displayName: user.name,
brandId: first?.brandId ?? null,
brandName: first?.brandName ?? null,
brandSlug: first?.brandSlug ?? null,
role,
active: true,
brandIds: membershipRows.map((m) => m.brandId),
});
}); });
}); } catch (err) {
console.error("[admin-permissions] Database query failed:", err);
return null;
}
} }
/** /**
@@ -146,19 +170,23 @@ function buildAdminUser(input: {
id: string; id: string;
email: string; email: string;
displayName: string | null; displayName: string | null;
brandId: string; brandId: string | null;
brandName: string; brandName: string | null;
brandSlug: string; brandSlug: string | null;
role: AdminRole; role: AdminRole;
active: boolean; active: boolean;
brandIds?: string[];
}): AdminUser { }): AdminUser {
const brandIds = input.brandIds && input.brandIds.length > 0
? input.brandIds
: (input.brandId ? [input.brandId] : []);
return { return {
id: input.id, id: input.id,
user_id: input.id, user_id: input.id,
email: input.email, email: input.email,
display_name: input.displayName, display_name: input.displayName,
brand_id: input.brandId, brand_id: input.brandId,
brand_ids: [input.brandId], brand_ids: brandIds,
brand_slug: input.brandSlug, brand_slug: input.brandSlug,
role: input.role, role: input.role,
active: input.active, active: input.active,
+39 -27
View File
@@ -6,58 +6,68 @@
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
// Use vi.hoisted to ensure mocks are available when vi.mock runs
const { mockDb, mockWithPlatformAdmin, mockGetSession, mockCookies } = vi.hoisted(() => {
const mockDb = {
select: vi.fn(),
};
return {
mockDb,
mockWithPlatformAdmin: vi.fn(async (fn: (db: typeof mockDb) => Promise<unknown>) => {
return fn(mockDb);
}),
mockGetSession: vi.fn(),
mockCookies: vi.fn(),
};
});
// Stub the server-only guard so the module can be imported under vitest. // Stub the server-only guard so the module can be imported under vitest.
vi.mock("server-only", () => ({})); vi.mock("server-only", () => ({}));
// Mock the Drizzle client wrapper so we don't need a real DB. // Mock the Drizzle client wrapper
type MockFn = (arg: unknown) => Promise<unknown>;
const mockSelect = vi.fn();
const mockWithPlatformAdmin = vi.fn(async (fn: MockFn) => fn({ select: mockSelect }));
vi.mock("@/db/client", () => ({ vi.mock("@/db/client", () => ({
withPlatformAdmin: (fn: MockFn) => mockWithPlatformAdmin(fn), withPlatformAdmin: mockWithPlatformAdmin,
})); }));
// Mock the getSession() function. The default mock returns null (no session). // Mock the getSession() function
const getSessionMock = vi.fn();
vi.mock("@/lib/auth", () => ({ vi.mock("@/lib/auth", () => ({
getSession: getSessionMock, getSession: mockGetSession,
})); }));
// Mock cookies() so we don't read a real cookie store. // Mock cookies() so we don't read a real cookie store
const cookieStoreGet = vi.fn();
vi.mock("next/headers", () => ({ vi.mock("next/headers", () => ({
cookies: () => cookies: mockCookies,
Promise.resolve({
get: (name: string) => cookieStoreGet(name),
}),
})); }));
import { getAdminUser, buildDevAdmin, permissionsForRole } from "@/lib/admin-permissions"; import { getAdminUser, buildDevAdmin, permissionsForRole } from "@/lib/admin-permissions";
const cookieStore = { get: vi.fn() };
beforeEach(() => { beforeEach(() => {
mockSelect.mockReset(); mockDb.select.mockReset();
getSessionMock.mockReset(); mockWithPlatformAdmin.mockReset();
cookieStoreGet.mockReset(); mockGetSession.mockReset();
mockCookies.mockImplementation(() => Promise.resolve(cookieStore));
cookieStore.get.mockReturnValue(undefined);
}); });
describe("getAdminUser()", () => { describe("getAdminUser()", () => {
it("returns null when there is no Neon Auth session", async () => { it("returns null when there is no Neon Auth session", async () => {
getSessionMock.mockResolvedValue({ data: null }); mockGetSession.mockResolvedValue({ data: null });
cookieStoreGet.mockReturnValue(undefined);
const u = await getAdminUser(); const u = await getAdminUser();
expect(u).toBeNull(); expect(u).toBeNull();
}); });
it("returns null when the session has no email", async () => { it("returns null when the session has no email", async () => {
getSessionMock.mockResolvedValue({ data: { user: { name: "no-email" } } }); mockGetSession.mockResolvedValue({ data: { user: { name: "no-email" } } });
const u = await getAdminUser(); const u = await getAdminUser();
expect(u).toBeNull(); expect(u).toBeNull();
}); });
it("returns null when the email is not in the users table", async () => { it("returns null when the email is not in the users table", async () => {
getSessionMock.mockResolvedValue({ data: { user: { email: "unknown@example.com" } } }); mockGetSession.mockResolvedValue({ data: { user: { email: "unknown@example.com" } } });
// First select: users. Returns empty. // First select: users. Returns empty.
mockSelect.mockReturnValueOnce({ mockDb.select.mockReturnValueOnce({
from: () => ({ from: () => ({
where: () => ({ where: () => ({
limit: async () => [], limit: async () => [],
@@ -69,9 +79,9 @@ describe("getAdminUser()", () => {
}); });
it("returns null when the user exists but has no admin_user_brands row", async () => { it("returns null when the user exists but has no admin_user_brands row", async () => {
getSessionMock.mockResolvedValue({ data: { user: { email: "no-brand@example.com" } } }); mockGetSession.mockResolvedValue({ data: { user: { email: "no-brand@example.com" } } });
// First select: users — returns the user // First select: users — returns the user
mockSelect mockDb.select
.mockReturnValueOnce({ .mockReturnValueOnce({
from: () => ({ from: () => ({
where: () => ({ where: () => ({
@@ -80,6 +90,7 @@ describe("getAdminUser()", () => {
id: "user-1", id: "user-1",
email: "no-brand@example.com", email: "no-brand@example.com",
name: "No Brand", name: "No Brand",
role: "brand_admin",
}, },
], ],
}), }),
@@ -100,8 +111,8 @@ describe("getAdminUser()", () => {
}); });
it("returns a fully-populated AdminUser for a provisioned brand_admin", async () => { it("returns a fully-populated AdminUser for a provisioned brand_admin", async () => {
getSessionMock.mockResolvedValue({ data: { user: { email: "admin@tuxedo.example" } } }); mockGetSession.mockResolvedValue({ data: { user: { email: "admin@tuxedo.example" } } });
mockSelect mockDb.select
.mockReturnValueOnce({ .mockReturnValueOnce({
from: () => ({ from: () => ({
where: () => ({ where: () => ({
@@ -110,6 +121,7 @@ describe("getAdminUser()", () => {
id: "user-tux", id: "user-tux",
email: "admin@tuxedo.example", email: "admin@tuxedo.example",
name: "Tux Admin", name: "Tux Admin",
role: "brand_admin",
}, },
], ],
}), }),
@@ -185,4 +197,4 @@ describe("permissionsForRole()", () => {
expect(p.can_manage_products).toBe(false); expect(p.can_manage_products).toBe(false);
expect(p.can_manage_billing).toBe(false); expect(p.can_manage_billing).toBe(false);
}); });
}); });