Files
red/.planning/phases/01-foundation/01-RESEARCH.md

27 KiB

Phase 1: Foundation - Research

Researched: 2026-03-19 Domain: Next.js 15 App Router, Auth.js v5 credentials auth, Drizzle ORM + Neon PostgreSQL, Vercel Blob, Vercel deployment Confidence: HIGH

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

  • Login page: Branded design with Teressa's logo, brand colors, and photo. Route: /agent/login. Title: "Agent Portal". Generic error on failure: "Invalid email or password". No forgot-password flow. Password visibility toggle included. Post-login redirect: /agent/dashboard.
  • Session behavior: 7-day rolling session, refreshes on each visit. Persistent across browser restarts (httpOnly cookie, not sessionStorage). On session expiry: silent redirect to /agent/login. Logout: immediate redirect to /agent/login with "You've been signed out" confirmation message.
  • Database schema: Auth tables only (users + sessions). ORM: Drizzle ORM. Password storage: bcrypt hashing. Migration files committed to /drizzle directory. Initial account: seed script from environment variables (no signup UI). Single-agent design — standard users table rows, no multi-tenant columns.
  • Deployment: Production only at teressacopelandhomes.com. Secrets via Vercel dashboard only (never committed). Vercel Blob: single store, organized by path (/documents/signed/, /documents/templates/). Deployment trigger: Vercel native Git integration (push to main → auto-deploy).

