# Phase 3 Report — Custom Auth (bcryptjs + jose) **Status:** COMPLETE **Date:** 2026-07-23 **Branch:** main --- ## Summary Replaced Supabase GoTrue authentication with a custom bcryptjs + jose stack. All 38 protected pages and API routes migrated. TypeScript clean, build clean, auth unit tests passing. --- ## New Files Created | File | Purpose | |------|---------| | `lib/auth/session.ts` | JWT sign/verify via jose; `createSession`, `verifySession`, `SessionPayload` | | `lib/auth/password.ts` | `hashPassword` / `verifyPassword` via bcryptjs at cost 10 | | `lib/auth/get-session.ts` | Server-only cookie reader; returns `SessionPayload \| null` | | `lib/notifications/mailer.ts` | `sendPasswordResetEmail` via Brevo raw fetch | | `app/api/auth/login/route.ts` | POST: bcrypt verify → JWT → set `ims_session` cookie | | `app/api/auth/logout/route.ts` | POST: clear `ims_session` cookie | | `app/api/auth/reset-request/route.ts` | POST: create `password_reset_tokens` row, send email | | `app/api/auth/reset-confirm/route.ts` | POST: validate token hash, update password hash | | `app/api/auth/change-password/route.ts` | POST: verify current password, update hash | | `supabase/migrations/20260724000001_password_reset_tokens.sql` | `password_reset_tokens` table | | `tests/lib/auth/session.test.ts` | Vitest: JWT create/verify round-trip, invalid token | | `tests/lib/auth/password.test.ts` | Vitest: hash/verify, Supabase-style `$2a$` hash compat | --- ## Modified Files ### Core Auth Infrastructure - `lib/auth/require-admin.ts` — rewritten: returns `{ session: SessionPayload | null }` (was `{ supabase, user }`) - `middleware.ts` — rewritten: edge-safe JWT-only verification, no DB imports - `lib/db/index.ts` — lazy singleton Proxy pattern to prevent module-level throw during Next.js build static analysis when `DATABASE_URL` not set in build env - `lib/db/schema.ts` — added `passwordResetTokens` table definition ### UI Components - `components/auth/login-form.tsx` — POST to `/api/auth/login` instead of supabase signIn - `components/layout/sidebar.tsx` — POST to `/api/auth/logout` instead of supabase signOut - `app/(auth)/forgot-password/page.tsx` — POST to `/api/auth/reset-request` - `app/(auth)/reset-password/page.tsx` — POST to `/api/auth/reset-confirm`, reads `token` from search params - `components/account/change-password-form.tsx` — POST to `/api/auth/change-password` ### Admin User CRUD - `app/api/admin/users/route.ts` — POST creates user with `hashPassword` + Drizzle insert; DELETE removes row directly; all handlers use `{ session }` from `requireAdmin()` - `app/api/admin/sites/route.ts` — updated to `{ session }` pattern + explicit `createClient()` for `.from()` calls - `app/api/admin/trucks/route.ts` — same ### Storage - `lib/supabase/storage.ts` — removed `supabase.auth.getUser()` call; added `userId: string` as 5th parameter to `uploadEvidenceFile` ### 38 Protected Pages and API Routes Pattern applied to all: - `import { getSession } from '@/lib/auth/get-session'` - `const session = await getSession()` replaces `supabase.auth.getUser()` - `session.sub` replaces `user.id` - `session.role` replaces profile fetch from DB - `session.siteId` replaces `profile.site_id` - `createClient()` retained where `.from()` queries still exist (Phase 4 will remove these) --- ## Issues Fixed During Implementation 1. **`app/api/admin/sites/route.ts` and `app/api/admin/trucks/route.ts`** — not in original brief scope but broken by `requireAdmin` signature change; fixed. 2. **`tests/lib/auth/password.test.ts`** — brief had `describe(name, fn, options)` which is wrong Vitest API; fixed to `describe(name, fn)`. 3. **`tests/lib/supabase/storage.test.ts`** — `uploadEvidenceFile` signature added `userId`; all 3 call sites updated; removed now-unused `auth.getUser` mock. 4. **`.next/types/validator.ts`** — stale reference to deleted `/api/auth/callback/route.ts`; removed the block. 5. **All 5 auth API routes** — missing `export const dynamic = 'force-dynamic'`; added to prevent Next.js static pre-rendering. 6. **`lib/db/index.ts`** — module-level `throw` when `DATABASE_URL` unset failed build's "collect page data" phase even with `force-dynamic`; refactored to lazy Proxy singleton. 7. **Test environment** — vitest global config uses `jsdom`; jose and bcryptjs use native `Uint8Array` which fails `instanceof` check across jsdom/Node realms; fixed with `// @vitest-environment node` in both auth test files. --- ## Invariants Preserved - `middleware.ts` imports only `jose` and `next/server` — no `pg`, `drizzle-orm`, or DB connections - All `.from()` Supabase queries retained untouched (Phase 4 scope) - `server-only` import in `get-session.ts` prevents client-side use - Passwords hashed at bcrypt cost 10 — compatible with existing Supabase GoTrue hashes --- ## Verification ``` npx tsc --noEmit → clean (0 errors) npm run build → clean (warnings only: img tag, unused UserDb type) npm test -- tests/lib/auth/ → 4/4 passed ``` --- ## Env Vars Required at Runtime | Var | Purpose | |-----|---------| | `JWT_SECRET` | ≥32 char random string for HMAC-SHA256 signing | | `DATABASE_URL` | app_user pool (RLS enforced) | | `DATABASE_URL_ADMIN` | app_admin pool (BYPASSRLS) | | `BREVO_API_KEY` | Transactional email for password reset | | `BREVO_FROM_EMAIL` | Sender address for password reset emails | | `APP_URL` | Base URL for reset link generation (server-side only) |