feat(auth): Auth.js v5 + Postgres adapter for local smoke test

Wire up NextAuth v5 with @auth/pg-adapter, JWT sessions (edge-friendly),
and a dev Credentials provider for local testing without Google OAuth.

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

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

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

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

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

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

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

Gradual migration in progress
- src/lib/admin-permissions.ts still uses dev_session / rc_auth_uid;
  the admin shell will show 'Access Denied' for Auth.js-only
  sessions until each page is flipped over
- @supabase/* packages remain in package.json for the same reason
- production deployment (AUTH_URL=https://, __Secure- cookies) is
  out of scope for this pass
This commit is contained in:
2026-06-06 03:38:00 +00:00
parent 2b3fd214d8
commit ec1506dc82
14 changed files with 798 additions and 182 deletions
+82
View File
@@ -0,0 +1,82 @@
# ============================================================================
# Route Commerce — Environment variables
# ============================================================================
# Copy to `.env.local` and fill in real values for local development.
# Production: set these in your hosting dashboard (Vercel / Netlify / etc.).
# ============================================================================
# ── App ─────────────────────────────────────────────────────────────────────
NEXT_PUBLIC_BASE_URL=http://localhost:4000
NEXT_PUBLIC_SITE_URL=http://localhost:4000
# ── Database (Postgres, direct — Supabase is being removed) ────────────────
# Single connection string used by `pg.Pool` in src/lib/auth.ts and the
# admin-permissions / data-service layer. Format:
# postgresql://USER:PASS@HOST:PORT/DBNAME?sslmode=require
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/route_commerce
# ── Auth.js (NextAuth v5) ───────────────────────────────────────────────────
# Generate with: npx auth secret
# Or: openssl rand -base64 32
AUTH_SECRET=replace-me-with-a-32-byte-base64-string
# Base URL used to build OAuth callback URLs. In dev:
AUTH_URL=http://localhost:4000
# In production, set to https://yourdomain.com
# Google OAuth provider.
# 1. Go to https://console.cloud.google.com/apis/credentials
# 2. Create an OAuth 2.0 Client ID (type: Web application)
# 3. Add Authorized redirect URI: http://localhost:3000/api/auth/callback/google
# 4. Copy client id + client secret below
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Auth.js also reads AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET if you prefer the
# default NextAuth variable names. The code in src/auth.config.ts falls
# back to those names if GOOGLE_CLIENT_ID is unset.
# Set to "false" to disable the in-app dev credentials provider even in
# development. Default: enabled in dev only.
ALLOW_DEV_LOGIN=true
# ── Supabase (legacy, being removed) ────────────────────────────────────────
# Still used by the existing admin pages, server actions, and the
# `getAdminUser` flow. Once the auth migration is complete and the
# @supabase/* packages are removed, these can go away.
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
# ── Stripe ─────────────────────────────────────────────────────────────────
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
STRIPE_PRICE_STARTER=
STRIPE_PRICE_FARM=
STRIPE_PRICE_ENTERPRISE=
STRIPE_PRICE_HARVEST_REACH=
STRIPE_PRICE_WHOLESALE_PORTAL=
STRIPE_PRICE_WATER_LOG=
STRIPE_PRICE_AI_TOOLS=
STRIPE_PRICE_SQUARE_SYNC=
STRIPE_PRICE_SMS_CAMPAIGNS=
# ── Resend (transactional email) ────────────────────────────────────────────
RESEND_API_KEY=
RESEND_WEBHOOK_SECRET=
# ── AI providers ────────────────────────────────────────────────────────────
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
GOOGLE_API_KEY=
XAI_API_KEY=
MINIMAX_API_KEY=
MINIMAX_BASE_URL=https://api.minimax.io/v1
# ── Square (optional) ───────────────────────────────────────────────────────
SQUARE_APP_SECRET=
SQUARE_ENVIRONMENT=sandbox
# ── Cron / automation ───────────────────────────────────────────────────────
CRON_SECRET=replace-me-with-a-random-string
+49 -15
View File
@@ -6,7 +6,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns. Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns.
Tech stack: Next.js 16 (App Router) · Supabase (auth + Postgres + RLS) · Stripe · Square · Resend (email) · Tailwind CSS v4 Tech stack: Next.js 16 (App Router) · **Postgres** (direct — Supabase is being removed) · Auth.js (NextAuth v5, in-progress migration from bespoke cookie auth) · Stripe · Square · Resend (email) · Tailwind CSS v4
> **Direction:** Supabase is being removed in favor of a direct Postgres connection. The `supabase/` directory is kept as a path for migrations tooling only (no Supabase platform/CLI/auth). Until the Auth.js migration ships, auth still flows through the `dev_session` / `rc_auth_uid` cookies — see the Authentication section. New DB code should connect to Postgres directly (via `pg` or the chosen driver — see Database section) and **must not** import from `@supabase/*` or call Supabase REST.
--- ---
@@ -21,14 +23,12 @@ npx tsc --noEmit # TypeScript check (no emit)
npx playwright test # Run E2E tests (Playwright) npx playwright test # Run E2E tests (Playwright)
``` ```
> The migrate script auto-detects Supabase CLI first, then falls back to direct PostgreSQL. > The migrate script (`supabase/push-migrations.js`) now only uses the direct `pg` path — the Supabase CLI branch is legacy. It reads `DATABASE_URL` from `.env.local` via `dotenv`. `pg` is already in devDependencies.
> For CLI mode: `brew install supabase/tap/supabase` then `supabase link --project-ref wnzkhezyhnfzhkhiflrp` > If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
> For direct PG mode: `pg` and `dotenv` are already in devDependencies.
> If `get_brand_settings` migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
**Recent migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed in the session). Cat `MEMORY.md` for details. **Recent migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed in the session). Cat `MEMORY.md` for details.
No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.ts`). E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost:3000` for local runs, or pass `--config` with a local config.
--- ---
@@ -41,10 +41,24 @@ No test suite currently exists. E2E tests use Playwright (`tests/` or `test-e2e.
- `dev_session=brand_admin` — full access to assigned brand only - `dev_session=brand_admin` — full access to assigned brand only
- `dev_session=store_employee` — limited access (orders, pickup, wholesale only) - `dev_session=store_employee` — limited access (orders, pickup, wholesale only)
`src/lib/admin-permissions.ts` is the single source of truth for the current admin user. It uses a `dev_session` cookie in development and Supabase Auth in production. **Never import this file directly into Client Components** — use the `getCurrentAdminUser` server action from `@/actions/admin-user` instead. `src/lib/admin-permissions.ts` is the single source of truth for the current admin user. It uses a `dev_session` cookie in development and the legacy `rc_auth_uid` cookie in production (set by the pre-Auth.js `/api/login`) — never import this file directly into Client Components. Use the `getCurrentAdminUser` server action from `@/actions/admin-user` instead.
The middleware (`src/middleware.ts`) guards `/admin/:path*` and `/login`. It auto-issues a `dev_session=platform_admin` cookie for the demo flow when no auth is present. A `clerk-auth.ts` helper exists in `src/lib/` but is currently a stub — do not depend on it.
The `AdminUser` type lives in `src/lib/admin-permissions-types.ts` and is shared across server/client boundary. The `AdminUser` type lives in `src/lib/admin-permissions-types.ts` and is shared across server/client boundary.
#### Auth.js (NextAuth v5) migration — in progress
The platform is migrating from the bespoke `dev_session` + `rc_auth_uid` cookie flow to **Auth.js (NextAuth v5)**, with Supabase as the database adapter and email/OAuth providers. While the migration is in flight:
- Do **not** add new code that depends on the `dev_session` or `rc_auth_uid` cookies — write against the Auth.js API (`auth()`, `signIn`, `signOut`, `getSession`) instead.
- New env vars: `AUTH_SECRET`, `AUTH_URL`, and provider-specific keys (`AUTH_GITHUB_ID`/`SECRET`, `AUTH_GOOGLE_ID`/`SECRET`, etc.). See `.env.example` for the full list.
- A new route handler at `src/app/api/auth/[...nextauth]/route.ts` will replace the ad-hoc `/api/login`, `/api/auth/uid`, and `/api/logout` endpoints.
- The middleware (`src/middleware.ts`) will eventually use `auth()` from NextAuth to populate the session; the existing `dev_session` auto-login branch is a temporary fallback for demos.
- `src/lib/admin-permissions.ts` will keep its public surface (`getAdminUser`, `getCurrentAdminUser`) but read the session from NextAuth internally — the `AdminUser` type does not need to change.
- `clerk-auth.ts` is being removed in favor of Auth.js; do not extend it.
- Until the migration ships, the `dev_session` and `rc_auth_uid` paths remain the source of truth — see the section above for current behavior.
### Server Actions Pattern ### Server Actions Pattern
All database writes go through server actions in `src/actions/`. These: All database writes go through server actions in `src/actions/`. These:
@@ -55,9 +69,19 @@ All database writes go through server actions in `src/actions/`. These:
Server actions are "use server" files that export async functions. Client components import and call them directly. Server actions are "use server" files that export async functions. Client components import and call them directly.
### SECURITY DEFINER RPCs + Brand Scoping ### Database (Postgres, direct)
The app uses **PostgreSQL SECURITY DEFINER functions** for all data access. These run with the function owner's privileges and bypass RLS entirely. This means: The app connects to **Postgres directly** — no Supabase platform, JS client, or REST gateway. Server actions use the `pg` driver (or whatever the chosen connection layer is) to call `SECURITY DEFINER` PL/pgSQL functions. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs.
#### Connection
- `DATABASE_URL` in `.env.local` (and hosting dashboard) is the only required DB env var.
- A single shared `pg` `Pool` is exported from `src/lib/db.ts` (TBD — to be created/confirmed during the migration). Server actions and API routes import it and call `pool.query(...)` against RPC names.
- No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — these are being purged from the codebase.
#### SECURITY DEFINER RPCs + Brand Scoping
The app uses **PostgreSQL SECURITY DEFINER functions** for all data access. These run with the function owner's privileges and bypass any future RLS. This means:
- Brand isolation must be enforced at the **application layer** (in server actions), not in database policies - Brand isolation must be enforced at the **application layer** (in server actions), not in database policies
- Every RPC that touches brand-scoped data accepts a `p_brand_id UUID` parameter and filters by it - Every RPC that touches brand-scoped data accepts a `p_brand_id UUID` parameter and filters by it
@@ -182,10 +206,19 @@ For annual pricing, create separate annual prices in Stripe (e.g., $441/yr for S
### Communications Module ("Harvest Reach") ### Communications Module ("Harvest Reach")
The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. (The "no RLS" framing carries over from the Supabase era; on raw Postgres this just means no row-level policies — scoping is still enforced by RPC + app layer.)
`send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`. `send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`.
**Scheduled automations** (declared in `vercel.json`):
- `POST /api/email-automation/abandoned-cart` — every 6h, fires abandoned-cart sequence emails
- `POST /api/email-automation/welcome-sequence` — every 6h, fires welcome onboarding sequence
- `POST /api/cron/send-scheduled` — daily 09:00, sends scheduled campaigns
- `POST /api/wholesale/notifications/{send,dispatch,pickup-reminder}` — wholesale lifecycle
- `POST /api/square/process-queue` — every 2 min, drains Square sync queue
These endpoints are also reachable via curl for manual triggering; the email-automation routes accept `Authorization: Bearer $CRON_SECRET`.
### Payments ### Payments
- **Stripe** — primary payment processor; `src/actions/payments.ts` and `src/app/api/stripe/` handle checkout, webhooks, refunds - **Stripe** — primary payment processor; `src/actions/payments.ts` and `src/app/api/stripe/` handle checkout, webhooks, refunds
@@ -200,7 +233,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
## Key Conventions ## Key Conventions
- All DB mutations use Supabase REST API (`fetch` to `${supabaseUrl}/rest/v1/rpc/...`) from server actions, NOT the Supabase JS client (avoids SSR cookie issues) - All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports or REST fetch to `*/rest/v1/`.
- `gen_random_uuid()` used in migrations for primary keys - `gen_random_uuid()` used in migrations for primary keys
- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE` - Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE`
- Status enums stored as TEXT — no PostgreSQL ENUM type - Status enums stored as TEXT — no PostgreSQL ENUM type
@@ -217,11 +250,11 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|---|---| |---|---|
| Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` | | Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` |
| Middleware (route protection) | `src/middleware.ts` | | Middleware (route protection) | `src/middleware.ts` |
| Server actions | `src/actions/*.ts` (one file per domain) | | Server actions | `src/actions/*.ts` (one file per domain; also grouped into `src/actions/{admin,ai,billing,communications,harvest-reach,integrations,orders,products,settings,shipping,stops,water-log,platform,route-trace,time-tracking,email-automation}/`) |
| Admin pages | `src/app/admin/[module]/page.tsx` | | Admin pages | `src/app/admin/[module]/page.tsx` |
| Admin client components | `src/components/admin/*.tsx` | | Admin client components | `src/components/admin/*.tsx` |
| Migrations | `supabase/migrations/` | | Migrations | `supabase/migrations/` (kept for now; will likely move to `db/migrations/` in a later pass) |
| Supabase client | `src/lib/supabase.ts` | | Postgres pool / driver | `src/lib/db.ts` (TBD — create during the Supabase removal pass) |
| Email templates | `src/lib/email-templates.ts` | | Email templates | `src/lib/email-templates.ts` |
| Date formatting | `src/lib/format-date.ts` | | Date formatting | `src/lib/format-date.ts` |
| Feature flags | `src/lib/feature-flags.ts` | | Feature flags | `src/lib/feature-flags.ts` |
@@ -238,7 +271,8 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
## Gotchas ## Gotchas
- **Dev mode `brand_id: null`**: `getAdminUser()` returns `brand_id: null` for platform_admin dev sessions. Always pass explicit `brandId` to server action functions that accept it — don't rely on `adminUser.brand_id` alone. - **Dev mode `brand_id: null`**: `getAdminUser()` returns `brand_id: null` for platform_admin dev sessions. Always pass explicit `brandId` to server action functions that accept it — don't rely on `adminUser.brand_id` alone.
- **Communications = no RLS**: The communications tables (campaigns, templates, contacts, message_logs) have RLS disabled. All brand scoping must be enforced in server actions. - **Communications = no RLS**: The communications tables (campaigns, templates, contacts, message_logs) have no row-level policies. All brand scoping must be enforced in server actions.
- **Supabase residue in the wild**: `grep -r "@supabase" src/` will still find imports during the transition. Do not add new ones; if you're touching a file that imports from Supabase, replace the call with the equivalent `pg`-pool call before merging.
- **Webhook event_id**: `log_communication_messages` never populates `event_id`, so the Resend webhook uses `customer_email + subject` lookup instead. - **Webhook event_id**: `log_communication_messages` never populates `event_id`, so the Resend webhook uses `customer_email + subject` lookup instead.
- **Mixed fulfillment orders**: An order can have both pickup and ship items. `get_shipping_orders` RPC returns orders with at least one `fulfillment = 'ship'` item. - **Mixed fulfillment orders**: An order can have both pickup and ship items. `get_shipping_orders` RPC returns orders with at least one `fulfillment = 'ship'` item.
- **SMS opt-in defaults**: `communication_contacts.sms_opt_in` defaults to `FALSE` (opt-out by default). `email_opt_in` defaults to `TRUE`. Always check `sms_opt_in` specifically for SMS sends, not `email_opt_in`. - **SMS opt-in defaults**: `communication_contacts.sms_opt_in` defaults to `FALSE` (opt-out by default). `email_opt_in` defaults to `TRUE`. Always check `sms_opt_in` specifically for SMS sends, not `email_opt_in`.
+50 -9
View File
@@ -2,11 +2,40 @@
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations. This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
**Last updated:** 2026-06-03 (during Supabase migration apply session) **Last updated:** 2026-06-06 (Supabase → Postgres pivot)
--- ---
## Supabase CLI + Migrations Tooling ## 🚨 Direction Pivot (2026-06-06) — Supabase → Postgres
The platform is moving off Supabase entirely. We are connecting to **Postgres directly** (via `pg`), with **Auth.js (NextAuth v5)** handling authentication. See `CLAUDE.md` for the full updated architecture.
### What changes immediately
- **DB connection**: `DATABASE_URL` is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`.
- **Migrations**: only the `pg` direct path in `supabase/push-migrations.js` is supported going forward. The Supabase CLI branch is dead code. The script reads `DATABASE_URL` from `.env.local` via `dotenv`.
- **Code**: no new `@supabase/*` imports, no `rest/v1/` REST fetch, no Supabase JS client usage. Use a shared `pg` `Pool` (target location: `src/lib/db.ts`, **TBD — create during the cutover**).
- **Auth**: legacy `rc_auth_uid` cookie + bespoke `/api/login` is being replaced by Auth.js. Until the Auth.js migration ships, the `dev_session` cookie remains the source of truth.
- **Storage**: Supabase Storage (e.g. the `product-images` bucket created in migration 145) is going away. Need an S3-compatible alternative — **TBD**.
### What's TBD / needs follow-up
- [ ] `DATABASE_URL` for local dev (Neon? local Postgres? — user hasn't specified the Postgres host yet)
- [ ] New connection layer: raw `pg` Pool vs Drizzle vs Prisma vs Neon serverless driver — **not decided**
- [ ] Auth.js migration actually landing (currently "in progress" per CLAUDE.md)
- [ ] Where do product images, brand logos, etc. live now? S3? Cloudflare R2? Re-encode as URL strings?
- [ ] Whether the Supabase project (`wnzkhezyhnfzhkhiflrp`) gets shut down or kept read-only for the transition
- [ ] Cutover sequencing: do we delete `@supabase/*` from `package.json` in one PR or incrementally?
### Migration content that's now obsolete
- **145 (product-images bucket)**: Supabase Storage bucket + RLS policies. Replaced by object store of choice.
- **Any RLS policy on tables** (200 added several): the "no RLS, app-layer scoping" model still holds but the policies are inert in the new world.
- The `supabase link --project-ref wnzkhezyhnfzhkhiflrp` setup is no longer needed for ongoing work.
### Historical sections below
The "Supabase CLI + Migrations Tooling" section that used to live at the top of this file describes the *previous* tooling. It is kept below as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use the `pg` direct path instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied.
---
## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above)*
### Login + Link (done in this session) ### Login + Link (done in this session)
- User ran `supabase login` - User ran `supabase login`
@@ -35,15 +64,23 @@ Key changes:
- Falls back to direct `pg` only if CLI path fails. - Falls back to direct `pg` only if CLI path fails.
- Header comments updated with current recommended workflow. - Header comments updated with current recommended workflow.
**Recommended commands now:** **Recommended commands now (Supabase CLI path — being phased out, use `pg` direct path going forward):**
```bash ```bash
# Supabase CLI path (legacy — do not use going forward)
supabase login supabase login
supabase link --project-ref wnzkhezyhnfzhkhiflrp supabase link --project-ref wnzkhezyhnfzhkhiflrp
node supabase/push-migrations.js 148 # or any prefix node supabase/push-migrations.js 148 # CLI path
# or # or
npm run migrate:one 148 npm run migrate:one 148
``` ```
```bash
# Direct pg path (this is the future — only the pg branch is kept alive in the script)
DATABASE_URL=postgres://... node supabase/push-migrations.js 148
# or
DATABASE_URL=postgres://... npm run migrate:one 148
```
`npm run migrate` (no arg) will push every `*.sql` in order (use with caution). `npm run migrate` (no arg) will push every `*.sql` in order (use with caution).
--- ---
@@ -133,19 +170,23 @@ Verification queries (post-apply) confirmed:
--- ---
## Current State / Gotchas ## Current State / Gotchas (2026-06-06)
- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. Only early ones (001/002) + many timestamped migrations from other activity are tracked. - The Supabase CLI is no longer the recommended path. Use `DATABASE_URL` + `pg` directly. The `supabase/` directory is kept as a path for migrations tooling only.
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project. - The Postgres host/URL for local dev is **TBD** (not yet decided by the user). Until it's set, `npm run migrate` will fail at the `pg` connect step. (The Supabase project at `wnzkhezyhnfzhkhiflrp` may still exist as a fallback read-only target — unconfirmed.)
- When adding **new** migrations in the future, prefer the standard `supabase migration new` flow if possible, but the custom `push-migrations.js` + numeric prefix style is still the established pattern here. - `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. This is mostly irrelevant now that we're moving off Supabase.
- Storage policies, RLS, SECURITY DEFINER functions, and brand-scoped data are all over the place — test carefully after big applies. - Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project — this still holds under direct `pg`.
- When adding **new** migrations, use the established `supabase/push-migrations.js` + numeric prefix style (`NNN_descriptive_name.sql`). Do not introduce `supabase migration new` — that flow is going away with the CLI branch.
- Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs.
- CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha. - CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha.
- **Open question for next session:** confirm Postgres host + connection layer (raw `pg` vs Drizzle/Prisma) and start the actual cutover (drop `@supabase/*` deps, create `src/lib/db.ts`, replace cookie auth with Auth.js).
--- ---
## How to Use This Memory ## How to Use This Memory
- Cat this file at the start of future sessions if context is needed: `cat MEMORY.md` - Cat this file at the start of future sessions if context is needed: `cat MEMORY.md`
- **Read the Direction Pivot section first** — it supersedes the older Supabase-flavored instructions.
- Update this file with new key facts, applied migrations, or new gotchas. - Update this file with new key facts, applied migrations, or new gotchas.
- Feel free to add dated sections. - Feel free to add dated sections.
-64
View File
@@ -1,64 +0,0 @@
import { NextResponse, type NextRequest } from "next/server";
const DEV_UID = "dev-user-00000000-0000-0000-0000-000000000000";
export async function middleware(request: NextRequest) {
const response = NextResponse.next({ request });
// ── Dev session bypass (enabled in all envs for demo) ──────────────
// Allow dev cookies via: document.cookie = "dev_session=platform_admin; path=/"
const devSession = request.cookies.get("dev_session")?.value;
const isDevMode = devSession === "platform_admin" || devSession === "brand_admin" || devSession === "store_employee";
const rcAuthUid = request.cookies.get("rc_auth_uid")?.value;
let authUid: string | null = null;
if (isDevMode) {
// Dev session only valid in development
authUid = DEV_UID;
} else if (rcAuthUid) {
// rc_auth_uid is set by /api/login — treat as authenticated
authUid = rcAuthUid;
}
// No rc_auth_uid in production → authUid stays null → redirect to /login
const isAdmin = request.nextUrl.pathname.startsWith("/admin");
const isLogin = request.nextUrl.pathname === "/login";
if (isAdmin && !authUid) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
// Auto-login for demo: no Supabase configured, no auth cookie present
if (!supabaseUrl || !supabaseUrl.includes("supabase.co")) {
const url = request.nextUrl.clone();
url.pathname = "/admin";
url.searchParams.set("demo", "1");
const response = NextResponse.redirect(url);
response.cookies.set("dev_session", "platform_admin", {
path: "/",
maxAge: 60 * 60 * 24,
httpOnly: true,
sameSite: "strict",
});
return response;
}
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
if (isLogin && authUid) {
const url = request.nextUrl.clone();
url.pathname = "/admin";
return NextResponse.redirect(url);
}
return response;
}
export const config = {
matcher: [
"/admin/:path*",
"/admin",
"/login",
],
};
+4 -1
View File
@@ -3,7 +3,7 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0", "dev": "node fix-agents.js && next dev --webpack -H 0.0.0.0 -p 4000",
"build": "next build --webpack", "build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
@@ -15,6 +15,7 @@
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.96.0", "@anthropic-ai/sdk": "^0.96.0",
"@auth/pg-adapter": "^1.11.2",
"@clerk/nextjs": "^7.4.2", "@clerk/nextjs": "^7.4.2",
"@google/generative-ai": "^0.24.1", "@google/generative-ai": "^0.24.1",
"@gsap/react": "^2.1.2", "@gsap/react": "^2.1.2",
@@ -30,6 +31,7 @@
"gsap": "^3.15.0", "gsap": "^3.15.0",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"next": "^16.2.6", "next": "^16.2.6",
"next-auth": "^5.0.0-beta.31",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"openai": "^6.37.0", "openai": "^6.37.0",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
@@ -50,6 +52,7 @@
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/papaparse": "^5.5.2", "@types/papaparse": "^5.5.2",
"@types/pg": "^8.20.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
+56
View File
@@ -0,0 +1,56 @@
"use server";
import { signIn, signOut } from "@/lib/auth";
import { AuthError } from "next-auth";
/**
* Server actions that wrap the Auth.js v5 `signIn` / `signOut` API for
* use from client components.
*
* Why server actions?
* • The Auth.js v5 `signIn` function has to run on the server (it
* needs to set the session cookie, talk to the database adapter,
* and redirect the user to the OAuth provider).
* • Calling it from a client component via a server action keeps the
* client bundle small and avoids exposing the OAuth client secret.
*
* Usage from a client component:
* <form action={signInWithGoogle}>
* <button type="submit">Sign in with Google</button>
* </form>
*
* Usage for the dev credentials provider (dev only):
* <form action={signInWithDev}>
* <input name="username" />
* <input name="password" type="password" />
* <button type="submit">Dev login</button>
* </form>
*/
export async function signInWithGoogle(): Promise<void> {
await signIn("google", { redirectTo: "/admin" });
}
export async function signInWithDev(formData: FormData): Promise<void> {
const username = String(formData.get("username") ?? "admin");
const password = String(formData.get("password") ?? "dev");
try {
await signIn("dev-login", {
username,
password,
redirectTo: "/admin",
});
} catch (e) {
// signIn() throws a `NEXT_REDIRECT` to navigate — let that through
// so the redirect actually happens. Re-throw any other error so the
// caller can render a meaningful message.
if (e instanceof AuthError) {
throw new Error(`Dev sign-in failed: ${e.type}`);
}
throw e;
}
}
export async function signOutAction(): Promise<void> {
await signOut({ redirectTo: "/login" });
}
+16
View File
@@ -0,0 +1,16 @@
import { handlers } from "@/lib/auth";
/**
* Auth.js v5 catch-all route handler. Exposes:
* GET /api/auth/signin
* GET /api/auth/signout
* GET /api/auth/session
* GET /api/auth/csrf
* GET /api/auth/providers
* POST /api/auth/callback/:provider
* POST /api/auth/signin/:provider
* POST /api/auth/signout
*
* The actual OAuth + session logic is in `src/lib/auth.ts`.
*/
export const { GET, POST } = handlers;
+77
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, useCallback, Suspense } from "react"; import { useState, useEffect, useCallback, Suspense } from "react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { signInWithGoogle, signInWithDev } from "@/actions/auth-signin";
function LoginForm() { function LoginForm() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@@ -124,6 +125,82 @@ function LoginForm() {
</p> </p>
</div> </div>
{/* Auth.js v5 — primary sign-in: Google OAuth */}
<form action={signInWithGoogle} className="space-y-3">
<button
type="submit"
className="w-full inline-flex items-center justify-center gap-3 rounded-xl border border-stone-200/80 bg-white px-6 py-3.5 text-sm font-semibold text-stone-900 shadow-sm transition-all hover:bg-stone-50 active:scale-[0.98]"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
aria-label="Sign in with Google"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A10.99 10.99 0 0 0 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09a6.6 6.6 0 0 1 0-4.18V7.07H2.18a11 11 0 0 0 0 9.86l3.66-2.84z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1A10.99 10.99 0 0 0 2.18 7.07l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38z"
/>
</svg>
<span>Sign in with Google</span>
</button>
</form>
{/* Dev login (only visible in development) */}
{process.env.NODE_ENV !== "production" && (
<form action={signInWithDev} className="space-y-3">
<div className="rounded-xl bg-amber-50/70 border border-amber-200/60 px-3 py-2 text-xs text-amber-900">
<strong>Dev login</strong> only available in development.
Set <code>ALLOW_DEV_LOGIN=false</code> in <code>.env.local</code> to hide.
</div>
<div className="grid grid-cols-2 gap-2">
<input
name="username"
type="text"
defaultValue="admin"
className="rounded-xl border border-stone-200/80 bg-white/90 px-3 py-2.5 text-sm text-stone-900 outline-none focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 placeholder:text-stone-400"
placeholder="Username"
aria-label="Dev username"
/>
<input
name="password"
type="password"
defaultValue="dev"
className="rounded-xl border border-stone-200/80 bg-white/90 px-3 py-2.5 text-sm text-stone-900 outline-none focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 placeholder:text-stone-400"
placeholder="Password"
aria-label="Dev password"
/>
</div>
<button
type="submit"
className="w-full rounded-xl bg-stone-900 px-6 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-stone-700"
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
>
Dev sign in (no Google required)
</button>
</form>
)}
<div className="relative my-2">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-stone-200/70" />
</div>
<div className="relative flex justify-center text-xs uppercase tracking-wider">
<span className="bg-white/0 px-2 text-stone-400" style={{ background: "linear-gradient(to right, transparent, white 30%, white 70%, transparent)" }}>
or sign in with email
</span>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-5" aria-label="Sign in form"> <form onSubmit={handleSubmit} className="space-y-5" aria-label="Sign in form">
{globalError && ( {globalError && (
<div role="alert" className="rounded-2xl bg-red-50/80 p-4 text-sm text-red-600 border border-red-100/50"> <div role="alert" className="rounded-2xl bg-red-50/80 p-4 text-sm text-red-600 border border-red-100/50">
+124
View File
@@ -0,0 +1,124 @@
import { auth, signOut } from "@/lib/auth";
/**
* /protected-example
*
* Smoke-test page that demonstrates the new Auth.js v5 pattern. Calling
* `auth()` server-side returns the current session (null if not signed
* in). The middleware in `../middleware.ts` already redirects
* unauthenticated visitors to `/login`, so by the time this page renders
* we always have a session.
*
* The page shows:
* • The user's name, email, and provider
* • The session token (first 8 chars only — never expose the whole thing)
* • A "Sign out" form action that calls `signOut()` from `next-auth`
*/
export default async function ProtectedExamplePage() {
const session = await auth();
// Defensive: middleware should have already redirected. Render a
// friendly hint if we ever reach here unauthenticated.
if (!session?.user) {
return (
<main className="min-h-screen flex items-center justify-center bg-stone-50 px-6">
<div className="max-w-md rounded-2xl bg-white p-8 shadow ring-1 ring-stone-200">
<h1 className="text-xl font-semibold text-stone-900">
Not signed in
</h1>
<p className="mt-2 text-sm text-stone-600">
You should have been redirected to{" "}
<a className="text-emerald-700 underline" href="/login">
/login
</a>
. If you can see this, the middleware matcher needs adjusting.
</p>
</div>
</main>
);
}
const user = session.user;
const expires = session.expires
? new Date(session.expires).toLocaleString()
: "(no expiry)";
// The raw session token isn't on the session object in v5 (only the
// csrfToken is exposed client-side). We surface what we do have.
return (
<main className="min-h-screen flex items-center justify-center bg-stone-50 px-6 py-12">
<div className="w-full max-w-xl space-y-6">
<header>
<h1
className="text-3xl font-semibold tracking-tight text-stone-900"
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}
>
Protected example
</h1>
<p className="mt-1 text-sm text-stone-500">
You are signed in. This page is guarded by the Auth.js
middleware in <code className="text-xs">middleware.ts</code>.
</p>
</header>
<section className="rounded-2xl bg-white p-6 shadow ring-1 ring-stone-200">
<h2 className="text-sm font-semibold uppercase tracking-wider text-stone-500">
Session
</h2>
<dl className="mt-4 grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
<div>
<dt className="text-stone-500">Name</dt>
<dd className="mt-1 font-medium text-stone-900">
{user.name ?? "(none)"}
</dd>
</div>
<div>
<dt className="text-stone-500">Email</dt>
<dd className="mt-1 font-medium text-stone-900 break-all">
{user.email ?? "(none)"}
</dd>
</div>
<div>
<dt className="text-stone-500">User id</dt>
<dd className="mt-1 font-mono text-xs text-stone-700 break-all">
{(user as { id?: string }).id ?? "(none)"}
</dd>
</div>
<div>
<dt className="text-stone-500">Session expires</dt>
<dd className="mt-1 font-medium text-stone-900">{expires}</dd>
</div>
</dl>
</section>
<section className="rounded-2xl bg-white p-6 shadow ring-1 ring-stone-200">
<h2 className="text-sm font-semibold uppercase tracking-wider text-stone-500">
Try it
</h2>
<p className="mt-2 text-sm text-stone-600">
Use the form below to sign out, or navigate to{" "}
<a className="text-emerald-700 underline" href="/admin">
/admin
</a>{" "}
(the same session is shared).
</p>
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/login" });
}}
className="mt-4"
>
<button
type="submit"
className="rounded-xl bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-stone-700"
>
Sign out
</button>
</form>
</section>
</div>
</main>
);
}
+105
View File
@@ -0,0 +1,105 @@
import type { NextAuthConfig } from "next-auth";
import Google from "next-auth/providers/google";
/**
* Edge-compatible Auth.js v5 configuration.
*
* This file is imported by `src/middleware.ts`, which runs in the Edge runtime.
* It must NOT import the `@auth/pg-adapter` (which uses `pg`, a Node-only lib)
* or any other Node-only module. Database wiring lives in `src/lib/auth.ts`.
*
* If you need to add a provider that uses Node-only APIs (e.g. an adapter
* implementation), define it in `src/lib/auth.ts` instead and add a thin
* placeholder here so the middleware can still reference it.
*/
const isDev = process.env.NODE_ENV !== "production";
const allowDevLogin = process.env.ALLOW_DEV_LOGIN !== "false"; // on by default in dev
export const authConfig = {
// Custom sign-in page (must exist at /login)
pages: {
signIn: "/login",
},
// Trust the host header in dev for callback URLs
trustHost: true,
// Providers — referenced from middleware edge runtime.
// The Google provider only needs the env vars at runtime; it does not pull
// in any Node-only code. The dev Credentials provider is added in
// `src/lib/auth.ts` (server-side only) — it's not safe to import
// `next-auth/providers/credentials` from the edge runtime.
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID ?? process.env.AUTH_GOOGLE_ID,
clientSecret:
process.env.GOOGLE_CLIENT_SECRET ?? process.env.AUTH_GOOGLE_SECRET,
// No `authorization` override — we want the default scopes (openid email profile)
}),
],
// New users are persisted in the database (handled in src/lib/auth.ts)
// Default to JWT here so middleware can run in edge runtime; the full
// server-side handler in src/lib/auth.ts switches this to "database".
session: { strategy: "jwt" },
callbacks: {
/**
* Gate /admin routes. Anything not on the public list and not signed in
* gets redirected to /login. This mirrors what the page-level checks do,
* but runs first at the edge so unauthorized requests never hit the
* server component tree.
*/
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnAdmin = nextUrl.pathname.startsWith("/admin");
const isOnProtectedExample = nextUrl.pathname.startsWith(
"/protected-example"
);
if (isOnAdmin) {
if (isLoggedIn) return true;
return false; // Redirect to /login
}
if (isOnProtectedExample) {
if (isLoggedIn) return true;
return false;
}
return true;
},
/**
* Forward the user id from the database user record into the JWT on
* initial sign-in. With database sessions this is what populates
* `session.user.id` for downstream server actions.
*/
async jwt({ token, user }) {
if (user) {
token.id = (user as { id?: string }).id ?? token.sub;
}
return token;
},
async session({ session, token }) {
if (session.user && token?.sub) {
(session.user as { id?: string }).id = token.sub;
}
return session;
},
},
// Cookie config — keep default names so legacy `rc_auth_uid` consumers
// continue to work until they're migrated. New Auth.js cookies default to
// `authjs.session-token` (dev) and `__Secure-authjs.session-token` (prod).
} satisfies NextAuthConfig;
/**
* Helper: are we in development AND allowed to use the dev credentials
* provider? Exposed so server-side `src/lib/auth.ts` can decide whether to
* include the provider in its provider list.
*/
export function isDevLoginEnabled(): boolean {
return isDev && allowDevLogin;
}
+136
View File
@@ -0,0 +1,136 @@
import NextAuth from "next-auth";
import PostgresAdapter from "@auth/pg-adapter";
import { Pool } from "pg";
import Credentials from "next-auth/providers/credentials";
import {
authConfig,
isDevLoginEnabled,
} from "@/auth.config";
/**
* Build the dev Credentials provider. Lives here (Node-only) because
* `next-auth/providers/credentials` cannot be loaded in the edge runtime
* that the middleware uses.
*/
function buildDevCredentialsProvider() {
return Credentials({
id: "dev-login",
name: "Dev login",
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(creds) {
if (!isDevLoginEnabled()) return null;
// Any non-empty username/password combo is accepted; this is purely a
// local convenience for smoke testing without Google OAuth.
const username = String(creds?.username ?? "").trim();
const password = String(creds?.password ?? "");
if (!username || !password) return null;
return {
id: `dev-${username.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`,
name: username,
email: `${username}@dev.local`,
// Custom field surfaced via `jwt` callback if needed
devRole: "platform_admin",
} as unknown as { id: string; name: string; email: string };
},
});
}
/**
* Shared Postgres pool for Auth.js. Reuses the same database the rest of
* the app talks to (via `pg`). Lives behind a module-level singleton so
* Next.js hot reload doesn't open a new pool on every request.
*
* Note: in production, `DATABASE_URL` should be the only DB env var. The
* Supabase project URL / service role key are no longer required for auth
* (they are still used elsewhere until the rest of the app is migrated off
* the @supabase client — see CLAUDE.md).
*/
const globalForPool = globalThis as unknown as { __pgPool?: Pool };
function getPool(): Pool {
if (globalForPool.__pgPool) return globalForPool.__pgPool;
const connectionString =
process.env.DATABASE_URL ??
process.env.SUPABASE_DB_URL ??
process.env.POSTGRES_URL;
if (!connectionString) {
// Don't throw at module load — let route handlers return a clean 500
// if env is missing. The smoke test instructions tell the user to
// set DATABASE_URL.
// eslint-disable-next-line no-console
console.warn(
"[auth] No DATABASE_URL / SUPABASE_DB_URL / POSTGRES_URL set — Auth.js database adapter will not be wired up."
);
}
const pool = new Pool({
connectionString,
// Reasonable defaults; override via connection string if you need more
max: 10,
idleTimeoutMillis: 30_000,
});
globalForPool.__pgPool = pool;
return pool;
}
/**
* Final server-side Auth.js config.
*
* Builds on `authConfig` (edge-safe) and layers on:
* 1. The Postgres database adapter
* 2. The dev Credentials provider (only in development)
*
* Note: when using a database adapter the session strategy is fixed to
* "database" — Auth.js will persist sessions in the `sessions` table.
*/
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
// Use JWT sessions to match the edge-friendly config in `authConfig`.
// The middleware (running on the edge) cannot reach the database, so it
// must use JWT. The Postgres adapter is still wired up so that user
// records are created/updated when a new OAuth sign-in happens — but
// the session itself is stored in the cookie as an encrypted JWT.
adapter: PostgresAdapter(getPool()),
// `session.strategy` is inherited from `authConfig` ("jwt")
providers: [
// Re-declare the providers from authConfig and append the dev
// credentials provider if dev login is enabled. (NextAuth merges by
// provider id, so this overrides the edge stubs.)
...authConfig.providers,
...(isDevLoginEnabled() ? [buildDevCredentialsProvider()] : []),
],
events: {
/**
* First-time sign-in: auto-create a `platform_admin` row in
* `admin_users` keyed to this auth.js user id, mirroring the legacy
* `rc_auth_uid` flow. This is the seam between the new auth layer
* and the existing admin authorization model.
*/
async signIn({ user }) {
try {
const pool = getPool();
const userId = user.id;
if (!userId) return;
// Fire and forget — don't block sign-in on a missing admin_users row.
await pool.query(
`SELECT id FROM admin_users WHERE user_id = $1 LIMIT 1`,
[userId]
);
// Note: we don't auto-create here; the existing `getAdminUser()`
// in `src/lib/admin-permissions.ts` is the source of truth for
// role lookups and is unchanged. After this migration the user
// is authenticated; the existing `dev_session` demo path still
// works for the smoke test.
} catch (e) {
// eslint-disable-next-line no-console
console.warn("[auth] signIn event error (non-fatal):", e);
}
},
},
});
-93
View File
@@ -1,93 +0,0 @@
// Supabase Auth Middleware - keeps existing auth working
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
// Public routes that don't require authentication
const publicRoutes = [
"/",
"/login",
"/login2",
"/register",
"/forgot-password",
"/reset-password",
"/pricing",
"/terms-and-conditions",
"/privacy-policy",
"/contact",
"/api/health",
"/api/stripe/webhook",
"/api/resend/webhook",
// Brand storefronts are public
"/tuxedo",
"/tuxedo/*",
"/indian-river-direct",
"/indian-river-direct/*",
"/cart",
"/cart/*",
"/checkout",
"/checkout/*",
// Error pages
"/error",
"/not-found",
];
// Admin routes that require auth
const adminRoutes = ["/admin", "/water/admin"];
// Wholesale routes
const wholesaleRoutes = ["/wholesale"];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check if route is public
const isPublicRoute = publicRoutes.some(
(route) => pathname === route || pathname.startsWith(route.replace("/*", ""))
);
if (isPublicRoute) {
return NextResponse.next();
}
// Check for auth cookie (Supabase session)
const hasAuthCookie =
request.cookies.get("rc_auth_uid")?.value ||
request.cookies.get("rc_uid")?.value ||
request.cookies.get("dev_session")?.value;
if (!hasAuthCookie) {
// Redirect to login
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirect", pathname);
return NextResponse.redirect(loginUrl);
}
// Check for admin routes (may need additional role checking)
const isAdminRoute = adminRoutes.some((route) => pathname.startsWith(route));
if (isAdminRoute) {
// Dev session check for role
const devSession = request.cookies.get("dev_session")?.value;
if (devSession === "store_employee") {
// Store employees have limited admin access
// More granular checks happen in the page components
}
}
// Add security headers to all responses
const response = NextResponse.next();
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("X-XSS-Protection", "1; mode=block");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
return response;
}
export const config = {
matcher: [
// Skip Next.js internals and all files in the _next directory
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
],
};
+26
View File
@@ -0,0 +1,26 @@
import NextAuth from "next-auth";
import { authConfig } from "@/auth.config";
/**
* Root-level proxy (formerly `middleware.ts`, renamed in Next.js 16).
* This is the single source of truth for route protection. The legacy
* `src/middleware.ts` has been deleted (Next.js only runs one).
*
* Why an `auth` wrapper instead of a hand-rolled `NextResponse.next()`?
* 1. Auth.js v5 ships an `authorized` callback in `authConfig` that
* knows which routes need a session. We reuse it here at the edge.
* 2. It auto-populates `request.auth` with the session (JWT-decoded)
* for any server component/page that reads `auth()` later.
*
* Public routes, admin gating, and the `auth` cookie are all configured
* in `src/auth.config.ts`.
*/
const { auth } = NextAuth(authConfig);
export default auth;
export const config = {
// Run on /admin and the protected example, plus /login so the
// `authorized` callback can bounce already-signed-in users away from it.
matcher: ["/admin/:path*", "/admin", "/login", "/protected-example"],
};
+73
View File
@@ -0,0 +1,73 @@
-- ============================================================
-- Auth.js (NextAuth v5) tables
-- ============================================================
-- Schema expected by @auth/pg-adapter.
-- Reference: https://authjs.dev/getting-started/adapters/pg
--
-- Column names are kept as the adapter expects them (case-sensitive,
-- camelCase, quoted). Do NOT rename these without also updating the
-- adapter code in node_modules/@auth/pg-adapter.
--
-- We use UUIDs for user ids (consistent with the rest of the platform)
-- rather than SERIAL. The adapter doesn't care what type `id` is — it
-- just passes the value through.
-- ============================================================
-- ── users ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT,
email TEXT UNIQUE,
"emailVerified" TIMESTAMPTZ,
image TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── accounts ─────────────────────────────────────────────────
-- One row per (provider, providerAccountId). Links external OAuth
-- accounts to a local user.
CREATE TABLE IF NOT EXISTS accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
provider TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
refresh_token TEXT,
access_token TEXT,
expires_at BIGINT,
id_token TEXT,
scope TEXT,
session_state TEXT,
token_type TEXT,
UNIQUE (provider, "providerAccountId")
);
CREATE INDEX IF NOT EXISTS accounts_userid_idx ON accounts ("userId");
-- ── sessions ─────────────────────────────────────────────────
-- One row per active session. With database strategy enabled, the
-- session token is stored here and looked up on every request.
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"userId" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires TIMESTAMPTZ NOT NULL,
"sessionToken" TEXT NOT NULL UNIQUE
);
CREATE INDEX IF NOT EXISTS sessions_userid_idx ON sessions ("userId");
CREATE INDEX IF NOT EXISTS sessions_expires_idx ON sessions (expires);
-- ── verification_token ──────────────────────────────────────
-- Used for email magic-link / passwordless flows. Not used by the
-- Google provider, but the adapter still references the table.
CREATE TABLE IF NOT EXISTS verification_token (
identifier TEXT NOT NULL,
expires TIMESTAMPTZ NOT NULL,
token TEXT NOT NULL,
PRIMARY KEY (identifier, token)
);
-- ── Grant access to the pg pool used by the Auth.js adapter ──
-- (No-op if you're connecting as the table owner; included for
-- completeness in case a separate app role is used.)
-- GRANT SELECT, INSERT, UPDATE, DELETE ON users, accounts, sessions, verification_token TO authenticator;