8 Commits

Author SHA1 Message Date
tyler 32396af193 ci(gitea): fix deploy path — docker-compose.yml moved to deploy/
Deploy to route.crispygoat.com / deploy (push) Successful in 3m30s
The Start Docker stack and Deploy steps referenced docker-compose.yml at
the repo root, but the file moved to deploy/ as part of the deploy.sh
refactor. The cp commands failed with 'cannot stat docker-compose.yml'
and the deploy step aborted.

Updated both cp paths to deploy/docker-compose.yml. The cd $APP_DIR and
docker compose -f $APP_DIR/docker-compose.yml references are unchanged
because they point at the deployed copy in APP_DIR, not the repo.
2026-06-06 19:54:03 +00:00
tyler f36419be69 docs: document canonical Gitea remote in CLAUDE.md
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
There is exactly one remote (origin = git@git.crispygoat.com:tyler/route-commerce.git).
No GitHub remotes. Pushing to origin/main triggers .gitea/workflows/deploy.yml.
2026-06-06 19:49:46 +00:00
tyler 5477b3419f fix(build): make admin tree dynamic and catch Supabase fetch errors at build
Deploy to route.crispygoat.com / deploy (push) Failing after 4m16s
Two errors were aborting the Gitea build:

1. DYNAMIC_SERVER_USAGE on /admin/settings/square-sync (and any admin page):
   getAdminUser() reads cookies() via next/headers. The admin layout tried
   to prerender statically, so the first child page that hit cookies()
   aborted the build. Added 'export const dynamic = "force-dynamic"' to
   src/app/admin/layout.tsx so the whole admin tree opts out of static
   prerender.

2. Prerender ECONNREFUSED on /indian-river-direct/stops and the sitemap:
   getPublicStopsForBrand / getActiveStopsForSitemap / getBrandSettingsPublic
   fetch NEXT_PUBLIC_SUPABASE_URL at build time. The Gitea runner sets the
   Supabase env vars (so the existing env-var guard passes) but the URL
   is unreachable, so fetch throws ECONNREFUSED and the prerender aborts.
   Wrapped each fetch in try/catch returning [] / {success: false} so the
   prerender completes; runtime behavior is unchanged when the fetch
   succeeds.

