Merge remote-tracking branch 'crispygoat/main'
Deploy to route.crispygoat.com / deploy (push) Failing after 1m59s
Deploy to route.crispygoat.com / deploy (push) Failing after 1m59s
# Conflicts: # src/actions/admin/password.ts # src/actions/brand-settings.ts # src/actions/stops.ts # src/app/change-password/page.tsx # src/app/login/LoginClient.tsx # src/app/logout/page.tsx # src/auth.config.ts # src/lib/admin-permissions.ts # src/lib/auth.ts # src/lib/db.ts
This commit is contained in:
@@ -40,6 +40,13 @@ GOOGLE_CLIENT_SECRET=
|
|||||||
# development. Default: enabled in dev only.
|
# development. Default: enabled in dev only.
|
||||||
ALLOW_DEV_LOGIN=true
|
ALLOW_DEV_LOGIN=true
|
||||||
|
|
||||||
|
# Comma-separated list of email addresses allowed to sign in via Google
|
||||||
|
# OAuth. If unset (or empty), any Google account can sign in and gets a
|
||||||
|
# `platform_admin` row auto-created — fine for demo/dev. Set this in
|
||||||
|
# production to lock sign-in down to a known set of admins.
|
||||||
|
# Example: ADMIN_ALLOWED_EMAILS=tyler@example.com,sarah@example.com
|
||||||
|
ADMIN_ALLOWED_EMAILS=
|
||||||
|
|
||||||
# ── Supabase (legacy, being removed) ────────────────────────────────────────
|
# ── Supabase (legacy, being removed) ────────────────────────────────────────
|
||||||
# Still used by the existing admin pages, server actions, and the
|
# Still used by the existing admin pages, server actions, and the
|
||||||
# `getAdminUser` flow. Once the auth migration is complete and the
|
# `getAdminUser` flow. Once the auth migration is complete and the
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
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 }}
|
||||||
|
|
||||||
|
# PostgREST — needs the DB URI at start time (it reads env
|
||||||
|
# from the container, not from .env.production which is
|
||||||
|
# written later by the Deploy step).
|
||||||
|
PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }}
|
||||||
|
PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }}
|
||||||
|
PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }}
|
||||||
|
run: |
|
||||||
|
APP_DIR=/home/tyler/route-commerce
|
||||||
|
mkdir -p $APP_DIR
|
||||||
|
|
||||||
|
# Seed config files into APP_DIR FIRST, before any docker compose
|
||||||
|
# command. The `docker compose down` below validates the compose
|
||||||
|
# file (including `env_file` paths) — if the old copy is still
|
||||||
|
# on the server with a broken `env_file`, the step fails before
|
||||||
|
# we get a chance to overwrite it.
|
||||||
|
# - docker-compose.yml: copied UNCONDITIONALLY so deploys pick
|
||||||
|
# up compose changes. The previous `[ -f ... ] ||` guard
|
||||||
|
# kept stale copies on the server.
|
||||||
|
# - .env.example: copied on first deploy only (it's a template;
|
||||||
|
# the real `.env` is built from it below).
|
||||||
|
[ -f $APP_DIR/.env.example ] || cp .env.example $APP_DIR/.env.example
|
||||||
|
cp -f deploy/docker-compose.yml $APP_DIR/docker-compose.yml
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
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 "PGRST_DB_URI=${PGRST_DB_URI}"
|
||||||
|
echo "PGRST_DB_ANON_ROLE=${PGRST_DB_ANON_ROLE:-anon}"
|
||||||
|
echo "PGRST_SERVER_PORT=${PGRST_SERVER_PORT:-3000}"
|
||||||
|
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.
|
||||||
|
# Only `postgrest` lives in docker; Postgres itself runs on the
|
||||||
|
# host (see the migrations step below, which uses
|
||||||
|
# `psql -h 127.0.0.1`).
|
||||||
|
docker compose up -d --force-recreate postgrest
|
||||||
|
# Wait for Postgres to accept connections on the host.
|
||||||
|
# The DB is on 127.0.0.1, not in a docker service.
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "SELECT 1" > /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 }}
|
||||||
|
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
|
||||||
|
|
||||||
|
# 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 }}
|
||||||
|
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
|
||||||
|
|
||||||
|
# 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 "ADMIN_ALLOWED_EMAILS=%s\n" "$ADMIN_ALLOWED_EMAILS"
|
||||||
|
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"
|
||||||
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -286,3 +286,207 @@ 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.
|
||||||
|
|
||||||
|
## Build green — 2026-06-06
|
||||||
|
|
||||||
|
Push `32396af` to `origin/main` triggered a successful Gitea deploy. Fixes that landed:
|
||||||
|
- `force-dynamic` on `src/app/admin/layout.tsx` + `src/app/admin/settings/square-sync/page.tsx`
|
||||||
|
- try/catch around Supabase REST fetches in `src/actions/stops.ts` and `src/actions/brand-settings.ts`
|
||||||
|
- `.gitea/workflows/deploy.yml` paths updated to `deploy/docker-compose.yml`
|
||||||
|
|
||||||
|
## Production prep — next steps
|
||||||
|
|
||||||
|
1. **Verify the stack is actually running.** SSH to the deploy host, `docker compose -p prod-app ps` in `$APP_DIR` (`/home/tyler/route-commerce`). All services should be `healthy`.
|
||||||
|
2. **Test Postgres connectivity.** `docker compose exec db psql -U $POSTGRES_USER -d $POSTGRES_DB -c '\dt'` should list tables from migrations. `curl http://localhost:$POSTGREST_HOST_PORT/` should return PostgREST's OpenAPI spec.
|
||||||
|
3. **Test app → PostgREST.** Hit any public page that reads from PostgREST (e.g. `/indian-river-direct/stops` after the revalidate window). If it returns stops, the chain works.
|
||||||
|
4. **Replace dummy secrets** in Gitea:
|
||||||
|
- `NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321` + `NEXT_PUBLIC_SUPABASE_ANON_KEY=dummy-supabase-anon-ke` — either set real Supabase project values, or remove entirely once the Postgres-direct migration is complete (CLAUDE.md direction).
|
||||||
|
- `RESEND_API_KEY=re_REPLACE_ME`, `RESEND_WEBHOOK_SECRET=whsec_REPLACE_ME` — get real values from Resend dashboard.
|
||||||
|
- `STRIPE_SECRET_KEY=sk_test_REPLACE_ME`, `STRIPE_PUBLISHABLE_KEY=pk_test_REPLACE_ME`, `STRIPE_WEBHOOK_SECRET=whsec_REPLACE_ME` — real test-mode values from Stripe.
|
||||||
|
5. **Supabase → direct Postgres migration.** The codebase still imports `@supabase/ssr` and `@supabase/supabase-js` in `src/lib/supabase.ts`, `src/lib/supabase/server.ts`, `src/actions/login.ts`, `src/actions/admin/users.ts`, `src/actions/admin/force-login.ts`, `src/actions/wholesale-auth.ts`. CLAUDE.md says these should be purged. The deploy stack already has PostgREST, so the path is: replace `supabase.from(...)` calls with `fetch` to `NEXT_PUBLIC_API_URL/rest/v1/...` or direct `pg` queries, then drop the `@supabase/*` deps.
|
||||||
|
6. **Auth.js hardening.** `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` aren't in the secret list — the workflow falls back to `BETTER_AUTH_*` names which exist. Set the canonical `AUTH_*` names too so the fallback isn't load-bearing.
|
||||||
|
|
||||||
|
## Login flow consolidated — 2026-06-06
|
||||||
|
|
||||||
|
Push `e499139` fixes the "dev login redirects back to /login" bug and
|
||||||
|
removes the three-mode login page.
|
||||||
|
|
||||||
|
**Root cause:** `src/middleware.ts` didn't exist, so the `authorized`
|
||||||
|
callback in `auth.config.ts` never ran at the edge. The demo buttons at
|
||||||
|
`/login?demo=1` set `dev_session` via `document.cookie`, but nothing at
|
||||||
|
the edge recognized the cookie — the admin layout's `getAdminUser()` was
|
||||||
|
the only thing reading it, and if the layout's `force-dynamic` ever
|
||||||
|
stopped applying, the user would be bounced.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- **New `src/middleware.ts`** — plain middleware (NOT the `auth()`
|
||||||
|
wrapper). Gates `/admin/*` and `/login`:
|
||||||
|
- If `dev_session`, `rc_auth_uid`, or `rc_uid` cookie is present →
|
||||||
|
`NextResponse.next()`.
|
||||||
|
- If no auth cookie, on `/admin/*`, and `ALLOW_DEV_LOGIN !== "false"`
|
||||||
|
(on by default) → set `dev_session=platform_admin` cookie and
|
||||||
|
`NextResponse.next()`. Invisible auto-login.
|
||||||
|
- If no auth and dev disabled → redirect to `/login`.
|
||||||
|
- If authenticated and on `/login` → redirect to `/admin`.
|
||||||
|
- **`src/app/login/LoginClient.tsx`** — stripped to a single Google
|
||||||
|
OAuth button. Removed:
|
||||||
|
- Email/password form (was hitting dummy Supabase and 500'ing).
|
||||||
|
- Dev credentials form (`signInWithDev`).
|
||||||
|
- `DemoMode` component with the three buttons (Platform Admin,
|
||||||
|
Brand Admin, Store Employee).
|
||||||
|
- `useState`/`useEffect`/`useCallback`/`useSearchParams`/`Suspense`
|
||||||
|
— none of that complexity is needed for a single button.
|
||||||
|
- **`src/actions/auth-signin.ts`** — removed `signInWithDev`. Kept
|
||||||
|
`signInWithGoogle` and `signOutAction`.
|
||||||
|
- **Deleted `src/app/dev-login/page.tsx`** and
|
||||||
|
**`src/app/api/dev-login/route.ts`** — dead routes, middleware
|
||||||
|
handles it.
|
||||||
|
|
||||||
|
**What "one way to log in" looks like now:**
|
||||||
|
- Dev/demo: visit `/admin` → middleware sets `dev_session` cookie →
|
||||||
|
`getAdminUser()` returns platform_admin → you're in.
|
||||||
|
- Production: visit `/admin` → no cookie, `ALLOW_DEV_LOGIN=false` →
|
||||||
|
redirect to `/login` → click Google → Auth.js OAuth flow.
|
||||||
|
|
||||||
|
**Note for Auth.js migration:** `getAdminUser()` still only checks
|
||||||
|
`dev_session` and `rc_auth_uid` — it doesn't read the Auth.js JWT.
|
||||||
|
After Google sign-in succeeds, the user has a valid Auth.js session
|
||||||
|
but `getAdminUser()` returns null. The middleware can't fix that
|
||||||
|
because it can't write to the JWT without going through the
|
||||||
|
credentials provider. This is the next piece of the Auth.js migration
|
||||||
|
(see CLAUDE.md "Auth.js migration — in progress"). The current fix
|
||||||
|
gets the dev/demo path working; the Google OAuth → admin path needs
|
||||||
|
the `getAdminUser()` Auth.js check wired up.
|
||||||
|
|
||||||
|
## Auth.js v5 wiring complete — 2026-06-06
|
||||||
|
|
||||||
|
Push `1e9f9c0` completes the Auth.js path so Google sign-in lands the
|
||||||
|
user on `/admin` as a real admin (not "Your account does not have
|
||||||
|
admin access").
|
||||||
|
|
||||||
|
**What landed:**
|
||||||
|
|
||||||
|
- **`src/lib/db.ts` (NEW)** — shared `pg.Pool` singleton. The single
|
||||||
|
connection pool for the whole app. Extracted from `src/lib/auth.ts`
|
||||||
|
(which had its own private pool). Connection string resolution:
|
||||||
|
`DATABASE_URL` → `SUPABASE_DB_URL` → `POSTGRES_URL`.
|
||||||
|
|
||||||
|
- **`src/lib/auth.ts`** — imports the shared pool. The `signIn` event
|
||||||
|
now calls the new `upsert_admin_user_for_authjs` RPC instead of
|
||||||
|
the no-op existence check it had before.
|
||||||
|
|
||||||
|
- **`supabase/migrations/209_authjs_auto_create_admin.sql` (NEW)** —
|
||||||
|
pushed automatically by the deploy workflow (line 130 of
|
||||||
|
`.gitea/workflows/deploy.yml` does `cat supabase/migrations/[0-9]*.sql
|
||||||
|
| $PG`). Contains:
|
||||||
|
- `ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS
|
||||||
|
can_manage_settings BOOLEAN NOT NULL DEFAULT false` — defensive,
|
||||||
|
since this column is in the TypeScript `AdminUser` type but not
|
||||||
|
in any tracked migration (was likely dashboard-added).
|
||||||
|
- SECURITY DEFINER RPC `upsert_admin_user_for_authjs(p_user_id UUID)`
|
||||||
|
that inserts a `platform_admin` row with all `can_manage_*` flags
|
||||||
|
true, `ON CONFLICT (user_id) DO NOTHING`.
|
||||||
|
- `NOTIFY pgrst, 'reload schema'` so PostgREST picks up the new RPC.
|
||||||
|
|
||||||
|
- **`src/lib/admin-permissions.ts`** — new Auth.js session check
|
||||||
|
between `dev_session` and `rc_auth_uid`. Uses `auth()` from
|
||||||
|
`@/lib/auth` to decrypt the JWT cookie server-side, then
|
||||||
|
`getAdminUserFromPool()` queries `admin_users` + `admin_user_brands`
|
||||||
|
via the shared pool. The legacy `rc_auth_uid` path is unchanged
|
||||||
|
(deferred — it still hits the dummy Supabase URL in prod).
|
||||||
|
|
||||||
|
- **`src/middleware.ts`** — recognizes `authjs.session-token` and
|
||||||
|
`__Secure-authjs.session-token` cookies at the edge so signed-in
|
||||||
|
users aren't bounced to `/login`.
|
||||||
|
|
||||||
|
**Key insight: same ID space.** Both `admin_users.user_id` (UUID, per
|
||||||
|
`028_fix_caller_uid_type.sql`) and Auth.js `users.id` (UUID, per
|
||||||
|
`204_authjs_tables.sql:18`) are in the same UUID space. The
|
||||||
|
`@auth/pg-adapter` auto-generates a fresh UUID per new user on first
|
||||||
|
sign-in; the Google `sub` claim is stored separately in
|
||||||
|
`accounts."providerAccountId"`. So no schema change was needed —
|
||||||
|
just a `user_id` lookup in `getAdminUserFromPool()`.
|
||||||
|
|
||||||
|
**Full sign-in flow now:**
|
||||||
|
|
||||||
|
1. Dev/demo: visit `/admin` → middleware auto-issues `dev_session`
|
||||||
|
cookie → `getAdminUser()` returns platform_admin. (No DB call.)
|
||||||
|
2. Production: click "Sign in with Google" → Auth.js OAuth →
|
||||||
|
`signIn` event fires → `upsert_admin_user_for_authjs` creates
|
||||||
|
the `admin_users` row → redirect to `/admin` → `getAdminUser()`
|
||||||
|
reads JWT, queries pool via `auth.js.user.id`, returns
|
||||||
|
platform_admin.
|
||||||
|
|
||||||
|
**What's still broken (out of scope for this push):**
|
||||||
|
|
||||||
|
- Legacy `rc_auth_uid` path in `getAdminUser()` still fetches from
|
||||||
|
`${NEXT_PUBLIC_SUPABASE_URL}/rest/v1/...` which is a dummy
|
||||||
|
`http://localhost:54321` in prod. Any pre-existing user with a
|
||||||
|
`rc_auth_uid` cookie will get null. Defer until the Supabase →
|
||||||
|
direct Postgres migration of the REST calls.
|
||||||
|
- `getCurrentAdminUser` (client-side variant) still reads from
|
||||||
|
server-passed props — no change needed.
|
||||||
|
- The `signIn` event RPC call will fail silently if `DATABASE_URL`
|
||||||
|
is not set. The user would see "Your account does not have admin
|
||||||
|
access" and need to sign out and back in once the env is fixed.
|
||||||
|
|
||||||
|
## Deploy fix — PostgREST env + dead nextjs service — 2026-06-06
|
||||||
|
|
||||||
|
Push `2d55791` fixes two issues that broke the "Start Docker stack" step:
|
||||||
|
|
||||||
|
1. **`PGRST_DB_URI` not set** — the env var was only in the "Deploy"
|
||||||
|
step's env, which runs after PostgREST has already started.
|
||||||
|
PostgREST booted with a blank DB URI. Now set in the "Start
|
||||||
|
Docker stack" step's env and written to `$APP_DIR/.env` (the
|
||||||
|
file docker compose auto-loads).
|
||||||
|
|
||||||
|
2. **`docker-compose.yml` had a dead `nextjs` service** with
|
||||||
|
`env_file: ../.env.production`. That file is written by the
|
||||||
|
"Deploy" step (later in the workflow), so at "Start Docker stack"
|
||||||
|
time the path doesn't exist. `docker compose up` validates the
|
||||||
|
whole compose file and bailed.
|
||||||
|
|
||||||
|
The `nextjs` service is dead code anyway — PM2 runs Next.js
|
||||||
|
directly from `$APP_DIR`, never through docker. Removed it.
|
||||||
|
|
||||||
|
**Other fixes in the same push:**
|
||||||
|
|
||||||
|
- `docker compose up -d db postgrest minio minio_init` referenced
|
||||||
|
services that don't exist in the compose file. Postgres runs on
|
||||||
|
the host (the migrations step uses `psql -h 127.0.0.1`), not in
|
||||||
|
docker. Changed to just `postgrest`.
|
||||||
|
|
||||||
|
- The `pg_isready` check was `docker compose exec -T db pg_isready`.
|
||||||
|
Since `db` is a host service, changed to
|
||||||
|
`PGPASSWORD=... psql -h 127.0.0.1 -U ... -d ... -c "SELECT 1"`.
|
||||||
|
|
||||||
|
**Architecture (now consistent):**
|
||||||
|
|
||||||
|
- Postgres: host (127.0.0.1:5432), migrations via `psql -h 127.0.0.1`
|
||||||
|
- PostgREST: docker, connects to host Postgres via `PGRST_DB_URI`
|
||||||
|
- Next.js: host, PM2 process, reads `DATABASE_URL` from `.env.production`
|
||||||
|
- MinIO: not yet wired up (the `MINIO_ROOT_USER`/`PASSWORD` env vars
|
||||||
|
are written to `.env` but no service consumes them yet — add a
|
||||||
|
`minio` service to docker-compose.yml when storage goes live)
|
||||||
|
|||||||
@@ -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>"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Runtime artefacts written by deploy.sh — do NOT commit these.
|
||||||
|
.deploy.lock
|
||||||
|
deploy.log
|
||||||
|
.postgrest-port
|
||||||
|
.nextjs-port
|
||||||
|
.env.production
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
Executable
+429
@@ -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
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# docker-compose.yml — production stack consumed by deploy.sh
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Only `postgrest` lives in docker. Postgres itself runs on the host
|
||||||
|
# (the deploy workflow applies migrations via `psql -h 127.0.0.1`).
|
||||||
|
# Next.js runs under PM2 on the host — it is NOT a docker service.
|
||||||
|
#
|
||||||
|
# The host-side port (POSTGREST_HOST_PORT) is written by the deploy
|
||||||
|
# workflow into $APP_DIR/.env. We interpolate from there with
|
||||||
|
# ${VAR:-3011} so a manual `docker compose up` without the deploy
|
||||||
|
# script still works.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
Executable
+54
@@ -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"
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
// Lock the file-tracing root to the project directory. Without this,
|
||||||
|
// Next.js 16 walks up from package.json looking for a lockfile, finds
|
||||||
|
// the homelab runner's stale `act` cache at
|
||||||
|
// /home/tyler/.cache/act/.../package-lock.json, and warns:
|
||||||
|
// "We detected multiple lockfiles and selected the directory of
|
||||||
|
// /home/tyler/package-lock.json as the root directory."
|
||||||
|
// The deploy runner's APP_DIR is /home/tyler/route-commerce, so
|
||||||
|
// resolving relative to the project root is correct both locally and
|
||||||
|
// in CI.
|
||||||
|
outputFileTracingRoot: ".",
|
||||||
|
|
||||||
// Enable strict mode
|
// Enable strict mode
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { signIn, signOut } from "@/lib/auth";
|
import { signIn, signOut } from "@/lib/auth";
|
||||||
import { AuthError } from "next-auth";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server actions that wrap the Auth.js v5 `signIn` / `signOut` API for
|
* Server actions that wrap the Auth.js v5 `signIn` / `signOut` API for
|
||||||
@@ -19,38 +18,15 @@ import { AuthError } from "next-auth";
|
|||||||
* <button type="submit">Sign in with Google</button>
|
* <button type="submit">Sign in with Google</button>
|
||||||
* </form>
|
* </form>
|
||||||
*
|
*
|
||||||
* Usage for the dev credentials provider (dev only):
|
* Note: dev/demo authentication is no longer a button on the login page.
|
||||||
* <form action={signInWithDev}>
|
* `src/middleware.ts` auto-issues the `dev_session` cookie for /admin/*
|
||||||
* <input name="username" />
|
* when ALLOW_DEV_LOGIN is enabled. See CLAUDE.md.
|
||||||
* <input name="password" type="password" />
|
|
||||||
* <button type="submit">Dev login</button>
|
|
||||||
* </form>
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export async function signInWithGoogle(): Promise<void> {
|
export async function signInWithGoogle(): Promise<void> {
|
||||||
await signIn("google", { redirectTo: "/admin" });
|
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> {
|
export async function signOutAction(): Promise<void> {
|
||||||
await signOut({ redirectTo: "/login" });
|
await signOut({ redirectTo: "/login" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
@@ -59,13 +64,25 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||||||
redirect("/change-password");
|
redirect("/change-password");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the active brand (URL > cookie > legacy > first of brand_ids)
|
// Resolve the active brand (URL > cookie > legacy > first of brand_ids).
|
||||||
const activeBrandId = await getActiveBrandId(adminUser);
|
// Wrapped in try/catch so a transient brand-resolution failure can't
|
||||||
|
// crash the whole admin shell — we fall back to null (no active brand).
|
||||||
|
let activeBrandId: string | null = null;
|
||||||
|
try {
|
||||||
|
activeBrandId = await getActiveBrandId(adminUser);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/layout] getActiveBrandId failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch accessible brands for the sidebar BrandSelector. We do this
|
// Fetch accessible brands for the sidebar BrandSelector. Wrapped in
|
||||||
// unconditionally — `listBrandsForAdmin` is cheap and the sidebar
|
// try/catch so the sidebar renders empty rather than crashing the page
|
||||||
// decides whether to show the dropdown.
|
// if the brands query fails.
|
||||||
const brands = await listBrandsForAdmin();
|
let brands: Awaited<ReturnType<typeof listBrandsForAdmin>> = [];
|
||||||
|
try {
|
||||||
|
brands = await listBrandsForAdmin();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/layout] listBrandsForAdmin failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProviderWrapper>
|
<ToastProviderWrapper>
|
||||||
|
|||||||
+13
-8
@@ -27,16 +27,21 @@ export default async function AdminPage() {
|
|||||||
// Resolve active brand via the canonical resolver (URL > cookie > legacy brand_id
|
// Resolve active brand via the canonical resolver (URL > cookie > legacy brand_id
|
||||||
// > first of brand_ids). For platform_admin in dev mode this is null, so we
|
// > first of brand_ids). For platform_admin in dev mode this is null, so we
|
||||||
// fall back to the first brand in the brands table to keep the dashboard's
|
// fall back to the first brand in the brands table to keep the dashboard's
|
||||||
// "Active Products" stat in sync with the billing page.
|
// "Active Products" stat in sync with the billing page. Wrapped in try/catch
|
||||||
|
// so a transient DB/network failure can't crash the whole admin page.
|
||||||
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
|
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
|
||||||
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
|
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
|
||||||
const { data: firstBrand } = await supabase
|
try {
|
||||||
.from("brands")
|
const { data: firstBrand } = await supabase
|
||||||
.select("id")
|
.from("brands")
|
||||||
.limit(1)
|
.select("id")
|
||||||
.single();
|
.limit(1)
|
||||||
if (firstBrand?.id) {
|
.single();
|
||||||
dashboardBrandId = firstBrand.id;
|
if (firstBrand?.id) {
|
||||||
|
dashboardBrandId = firstBrand.id;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/page] supabase brands lookup failed:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { updatePasswordAction } from "@/actions/admin/password";
|
||||||
|
import { logUserActivity } from "@/actions/admin/audit";
|
||||||
|
|
||||||
|
export default function ChangePasswordForm({ userId }: { userId: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError("Password must be at least 8 characters.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== confirm) {
|
||||||
|
setError("Passwords do not match.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
const result = await updatePasswordAction(password);
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logUserActivity({
|
||||||
|
user_id: userId,
|
||||||
|
activity_type: "password_change",
|
||||||
|
details: {},
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
router.push("/admin");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8">
|
||||||
|
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-full bg-amber-900/30 border border-amber-700/40">
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6 text-amber-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-zinc-100">Change Password</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
You must set a new password before continuing.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-xl bg-red-900/20 border border-red-800/50 p-4 text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-400 mb-1">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors"
|
||||||
|
placeholder="Min. 8 characters"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-400 mb-1">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors"
|
||||||
|
placeholder="Repeat password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full rounded-xl bg-violet-600 hover:bg-violet-500 px-6 py-4 text-base font-bold text-white disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? "Updating..." : "Update Password"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+72
-15
@@ -1,26 +1,83 @@
|
|||||||
import NextAuth from "next-auth";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import { authConfig } from "@/auth.config";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root-level proxy (formerly `middleware.ts`, renamed in Next.js 16).
|
* 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()`?
|
* Routing policy:
|
||||||
* 1. Auth.js v5 ships an `authorized` callback in `authConfig` that
|
* 1. `/admin/*` with no auth cookie + `ALLOW_DEV_LOGIN !== "false"`
|
||||||
* knows which routes need a session. We reuse it here at the edge.
|
* → set `dev_session=platform_admin` and let the request through.
|
||||||
* 2. It auto-populates `request.auth` with the session (JWT-decoded)
|
* This makes `/admin` "just work" in dev/demo without any login
|
||||||
* for any server component/page that reads `auth()` later.
|
* UI gymnastics.
|
||||||
|
* 2. `/login` with an auth cookie (any flavour)
|
||||||
|
* → redirect to `/admin` so an authenticated user never sees the
|
||||||
|
* login form.
|
||||||
|
* 3. `/admin/*` (or `/protected-example`) with no auth cookie
|
||||||
|
* → redirect to `/login`.
|
||||||
|
* 4. Everything else → continue.
|
||||||
*
|
*
|
||||||
* Public routes, admin gating, and the `auth` cookie are all configured
|
* Auth-cookie flavours recognised:
|
||||||
* in `src/auth.config.ts`.
|
* - `dev_session` (dev auto-login, see above)
|
||||||
|
* - `rc_auth_uid` / `rc_uid` (legacy /api/login flow)
|
||||||
|
* - `authjs.session-token` / `__Secure-authjs.session-token` (Auth.js v5)
|
||||||
|
*
|
||||||
|
* The proxy only checks cookie *presence*. The real auth check (JWT
|
||||||
|
* signature decryption, admin role lookup) happens in
|
||||||
|
* `getAdminUser()` server-side. The proxy is just routing.
|
||||||
*/
|
*/
|
||||||
const { auth } = NextAuth(authConfig);
|
|
||||||
|
|
||||||
export default auth;
|
function isAuthenticated(request: NextRequest): boolean {
|
||||||
|
const dev = request.cookies.get("dev_session")?.value;
|
||||||
|
if (
|
||||||
|
dev === "platform_admin" ||
|
||||||
|
dev === "brand_admin" ||
|
||||||
|
dev === "store_employee"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (request.cookies.get("rc_auth_uid")?.value) return true;
|
||||||
|
if (request.cookies.get("rc_uid")?.value) return true;
|
||||||
|
if (request.cookies.get("authjs.session-token")?.value) return true;
|
||||||
|
if (request.cookies.get("__Secure-authjs.session-token")?.value) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function proxy(request: NextRequest) {
|
||||||
|
const { nextUrl } = request;
|
||||||
|
const isOnAdmin = nextUrl.pathname.startsWith("/admin");
|
||||||
|
const isOnProtectedExample = nextUrl.pathname.startsWith(
|
||||||
|
"/protected-example"
|
||||||
|
);
|
||||||
|
const isOnLogin = nextUrl.pathname === "/login";
|
||||||
|
const authenticated = isAuthenticated(request);
|
||||||
|
|
||||||
|
// ── 1. Dev auto-login for /admin/* ───────────────────────────────
|
||||||
|
if (isOnAdmin && !authenticated) {
|
||||||
|
const allowDev = process.env.ALLOW_DEV_LOGIN !== "false";
|
||||||
|
if (allowDev) {
|
||||||
|
const response = NextResponse.next();
|
||||||
|
response.cookies.set("dev_session", "platform_admin", {
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24, // 1 day
|
||||||
|
sameSite: "lax",
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Bounce authenticated users away from /login ───────────────
|
||||||
|
if (isOnLogin && isAuthenticated(request)) {
|
||||||
|
return NextResponse.redirect(new URL("/admin", nextUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Gate protected routes ─────────────────────────────────────
|
||||||
|
if ((isOnAdmin || isOnProtectedExample) && !authenticated) {
|
||||||
|
return NextResponse.redirect(new URL("/login", nextUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Everything else: continue ─────────────────────────────────
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
export const config = {
|
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"],
|
matcher: ["/admin/:path*", "/admin", "/login", "/protected-example"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
-- 209_authjs_auto_create_admin.sql
|
||||||
|
-- Auto-create a platform_admin row when a new user signs in via Auth.js.
|
||||||
|
--
|
||||||
|
-- Called from the `signIn` event in `src/lib/auth.ts`. The RPC is
|
||||||
|
-- idempotent (ON CONFLICT DO NOTHING) so repeat sign-ins are no-ops.
|
||||||
|
|
||||||
|
-- Defensive: ensure can_manage_settings column exists. It was likely
|
||||||
|
-- added via the Supabase dashboard (it's referenced in the TypeScript
|
||||||
|
-- `AdminUser` type at `src/lib/admin-permissions-types.ts` but not in
|
||||||
|
-- any tracked migration). ADD COLUMN IF NOT EXISTS is safe to re-run.
|
||||||
|
ALTER TABLE admin_users
|
||||||
|
ADD COLUMN IF NOT EXISTS can_manage_settings BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Defensive: ensure admin_users.user_id has a unique constraint so the
|
||||||
|
-- `ON CONFLICT (user_id)` below resolves. The table was created via the
|
||||||
|
-- Supabase dashboard — we can't be sure the dashboard created a UNIQUE
|
||||||
|
-- index on user_id. If the constraint is missing, the ON CONFLICT
|
||||||
|
-- clause will fail the whole "Apply migrations" step on the deploy
|
||||||
|
-- runner. Skip silently if a matching unique/primary constraint already
|
||||||
|
-- exists, otherwise add one (cleaning up any duplicate rows first so
|
||||||
|
-- the ADD CONSTRAINT doesn't fail).
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conrelid = 'public.admin_users'::regclass
|
||||||
|
AND contype IN ('u', 'p') -- unique or primary key
|
||||||
|
AND pg_get_constraintdef(oid) ILIKE '%(user_id)%'
|
||||||
|
) THEN
|
||||||
|
-- Shouldn't happen in practice (this RPC is the only writer for new
|
||||||
|
-- rows), but guard against duplicate user_id values that would
|
||||||
|
-- block the unique constraint from being created.
|
||||||
|
DELETE FROM admin_users a
|
||||||
|
USING admin_users b
|
||||||
|
WHERE a.user_id = b.user_id
|
||||||
|
AND a.ctid > b.ctid;
|
||||||
|
ALTER TABLE admin_users
|
||||||
|
ADD CONSTRAINT admin_users_user_id_key UNIQUE (user_id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- SECURITY DEFINER RPC: upsert a platform_admin row for the given
|
||||||
|
-- Auth.js user id.
|
||||||
|
--
|
||||||
|
-- Bypasses RLS on admin_users (which is enabled — see
|
||||||
|
-- 109_enable_rls_critical.sql:21). Runs with the function owner's
|
||||||
|
-- privileges so the auto-create on first sign-in can always succeed.
|
||||||
|
CREATE OR REPLACE FUNCTION upsert_admin_user_for_authjs(p_user_id UUID)
|
||||||
|
RETURNS SETOF admin_users
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
INSERT INTO admin_users (
|
||||||
|
user_id,
|
||||||
|
role,
|
||||||
|
active,
|
||||||
|
must_change_password,
|
||||||
|
can_manage_products,
|
||||||
|
can_manage_stops,
|
||||||
|
can_manage_orders,
|
||||||
|
can_manage_pickup,
|
||||||
|
can_manage_messages,
|
||||||
|
can_manage_refunds,
|
||||||
|
can_manage_users,
|
||||||
|
can_manage_water_log,
|
||||||
|
can_manage_reports,
|
||||||
|
can_manage_settings
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
p_user_id,
|
||||||
|
'platform_admin',
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
ON CONFLICT (user_id) DO NOTHING
|
||||||
|
RETURNING *;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Reload PostgREST schema cache so the new RPC is immediately callable.
|
||||||
|
NOTIFY pgrst, 'reload schema';
|
||||||
Reference in New Issue
Block a user