Claude's Discretion

  • Exact brand color palette and visual design details (Teressa's brand assets to be used, layout specifics are open)
  • Loading/submitting state on the login form button
  • Exact bcrypt salt rounds
  • Session token storage implementation details (Auth.js/NextAuth vs custom JWT)

Deferred Ideas (OUT OF SCOPE)

  • Forgot password / email reset flow — backlog for a future phase
  • Multi-agent support / role-based access — intentionally deferred, single-agent product for now
  • Staging or preview environments — production-only for this build </user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
AUTH-01 Agent (Teressa) can log in to the portal with email and password Auth.js v5 Credentials provider with bcryptjs password comparison; authorize() callback in auth.ts
AUTH-02 Agent session persists across browser refresh and tab closes Auth.js v5 JWT strategy with maxAge: 7 * 24 * 60 * 60; httpOnly encrypted cookie is the default; no sessionStorage
AUTH-03 All agent portal routes are protected — unauthenticated users are redirected to login middleware.ts with auth export and matcher: ["/agent/:path*"]; authorized callback redirects to /agent/login
AUTH-04 Agent can log out from any portal page Auth.js signOut() server action with redirectTo: "/agent/login" and a ?signed_out=1 query param or a dedicated confirmation route
</phase_requirements>

Summary

Phase 1 establishes the entire infrastructure foundation: a Next.js 15 App Router project, Drizzle ORM schema against Neon PostgreSQL, Vercel Blob bucket provisioning, and single-agent authentication via Auth.js v5. Every subsequent phase builds on top of this layer without touching these concerns again.

The authentication stack is well-defined by the user decisions: Auth.js v5 (beta) with the Credentials provider handles email/password login, JWT session strategy stores an encrypted httpOnly cookie, and Next.js middleware protects all /agent/* routes. The Drizzle adapter for Auth.js exists but is only needed if using database sessions — since the decision defers to Claude's discretion, the JWT strategy (no sessions table required, simpler schema) is recommended and matches the rolling-session requirement without database writes per request.

The Neon + Drizzle + Vercel deployment triplet is a first-class supported combination with official documentation, making this phase a well-trodden path in 2026. The main risk areas are: Auth.js v5 is still in beta (use next-auth@beta, pin a specific beta version), the signOut server action has known quirks in Next.js 15 that must be handled correctly, and the Vercel Blob bucket must be created and linked to the project before environment variables are available.

Primary recommendation: Use Auth.js v5 (beta) with JWT strategy + Credentials provider. Skip the Drizzle adapter (no sessions table needed). Keep the schema minimal: one users table with id, email, password_hash, created_at. Run one migration, seed Teressa's account, deploy to Vercel, wire env vars.


Standard Stack

Core

Library Version Purpose Why Standard
next 15.x (latest) Full-stack React framework Locked by client; App Router is the current default
next-auth 5.0.0-beta (pin latest beta) Authentication — credentials, session, middleware Official Next.js auth solution; only option with full App Router support
drizzle-orm ^0.40+ (latest stable) TypeScript-native ORM for Neon TypeScript-first, excellent Next.js + Neon support, locked by user
drizzle-kit ^0.30+ (latest stable) Migration generation and running Required companion to drizzle-orm; locked by user
@neondatabase/serverless ^0.10+ Neon HTTP driver for serverless Required for Vercel serverless functions — uses HTTP not persistent TCP
bcryptjs ^2.4.3 Password hashing Pure JS (no native binaries), works in all serverless environments
@vercel/blob ^0.27+ File/blob storage Locked by user decision; single store for all documents

Supporting

Library Version Purpose When to Use
@types/bcryptjs ^2.4.x TypeScript types for bcryptjs Development dependency — required for TypeScript projects
tsx ^4.x Run TypeScript scripts directly Used to run the seed script (drizzle:seed)
zod ^3.x Runtime input validation Validate login form credentials in authorize() callback

Alternatives Considered

Instead of Could Use Tradeoff
Auth.js v5 JWT strategy Database session strategy Database strategy requires a sessions table and a write on every visit; JWT is simpler for single-user and satisfies the rolling-session requirement with updateAge
bcryptjs bcrypt (native) Native bcrypt is faster but requires C++ compiler — fails in some Vercel builds; bcryptjs is pure JS and always works
Auth.js v5 Custom JWT + iron-session Auth.js handles the hard parts (cookie encryption, session refresh, middleware integration); custom is more control but more bugs

Installation:

npx create-next-app@latest teressa-copeland-homes --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd teressa-copeland-homes

npm install next-auth@beta
npm install drizzle-orm @neondatabase/serverless
npm install bcryptjs @vercel/blob
npm install -D drizzle-kit tsx @types/bcryptjs

Architecture Patterns

src/
├── app/
│   ├── agent/
│   │   ├── login/
│   │   │   └── page.tsx          # Login page (branded, password toggle, error display)
│   │   └── dashboard/
│   │       └── page.tsx          # Post-login landing (blank in Phase 1)
│   ├── api/
│   │   └── auth/
│   │       └── [...nextauth]/
│   │           └── route.ts      # Auth.js handler export
│   ├── layout.tsx                # Root layout
│   └── page.tsx                  # Public homepage (placeholder in Phase 1)
├── lib/
│   ├── auth.ts                   # Auth.js configuration (providers, callbacks, session)
│   └── db/
│       ├── index.ts              # Drizzle client initialization
│       └── schema.ts             # Database schema (users table only in Phase 1)
├── scripts/
│   └── seed.ts                   # One-time seed script to create Teressa's account
drizzle/                          # Migration files (committed to repo per user decision)
drizzle.config.ts                 # Drizzle Kit configuration
middleware.ts                     # Route protection (protects /agent/* routes)

Pattern 1: Auth.js v5 Configuration (JWT Strategy)

What: Central auth configuration that exports handlers, auth, signIn, signOut. JWT strategy with 7-day rolling expiry. httpOnly encrypted cookie by default. When to use: Phase 1 and every subsequent phase that needs to verify the session. Example:

// src/lib/auth.ts
// Source: https://authjs.dev/reference/nextjs
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { z } from "zod";

const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1),
});

export const { handlers, auth, signIn, signOut } = NextAuth({
  session: {
    strategy: "jwt",
    maxAge: 7 * 24 * 60 * 60,   // 7-day rolling expiry
    updateAge: 24 * 60 * 60,    // Refresh cookie once per day (not on every request)
  },
  pages: {
    signIn: "/agent/login",
  },
  providers: [
    Credentials({
      async authorize(credentials) {
        const parsed = loginSchema.safeParse(credentials);
        if (!parsed.success) return null;

        const [user] = await db
          .select()
          .from(users)
          .where(eq(users.email, parsed.data.email))
          .limit(1);

        if (!user) return null;

        const passwordMatch = await bcrypt.compare(
          parsed.data.password,
          user.passwordHash
        );
        if (!passwordMatch) return null;

        return { id: user.id, email: user.email };
      },
    }),
  ],
  callbacks: {
    jwt({ token, user }) {
      if (user) token.id = user.id;
      return token;
    },
    session({ session, token }) {
      session.user.id = token.id as string;
      return session;
    },
  },
});

Pattern 2: Middleware Route Protection

What: Protect all /agent/* routes at the edge. Unauthenticated requests redirect to /agent/login. Does NOT run on static assets or API routes. When to use: Phase 1 only (this file never needs modification in later phases unless new protected route prefixes are added). Example:

// middleware.ts (project root, NOT inside src/)
// Source: https://authjs.dev/getting-started/session-management/protecting
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";

export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isAgentRoute = req.nextUrl.pathname.startsWith("/agent");
  const isLoginPage = req.nextUrl.pathname === "/agent/login";

  if (isAgentRoute && !isLoginPage && !isLoggedIn) {
    return NextResponse.redirect(new URL("/agent/login", req.nextUrl.origin));
  }

  // Redirect already-logged-in users away from login page
  if (isLoginPage && isLoggedIn) {
    return NextResponse.redirect(new URL("/agent/dashboard", req.nextUrl.origin));
  }
});

export const config = {
  matcher: [
    // Match /agent/* but exclude static files and Next.js internals
    "/((?!_next/static|_next/image|favicon.ico).*)",
  ],
};

Pattern 3: Drizzle Client + Schema

What: Serverless-safe Drizzle client using Neon HTTP driver. Users table only in Phase 1. When to use: Import db from @/lib/db in any server component, route handler, or server action. Example:

// src/lib/db/index.ts
// Source: https://neon.com/docs/guides/drizzle
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
import * as schema from "./schema";

const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle({ client: sql, schema });

// src/lib/db/schema.ts
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { createId } from "@paralleldrive/cuid2"; // or use crypto.randomUUID()

export const users = pgTable("users", {
  id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
  email: text("email").notNull().unique(),
  passwordHash: text("password_hash").notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

Pattern 4: Login Form (Server Action)

What: Login form that calls the Auth.js signIn server action. Displays generic error message. Password toggle for UX. When to use: /agent/login/page.tsx Example:

// src/app/agent/login/page.tsx (simplified structure)
import { signIn } from "@/lib/auth";
import { AuthError } from "next-auth";
import { redirect } from "next/navigation";

async function loginAction(formData: FormData) {
  "use server";
  try {
    await signIn("credentials", {
      email: formData.get("email"),
      password: formData.get("password"),
      redirectTo: "/agent/dashboard",
    });
  } catch (error) {
    if (error instanceof AuthError) {
      // Return to login with error flag — do NOT re-throw
      redirect("/agent/login?error=invalid");
    }
    throw error; // Re-throw redirects (NEXT_REDIRECT must bubble up)
  }
}

Pattern 5: Sign Out Server Action

What: Logout from any portal page. Redirects to /agent/login with a confirmation message. When to use: Logout button in the portal header/nav. Example:

// In a portal layout or header component
import { signOut } from "@/lib/auth";

async function logoutAction() {
  "use server";
  await signOut({ redirectTo: "/agent/login?signed_out=1" });
}

// On /agent/login page, check for ?signed_out=1 and show the message:
// "You've been signed out."

Pattern 6: Drizzle Migration + Seed Script

What: Generate migration files and seed Teressa's account from environment variables. When to use: Once during Phase 1 setup; migrations added in future phases for new tables. Example:

// scripts/seed.ts
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
import { users } from "../src/lib/db/schema";
import bcrypt from "bcryptjs";

const sql = neon(process.env.DATABASE_URL!);
const db = drizzle({ client: sql });

async function seed() {
  const email = process.env.AGENT_EMAIL;
  const password = process.env.AGENT_PASSWORD;

  if (!email || !password) {
    throw new Error("AGENT_EMAIL and AGENT_PASSWORD env vars required");
  }

  const passwordHash = await bcrypt.hash(password, 12);

  await db.insert(users).values({ email, passwordHash })
    .onConflictDoNothing(); // Safe to re-run

  console.log(`Seeded agent account: ${email}`);
  process.exit(0);
}

seed().catch((err) => {
  console.error(err);
  process.exit(1);
});
// package.json scripts section additions:
{
  "db:generate": "drizzle-kit generate",
  "db:migrate": "drizzle-kit migrate",
  "db:seed": "tsx scripts/seed.ts",
  "db:studio": "drizzle-kit studio"
}

Anti-Patterns to Avoid

  • Wrapping signIn/signOut in try/catch without re-throwing: Auth.js uses NEXT_REDIRECT as a control-flow mechanism. Catching it silently swallows the redirect. Only catch AuthError — everything else must be re-thrown.
  • Putting middleware.ts inside src/: Next.js middleware must live at the project root (same level as package.json), not inside src/.
  • Using @auth/drizzle-adapter when using JWT strategy: The Drizzle adapter is only needed for database session strategy. JWT strategy does not require an adapter or a sessions table.
  • Exposing BLOB_READ_WRITE_TOKEN to client components: Always call Vercel Blob from server actions or API routes. Never import from client components.
  • Using bcrypt (native) instead of bcryptjs: Native bcrypt requires C++ compilation which can fail on Vercel's build system. Always use bcryptjs for serverless.
  • Storing DATABASE_URL or AUTH_SECRET in .env committed to repo: All secrets go through Vercel dashboard only, per user decision.

Don't Hand-Roll

Problem Don't Build Use Instead Why
Session management Custom JWT encode/decode/cookie logic Auth.js v5 Cookie rotation, encryption key management, expiry handling, CSRF protection — all handled; custom implementation has attack vectors
Password hashing Custom hash functions, MD5, SHA-256 alone bcryptjs bcrypt is adaptive (salt rounds increase over time), includes automatic salting, designed for passwords specifically
Route protection Manual session checks in every layout/page Auth.js middleware + auth() in layouts Consistent behavior; middleware runs before page renders so no flash of protected content
Database migrations Manual SQL files, ad-hoc ALTER TABLE Drizzle Kit generate + migrate Generates SQL from TypeScript schema diff; migration files are version-controlled
Blob URL generation Custom file storage URLs Vercel Blob put() CDN distribution, globally cached, secure access controls built in

Key insight: The entire auth domain (sessions, cookies, token rotation, CSRF) has hundreds of edge cases. Auth.js handles them correctly by default — do not re-implement.


Common Pitfalls

Pitfall 1: NEXT_REDIRECT Swallowed in Try/Catch

What goes wrong: signIn() throws a NEXT_REDIRECT error to perform the redirect after successful login. If wrapped in a catch-all try/catch, the redirect never fires — the form appears to do nothing after a correct password. Why it happens: Next.js uses thrown errors (not return values) for redirects and notFound. AuthError is a real error; NEXT_REDIRECT is a control-flow mechanism masquerading as an error. How to avoid: Only catch instanceof AuthError. Re-throw everything else. Warning signs: Login with correct credentials submits but nothing happens; no redirect occurs.

Pitfall 2: Auth.js v5 Beta Version Instability

What goes wrong: next-auth@beta is not a stable release. Minor beta updates have broken Credentials provider behavior, session callbacks, and Next.js 16 compatibility. Why it happens: v5 is actively developed; API surface changes between betas. How to avoid: Pin to a specific beta version (e.g., next-auth@5.0.0-beta.25). Test after upgrading. Check the releases page for breaking changes before updating. Warning signs: Login stops working after npm update; TypeScript types change.

Pitfall 3: Middleware.ts Location

What goes wrong: Placing middleware.ts inside src/ causes it to be ignored entirely — no route protection, /agent/dashboard is publicly accessible. Why it happens: Next.js middleware must be at the root of the project (same level as package.json and next.config.ts), not inside src/. How to avoid: Always create middleware.ts at the project root, not src/middleware.ts. Warning signs: Protected routes are accessible without login; no redirects occur.

Pitfall 4: DATABASE_URL Not Loaded in Production

What goes wrong: Neon connection fails in production with "DATABASE_URL is not set" because the env var was only added to .env.local and not to Vercel dashboard. Why it happens: .env.local is local-only. Vercel requires environment variables set in the dashboard (or via vercel env add). How to avoid: Add DATABASE_URL, AUTH_SECRET, AGENT_EMAIL, AGENT_PASSWORD, and BLOB_READ_WRITE_TOKEN to the Vercel dashboard before first deploy. Run vercel env pull locally. Warning signs: Production site works for static pages but fails on any DB-dependent route; Vercel function logs show missing env var.

Pitfall 5: Drizzle Client Re-initialized on Every Serverless Invocation

What goes wrong: Neon connection pool exhaustion in development (not production with HTTP driver, but can affect local PostgreSQL testing). Why it happens: Each serverless function invocation creates a new connection if the db client is not cached. How to avoid: Use a module-level singleton in src/lib/db/index.tsconst sql = neon(...) at module scope. The Neon HTTP driver is stateless per-request anyway, but the pattern prevents future issues with database adapters. Warning signs: "too many connections" errors in Neon console.

Pitfall 6: Logout Redirect Without Signed-Out Message

What goes wrong: signOut({ redirectTo: "/agent/login" }) redirects cleanly but shows no confirmation message. The "You've been signed out" message (user requirement) is lost. Why it happens: Auth.js v5 signOut does not pass flash messages; there is no built-in toast/flash system. How to avoid: Pass a query parameter: signOut({ redirectTo: "/agent/login?signed_out=1" }). On the login page, read searchParams.signed_out and render the confirmation message conditionally. Warning signs: Login page shows no confirmation text after logout even though redirect worked.


Code Examples

Verified patterns from official sources:

Drizzle Config (drizzle.config.ts)

// Source: https://neon.com/docs/guides/drizzle
import "dotenv/config";
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  schema: "./src/lib/db/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

API Route Handler for Auth.js

// src/app/api/auth/[...nextauth]/route.ts
// Source: https://authjs.dev/reference/nextjs
export { GET, POST } from "@/lib/auth";
// (auth.ts exports `handlers`, and { handlers: { GET, POST } } is de-structured here)

// More explicitly:
// import { handlers } from "@/lib/auth";
// export const { GET, POST } = handlers;

Vercel Blob Upload (Server Action)

// Source: https://vercel.com/docs/vercel-blob/using-blob-sdk
"use server";
import { put } from "@vercel/blob";

export async function uploadDocument(formData: FormData) {
  const file = formData.get("file") as File;
  const blob = await put(`documents/templates/${file.name}`, file, {
    access: "private",  // Documents are never publicly accessible (LEGAL-03)
  });
  return blob.url;
}

Session Check in Server Component

// Source: https://authjs.dev/reference/nextjs
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  const session = await auth();
  if (!session) redirect("/agent/login"); // Defense-in-depth beyond middleware

  return <div>Welcome, {session.user?.email}</div>;
}

State of the Art

Old Approach Current Approach When Changed Impact
NextAuth v4 (next-auth@4) Auth.js v5 (next-auth@beta) 2024-2025 New unified API across frameworks; App Router native; single auth() function replaces getServerSession()
pages/api/auth/[...nextauth].ts app/api/auth/[...nextauth]/route.ts Next.js 13+ App Router File location changes; export pattern changes
getServerSideProps + getSession await auth() in Server Components Next.js 13+ No more getServerSideProps; direct async/await in RSC
Prisma ORM Drizzle ORM (preferred for new projects) 2023-2024 Drizzle is TypeScript-first, lighter, and has better Neon serverless support
next-auth@4 database sessions JWT sessions (v5 default) v5 Database sessions now require explicit adapter; JWT is simpler for single-user apps

Deprecated/outdated:

  • getServerSession(authOptions): replaced by await auth() in Auth.js v5
  • pages/api/auth/[...nextauth].js: replaced by App Router route handler
  • @auth/prisma-adapter for this project: not needed — using JWT strategy, not database sessions
  • process.env.NEXTAUTH_SECRET: renamed to AUTH_SECRET in v5; both work but v5 uses AUTH_ prefix

Open Questions

  1. Auth.js v5 exact beta version to pin

    • What we know: next-auth@beta points to latest beta; Next.js 16 has a known signIn server action bug with beta.30
    • What's unclear: Which beta is most stable with Next.js 15 specifically
    • Recommendation: Install next-auth@beta initially, note the exact version installed (npm ls next-auth), pin it in package.json. Test login before declaring Phase 1 complete.
  2. Vercel Blob store creation timing

    • What we know: Store must be created before BLOB_READ_WRITE_TOKEN is available; Phase 1 only provisions it, not uses it
    • What's unclear: Whether the planner should treat blob provisioning as a separate task or bundled with deployment setup
    • Recommendation: Make blob store creation a distinct task in Wave 1 (infrastructure setup) even though it's not exercised until Phase 5.
  3. Teressa's brand assets location

    • What we know: Agent photo is at /Users/ccopeland/Downloads/red.jpg; brand colors and logo not yet confirmed
    • What's unclear: Whether brand assets will be provided during Phase 1 or if a placeholder design is acceptable
    • Recommendation: Build login page with placeholder brand vars (CSS custom properties for colors) so they can be swapped without code changes. Use the photo from the known path.

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence — verify during implementation)

  • Auth.js v5 beta.25 being "most stable" for Next.js 15 — from community discussion, not official docs; pin and test
  • signOut UI refresh bug requiring window.location.reload() — reported in GitHub discussions, not documented officially

Metadata

Confidence breakdown:

  • Standard stack: HIGH — all packages verified via official docs and npm; versions are current
  • Architecture: HIGH — file locations and export patterns verified against Auth.js v5 and Next.js docs
  • Pitfalls: MEDIUM-HIGH — most from official docs or verified GitHub issues; NEXT_REDIRECT behavior confirmed in Auth.js docs

Research date: 2026-03-19 Valid until: 2026-04-18 (30 days — Auth.js beta moves fast; re-verify if planning is delayed more than 2 weeks)