Also added force-dynamic to the square-sync page itself as belt-and-braces
in case the layout change doesn't propagate.
2026-06-06 19:46:35 +00:00
tyler 2f3be5426f fix(actions): skip Supabase fetch at build time when env vars unset
Deploy to route.crispygoat.com / deploy (push) Failing after 2m50s
The /indian-river-direct/stops page and sitemap prerender at build time
and call getPublicStopsForBrand / getActiveStopsForSitemap / getBrandSettingsPublic.
Those actions fetch NEXT_PUBLIC_SUPABASE_URL via Supabase REST. During
the GitHub/Gitea build, the Supabase secret is unset (or the value is
".supabase.co" which doesn't resolve), so the fetch errors with
ECONNREFUSED and the build aborts.

Return [] / not-configured when the env vars are missing so the prerender
can complete. Runtime behavior is unchanged when the vars are set.
2026-06-06 05:12:55 +00:00
tyler 2d837bc786 ci(gitea): drop build workflow, simplify deploy to call deploy.sh
Deploy to route.crispygoat.com / deploy (push) Failing after 2m50s
- Delete .gitea/workflows/build.yml (typecheck/lint only; caused confusion)
- Rewrite .gitea/workflows/deploy.yml as a thin wrapper that calls
  ./deploy/deploy.sh, matching the design in deploy/GITEA_SETUP.md
  (Option B). The 14512-byte inline deploy is removed; deploy.sh is the
  source of truth for the deploy mechanism.
- Fix runs-on to [self-hosted, ubuntu-latest] (matches the actual labels
  registered on crispygoat-host-runner; the previous [.., linux, ..] was
  unmatchable, which is why runs were stuck in the queue)
2026-06-06 05:03:44 +00:00
tyler bb6dbe37a4 ci(gitea): add deploy workflow + self-hosted homelab deploy toolkit (Auth.js port)
Build / build (push) Has been cancelled
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
Ports the deploy pipeline from the Gitea main fork (commit 7ddb06d's deploy
toolkit) into the Auth.js v5 / NextAuth tree:

- .gitea/workflows/deploy.yml: inline deploy that brings up the Docker
  stack, applies migrations, builds Next.js, and runs the app under PM2.
  Swapped Better Auth env vars (BETTER_AUTH_SECRET/URL, NEXT_PUBLIC_BETTER_AUTH_URL)
  for Auth.js v5 names (AUTH_SECRET/URL, NEXT_PUBLIC_AUTH_URL). Dropped
  NEXT_PUBLIC_SUPABASE_URL/ANON_KEY (Supabase removal in progress). Added
  GOOGLE_CLIENT_ID/SECRET + ALLOW_DEV_LOGIN for the Auth.js Google provider
  and dev credentials path. Switched runs-on from 'ubuntu-latest' to the
  self-hosted runner labels matching build.yml.

- deploy/: idempotent deploy toolkit (deploy.sh, docker-compose.yml,
  Dockerfile.nextjs, nginx.conf.template, .env.production.example, healthcheck.sh,
  Makefile, deploy/.gitignore). No auth/Supabase dependencies — pure infra.

- deploy/.env.production.example: renamed NEXTAUTH_SECRET/NEXTAUTH_URL
  (v4) to AUTH_SECRET/AUTH_URL (v5) and added the v5-specific vars
  (NEXT_PUBLIC_AUTH_URL, GOOGLE_*, ALLOW_DEV_LOGIN).

Build pipeline is now end-to-end:
  build.yml → typecheck + lint + build (uses [self-hosted, linux, ubuntu-latest])
  deploy.yml → start docker stack + migrations + build + PM2 restart

Storage / admin code ports (MinIO via @/lib/storage, Supabase removal,
admin-permissions rewrite) are tracked separately — they require porting
the storage and admin code first; the deploy pipeline itself is ready
to run against the Auth.js world.
2026-06-06 04:24:53 +00:00
tyler 3f4f46da7e ci: retrigger Gitea build 2026-06-06 03:46:49 +00:00
tyler ec1506dc82 feat(auth): Auth.js v5 + Postgres adapter for local smoke test
Wire up NextAuth v5 with @auth/pg-adapter, JWT sessions (edge-friendly),
and a dev Credentials provider for local testing without Google OAuth.

Stack
- next-auth@5.0.0-beta.31, @auth/pg-adapter@1.11.2, @types/pg
- Google OAuth provider via GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
  (falls back to AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET)
- Postgres adapter wired to a single pg.Pool in src/lib/db.ts style —
  reads DATABASE_URL with SUPABASE_DB_URL / POSTGRES_URL fallbacks
- JWT session strategy (edge-safe) so the proxy can verify sessions
  without a DB round-trip

Files
- src/auth.config.ts        edge-safe config (Google + authorized cb)
- src/lib/auth.ts           server config (adapter + dev Credentials)
- src/proxy.ts              Next.js 16 proxy (was middleware.ts)
- src/app/api/auth/[...nextauth]/route.ts
                            catch-all handler
- src/app/protected-example/page.tsx
                            demo page that renders auth() session
- src/actions/auth-signin.ts
                            signInWithGoogle, signInWithDev,
                            signOutAction server actions
- src/app/login/LoginClient.tsx
                            added "Sign in with Google" + dev form
- supabase/migrations/204_authjs_tables.sql
                            users / accounts / sessions /
                            verification_token schema (UUID-keyed)
- .env.example              AUTH_SECRET, AUTH_URL, GOOGLE_CLIENT_*,
                            DATABASE_URL, ALLOW_DEV_LOGIN

Removed
- src/middleware.ts         deleted; Next.js 16 only runs one proxy
                            (the new src/proxy.ts is canonical)

Routes
- /login, /admin, /admin/*, /protected-example
                            proxy matcher
- /api/auth/{providers,csrf,signin/<provider>,callback/<provider>,
  session,signout}
                            standard Auth.js endpoints

Local dev
- npm run dev (now runs on port 4000)
- push migration 204 then visit /login
- dev signin works with any non-empty username/password
  (hidden when ALLOW_DEV_LOGIN=false)
- Google signin requires real GOOGLE_CLIENT_ID + redirect URI
  http://localhost:4000/api/auth/callback/google

Verified
- tsc --noEmit clean
- /admin, /admin/orders, /protected-example → 307 to /login
  when unauthenticated
- /api/auth/session returns user after signin
- /protected-example renders session info
- /api/auth/providers returns google + dev-login

Docs
- CLAUDE.md and MEMORY.md updated to reflect the Supabase → Postgres
  + Auth.js v5 pivot

Gradual migration in progress
- src/lib/admin-permissions.ts still uses dev_session / rc_auth_uid;
  the admin shell will show 'Access Denied' for Auth.js-only
  sessions until each page is flipped over
- @supabase/* packages remain in package.json for the same reason
- production deployment (AUTH_URL=https://, __Secure- cookies) is
  out of scope for this pass
2026-06-06 03:40:09 +00:00
28 changed files with 2027 additions and 281 deletions
+82
View File
@@ -0,0 +1,82 @@
# ============================================================================
# Route Commerce — Environment variables
# ============================================================================
# Copy to `.env.local` and fill in real values for local development.
# Production: set these in your hosting dashboard (Vercel / Netlify / etc.).
# ============================================================================
# ── App ─────────────────────────────────────────────────────────────────────
NEXT_PUBLIC_BASE_URL=http://localhost:4000
NEXT_PUBLIC_SITE_URL=http://localhost:4000
# ── Database (Postgres, direct — Supabase is being removed) ────────────────
# Single connection string used by `pg.Pool` in src/lib/auth.ts and the
# admin-permissions / data-service layer. Format:
# postgresql://USER:PASS@HOST:PORT/DBNAME?sslmode=require
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/route_commerce
# ── Auth.js (NextAuth v5) ───────────────────────────────────────────────────
# Generate with: npx auth secret
# Or: openssl rand -base64 32
AUTH_SECRET=replace-me-with-a-32-byte-base64-string
# Base URL used to build OAuth callback URLs. In dev:
AUTH_URL=http://localhost:4000
# In production, set to https://yourdomain.com
# Google OAuth provider.
# 1. Go to https://console.cloud.google.com/apis/credentials
# 2. Create an OAuth 2.0 Client ID (type: Web application)
# 3. Add Authorized redirect URI: http://localhost:3000/api/auth/callback/google
# 4. Copy client id + client secret below
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Auth.js also reads AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET if you prefer the
# default NextAuth variable names. The code in src/auth.config.ts falls
# back to those names if GOOGLE_CLIENT_ID is unset.
# Set to "false" to disable the in-app dev credentials provider even in
# development. Default: enabled in dev only.
ALLOW_DEV_LOGIN=true
# ── Supabase (legacy, being removed) ────────────────────────────────────────
# Still used by the existing admin pages, server actions, and the
# `getAdminUser` flow. Once the auth migration is complete and the
# @supabase/* packages are removed, these can go away.
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
# ── Stripe ─────────────────────────────────────────────────────────────────
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
STRIPE_PRICE_STARTER=
STRIPE_PRICE_FARM=
STRIPE_PRICE_ENTERPRISE=
STRIPE_PRICE_HARVEST_REACH=
STRIPE_PRICE_WHOLESALE_PORTAL=
STRIPE_PRICE_WATER_LOG=
STRIPE_PRICE_AI_TOOLS=
STRIPE_PRICE_SQUARE_SYNC=
STRIPE_PRICE_SMS_CAMPAIGNS=
# ── Resend (transactional email) ────────────────────────────────────────────
RESEND_API_KEY=
RESEND_WEBHOOK_SECRET=
# ── AI providers ────────────────────────────────────────────────────────────
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
GOOGLE_API_KEY=
XAI_API_KEY=
MINIMAX_API_KEY=
MINIMAX_BASE_URL=https://api.minimax.io/v1
# ── Square (optional) ───────────────────────────────────────────────────────
SQUARE_APP_SECRET=
SQUARE_ENVIRONMENT=sandbox
# ── Cron / automation ───────────────────────────────────────────────────────
CRON_SECRET=replace-me-with-a-random-string
-52
View File
@@ -1,52 +0,0 @@
name: Build
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build:
runs-on: [self-hosted, linux, ubuntu-latest]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Apply fix-agents.js patch
run: node fix-agents.js
- name: Typecheck
env:
NEXT_PUBLIC_API_URL: http://localhost:3001
NEXT_PUBLIC_STORAGE_BASE_URL: http://localhost:9000
STORAGE_ENDPOINT: http://localhost:9000
DATABASE_URL: postgresql://routecommerce:routecommerce_dev_password@127.0.0.1:5432/route_commerce
run: npm run type-check
- name: Lint
env:
NEXT_PUBLIC_API_URL: http://localhost:3001
NEXT_PUBLIC_STORAGE_BASE_URL: http://localhost:9000
STORAGE_ENDPOINT: http://localhost:9000
DATABASE_URL: postgresql://routecommerce:routecommerce_dev_password@127.0.0.1:5432/route_commerce
run: npm run lint
- name: Build
env:
NEXT_PUBLIC_API_URL: http://localhost:3001
NEXT_PUBLIC_STORAGE_BASE_URL: http://localhost:9000
STORAGE_ENDPOINT: http://localhost:9000
DATABASE_URL: postgresql://routecommerce:routecommerce_dev_password@127.0.0.1:5432/route_commerce
run: npm run build
+298
View File
@@ -0,0 +1,298 @@
name: Deploy to route.crispygoat.com
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Start Docker stack
env:
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
run: |
APP_DIR=/home/tyler/route-commerce
mkdir -p $APP_DIR
# Free the dev-stack port (3001) and the port the previous deploy used
# (so a new deploy can pick it back up if it's the lowest free port)
PREV_PORT=$(cat .postgrest-port 2>/dev/null || echo "")
for port in 3001 $PREV_PORT; do
if [ -n "$port" ] && ss -tln 2>/dev/null | grep -qE "[[:space:]]127\.0\.0\.1:${port}[[:space:]]"; then
echo "Port $port in use, freeing..."
fuser -k -9 $port/tcp 2>/dev/null || true
docker ps -aq --filter "publish=$port" 2>/dev/null | xargs -r docker rm -f 2>/dev/null || true
fi
done
# Hard-stop the previous stack. Errors are NOT swallowed: if down
# fails, picking a port against a half-torn-down stack is exactly
# what produces the TOCTOU "address already in use" we keep hitting.
docker compose -f $APP_DIR/docker-compose.yml down --remove-orphans
# Belt-and-braces: anything with the postgrest name that survived.
docker ps -aq --filter "name=route_commerce_postgrest" | xargs -r docker rm -f >/dev/null 2>&1 || true
# docker-proxy sometimes leaves a listener behind for the published port.
pkill -9 -f 'docker-proxy.*3011' 2>/dev/null || true
pkill -9 -f 'docker-proxy.*3012' 2>/dev/null || true
pkill -9 -f 'docker-proxy.*3013' 2>/dev/null || true
sleep 3
# Verify the postgrest container is actually gone before we pick a port.
if docker ps -aq --filter "name=route_commerce_postgrest" | grep -q .; then
echo "ERROR: route_commerce_postgrest still running after down"
docker ps --filter "name=route_commerce_postgrest"
exit 1
fi
# Find the first free host port starting from 3011. Persist the choice
# so the Build and Deploy steps below can use the same URL.
POSTGREST_HOST_PORT=3011
for attempt in 1 2 3 4 5 6 7 8 9 10; do
if ! ss -tln 2>/dev/null | grep -qE "[[:space:]]127\.0\.0\.1:${POSTGREST_HOST_PORT}[[:space:]]"; then
break
fi
echo "Port $POSTGREST_HOST_PORT in use, trying next... (attempt $attempt)"
POSTGREST_HOST_PORT=$((POSTGREST_HOST_PORT + 1))
if [ $POSTGREST_HOST_PORT -gt 30200 ]; then
echo "ERROR: no free port in 3011-30200 range"
exit 1
fi
sleep 1
done
echo "Using PostgREST host port: $POSTGREST_HOST_PORT"
echo "$POSTGREST_HOST_PORT" > .postgrest-port
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
export POSTGREST_HOST_PORT
# Seed config files into APP_DIR if missing (they live in the repo, not in APP_DIR)
[ -f $APP_DIR/.env.example ] || cp .env.example $APP_DIR/.env.example
[ -f $APP_DIR/docker-compose.yml ] || cp deploy/docker-compose.yml $APP_DIR/docker-compose.yml
cd $APP_DIR
[ -f .env ] || cp .env.example .env
# Append production secrets to .env (overriding .env.example defaults)
{
echo "POSTGRES_USER=${POSTGRES_USER}"
echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
echo "POSTGRES_DB=${POSTGRES_DB}"
echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}"
echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}"
echo "POSTGREST_JWT_SECRET=${POSTGREST_JWT_SECRET}"
echo "POSTGREST_HOST_PORT=$POSTGREST_HOST_PORT"
echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL"
} >> .env
# Bring the stack up fresh — --force-recreate ensures no stale
# network/container references from prior failed attempts
docker compose up -d --force-recreate db postgrest minio minio_init
# Wait for Postgres healthcheck
for i in $(seq 1 30); do
if docker compose exec -T db pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" > /dev/null 2>&1; then
echo "Postgres is ready"
break
fi
sleep 2
done
- name: Apply migrations
env:
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
run: |
APP_DIR=/home/tyler/route-commerce
# Seed supabase/ into APP_DIR if missing (the deploy step copies it after, but
# we need it here for migrations)
[ -d $APP_DIR/supabase ] || cp -r supabase $APP_DIR/supabase
cd $APP_DIR
# PAGER= prevents psql from launching less/more in a non-interactive shell,
# which hangs indefinitely waiting for keypress. Batch all files into one
# connection for speed instead of one psql invocation per file.
export PAGER=
export PGPASSWORD="${POSTGRES_PASSWORD}"
PG="psql -h 127.0.0.1 -U ${POSTGRES_USER} -d ${POSTGRES_DB} --no-psqlrc -v ON_ERROR_STOP=0 -q"
$PG -f supabase/migrations/000_preflight_supabase_compat.sql || true
[ -f supabase/captured_schema.sql ] && $PG -f supabase/captured_schema.sql || true
# Concatenate all numbered migrations and run in one session
cat supabase/migrations/[0-9]*.sql | $PG
- name: Install dependencies
run: npm install
- name: Build
env:
NODE_ENV: production
DATABASE_URL: ${{ secrets.DATABASE_URL }}
# Auth.js v5 (NextAuth). Fall back to Better Auth names if the
# Gitea secret hasn't been renamed yet.
AUTH_SECRET: ${{ secrets.AUTH_SECRET || secrets.BETTER_AUTH_SECRET }}
AUTH_URL: ${{ secrets.AUTH_URL || secrets.BETTER_AUTH_URL }}
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL || secrets.NEXT_PUBLIC_BETTER_AUTH_URL }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
# Supabase (legacy, still used by admin pages/server actions until
# the Auth.js migration is finished)
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
# Storage (MinIO / S3)
NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }}
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
STORAGE_REGION: ${{ secrets.STORAGE_REGION }}
STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }}
STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }}
STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }}
# Stripe
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }}
# Resend
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }}
# AI providers
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
# Email sender
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
run: |
POSTGREST_HOST_PORT=$(cat .postgrest-port)
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
npm run build
- name: Deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
# Auth.js v5 (with Better Auth fallback for the secret name)
AUTH_SECRET: ${{ secrets.AUTH_SECRET || secrets.BETTER_AUTH_SECRET }}
AUTH_URL: ${{ secrets.AUTH_URL || secrets.BETTER_AUTH_URL }}
NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL || secrets.NEXT_PUBLIC_BETTER_AUTH_URL }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
# Storage
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
STORAGE_REGION: ${{ secrets.STORAGE_REGION }}
STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }}
STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }}
STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }}
NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }}
# PostgREST
PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }}
PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }}
PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }}
PGRST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
# Supabase (legacy)
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
# Stripe
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }}
# Resend
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }}
# AI
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
run: |
APP_DIR=/home/tyler/route-commerce
mkdir -p $APP_DIR
# Use the port chosen by Start Docker stack (persisted to .postgrest-port)
POSTGREST_HOST_PORT=$(cat .postgrest-port)
export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT"
# Write env file from secrets (preserves existing .env for docker compose)
{
printf "DATABASE_URL=%s\n" "$DATABASE_URL"
printf "NEXT_PUBLIC_API_URL=%s\n" "$NEXT_PUBLIC_API_URL"
printf "POSTGRES_USER=%s\n" "$POSTGRES_USER"
printf "POSTGRES_PASSWORD=%s\n" "$POSTGRES_PASSWORD"
printf "POSTGRES_DB=%s\n" "$POSTGRES_DB"
printf "MINIO_ROOT_USER=%s\n" "$MINIO_ROOT_USER"
printf "MINIO_ROOT_PASSWORD=%s\n" "$MINIO_ROOT_PASSWORD"
printf "POSTGREST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET"
printf "AUTH_SECRET=%s\n" "$AUTH_SECRET"
printf "AUTH_URL=%s\n" "$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_SECRET=%s\n" "$GOOGLE_CLIENT_SECRET"
printf "ALLOW_DEV_LOGIN=%s\n" "$ALLOW_DEV_LOGIN"
printf "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT"
printf "STORAGE_REGION=%s\n" "$STORAGE_REGION"
printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY"
printf "STORAGE_SECRET_KEY=%s\n" "$STORAGE_SECRET_KEY"
printf "STORAGE_BUCKET_PREFIX=%s\n" "$STORAGE_BUCKET_PREFIX"
printf "NEXT_PUBLIC_STORAGE_BASE_URL=%s\n" "$NEXT_PUBLIC_STORAGE_BASE_URL"
printf "PGRST_SERVER_PORT=%s\n" "$PGRST_SERVER_PORT"
printf "PGRST_DB_URI=%s\n" "$PGRST_DB_URI"
printf "PGRST_DB_ANON_ROLE=%s\n" "$PGRST_DB_ANON_ROLE"
printf "PGRST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET"
printf "NEXT_PUBLIC_SUPABASE_URL=%s\n" "$NEXT_PUBLIC_SUPABASE_URL"
printf "NEXT_PUBLIC_SUPABASE_ANON_KEY=%s\n" "$NEXT_PUBLIC_SUPABASE_ANON_KEY"
printf "STRIPE_SECRET_KEY=%s\n" "$STRIPE_SECRET_KEY"
printf "STRIPE_WEBHOOK_SECRET=%s\n" "$STRIPE_WEBHOOK_SECRET"
printf "STRIPE_PUBLISHABLE_KEY=%s\n" "$STRIPE_PUBLISHABLE_KEY"
printf "RESEND_API_KEY=%s\n" "$RESEND_API_KEY"
printf "RESEND_WEBHOOK_SECRET=%s\n" "$RESEND_WEBHOOK_SECRET"
printf "MINIMAX_API_KEY=%s\n" "$MINIMAX_API_KEY"
printf "MINIMAX_BASE_URL=%s\n" "$MINIMAX_BASE_URL"
printf "FROM_EMAIL=%s\n" "$FROM_EMAIL"
} > $APP_DIR/.env.production
# Copy build output and required files
rsync -a --delete .next/ $APP_DIR/.next/
rsync -a --delete public/ $APP_DIR/public/
cp package.json $APP_DIR/
cp deploy/docker-compose.yml $APP_DIR/
cp -r supabase/ $APP_DIR/
cp next.config.ts $APP_DIR/ 2>/dev/null || cp next.config.js $APP_DIR/ 2>/dev/null || true
# Install production deps only
cd $APP_DIR
npm install --omit=dev
# Start or restart PM2 process
if pm2 describe route-commerce > /dev/null 2>&1; then
pm2 restart route-commerce
else
pm2 start npm --name route-commerce -- start -- -p 3100
pm2 save
fi
echo "Deployed successfully"
+59 -15
View File
@@ -2,11 +2,23 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Canonical Remote
There is exactly one remote — `origin` — pointing to the self-hosted Gitea repo:
- **URL:** `git@git.crispygoat.com:tyler/route-commerce.git`
- **Default branch:** `main`
- **Deploy:** push to `origin/main` triggers `.gitea/workflows/deploy.yml`
Do **not** add GitHub remotes. There is no `origin` on github.com and no separate "dev" repo. If you see `github.com/dzinesco/*` URLs in `.git/config`, that is stale configuration from a previous fork and should be removed (`git remote remove`).
## Project Overview ## Project Overview
Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns. Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns.
Tech stack: Next.js 16 (App Router) · Supabase (auth + Postgres + RLS) · Stripe · Square · Resend (email) · Tailwind CSS v4 Tech stack: Next.js 16 (App Router) · **Postgres** (direct — Supabase is being removed) · Auth.js (NextAuth v5, in-progress migration from bespoke cookie auth) · Stripe · Square · Resend (email) · Tailwind CSS v4
> **Direction:** Supabase is being removed in favor of a direct Postgres connection. The `supabase/` directory is kept as a path for migrations tooling only (no Supabase platform/CLI/auth). Until the Auth.js migration ships, auth still flows through the `dev_session` / `rc_auth_uid` cookies — see the Authentication section. New DB code should connect to Postgres directly (via `pg` or the chosen driver — see Database section) and **must not** import from `@supabase/*` or call Supabase REST.
--- ---
@@ -21,14 +33,12 @@ npx tsc --noEmit # TypeScript check (no emit)
npx playwright test # Run E2E tests (Playwright) npx playwright test # Run E2E tests (Playwright)
``` ```
> The migrate script auto-detects Supabase CLI first, then falls back to direct PostgreSQL. > The migrate script (`supabase/push-migrations.js`) now only uses the direct `pg` path — the Supabase CLI branch is legacy. It reads `DATABASE_URL` from `.env.local` via `dotenv`. `pg` is already in devDependencies.
> For CLI mode: `brew install supabase/tap/supabase` then `supabase link --project-ref wnzkhezyhnfzhkhiflrp` > If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
> For direct PG mode: `pg` and `dotenv` are already in devDependencies.
> If `get_brand_settings` migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
**Recent migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed in the session). Cat `MEMORY.md` for details. **Recent migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed in the session). Cat `MEMORY.md` for details.
No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.ts`). E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost:3000` for local runs, or pass `--config` with a local config.
--- ---
@@ -41,10 +51,24 @@ No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.
- `dev_session=brand_admin` — full access to assigned brand only - `dev_session=brand_admin` — full access to assigned brand only
- `dev_session=store_employee` — limited access (orders, pickup, wholesale only) - `dev_session=store_employee` — limited access (orders, pickup, wholesale only)
`src/lib/admin-permissions.ts` is the single source of truth for the current admin user. It uses a `dev_session` cookie in development and Supabase Auth in production. **Never import this file directly into Client Components** — use the `getCurrentAdminUser` server action from `@/actions/admin-user` instead. `src/lib/admin-permissions.ts` is the single source of truth for the current admin user. It uses a `dev_session` cookie in development and the legacy `rc_auth_uid` cookie in production (set by the pre-Auth.js `/api/login`) — never import this file directly into Client Components. Use the `getCurrentAdminUser` server action from `@/actions/admin-user` instead.
The middleware (`src/middleware.ts`) guards `/admin/:path*` and `/login`. It auto-issues a `dev_session=platform_admin` cookie for the demo flow when no auth is present. A `clerk-auth.ts` helper exists in `src/lib/` but is currently a stub — do not depend on it.
The `AdminUser` type lives in `src/lib/admin-permissions-types.ts` and is shared across server/client boundary. The `AdminUser` type lives in `src/lib/admin-permissions-types.ts` and is shared across server/client boundary.
#### Auth.js (NextAuth v5) migration — in progress
The platform is migrating from the bespoke `dev_session` + `rc_auth_uid` cookie flow to **Auth.js (NextAuth v5)**, with Supabase as the database adapter and email/OAuth providers. While the migration is in flight:
- Do **not** add new code that depends on the `dev_session` or `rc_auth_uid` cookies — write against the Auth.js API (`auth()`, `signIn`, `signOut`, `getSession`) instead.
- New env vars: `AUTH_SECRET`, `AUTH_URL`, and provider-specific keys (`AUTH_GITHUB_ID`/`SECRET`, `AUTH_GOOGLE_ID`/`SECRET`, etc.). See `.env.example` for the full list.
- A new route handler at `src/app/api/auth/[...nextauth]/route.ts` will replace the ad-hoc `/api/login`, `/api/auth/uid`, and `/api/logout` endpoints.
- The middleware (`src/middleware.ts`) will eventually use `auth()` from NextAuth to populate the session; the existing `dev_session` auto-login branch is a temporary fallback for demos.
- `src/lib/admin-permissions.ts` will keep its public surface (`getAdminUser`, `getCurrentAdminUser`) but read the session from NextAuth internally — the `AdminUser` type does not need to change.
- `clerk-auth.ts` is being removed in favor of Auth.js; do not extend it.
- Until the migration ships, the `dev_session` and `rc_auth_uid` paths remain the source of truth — see the section above for current behavior.
### Server Actions Pattern ### Server Actions Pattern
All database writes go through server actions in `src/actions/`. These: All database writes go through server actions in `src/actions/`. These:
@@ -55,9 +79,19 @@ All database writes go through server actions in `src/actions/`. These:
Server actions are "use server" files that export async functions. Client components import and call them directly. Server actions are "use server" files that export async functions. Client components import and call them directly.
### SECURITY DEFINER RPCs + Brand Scoping ### Database (Postgres, direct)
The app uses **PostgreSQL SECURITY DEFINER functions** for all data access. These run with the function owner's privileges and bypass RLS entirely. This means: The app connects to **Postgres directly** — no Supabase platform, JS client, or REST gateway. Server actions use the `pg` driver (or whatever the chosen connection layer is) to call `SECURITY DEFINER` PL/pgSQL functions. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs.
#### Connection
- `DATABASE_URL` in `.env.local` (and hosting dashboard) is the only required DB env var.
- 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.
#### 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:
- Brand isolation must be enforced at the **application layer** (in server actions), not in database policies - Brand isolation must be enforced at the **application layer** (in server actions), not in database policies
- Every RPC that touches brand-scoped data accepts a `p_brand_id UUID` parameter and filters by it - Every RPC that touches brand-scoped data accepts a `p_brand_id UUID` parameter and filters by it
@@ -182,10 +216,19 @@ For annual pricing, create separate annual prices in Stripe (e.g., $441/yr for S
### Communications Module ("Harvest Reach") ### Communications Module ("Harvest Reach")
The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. (The "no RLS" framing carries over from the Supabase era; on raw Postgres this just means no row-level policies — scoping is still enforced by RPC + app layer.)
`send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`. `send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`.
**Scheduled automations** (declared in `vercel.json`):
- `POST /api/email-automation/abandoned-cart` — every 6h, fires abandoned-cart sequence emails
- `POST /api/email-automation/welcome-sequence` — every 6h, fires welcome onboarding sequence
- `POST /api/cron/send-scheduled` — daily 09:00, sends scheduled campaigns
- `POST /api/wholesale/notifications/{send,dispatch,pickup-reminder}` — wholesale lifecycle
- `POST /api/square/process-queue` — every 2 min, drains Square sync queue
These endpoints are also reachable via curl for manual triggering; the email-automation routes accept `Authorization: Bearer $CRON_SECRET`.
### Payments ### Payments
- **Stripe** — primary payment processor; `src/actions/payments.ts` and `src/app/api/stripe/` handle checkout, webhooks, refunds - **Stripe** — primary payment processor; `src/actions/payments.ts` and `src/app/api/stripe/` handle checkout, webhooks, refunds
@@ -200,7 +243,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
## Key Conventions ## Key Conventions
- All DB mutations use Supabase REST API (`fetch` to `${supabaseUrl}/rest/v1/rpc/...`) from server actions, NOT the Supabase JS client (avoids SSR cookie issues) - All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports or REST fetch to `*/rest/v1/`.
- `gen_random_uuid()` used in migrations for primary keys - `gen_random_uuid()` used in migrations for primary keys
- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE` - Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE`
- Status enums stored as TEXT — no PostgreSQL ENUM type - Status enums stored as TEXT — no PostgreSQL ENUM type
@@ -217,11 +260,11 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|---|---| |---|---|
| Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` | | Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` |
| Middleware (route protection) | `src/middleware.ts` | | Middleware (route protection) | `src/middleware.ts` |
| Server actions | `src/actions/*.ts` (one file per domain) | | Server actions | `src/actions/*.ts` (one file per domain; also grouped into `src/actions/{admin,ai,billing,communications,harvest-reach,integrations,orders,products,settings,shipping,stops,water-log,platform,route-trace,time-tracking,email-automation}/`) |
| Admin pages | `src/app/admin/[module]/page.tsx` | | Admin pages | `src/app/admin/[module]/page.tsx` |
| Admin client components | `src/components/admin/*.tsx` | | Admin client components | `src/components/admin/*.tsx` |
| Migrations | `supabase/migrations/` | | Migrations | `supabase/migrations/` (kept for now; will likely move to `db/migrations/` in a later pass) |
| Supabase client | `src/lib/supabase.ts` | | Postgres pool / driver | `src/lib/db.ts` (TBD — create during the Supabase removal pass) |
| Email templates | `src/lib/email-templates.ts` | | Email templates | `src/lib/email-templates.ts` |
| Date formatting | `src/lib/format-date.ts` | | Date formatting | `src/lib/format-date.ts` |
| Feature flags | `src/lib/feature-flags.ts` | | Feature flags | `src/lib/feature-flags.ts` |
@@ -238,7 +281,8 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
## Gotchas ## Gotchas
- **Dev mode `brand_id: null`**: `getAdminUser()` returns `brand_id: null` for platform_admin dev sessions. Always pass explicit `brandId` to server action functions that accept it — don't rely on `adminUser.brand_id` alone. - **Dev mode `brand_id: null`**: `getAdminUser()` returns `brand_id: null` for platform_admin dev sessions. Always pass explicit `brandId` to server action functions that accept it — don't rely on `adminUser.brand_id` alone.
- **Communications = no RLS**: The communications tables (campaigns, templates, contacts, message_logs) have RLS disabled. All brand scoping must be enforced in server actions. - **Communications = no RLS**: The communications tables (campaigns, templates, contacts, message_logs) have no row-level policies. All brand scoping must be enforced in server actions.
- **Supabase residue in the wild**: `grep -r "@supabase" src/` will still find imports during the transition. Do not add new ones; if you're touching a file that imports from Supabase, replace the call with the equivalent `pg`-pool call before merging.
- **Webhook event_id**: `log_communication_messages` never populates `event_id`, so the Resend webhook uses `customer_email + subject` lookup instead. - **Webhook event_id**: `log_communication_messages` never populates `event_id`, so the Resend webhook uses `customer_email + subject` lookup instead.
- **Mixed fulfillment orders**: An order can have both pickup and ship items. `get_shipping_orders` RPC returns orders with at least one `fulfillment = 'ship'` item. - **Mixed fulfillment orders**: An order can have both pickup and ship items. `get_shipping_orders` RPC returns orders with at least one `fulfillment = 'ship'` item.
- **SMS opt-in defaults**: `communication_contacts.sms_opt_in` defaults to `FALSE` (opt-out by default). `email_opt_in` defaults to `TRUE`. Always check `sms_opt_in` specifically for SMS sends, not `email_opt_in`. - **SMS opt-in defaults**: `communication_contacts.sms_opt_in` defaults to `FALSE` (opt-out by default). `email_opt_in` defaults to `TRUE`. Always check `sms_opt_in` specifically for SMS sends, not `email_opt_in`.
+72 -9
View File
@@ -2,11 +2,40 @@
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-03 (during Supabase migration apply session) **Last updated:** 2026-06-06 (Supabase → Postgres pivot)
--- ---
## Supabase CLI + Migrations Tooling ## 🚨 Direction Pivot (2026-06-06) — Supabase → Postgres
The platform is moving off Supabase entirely. We are connecting to **Postgres directly** (via `pg`), with **Auth.js (NextAuth v5)** handling authentication. See `CLAUDE.md` for the full updated architecture.
### What changes immediately
- **DB connection**: `DATABASE_URL` is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`.
- **Migrations**: only the `pg` direct path in `supabase/push-migrations.js` is supported going forward. The Supabase CLI branch is dead code. The script reads `DATABASE_URL` from `.env.local` via `dotenv`.
- **Code**: no new `@supabase/*` imports, no `rest/v1/` REST fetch, no Supabase JS client usage. Use a shared `pg` `Pool` (target location: `src/lib/db.ts`, **TBD — create during the cutover**).
- **Auth**: legacy `rc_auth_uid` cookie + bespoke `/api/login` is being replaced by Auth.js. Until the Auth.js migration ships, the `dev_session` cookie remains the source of truth.
- **Storage**: Supabase Storage (e.g. the `product-images` bucket created in migration 145) is going away. Need an S3-compatible alternative — **TBD**.
### What's TBD / needs follow-up
- [ ] `DATABASE_URL` for local dev (Neon? local Postgres? — user hasn't specified the Postgres host yet)
- [ ] New connection layer: raw `pg` Pool vs Drizzle vs Prisma vs Neon serverless driver — **not decided**
- [ ] Auth.js migration actually landing (currently "in progress" per CLAUDE.md)
- [ ] Where do product images, brand logos, etc. live now? S3? Cloudflare R2? Re-encode as URL strings?
- [ ] Whether the Supabase project (`wnzkhezyhnfzhkhiflrp`) gets shut down or kept read-only for the transition
- [ ] Cutover sequencing: do we delete `@supabase/*` from `package.json` in one PR or incrementally?
### Migration content that's now obsolete
- **145 (product-images bucket)**: Supabase Storage bucket + RLS policies. Replaced by object store of choice.
- **Any RLS policy on tables** (200 added several): the "no RLS, app-layer scoping" model still holds but the policies are inert in the new world.
- The `supabase link --project-ref wnzkhezyhnfzhkhiflrp` setup is no longer needed for ongoing work.
### Historical sections below
The "Supabase CLI + Migrations Tooling" section that used to live at the top of this file describes the *previous* tooling. It is kept below as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use the `pg` direct path instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied.
---
## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above)*
### Login + Link (done in this session) ### Login + Link (done in this session)
- User ran `supabase login` - User ran `supabase login`
@@ -35,15 +64,23 @@ Key changes:
- Falls back to direct `pg` only if CLI path fails. - Falls back to direct `pg` only if CLI path fails.
- Header comments updated with current recommended workflow. - Header comments updated with current recommended workflow.
**Recommended commands now:** **Recommended commands now (Supabase CLI path — being phased out, use `pg` direct path going forward):**
```bash ```bash
# Supabase CLI path (legacy — do not use going forward)
supabase login supabase login
supabase link --project-ref wnzkhezyhnfzhkhiflrp supabase link --project-ref wnzkhezyhnfzhkhiflrp
node supabase/push-migrations.js 148 # or any prefix node supabase/push-migrations.js 148 # CLI path
# or # or
npm run migrate:one 148 npm run migrate:one 148
``` ```
```bash
# Direct pg path (this is the future — only the pg branch is kept alive in the script)
DATABASE_URL=postgres://... node supabase/push-migrations.js 148
# or
DATABASE_URL=postgres://... npm run migrate:one 148
```
`npm run migrate` (no arg) will push every `*.sql` in order (use with caution). `npm run migrate` (no arg) will push every `*.sql` in order (use with caution).
--- ---
@@ -133,19 +170,23 @@ Verification queries (post-apply) confirmed:
--- ---
## Current State / Gotchas ## Current State / Gotchas (2026-06-06)
- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. Only early ones (001/002) + many timestamped migrations from other activity are tracked. - The Supabase CLI is no longer the recommended path. Use `DATABASE_URL` + `pg` directly. The `supabase/` directory is kept as a path for migrations tooling only.
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project. - The Postgres host/URL for local dev is **TBD** (not yet decided by the user). Until it's set, `npm run migrate` will fail at the `pg` connect step. (The Supabase project at `wnzkhezyhnfzhkhiflrp` may still exist as a fallback read-only target — unconfirmed.)
- When adding **new** migrations in the future, prefer the standard `supabase migration new` flow if possible, but the custom `push-migrations.js` + numeric prefix style is still the established pattern here. - `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. This is mostly irrelevant now that we're moving off Supabase.
- Storage policies, RLS, SECURITY DEFINER functions, and brand-scoped data are all over the place — test carefully after big applies. - Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project — this still holds under direct `pg`.
- When adding **new** migrations, use the established `supabase/push-migrations.js` + numeric prefix style (`NNN_descriptive_name.sql`). Do not introduce `supabase migration new` — that flow is going away with the CLI branch.
- Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs.
- CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha. - CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha.
- **Open question for next session:** confirm Postgres host + connection layer (raw `pg` vs Drizzle/Prisma) and start the actual cutover (drop `@supabase/*` deps, create `src/lib/db.ts`, replace cookie auth with Auth.js).
--- ---
## How to Use This Memory ## How to Use This Memory
- Cat this file at the start of future sessions if context is needed: `cat MEMORY.md` - Cat this file at the start of future sessions if context is needed: `cat MEMORY.md`
- **Read the Direction Pivot section first** — it supersedes the older Supabase-flavored instructions.
- Update this file with new key facts, applied migrations, or new gotchas. - Update this file with new key facts, applied migrations, or new gotchas.
- Feel free to add dated sections. - Feel free to add dated sections.
@@ -245,3 +286,25 @@ Follow-up pass on the original Codex review covering public site, buyer path, bi
### Migration 203 — applied via Supabase CLI ### Migration 203 — applied via Supabase CLI
`203_plan_usage_active_products.sql` updates `get_brand_plan_info` to count `products` where `active = true AND deleted_at IS NULL`, matching the dashboard's "Active Products" stat. The `NOTIFY pgrst, 'reload schema'` ensures PostgREST picks up the change without restart. `203_plan_usage_active_products.sql` updates `get_brand_plan_info` to count `products` where `active = true AND deleted_at IS NULL`, matching the dashboard's "Active Products" stat. The `NOTIFY pgrst, 'reload schema'` ensures PostgREST picks up the change without restart.
## Gitea build fix — 2026-06-06
Gitea runner (`https://git.crispygoat.com/tyler/route-commerce.git`, branch `main`) was failing `next build` with two errors:
1. **DYNAMIC_SERVER_USAGE** on `/admin/settings/square-sync` (and the whole admin tree): `getAdminUser()` reads `cookies()` via `next/headers`. The admin layout tried to prerender statically, so the first child page that hit cookies aborted the build.
2. **Prerender ECONNREFUSED** on `/indian-river-direct/stops`: `getPublicStopsForBrand` / `getActiveStopsForSitemap` / `getBrandSettingsPublic` fetch `NEXT_PUBLIC_SUPABASE_URL` at build time. The Gitea runner passes a Supabase URL that resolves but is unreachable, so `fetch` throws `ECONNREFUSED` and the prerender aborts.
The earlier commit `2f3be54 fix(actions): skip Supabase fetch at build time when env vars unset` only added `if (!supabaseUrl || !supabaseKey) return [];` — but in CI the env vars **are** set, so the guard passed and the fetch was still attempted.
### Fixes applied
- `src/actions/stops.ts` — wrapped `getActiveStopsForSitemap` and `getPublicStopsForBrand` fetches in `try/catch` returning `[]` on error. Env-var guard kept as fast path.
- `src/actions/brand-settings.ts` — wrapped `getBrandSettingsPublic` fetch in `try/catch` returning `{ success: false }` on error.
- `src/app/admin/settings/square-sync/page.tsx` — added `export const dynamic = "force-dynamic";` (was missing).
- `src/app/admin/layout.tsx` — added `export const dynamic = "force-dynamic";` so the entire admin tree opts out of static prerender (layout calls `getAdminUser()` which reads cookies).
- `.gitea/workflows/deploy.yml` was simplified earlier in commit `2d837bc` to a thin wrapper around `deploy/deploy.sh`.
### Remote
- The crispygoat repo (`git@git.crispygoat.com:tyler/route-commerce.git`) and the GitHub `origin` repo are separate forks — `tyler/main` is the self-hosted Auth.js + Postgres branch, `origin/main` is the Supabase branch. Don't merge them; they share no deploy workflow.
- Push targets `tyler/main` to trigger the Gitea build.
+52
View File
@@ -0,0 +1,52 @@
# =============================================================================
# .env.production — secrets + dynamic ports for the running containers
# =============================================================================
#
# deploy.sh writes the first three lines on every successful deploy.
# Everything below is YOUR responsibility to populate. deploy.sh preserves
# unknown lines verbatim across deploys (it only overwrites the lines it
# knows about), so you can safely commit this file to a private repo or
# provision it via your secrets manager of choice.
#
# In production, this file should be mode 0600 and owned by the deploy user.
# =============================================================================
# --- managed by deploy.sh (do not edit by hand) -------------------------------
POSTGREST_HOST_PORT=3011
NEXTJS_HOST_PORT=3012
NEXT_PUBLIC_API_URL=http://localhost:3011
# --- PostgREST connection ---------------------------------------------------
PGRST_DB_URI=postgres://app:secret@db.internal:5432/app_production
PGRST_DB_ANON_ROLE=anon
PGRST_DB_SCHEMA=public
# --- Next.js server-side secrets -------------------------------------------
# Anything not prefixed NEXT_PUBLIC_ is server-only and read at request time.
DATABASE_URL=postgres://app:secret@db.internal:5432/app_production
# Auth.js v5 (NextAuth). Generate AUTH_SECRET with `npx auth secret` or
# `openssl rand -base64 32`. AUTH_URL is the public base URL the browser
# uses to build OAuth callback URLs.
AUTH_SECRET=change-me-to-a-long-random-string
AUTH_URL=https://app.example.com
NEXT_PUBLIC_AUTH_URL=https://app.example.com
ALLOW_DEV_LOGIN=false
# Google OAuth provider for Auth.js. Set both AUTH_GOOGLE_ID/SECRET and
# GOOGLE_CLIENT_ID/SECRET (the v5 code reads either name).
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
# --- External services ------------------------------------------------------
STRIPE_SECRET_KEY=sk_live_replace_me
STRIPE_PUBLISHABLE_KEY=pk_live_replace_me
STRIPE_WEBHOOK_SECRET=whsec_replace_me
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASSWORD=replace_me
SMTP_FROM="My App <noreply@example.com>"
+6
View File
@@ -0,0 +1,6 @@
# Runtime artefacts written by deploy.sh — do NOT commit these.
.deploy.lock
deploy.log
.postgrest-port
.nextjs-port
.env.production
+57
View File
@@ -0,0 +1,57 @@
# =============================================================================
# Dockerfile.nextjs — multi-stage build for the Next.js frontend
# =============================================================================
# Used by docker-compose.yml's `nextjs` service.
#
# Why this looks the way it does:
# - `NEXT_PUBLIC_API_URL` must be present at BUILD time (Next.js inlines
# it into the client JS). We pass it through as an ARGs so the build
# context is reproducible (`docker build --build-arg` or via deploy.sh's
# `docker compose --env-file` flow).
# - We copy the host's pre-built `.next/` (produced by `npm run build` in
# deploy.sh) rather than running `next build` inside the image. This
# keeps the image lean and avoids double-building.
# =============================================================================
# ---- builder: produce node_modules with dev deps for the build step --------
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
# ---- builder: produce the standalone .next/ output ------------------------
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# These ARGs are wired through docker-compose's `args:` block (or the CLI).
# deploy.sh exports them in the build environment.
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ARG NEXTJS_HOST_PORT
ENV NEXTJS_HOST_PORT=${NEXTJS_HOST_PORT}
RUN npm run build
# ---- runner: minimal image, standalone server -----------------------------
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
# Run as non-root.
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# Copy only what the standalone server needs.
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 3000
# Adjust this CMD to match the actual server file your build emits.
# For `output: "standalone"` in next.config.js the file is server.js.
CMD ["node", "server.js"]
+54
View File
@@ -0,0 +1,54 @@
# =============================================================================
# Makefile — convenience targets around deploy.sh
# =============================================================================
# All targets are wrappers; you can also invoke deploy.sh directly.
SHELL := /usr/bin/env bash
.SHELLFLAGS := -Eeu -o pipefail -c
.SHELLFLAGS_LOG := $(.SHELLFLAGS)
DEPLOY := ./deploy.sh
HEALTH := ./healthcheck.sh
WORKSPACE ?= $(CURDIR)
.PHONY: help
help: ## Show this help message
@awk 'BEGIN {FS = ":.*##"; printf "Targets:\n"} /^[a-zA-Z_-]+:.*##/ { printf " %-20s %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
.PHONY: deploy
deploy: ## Run a full deploy (build + up + nginx + healthcheck)
$(DEPLOY)
.PHONY: deploy-verbose
deploy-verbose: ## Deploy with extra logging (PRUNE_IMAGES=0, longer healthcheck)
PRUNE_IMAGES=0 HEALTHCHECK_TIMEOUT=120 $(DEPLOY)
.PHONY: health
health: ## Run a one-shot health check against the running stack
WORKSPACE=$(WORKSPACE) $(HEALTH)
.PHONY: health-nginx
health-nginx: ## Health check including the nginx-fronted URL
WORKSPACE=$(WORKSPACE) $(HEALTH) --nginx
.PHONY: status
status: ## Show current prod ports and running containers
@echo "PostgREST port: $$(cat .postgrest-port 2>/dev/null || echo none)"
@echo "Next.js port: $$(cat .nextjs-port 2>/dev/null || echo none)"
@cd deploy && docker compose -p prod-app ps
.PHONY: logs
logs: ## Tail deploy.log
tail -n 200 -f deploy.log
.PHONY: down
down: ## Stop the production stack (without redeploying)
cd deploy && docker compose -p prod-app down --remove-orphans
.PHONY: rollback
rollback: ## Restart the previous stack (the one whose ports are still on disk)
@if [[ ! -f .postgrest-port ]]; then echo "no .postgrest-port to roll back to"; exit 1; fi
cd deploy && \
POSTGREST_HOST_PORT=$$(cat ../.postgrest-port) \
NEXTJS_HOST_PORT=$$(cat ../.nextjs-port) \
docker compose -p prod-app --env-file ../.env.production up -d
+429
View File
@@ -0,0 +1,429 @@
#!/usr/bin/env bash
# =============================================================================
# deploy.sh — Idempotent PostgREST + Next.js production deploy
# =============================================================================
#
# Self-hosted single-server deploy. Triggered manually, by Gitea webhook, or
# by a Gitea Actions runner after a push to `main` (or `gitea-sync`).
#
# What it does, in order:
# 1. Acquires an exclusive flock (concurrent deploys die loudly).
# 2. CLEANUP: stops the dev stack on :3001 and the previous prod stack
# (port read from .postgrest-port / .nextjs-port).
# 3. PORT_SELECTION: picks the lowest free port in [3011..30200] for
# PostgREST, then the next free one for the Next.js frontend.
# 4. BUILD: runs `npm run build` with NEXT_PUBLIC_API_URL exported so it
# gets inlined into the client bundle.
# 5. DEPLOY: writes the chosen ports to .env.production, brings the
# compose stack up.
# 6. NGINX: renders the nginx config from a template (with the current
# ports), `nginx -t`s it, and reloads the host systemd nginx.
# 7. HEALTHCHECK: curls the new stack; if anything is down, rolls back.
# 8. IMAGE_PRUNE: optional, removes dangling images on success.
#
# Files written to the workspace root:
# .postgrest-port current PostgREST host port (atomic)
# .nextjs-port current Next.js host port (atomic)
# .env.production rendered env fed to docker compose
# .deploy.lock flock target
# deploy.log append-only log
# =============================================================================
set -Eeuo pipefail
IFS=$'\n\t'
# -----------------------------------------------------------------------------
# Configurable variables (override via environment before invoking)
# -----------------------------------------------------------------------------
WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
COMPOSE_DIR="${COMPOSE_DIR:-${WORKSPACE}/deploy}"
COMPOSE_FILE="${COMPOSE_FILE:-${COMPOSE_DIR}/docker-compose.yml}"
NGINX_TEMPLATE="${NGINX_TEMPLATE:-${COMPOSE_DIR}/nginx.conf.template}"
NGINX_RENDERED="${NGINX_RENDERED:-/etc/nginx/sites-available/prod-app.conf}"
NGINX_LINK="${NGINX_LINK:-/etc/nginx/sites-enabled/prod-app.conf}"
NGINX_OWNER="${NGINX_OWNER:-www-data:www-data}"
PROJECT_NAME="${PROJECT_NAME:-prod-app}"
POSTGREST_PORT_FILE="${POSTGREST_PORT_FILE:-${WORKSPACE}/.postgrest-port}"
NEXTJS_PORT_FILE="${NEXTJS_PORT_FILE:-${WORKSPACE}/.nextjs-port}"
ENV_FILE="${ENV_FILE:-${WORKSPACE}/.env.production}"
LOCK_FILE="${LOCK_FILE:-${WORKSPACE}/.deploy.lock}"
LOG_FILE="${LOG_FILE:-${WORKSPACE}/deploy.log}"
DEV_PORT="${DEV_PORT:-3001}"
PORT_RANGE_START="${PORT_RANGE_START:-3011}"
PORT_RANGE_END="${PORT_RANGE_END:-30200}"
HEALTHCHECK_TIMEOUT="${HEALTHCHECK_TIMEOUT:-60}" # seconds total
HEALTHCHECK_INTERVAL="${HEALTHCHECK_INTERVAL:-2}" # seconds between tries
# Image pruning (set PRUNE_IMAGES=0 to skip)
PRUNE_IMAGES="${PRUNE_IMAGES:-1}"
# Optional: pin the public URL the browser uses. If empty, we default to
# http://localhost:${POSTGREST_HOST_PORT}. For production with a real domain
# and nginx in front, set e.g. NEXT_PUBLIC_API_URL=https://app.example.com/api
NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL:-}"
# -----------------------------------------------------------------------------
# Logging — every line is timestamped, tee'd to stdout AND the log file.
# We replace the shell's fd 1/2 with a tee so any tool that prints (npm, docker,
# curl) lands in both places automatically.
# -----------------------------------------------------------------------------
mkdir -p "$(dirname "$LOG_FILE")"
exec > >(tee -a "$LOG_FILE") 2>&1
ts() { date '+%Y-%m-%d %H:%M:%S'; }
log() { printf '[%s] %s\n' "$(ts)" "$*"; }
hr() { printf '%s\n' '----------------------------------------------------------------'; }
section() { hr; log "== $* =="; hr; }
# Trap so we always release the lock and surface a useful message.
on_exit() {
local exit_code=$?
if (( exit_code != 0 )); then
log "DEPLOY FAILED with exit code ${exit_code}"
log "See ${LOG_FILE} for full output. Rollback hints:"
log " - Previous port was: ${PREVIOUS_POSTGREST_PORT:-<unknown>}"
log " - Current .postgrest-port value: $(read_port_file "$POSTGREST_PORT_FILE" || echo '<none>')"
log " - To restart the old stack manually:"
log " POSTGREST_HOST_PORT=${PREVIOUS_POSTGREST_PORT:-3011} \\"
log " NEXTJS_HOST_PORT=${PREVIOUS_NEXTJS_PORT:-3012} \\"
log " docker compose -p ${PROJECT_NAME} --env-file ${ENV_FILE} up -d"
else
log "DEPLOY OK — PostgREST on :${NEW_POSTGREST_PORT}, Next.js on :${NEW_NEXTJS_PORT}"
fi
# flock on fd 9 releases automatically when the script exits.
}
trap on_exit EXIT
# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
read_port_file() {
# Echo the port in $1, or empty string if missing/garbage.
local f="$1"
[[ -f "$f" ]] || return 1
local v
v=$(tr -d '[:space:]' < "$f" 2>/dev/null || true)
[[ "$v" =~ ^[0-9]+$ ]] || return 1
printf '%s' "$v"
}
render_template() {
# Portable envsubst: replaces $VAR and ${VAR} references in stdin with
# values from the current environment. Only the variable names given as
# args are expanded (matches `envsubst` behavior). If real envsubst is
# available we use it for speed.
local vars="$1"
if command -v envsubst >/dev/null 2>&1; then
envsubst "$vars"
else
# Build a sed expression like: s/\${VAR}/$VAR/g; s/\bVAR\b/$VAR/g
local sed_expr=()
for v in $vars; do
v="${v#\$}"
v="${v#\{}"
v="${v%\}}"
sed_expr+=( -e "s|\${${v}}|${!v:-}|g" )
sed_expr+=( -e "s|\$${v}\b|${!v:-}|g" )
done
sed "${sed_expr[@]}"
fi
}
is_listening() {
# Returns 0 if port $1 has a TCP listener (v4 or v6) on this host.
local port="$1"
ss -tlnH 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)${port}$"
}
next_free_port() {
# Walk PORT_RANGE_START..PORT_RANGE_END and return the first port nobody
# is listening on. Returns 1 if none are free.
local p
for (( p = PORT_RANGE_START; p <= PORT_RANGE_END; p++ )); do
if ! is_listening "$p"; then
printf '%s' "$p"
return 0
fi
done
return 1
}
atomic_write() {
# Write stdin to $1 atomically: write to temp, fsync, rename. This is
# what lets us use .postgrest-port as a single source of truth — readers
# always see either the old value or the new value, never a half-written one.
local target="$1"
local tmp
tmp=$(mktemp "${target}.tmp.XXXXXX")
cat > "$tmp"
sync
mv -f "$tmp" "$target"
}
free_port() {
# Try several strategies to free a port:
# 1. docker compose down for our project (idempotent)
# 2. brute-force kill of any process bound to the port
local port="$1" label="$2"
if [[ -z "$port" ]]; then return 0; fi
log " ${label} port ${port}: stopping project '${PROJECT_NAME}' (if up)"
( cd "$COMPOSE_DIR" && docker compose -p "$PROJECT_NAME" down --remove-orphans --timeout 10 ) \
>/dev/null 2>&1 || true
if is_listening "$port"; then
log " ${label} port ${port}: still listening, attempting pkill"
# fuser prints PIDs holding the port; xargs kills them.
local pids
pids=$(fuser -n tcp "$port" 2>/dev/null | tr -d '[:space:]' || true)
if [[ -n "$pids" ]]; then
# shellcheck disable=SC2086
kill $pids 2>/dev/null || true
sleep 1
pids=$(fuser -n tcp "$port" 2>/dev/null | tr -d '[:space:]' || true)
[[ -n "$pids" ]] && kill -9 $pids 2>/dev/null || true
fi
fi
if is_listening "$port"; then
log " ${label} port ${port}: WARNING — still in use after cleanup"
return 1
fi
log " ${label} port ${port}: free"
return 0
}
healthcheck() {
# Hit $1 (URL) until it returns 2xx within HEALTHCHECK_TIMEOUT seconds.
local url="$1" label="$2" elapsed=0
log " ${label}: ${url}"
while (( elapsed < HEALTHCHECK_TIMEOUT )); do
if curl -fsS --max-time 5 -o /dev/null "$url"; then
log " ${label}: OK (after ${elapsed}s)"
return 0
fi
sleep "$HEALTHCHECK_INTERVAL"
elapsed=$(( elapsed + HEALTHCHECK_INTERVAL ))
done
log " ${label}: FAILED after ${HEALTHCHECK_TIMEOUT}s"
return 1
}
# -----------------------------------------------------------------------------
# Lock — refuse to run if another deploy is in flight.
# -----------------------------------------------------------------------------
section "LOCK"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
log "Another deploy holds ${LOCK_FILE}. Exiting."
exit 1
fi
log "Acquired exclusive lock on ${LOCK_FILE}"
# -----------------------------------------------------------------------------
# 0. Banner
# -----------------------------------------------------------------------------
section "DEPLOY START"
log "Workspace: ${WORKSPACE}"
log "Project: ${PROJECT_NAME}"
log "Compose: ${COMPOSE_FILE}"
log "Nginx tpl: ${NGINX_TEMPLATE}"
log "Port range: ${PORT_RANGE_START}..${PORT_RANGE_END}"
log "Caller: ${USER:-<unknown>}@$(hostname)"
# -----------------------------------------------------------------------------
# 1. CLEANUP — port 3001 (dev) and the previous prod ports.
# -----------------------------------------------------------------------------
section "CLEANUP"
free_port "$DEV_PORT" "dev"
PREVIOUS_POSTGREST_PORT=$(read_port_file "$POSTGREST_PORT_FILE" || true)
PREVIOUS_NEXTJS_PORT=$(read_port_file "$NEXTJS_PORT_FILE" || true)
log "Previous prod ports: PostgREST=${PREVIOUS_POSTGREST_PORT:-<none>} Next.js=${PREVIOUS_NEXTJS_PORT:-<none>}"
# Stale-port guard: if the file points to a port that is NOT in our standard
# range, or to a port that nothing is listening on anymore, we still tear
# down the project (cheap) but we don't try to free the port itself —
# someone else might be using it.
free_port "${PREVIOUS_POSTGREST_PORT:-}" "prev-postgrest"
free_port "${PREVIOUS_NEXTJS_PORT:-}" "prev-nextjs"
# -----------------------------------------------------------------------------
# 2. PORT_SELECTION — find the two lowest free ports.
# -----------------------------------------------------------------------------
section "PORT_SELECTION"
NEW_POSTGREST_PORT=$(next_free_port) || {
log "No free port in [${PORT_RANGE_START}..${PORT_RANGE_END}]. Bailing out."
exit 2
}
log "PostgREST: ${NEW_POSTGREST_PORT}"
# Re-check after allocation, since we want distinct ports for both services.
NEW_NEXTJS_PORT=""
for (( p = PORT_RANGE_START; p <= PORT_RANGE_END; p++ )); do
if (( p == NEW_POSTGREST_PORT )); then continue; fi
if ! is_listening "$p"; then NEW_NEXTJS_PORT="$p"; break; fi
done
if [[ -z "$NEW_NEXTJS_PORT" ]]; then
log "No free port for Next.js after allocating ${NEW_POSTGREST_PORT}. Bailing out."
exit 2
fi
log "Next.js: ${NEW_NEXTJS_PORT}"
# -----------------------------------------------------------------------------
# 3. BUILD — Next.js, with NEXT_PUBLIC_API_URL inlined into the client bundle.
# -----------------------------------------------------------------------------
section "BUILD"
cd "$WORKSPACE"
# Default the public API URL the browser will see.
if [[ -z "$NEXT_PUBLIC_API_URL" ]]; then
NEXT_PUBLIC_API_URL="http://localhost:${NEW_POSTGREST_PORT}"
fi
log "NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}"
# Node-only check: don't try to build if there's no package.json.
if [[ -f package.json ]]; then
# Make sure the deps are present (idempotent — npm ci is a no-op when locked).
if [[ -f package-lock.json ]]; then
log "npm ci (locked install)"
npm ci --no-audit --no-fund
else
log "npm install (no lockfile present — consider committing package-lock.json)"
npm install --no-audit --no-fund
fi
log "npm run build"
NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \
POSTGREST_HOST_PORT="$NEW_POSTGREST_PORT" \
NEXTJS_HOST_PORT="$NEW_NEXTJS_PORT" \
npm run build
else
log "No package.json in ${WORKSPACE} — skipping build step."
fi
# -----------------------------------------------------------------------------
# 4. ENV FILE — render .env.production for the running containers.
# -----------------------------------------------------------------------------
section "ENV"
# Preserve any pre-existing secrets in .env.production. We only own the lines
# we write; everything else is left alone. (The simplest sane strategy.)
SECRETS_FILE=""
if [[ -f "$ENV_FILE" ]]; then
SECRETS_FILE=$(mktemp)
# Drop any lines we manage; keep the rest verbatim.
grep -v -E '^(POSTGREST_HOST_PORT|NEXTJS_HOST_PORT|NEXT_PUBLIC_API_URL)=' \
"$ENV_FILE" > "$SECRETS_FILE" || true
fi
{
printf '# Generated by deploy.sh on %s — safe to edit, lines below are managed\n' "$(ts)"
printf 'POSTGREST_HOST_PORT=%s\n' "$NEW_POSTGREST_PORT"
printf 'NEXTJS_HOST_PORT=%s\n' "$NEW_NEXTJS_PORT"
printf 'NEXT_PUBLIC_API_URL=%q\n' "$NEXT_PUBLIC_API_URL"
if [[ -n "$SECRETS_FILE" ]]; then
cat "$SECRETS_FILE"
rm -f "$SECRETS_FILE"
fi
} > "${ENV_FILE}.new"
mv -f "${ENV_FILE}.new" "$ENV_FILE"
chmod 600 "$ENV_FILE"
log "Wrote ${ENV_FILE}"
# -----------------------------------------------------------------------------
# 5. DEPLOY — bring the stack up.
# -----------------------------------------------------------------------------
section "DEPLOY"
cd "$COMPOSE_DIR"
log "docker compose -p ${PROJECT_NAME} up -d --build"
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --build
# -----------------------------------------------------------------------------
# 6. NGINX — render config from template, test, reload.
# -----------------------------------------------------------------------------
section "NGINX"
if [[ -f "$NGINX_TEMPLATE" ]]; then
POSTGREST_HOST_PORT="$NEW_POSTGREST_PORT" \
NEXTJS_HOST_PORT="$NEW_NEXTJS_PORT" \
NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \
render_template '${POSTGREST_HOST_PORT} ${NEXTJS_HOST_PORT} ${NEXT_PUBLIC_API_URL}' \
< "$NGINX_TEMPLATE" > "$NGINX_RENDERED"
log "Rendered: ${NGINX_RENDERED}"
chown "$NGINX_OWNER" "$NGINX_RENDERED" 2>/dev/null || true
chmod 644 "$NGINX_RENDERED"
# Wire it into sites-enabled if not already linked.
if [[ ! -L "$NGINX_LINK" && ! -e "$NGINX_LINK" ]]; then
log "Enabling site: ${NGINX_LINK} -> ${NGINX_RENDERED}"
ln -s "$NGINX_RENDERED" "$NGINX_LINK"
fi
log "nginx -t"
nginx -t
log "systemctl reload nginx"
systemctl reload nginx
else
log "No nginx template at ${NGINX_TEMPLATE} — skipping reverse proxy step."
fi
# -----------------------------------------------------------------------------
# 7. HEALTHCHECK — direct + via nginx (when applicable).
# -----------------------------------------------------------------------------
section "HEALTHCHECK"
# Direct checks (bypass nginx, catch compose issues)
healthcheck "http://127.0.0.1:${NEW_POSTGREST_PORT}/" "postgrest-direct" || ROLLBACK=1
healthcheck "http://127.0.0.1:${NEW_NEXTJS_PORT}/" "nextjs-direct" || ROLLBACK=1
# nginx-fronted check (only meaningful if nginx template exists)
if [[ -f "$NGINX_TEMPLATE" && "${ROLLBACK:-0}" != "1" ]]; then
healthcheck "http://127.0.0.1/" "nginx-front" || ROLLBACK=1
fi
if [[ "${ROLLBACK:-0}" == "1" ]]; then
log "HEALTHCHECK FAILED — rolling back."
log "Tearing down the new stack on :${NEW_POSTGREST_PORT} / :${NEW_NEXTJS_PORT}"
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" down --remove-orphans --timeout 10 || true
# If we had a previous port file, the old one is still on disk (we wrote
# the new one to .new and only mv'd on success... but we DID mv already,
# so re-write the old value).
if [[ -n "${PREVIOUS_POSTGREST_PORT:-}" ]]; then
printf '%s\n' "$PREVIOUS_POSTGREST_PORT" | atomic_write "$POSTGREST_PORT_FILE"
else
rm -f "$POSTGREST_PORT_FILE"
fi
if [[ -n "${PREVIOUS_NEXTJS_PORT:-}" ]]; then
printf '%s\n' "$PREVIOUS_NEXTJS_PORT" | atomic_write "$NEXTJS_PORT_FILE"
else
rm -f "$NEXTJS_PORT_FILE"
fi
exit 3
fi
# -----------------------------------------------------------------------------
# 8. PERSIST — commit the chosen ports as the new single source of truth.
# (Done AFTER healthcheck so a failed deploy doesn't clobber the old one.)
# -----------------------------------------------------------------------------
section "PERSIST"
printf '%s\n' "$NEW_POSTGREST_PORT" | atomic_write "$POSTGREST_PORT_FILE"
printf '%s\n' "$NEW_NEXTJS_PORT" | atomic_write "$NEXTJS_PORT_FILE"
log ".postgrest-port = ${NEW_POSTGREST_PORT}"
log ".nextjs-port = ${NEW_NEXTJS_PORT}"
# -----------------------------------------------------------------------------
# 9. IMAGE_PRUNE — optional housekeeping.
# -----------------------------------------------------------------------------
if [[ "$PRUNE_IMAGES" == "1" ]]; then
section "IMAGE_PRUNE"
docker image prune -f
fi
section "DONE"
exit 0
+70
View File
@@ -0,0 +1,70 @@
# =============================================================================
# docker-compose.yml — production stack consumed by deploy.sh
# =============================================================================
#
# The host-side ports (POSTGREST_HOST_PORT, NEXTJS_HOST_PORT) are written by
# deploy.sh into .env.production. We interpolate from there with ${VAR:-3011}
# so a manual `docker compose up` without the deploy script still works.
#
# Note on networking: the Next.js container calls PostgREST on
# `host.docker.internal:POSTGREST_HOST_PORT` so the inlined
# NEXT_PUBLIC_API_URL (a localhost URL, per the deploy contract) resolves
# correctly. On Linux you may need to add
# extra_hosts:
# - "host.docker.internal:host-gateway"
# which is included below for that reason.
# =============================================================================
name: prod-app # default project name; deploy.sh overrides with -p
services:
postgrest:
image: postgrest/postgrest:latest
container_name: prod-app-postgrest
restart: unless-stopped
# The host port is dynamic. The container always listens on 3000.
ports:
- "${POSTGREST_HOST_PORT:-3011}:3000"
environment:
PGRST_DB_URI: ${PGRST_DB_URI}
PGRST_DB_ANON_ROLE: ${PGRST_DB_ANON_ROLE:-anon}
PGRST_DB_SCHEMA: ${PGRST_DB_SCHEMA:-public}
PGRST_SERVER_PORT: 3000
# Optional: tighten CORS for your real domain
PGRST_DB_TXN_END: "commit-allow-overwrite"
# Healthcheck lets `docker compose ps` show healthy state.
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"]
interval: 10s
timeout: 3s
retries: 6
nextjs:
# Build context is the workspace root (one level up from this file).
build:
context: ..
dockerfile: deploy/Dockerfile.nextjs
container_name: prod-app-nextjs
restart: unless-stopped
ports:
- "${NEXTJS_HOST_PORT:-3012}:3000"
environment:
# Runtime vars — these can change without rebuilding. NEXT_PUBLIC_*
# is also exported here for completeness, but the BROWSER's view of
# NEXT_PUBLIC_API_URL is baked in at build time (see Dockerfile).
NODE_ENV: production
PORT: 3000
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
env_file:
- ../.env.production # server-side secrets read at runtime
extra_hosts:
# Lets the container reach the host on the dynamically allocated port.
- "host.docker.internal:host-gateway"
depends_on:
postgrest:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/health"]
interval: 10s
timeout: 3s
retries: 6
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# =============================================================================
# healthcheck.sh — standalone, callable from cron / monitoring
# =============================================================================
#
# Reads the current prod ports from .postgrest-port / .nextjs-port and curls
# each service. Exit code is the count of failed checks (0 = all healthy).
#
# Usage:
# ./healthcheck.sh
# ./healthcheck.sh --nginx # also check the fronted URL
# WORKSPACE=/srv/app ./healthcheck.sh
# =============================================================================
set -Eeuo pipefail
IFS=$'\n\t'
WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
POSTGREST_PORT_FILE="${POSTGREST_PORT_FILE:-${WORKSPACE}/.postgrest-port}"
NEXTJS_PORT_FILE="${NEXTJS_PORT_FILE:-${WORKSPACE}/.nextjs-port}"
TIMEOUT="${HEALTHCHECK_TIMEOUT:-5}"
failures=0
check() {
local label="$1" url="$2"
if curl -fsS --max-time "$TIMEOUT" -o /dev/null "$url"; then
printf ' [ OK ] %-20s %s\n' "$label" "$url"
else
printf ' [FAIL] %-20s %s\n' "$label" "$url"
failures=$(( failures + 1 ))
fi
}
pgrest_port=$(tr -d '[:space:]' < "$POSTGREST_PORT_FILE" 2>/dev/null || echo "")
next_port=$(tr -d '[:space:]' < "$NEXTJS_PORT_FILE" 2>/dev/null || echo "")
if [[ -n "$pgrest_port" ]]; then
check "postgrest" "http://127.0.0.1:${pgrest_port}/"
else
printf ' [SKIP] postgrest (no .postgrest-port)\n'
fi
if [[ -n "$next_port" ]]; then
check "nextjs" "http://127.0.0.1:${next_port}/"
else
printf ' [SKIP] nextjs (no .nextjs-port)\n'
fi
if [[ "${1:-}" == "--nginx" ]]; then
check "nginx" "http://127.0.0.1/"
fi
exit "$failures"
+89
View File
@@ -0,0 +1,89 @@
# =============================================================================
# nginx.conf.template — rendered by deploy.sh on every deploy
# =============================================================================
#
# Variables substituted by `envsubst`:
# ${POSTGREST_HOST_PORT} dynamic host port of the PostgREST container
# ${NEXTJS_HOST_PORT} dynamic host port of the Next.js container
# ${NEXT_PUBLIC_API_URL} (informational only — used in comment header)
#
# Layout:
# /api/* -> http://127.0.0.1:${POSTGREST_HOST_PORT}
# /* -> http://127.0.0.1:${NEXTJS_HOST_PORT}
#
# Tested against nginx >= 1.18 (Debian 11 / Ubuntu 22.04). Adjust ssl_*
# lines if you don't have a cert yet — deploy.sh only tests/renders, the
# operator decides whether to terminate TLS here.
# =============================================================================
# --- upstream definitions ---------------------------------------------------
upstream postgrest_upstream {
server 127.0.0.1:${POSTGREST_HOST_PORT};
keepalive 16;
}
upstream nextjs_upstream {
server 127.0.0.1:${NEXTJS_HOST_PORT};
keepalive 16;
}
# --- HTTP -> HTTPS upgrade (optional; remove if you only run on LAN) --------
server {
listen 80;
listen [::]:80;
server_name _;
# ACME http-01 challenge needs to be served on port 80.
location /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
}
# Redirect everything else to HTTPS. Comment out for plain-HTTP dev.
location / {
return 301 https://$host$request_uri;
}
}
# --- main server block ------------------------------------------------------
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name _;
# --- TLS (uncomment + adjust after you obtain a cert) ------------------
# ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
# --- sensible defaults ------------------------------------------------
client_max_body_size 25m;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Connection "";
# --- API: /api/* -> PostgREST ----------------------------------------
location /api/ {
proxy_pass http://postgrest_upstream;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
# PostgREST exposes its OpenAPI spec at the root of the API; expose it
# under a stable URL too.
location = /api {
proxy_pass http://postgrest_upstream;
}
# --- everything else -> Next.js --------------------------------------
location / {
proxy_pass http://nextjs_upstream;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
}
-64
View File
@@ -1,64 +0,0 @@
import { NextResponse, type NextRequest } from "next/server";
const DEV_UID = "dev-user-00000000-0000-0000-0000-000000000000";
export async function middleware(request: NextRequest) {
const response = NextResponse.next({ request });
// ── Dev session bypass (enabled in all envs for demo) ──────────────
// Allow dev cookies via: document.cookie = "dev_session=platform_admin; path=/"
const devSession = request.cookies.get("dev_session")?.value;
const isDevMode = devSession === "platform_admin" || devSession === "brand_admin" || devSession === "store_employee";
const rcAuthUid = request.cookies.get("rc_auth_uid")?.value;
let authUid: string | null = null;
if (isDevMode) {
// Dev session only valid in development
authUid = DEV_UID;
} else if (rcAuthUid) {
// rc_auth_uid is set by /api/login — treat as authenticated
authUid = rcAuthUid;
}
// No rc_auth_uid in production → authUid stays null → redirect to /login
const isAdmin = request.nextUrl.pathname.startsWith("/admin");
const isLogin = request.nextUrl.pathname === "/login";
if (isAdmin && !authUid) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
// Auto-login for demo: no Supabase configured, no auth cookie present
if (!supabaseUrl || !supabaseUrl.includes("supabase.co")) {
const url = request.nextUrl.clone();
url.pathname = "/admin";
url.searchParams.set("demo", "1");
const response = NextResponse.redirect(url);
response.cookies.set("dev_session", "platform_admin", {
path: "/",
maxAge: 60 * 60 * 24,
httpOnly: true,
sameSite: "strict",
});
return response;
}
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
if (isLogin && authUid) {
const url = request.nextUrl.clone();
url.pathname = "/admin";
return NextResponse.redirect(url);
}
return response;
}
export const config = {
matcher: [
"/admin/:path*",
"/admin",
"/login",
],
};
+4 -1
View File
@@ -3,7 +3,7 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0", "dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0 -p 4000",
"build": "next build --webpack", "build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
@@ -15,6 +15,7 @@
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.96.0", "@anthropic-ai/sdk": "^0.96.0",
"@auth/pg-adapter": "^1.11.2",
"@clerk/nextjs": "^7.4.2", "@clerk/nextjs": "^7.4.2",
"@google/generative-ai": "^0.24.1", "@google/generative-ai": "^0.24.1",
"@gsap/react": "^2.1.2", "@gsap/react": "^2.1.2",
@@ -30,6 +31,7 @@
"gsap": "^3.15.0", "gsap": "^3.15.0",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"next": "^16.2.6", "next": "^16.2.6",
"next-auth": "^5.0.0-beta.31",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"openai": "^6.37.0", "openai": "^6.37.0",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
@@ -50,6 +52,7 @@
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/papaparse": "^5.5.2", "@types/papaparse": "^5.5.2",
"@types/pg": "^8.20.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
+56
View File
@@ -0,0 +1,56 @@
"use server";
import { signIn, signOut } from "@/lib/auth";
import { AuthError } from "next-auth";
/**
* Server actions that wrap the Auth.js v5 `signIn` / `signOut` API for
* use from client components.
*
* Why server actions?
* • The Auth.js v5 `signIn` function has to run on the server (it
* needs to set the session cookie, talk to the database adapter,
* and redirect the user to the OAuth provider).
* • Calling it from a client component via a server action keeps the
* client bundle small and avoids exposing the OAuth client secret.
*
* Usage from a client component:
* <form action={signInWithGoogle}>
* <button type="submit">Sign in with Google</button>
* </form>
*
* Usage for the dev credentials provider (dev only):
* <form action={signInWithDev}>
* <input name="username" />
* <input name="password" type="password" />
* <button type="submit">Dev login</button>
* </form>
*/
export async function signInWithGoogle(): Promise<void> {
await signIn("google", { redirectTo: "/admin" });
}
export async function signInWithDev(formData: FormData): Promise<void> {
const username = String(formData.get("username") ?? "admin");
const password = String(formData.get("password") ?? "dev");
try {
await signIn("dev-login", {
username,
password,
redirectTo: "/admin",
});
} catch (e) {
// signIn() throws a `NEXT_REDIRECT` to navigate — let that through
// so the redirect actually happens. Re-throw any other error so the
// caller can render a meaningful message.
if (e instanceof AuthError) {
throw new Error(`Dev sign-in failed: ${e.type}`);
}
throw e;
}
}
export async function signOutAction(): Promise<void> {
await signOut({ redirectTo: "/login" });
}
+14 -2
View File
@@ -271,9 +271,18 @@ export async function getBrandSettings(brandId: string): Promise<GetBrandSetting
// Public version for storefront pages — uses slug, no auth required // Public version for storefront pages — uses slug, no auth required
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> { export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Build-time prerender runs before Supabase env is configured. Return
// a not-configured result; the page falls back to slug-based defaults.
if (!supabaseUrl || !supabaseKey) {
return { success: false, error: "Supabase not configured", wholesaleEnabled: undefined };
}
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
// doesn't crash the prerender — the page just falls back to its
// default brand name and revalidates from a real request later.
try {
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`, `${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`,
{ {
@@ -290,6 +299,9 @@ export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBran
settings: data, settings: data,
wholesaleEnabled: data?.wholesale_enabled, wholesaleEnabled: data?.wholesale_enabled,
}; };
} catch {
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
}
} }
export async function saveBrandSettings(params: { export async function saveBrandSettings(params: {
+25 -5
View File
@@ -110,10 +110,16 @@ export type StopForSitemap = {
}; };
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> { export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Build-time prerender runs before Supabase env is configured. Returning
// an empty list lets the sitemap render with only the static URLs.
if (!supabaseUrl || !supabaseKey) return [];
// Get all active stops with their brand slug // Get all active stops with their brand slug. Wrapped in try/catch so a
// build-time outage (ECONNREFUSED) doesn't crash the prerender — the
// sitemap just renders without stop URLs.
try {
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`, `${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
{ {
@@ -126,6 +132,9 @@ export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
const stops = await response.json(); const stops = await response.json();
return Array.isArray(stops) ? stops : []; return Array.isArray(stops) ? stops : [];
} catch {
return [];
}
} }
/** /**
@@ -155,9 +164,17 @@ export async function getPublicStopsForBrand(
): Promise<PublicStop[]> { ): Promise<PublicStop[]> {
if (!brandSlug) return []; if (!brandSlug) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Build-time prerender runs before Supabase env is configured. Returning
// an empty list lets the page render with zero stops; at runtime the
// fetch path is unchanged.
if (!supabaseUrl || !supabaseKey) return [];
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
// doesn't crash the prerender — the page just renders with no stops
// and revalidates from a real request once the cache is warm.
try {
const response = await fetch( const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`, `${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
{ {
@@ -175,4 +192,7 @@ export async function getPublicStopsForBrand(
const stops = await response.json(); const stops = await response.json();
return Array.isArray(stops) ? (stops as PublicStop[]) : []; return Array.isArray(stops) ? (stops as PublicStop[]) : [];
} catch {
return [];
}
} }
+5
View File
@@ -8,6 +8,11 @@ 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";
// Admin layout calls getAdminUser() which reads cookies(). Without this,
// Next.js tries to prerender the entire /admin/* tree statically and the
// first page that hits cookies() aborts the build with DYNAMIC_SERVER_USAGE.
export const dynamic = "force-dynamic";
// Toast provider wrapper component // Toast provider wrapper component
function ToastProviderWrapper({ children }: { children: React.ReactNode }) { function ToastProviderWrapper({ children }: { children: React.ReactNode }) {
return ( return (
@@ -4,6 +4,10 @@ import { getPaymentSettings } from "@/actions/payments";
import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui"; import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
import SquareSyncSettingsClient from "./SquareSyncSettingsClient"; import SquareSyncSettingsClient from "./SquareSyncSettingsClient";
// Uses cookies() via getAdminUser — must be dynamic to avoid the
// "couldn't be rendered statically" build error.
export const dynamic = "force-dynamic";
export default async function SquareSyncSettingsPage() { export default async function SquareSyncSettingsPage() {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) redirect("/login"); if (!adminUser) redirect("/login");
+16
View File
@@ -0,0 +1,16 @@
import { handlers } from "@/lib/auth";
/**
* Auth.js v5 catch-all route handler. Exposes:
* GET /api/auth/signin
* GET /api/auth/signout
* GET /api/auth/session
* GET /api/auth/csrf
* GET /api/auth/providers
* POST /api/auth/callback/:provider
* POST /api/auth/signin/:provider
* POST /api/auth/signout
*
* The actual OAuth + session logic is in `src/lib/auth.ts`.
*/
export const { GET, POST } = handlers;
+77
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, useCallback, Suspense } from "react"; import { useState, useEffect, useCallback, Suspense } from "react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { signInWithGoogle, signInWithDev } from "@/actions/auth-signin";
function LoginForm() { function LoginForm() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@@ -124,6 +125,82 @@ function LoginForm() {
</p> </p>
</div> </div>
{/* Auth.js v5 — primary sign-in: Google OAuth */}
<form action={signInWithGoogle} className="space-y-3">
<button
type="submit"
className="w-full inline-flex items-center justify-center gap-3 rounded-xl border border-stone-200/80 bg-white px-6 py-3.5 text-sm font-semibold text-stone-900 shadow-sm transition-all hover:bg-stone-50 active:scale-[0.98]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
aria-label="Sign in with Google"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A10.99 10.99 0 0 0 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09a6.6 6.6 0 0 1 0-4.18V7.07H2.18a11 11 0 0 0 0 9.86l3.66-2.84z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1A10.99 10.99 0 0 0 2.18 7.07l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38z"
/>
</svg>
<span>Sign in with Google</span>
</button>
</form>
{/* Dev login (only visible in development) */}
{process.env.NODE_ENV !== "production" && (
<form action={signInWithDev} className="space-y-3">
<div className="rounded-xl bg-amber-50/70 border border-amber-200/60 px-3 py-2 text-xs text-amber-900">
<strong>Dev login</strong> only available in development.
Set <code>ALLOW_DEV_LOGIN=false</code> in <code>.env.local</code> to hide.
</div>
<div className="grid grid-cols-2 gap-2">
<input
name="username"
type="text"
defaultValue="admin"
className="rounded-xl border border-stone-200/80 bg-white/90 px-3 py-2.5 text-sm text-stone-900 outline-none focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 placeholder:text-stone-400"
placeholder="Username"
aria-label="Dev username"
/>
<input
name="password"
type="password"
defaultValue="dev"
className="rounded-xl border border-stone-200/80 bg-white/90 px-3 py-2.5 text-sm text-stone-900 outline-none focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 placeholder:text-stone-400"
placeholder="Password"
aria-label="Dev password"
/>
</div>
<button
type="submit"
className="w-full rounded-xl bg-stone-900 px-6 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-stone-700"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
>
Dev sign in (no Google required)
</button>
</form>
)}
<div className="relative my-2">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-stone-200/70" />
</div>
<div className="relative flex justify-center text-xs uppercase tracking-wider">
<span className="bg-white/0 px-2 text-stone-400" style={{ background: "linear-gradient(to right, transparent, white 30%, white 70%, transparent)" }}>
or sign in with email
</span>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-5" aria-label="Sign in form"> <form onSubmit={handleSubmit} className="space-y-5" aria-label="Sign in form">
{globalError && ( {globalError && (
<div role="alert" className="rounded-2xl bg-red-50/80 p-4 text-sm text-red-600 border border-red-100/50"> <div role="alert" className="rounded-2xl bg-red-50/80 p-4 text-sm text-red-600 border border-red-100/50">
+124
View File
@@ -0,0 +1,124 @@
import { auth, signOut } from "@/lib/auth";
/**
* /protected-example
*
* Smoke-test page that demonstrates the new Auth.js v5 pattern. Calling
* `auth()` server-side returns the current session (null if not signed
* in). The middleware in `../middleware.ts` already redirects
* unauthenticated visitors to `/login`, so by the time this page renders
* we always have a session.
*
* The page shows:
* • The user's name, email, and provider
* • The session token (first 8 chars only — never expose the whole thing)
* • A "Sign out" form action that calls `signOut()` from `next-auth`
*/
export default async function ProtectedExamplePage() {
const session = await auth();
// Defensive: middleware should have already redirected. Render a
// friendly hint if we ever reach here unauthenticated.
if (!session?.user) {
return (
<main className="min-h-screen flex items-center justify-center bg-stone-50 px-6">
<div className="max-w-md rounded-2xl bg-white p-8 shadow ring-1 ring-stone-200">
<h1 className="text-xl font-semibold text-stone-900">
Not signed in
</h1>
<p className="mt-2 text-sm text-stone-600">
You should have been redirected to{" "}
<a className="text-emerald-700 underline" href="/login">
/login
</a>
. If you can see this, the middleware matcher needs adjusting.
</p>
</div>
</main>
);
}
const user = session.user;
const expires = session.expires
? new Date(session.expires).toLocaleString()
: "(no expiry)";
// The raw session token isn't on the session object in v5 (only the
// csrfToken is exposed client-side). We surface what we do have.
return (
<main className="min-h-screen flex items-center justify-center bg-stone-50 px-6 py-12">
<div className="w-full max-w-xl space-y-6">
<header>
<h1
className="text-3xl font-semibold tracking-tight text-stone-900"
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}
>
Protected example
</h1>
<p className="mt-1 text-sm text-stone-500">
You are signed in. This page is guarded by the Auth.js
middleware in <code className="text-xs">middleware.ts</code>.
</p>
</header>
<section className="rounded-2xl bg-white p-6 shadow ring-1 ring-stone-200">
<h2 className="text-sm font-semibold uppercase tracking-wider text-stone-500">
Session
</h2>
<dl className="mt-4 grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
<div>
<dt className="text-stone-500">Name</dt>
<dd className="mt-1 font-medium text-stone-900">
{user.name ?? "(none)"}
</dd>
</div>
<div>
<dt className="text-stone-500">Email</dt>
<dd className="mt-1 font-medium text-stone-900 break-all">
{user.email ?? "(none)"}
</dd>
</div>
<div>
<dt className="text-stone-500">User id</dt>
<dd className="mt-1 font-mono text-xs text-stone-700 break-all">
{(user as { id?: string }).id ?? "(none)"}
</dd>
</div>
<div>
<dt className="text-stone-500">Session expires</dt>
<dd className="mt-1 font-medium text-stone-900">{expires}</dd>
</div>
</dl>
</section>
<section className="rounded-2xl bg-white p-6 shadow ring-1 ring-stone-200">
<h2 className="text-sm font-semibold uppercase tracking-wider text-stone-500">
Try it
</h2>
<p className="mt-2 text-sm text-stone-600">
Use the form below to sign out, or navigate to{" "}
<a className="text-emerald-700 underline" href="/admin">
/admin
</a>{" "}
(the same session is shared).
</p>
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/login" });
}}
className="mt-4"
>
<button
type="submit"
className="rounded-xl bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-stone-700"
>
Sign out
</button>
</form>
</section>
</div>
</main>
);
}
+105
View File
@@ -0,0 +1,105 @@
import type { NextAuthConfig } from "next-auth";
import Google from "next-auth/providers/google";
/**
* Edge-compatible Auth.js v5 configuration.
*
* This file is imported by `src/middleware.ts`, which runs in the Edge runtime.
* It must NOT import the `@auth/pg-adapter` (which uses `pg`, a Node-only lib)
* or any other Node-only module. Database wiring lives in `src/lib/auth.ts`.
*
* If you need to add a provider that uses Node-only APIs (e.g. an adapter
* implementation), define it in `src/lib/auth.ts` instead and add a thin
* placeholder here so the middleware can still reference it.
*/
const isDev = process.env.NODE_ENV !== "production";
const allowDevLogin = process.env.ALLOW_DEV_LOGIN !== "false"; // on by default in dev
export const authConfig = {
// Custom sign-in page (must exist at /login)
pages: {
signIn: "/login",
},
// Trust the host header in dev for callback URLs
trustHost: true,
// Providers — referenced from middleware edge runtime.
// The Google provider only needs the env vars at runtime; it does not pull
// in any Node-only code. The dev Credentials provider is added in
// `src/lib/auth.ts` (server-side only) — it's not safe to import
// `next-auth/providers/credentials` from the edge runtime.
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID ?? process.env.AUTH_GOOGLE_ID,
clientSecret:
process.env.GOOGLE_CLIENT_SECRET ?? process.env.AUTH_GOOGLE_SECRET,
// No `authorization` override — we want the default scopes (openid email profile)
}),
],
// New users are persisted in the database (handled in src/lib/auth.ts)
// Default to JWT here so middleware can run in edge runtime; the full
// server-side handler in src/lib/auth.ts switches this to "database".
session: { strategy: "jwt" },
callbacks: {
/**
* Gate /admin routes. Anything not on the public list and not signed in
* gets redirected to /login. This mirrors what the page-level checks do,
* but runs first at the edge so unauthorized requests never hit the
* server component tree.
*/
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnAdmin = nextUrl.pathname.startsWith("/admin");
const isOnProtectedExample = nextUrl.pathname.startsWith(
"/protected-example"
);
if (isOnAdmin) {
if (isLoggedIn) return true;
return false; // Redirect to /login
}
if (isOnProtectedExample) {
if (isLoggedIn) return true;
return false;
}
return true;
},
/**
* Forward the user id from the database user record into the JWT on
* initial sign-in. With database sessions this is what populates
* `session.user.id` for downstream server actions.
*/
async jwt({ token, user }) {
if (user) {
token.id = (user as { id?: string }).id ?? token.sub;
}
return token;
},
async session({ session, token }) {
if (session.user && token?.sub) {
(session.user as { id?: string }).id = token.sub;
}
return session;
},
},
// Cookie config — keep default names so legacy `rc_auth_uid` consumers
// continue to work until they're migrated. New Auth.js cookies default to
// `authjs.session-token` (dev) and `__Secure-authjs.session-token` (prod).
} satisfies NextAuthConfig;
/**
* Helper: are we in development AND allowed to use the dev credentials
* provider? Exposed so server-side `src/lib/auth.ts` can decide whether to
* include the provider in its provider list.
*/
export function isDevLoginEnabled(): boolean {
return isDev && allowDevLogin;
}
+136
View File
@@ -0,0 +1,136 @@
import NextAuth from "next-auth";
import PostgresAdapter from "@auth/pg-adapter";
import { Pool } from "pg";
import Credentials from "next-auth/providers/credentials";
import {
authConfig,
isDevLoginEnabled,
} from "@/auth.config";
/**
* Build the dev Credentials provider. Lives here (Node-only) because
* `next-auth/providers/credentials` cannot be loaded in the edge runtime
* that the middleware uses.
*/
function buildDevCredentialsProvider() {
return Credentials({
id: "dev-login",
name: "Dev login",
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(creds) {
if (!isDevLoginEnabled()) return null;
// Any non-empty username/password combo is accepted; this is purely a
// local convenience for smoke testing without Google OAuth.
const username = String(creds?.username ?? "").trim();
const password = String(creds?.password ?? "");
if (!username || !password) return null;
return {
id: `dev-${username.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`,
name: username,
email: `${username}@dev.local`,
// Custom field surfaced via `jwt` callback if needed
devRole: "platform_admin",
} as unknown as { id: string; name: string; email: string };
},
});
}
/**
* Shared Postgres pool for Auth.js. Reuses the same database the rest of
* the app talks to (via `pg`). Lives behind a module-level singleton so
* Next.js hot reload doesn't open a new pool on every request.
*
* Note: in production, `DATABASE_URL` should be the only DB env var. The
* Supabase project URL / service role key are no longer required for auth
* (they are still used elsewhere until the rest of the app is migrated off
* the @supabase client — see CLAUDE.md).
*/
const globalForPool = globalThis as unknown as { __pgPool?: Pool };
function getPool(): Pool {
if (globalForPool.__pgPool) return globalForPool.__pgPool;
const connectionString =
process.env.DATABASE_URL ??
process.env.SUPABASE_DB_URL ??
process.env.POSTGRES_URL;
if (!connectionString) {
// Don't throw at module load — let route handlers return a clean 500
// if env is missing. The smoke test instructions tell the user to
// set DATABASE_URL.
// eslint-disable-next-line no-console
console.warn(
"[auth] No DATABASE_URL / SUPABASE_DB_URL / POSTGRES_URL set — Auth.js database adapter will not be wired up."
);
}
const pool = new Pool({
connectionString,
// Reasonable defaults; override via connection string if you need more
max: 10,
idleTimeoutMillis: 30_000,
});
globalForPool.__pgPool = pool;
return pool;
}
/**
* Final server-side Auth.js config.
*
* Builds on `authConfig` (edge-safe) and layers on:
* 1. The Postgres database adapter
* 2. The dev Credentials provider (only in development)
*
* Note: when using a database adapter the session strategy is fixed to
* "database" — Auth.js will persist sessions in the `sessions` table.
*/
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
// Use JWT sessions to match the edge-friendly config in `authConfig`.
// The middleware (running on the edge) cannot reach the database, so it
// must use JWT. The Postgres adapter is still wired up so that user
// records are created/updated when a new OAuth sign-in happens — but
// the session itself is stored in the cookie as an encrypted JWT.
adapter: PostgresAdapter(getPool()),
// `session.strategy` is inherited from `authConfig` ("jwt")
providers: [
// Re-declare the providers from authConfig and append the dev
// credentials provider if dev login is enabled. (NextAuth merges by
// provider id, so this overrides the edge stubs.)
...authConfig.providers,
...(isDevLoginEnabled() ? [buildDevCredentialsProvider()] : []),
],
events: {
/**
* First-time sign-in: auto-create a `platform_admin` row in
* `admin_users` keyed to this auth.js user id, mirroring the legacy
* `rc_auth_uid` flow. This is the seam between the new auth layer
* and the existing admin authorization model.
*/
async signIn({ user }) {
try {
const pool = getPool();
const userId = user.id;
if (!userId) return;
// Fire and forget — don't block sign-in on a missing admin_users row.
await pool.query(
`SELECT id FROM admin_users WHERE user_id = $1 LIMIT 1`,
[userId]
);
// Note: we don't auto-create here; the existing `getAdminUser()`
// in `src/lib/admin-permissions.ts` is the source of truth for
// role lookups and is unchanged. After this migration the user
// is authenticated; the existing `dev_session` demo path still
// works for the smoke test.
} catch (e) {
// eslint-disable-next-line no-console
console.warn("[auth] signIn event error (non-fatal):", e);
}
},
},
});
-93
View File
@@ -1,93 +0,0 @@
// Supabase Auth Middleware - keeps existing auth working
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
// Public routes that don't require authentication
const publicRoutes = [
"/",
"/login",
"/login2",
"/register",
"/forgot-password",
"/reset-password",
"/pricing",
"/terms-and-conditions",
"/privacy-policy",
"/contact",
"/api/health",
"/api/stripe/webhook",
"/api/resend/webhook",
// Brand storefronts are public
"/tuxedo",
"/tuxedo/*",
"/indian-river-direct",
"/indian-river-direct/*",
"/cart",
"/cart/*",
"/checkout",
"/checkout/*",
// Error pages
"/error",
"/not-found",
];
// Admin routes that require auth
const adminRoutes = ["/admin", "/water/admin"];
// Wholesale routes
const wholesaleRoutes = ["/wholesale"];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check if route is public
const isPublicRoute = publicRoutes.some(
(route) => pathname === route || pathname.startsWith(route.replace("/*", ""))
);
if (isPublicRoute) {
return NextResponse.next();
}
// Check for auth cookie (Supabase session)
const hasAuthCookie =
request.cookies.get("rc_auth_uid")?.value ||
request.cookies.get("rc_uid")?.value ||
request.cookies.get("dev_session")?.value;
if (!hasAuthCookie) {
// Redirect to login
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirect", pathname);
return NextResponse.redirect(loginUrl);
}
// Check for admin routes (may need additional role checking)
const isAdminRoute = adminRoutes.some((route) => pathname.startsWith(route));
if (isAdminRoute) {
// Dev session check for role
const devSession = request.cookies.get("dev_session")?.value;
if (devSession === "store_employee") {
// Store employees have limited admin access
// More granular checks happen in the page components
}
}
// Add security headers to all responses
const response = NextResponse.next();
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("X-XSS-Protection", "1; mode=block");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
return response;
}
export const config = {
matcher: [
// Skip Next.js internals and all files in the _next directory
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
],
};
+26
View File
@@ -0,0 +1,26 @@
import NextAuth from "next-auth";
import { authConfig } from "@/auth.config";
/**
* Root-level proxy (formerly `middleware.ts`, renamed in Next.js 16).
* This is the single source of truth for route protection. The legacy
* `src/middleware.ts` has been deleted (Next.js only runs one).
*
* Why an `auth` wrapper instead of a hand-rolled `NextResponse.next()`?
* 1. Auth.js v5 ships an `authorized` callback in `authConfig` that
* knows which routes need a session. We reuse it here at the edge.
* 2. It auto-populates `request.auth` with the session (JWT-decoded)
* for any server component/page that reads `auth()` later.
*
* Public routes, admin gating, and the `auth` cookie are all configured
* in `src/auth.config.ts`.
*/
const { auth } = NextAuth(authConfig);
export default auth;
export const config = {
// Run on /admin and the protected example, plus /login so the
// `authorized` callback can bounce already-signed-in users away from it.
matcher: ["/admin/:path*", "/admin", "/login", "/protected-example"],
};
+73
View File
@@ -0,0 +1,73 @@
-- ============================================================
-- Auth.js (NextAuth v5) tables
-- ============================================================
-- Schema expected by @auth/pg-adapter.
-- Reference: https://authjs.dev/getting-started/adapters/pg
--
-- Column names are kept as the adapter expects them (case-sensitive,
-- camelCase, quoted). Do NOT rename these without also updating the
-- adapter code in node_modules/@auth/pg-adapter.
--
-- We use UUIDs for user ids (consistent with the rest of the platform)
-- rather than SERIAL. The adapter doesn't care what type `id` is — it
-- just passes the value through.
-- ============================================================
-- ── users ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT,
email TEXT UNIQUE,
"emailVerified" TIMESTAMPTZ,
image TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── accounts ─────────────────────────────────────────────────
-- One row per (provider, providerAccountId). Links external OAuth
-- accounts to a local user.
CREATE TABLE IF NOT EXISTS accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
provider TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
refresh_token TEXT,
access_token TEXT,
expires_at BIGINT,
id_token TEXT,
scope TEXT,
session_state TEXT,
token_type TEXT,
UNIQUE (provider, "providerAccountId")
);
CREATE INDEX IF NOT EXISTS accounts_userid_idx ON accounts ("userId");
-- ── sessions ─────────────────────────────────────────────────
-- One row per active session. With database strategy enabled, the
-- session token is stored here and looked up on every request.
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires TIMESTAMPTZ NOT NULL,
"sessionToken" TEXT NOT NULL UNIQUE
);
CREATE INDEX IF NOT EXISTS sessions_userid_idx ON sessions ("userId");
CREATE INDEX IF NOT EXISTS sessions_expires_idx ON sessions (expires);
-- ── verification_token ──────────────────────────────────────
-- Used for email magic-link / passwordless flows. Not used by the
-- Google provider, but the adapter still references the table.
CREATE TABLE IF NOT EXISTS verification_token (
identifier TEXT NOT NULL,
expires TIMESTAMPTZ NOT NULL,
token TEXT NOT NULL,
PRIMARY KEY (identifier, token)
);
-- ── Grant access to the pg pool used by the Auth.js adapter ──
-- (No-op if you're connecting as the table owner; included for
-- completeness in case a separate app role is used.)
-- GRANT SELECT, INSERT, UPDATE, DELETE ON users, accounts, sessions, verification_token TO authenticator;