Skip to content
This repository was archived by the owner on Sep 25, 2026. It is now read-only.
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions docs/superpowers/specs/2026-07-28-phase1-foundation-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Phase 1 β€” Foundation: Design

**Status:** Approved
**Date:** 2026-07-28
**Scope:** `todo.md` Phase 1 β€” a deployable Next.js app with auth, multi-tenant data model, Railway pipeline, test infra, and a base UI shell.

Reference docs: [`AGENTS.md`](../../../AGENTS.md) (architecture principles, tech stack), [`readme.md`](../../../readme.md) (product scope, roadmap), [`todo.md`](../../../todo.md) (Phase 1 task list and phase-wide non-negotiables).

## Decisions made during brainstorming

| Decision | Choice | Why |
|---|---|---|
| Railway provisioning | Provision live via Railway MCP during this phase | User approved OAuth now; fastest path to a real staging deploy, which is part of Phase 1's Definition of Done. |
| Auth.js session strategy | Database sessions (Prisma adapter) | Server-side revocation, supports an `activeBusinessId` column on the session row, aligns with the httpOnly/secure/sameSite + CSRF posture already required. JWT was rejected β€” no server-side revocation, harder to scope an active business per request. |
| Auth transactional email | Pull Resend forward into Phase 1 (not deferred to Phase 3) | Email verification + password reset require sending real email. Stubbing to console/logs would mean auth flows can't be tested end-to-end until Phase 3, and Phase 3 would need to swap in real Resend anyway β€” no benefit to deferring. `RESEND_API_KEY`/`RESEND_FROM_EMAIL` are wired now, reused unchanged in Phase 3 for reminders/confirmations. |
| User↔Business relationship | Many-to-many via a `Membership(userId, businessId, role)` join table, from day one | Phase 5 requires multi-business support (a user manages multiple businesses/locations). A `User.businessId` FK would need a breaking migration under production data to add that later. The join table costs a little more schema now and is free later. |
| CSRF approach | Auth.js's built-in double-submit-cookie CSRF for its own routes; `Origin`/`Sec-Fetch-Site` header check for custom mutation routes | Avoids fighting Auth.js's own CSRF handling or adding a token-fetch round trip to every Server Action. A global synchronizer-token scheme was considered and rejected as unnecessary uniformity for the cost. |
| Zod validation pattern | Centralized schema modules (`lib/validation/*.schema.ts`), one domain per file, imported by both server validation and client-side form feedback | Single source of truth; avoids duplicating validation rules between client and server or across similar endpoints. Inline per-route schemas were rejected as a maintenance/duplication risk once more domains (Phase 2+) land. |
| Unverified-user access | Soft gate: unverified users can log in, see a persistent "verify your email" banner, and get read-only access β€” not a hard lockout | Losing access to your own brand-new account is a worse failure mode than a temporary soft nag; matches common SaaS UX and doesn't block a user who mistyped their inbox from at least seeing the app. |

## Architecture

Single Next.js 15 App Router app (TypeScript), deployed as one Railway service. API routes and Server Actions share one Prisma client and the centralized Zod schemas. Redis + BullMQ are provisioned now (per AGENTS.md's background-job-first principle) but Phase 1 has no notification jobs yet β€” the queue infrastructure exists and is proven via a trivial smoke-test job; real jobs (reminders, confirmations) arrive in Phase 3. Sentry + pino wrap every route/job boundary so failures surface, never get swallowed.

```
Browser β†’ Next.js (App Router: pages + route handlers + Server Actions)
β”‚
β”œβ”€ Auth.js (database sessions via Prisma adapter)
β”œβ”€ Zod validation (lib/validation/*.schema.ts) β€” every boundary
β”œβ”€ Prisma β†’ PostgreSQL (Business, Membership, User, Session…)
β”œβ”€ BullMQ β†’ Redis (queue proven via smoke job; real jobs in Phase 3)
└─ Resend (auth emails only: verify, reset)
```

## Data model

```prisma
model Business {
id String @id @default(cuid())
name String
timezone String // IANA, e.g. "America/Chicago" β€” business-level default
memberships Membership[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

model User {
id String @id @default(cuid())
email String @unique
emailVerified DateTime?
passwordHash String
memberships Membership[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

model Membership {
id String @id @default(cuid())
userId String
businessId String
role Role @default(OWNER) // OWNER only in Phase 1; roles expand in Phase 6
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())

@@unique([userId, businessId])
}

enum Role {
OWNER
}

// Auth.js Prisma adapter models (Account, Session, VerificationToken) β€” standard
// shape from @auth/prisma-adapter, with one addition: Session gets an extra
// `activeBusinessId String?` column (see Session Scoping below).
```

Every future tenant-scoped table (Customer, Appointment, etc. β€” Phase 2+) carries a required `businessId String` FK, no exceptions, per AGENTS.md's multi-tenancy principle. `Role` is an enum with a single value (`OWNER`) in Phase 1; Phase 6 adds `ADMIN`/`MEMBER` as an additive, backward-compatible enum change.

## Auth flows & session scoping

- **Registration:** email + password (argon2 hash) β†’ creates `User` + a new `Business` (name collected at signup) + `Membership(role: OWNER)` in one DB transaction β†’ sends verification email via Resend β†’ user lands on a "verify your email" holding page.
- **Unverified access:** unverified users can log in but see a persistent verify-email banner and get read-only access (write actions blocked server-side, not just hidden in the UI) until verified.
- **Login:** Auth.js Credentials provider; database session created via the Prisma adapter. The session row's `activeBusinessId` is set to the user's first (only, in Phase 1) `Membership.businessId` on login.
- **Session scoping helper:** a single `getSessionContext()` server-side helper β€” used by every route handler and Server Action β€” reads the session, resolves `activeBusinessId`, and re-validates a `Membership` row exists for `(userId, activeBusinessId)` on every call. This is the one chokepoint that enforces AGENTS.md's "derive tenant scope from the authenticated session server-side, never trust a client-supplied tenant ID" principle. No route computes tenant scope any other way.
- **Password reset:** custom flow (Auth.js Credentials doesn't ship this) β€” request β†’ single-use emailed token (1h expiry, hashed at rest) β†’ reset form β†’ on success, invalidates all existing sessions for that user.
- **CSRF:** Auth.js's built-in double-submit-cookie CSRF protects its own routes (signin/signout/callback). Custom mutation routes (password reset, business creation) verify `Origin`/`Sec-Fetch-Site` matches `APP_URL` before processing.
- **Session cookies:** httpOnly, secure, sameSite=lax (per AGENTS.md).

## Validation pattern

`lib/validation/*.schema.ts`, one file per domain (`auth.schema.ts` for Phase 1: register, login, verify-email-request, password-reset-request, password-reset-submit). A `parseBody(schema, req)` helper wraps every route handler / Server Action entry point and standardizes the 400 error shape (`{ error: string, issues: ZodIssue[] }`). The same schema exports are imported client-side for form-level validation feedback β€” one source of truth, not duplicated rules.

## Testing plan

- **Unit (Vitest):** password hashing/verification, `getSessionContext()` tenant-resolution (rejects a mismatched or revoked `activeBusinessId`, never trusts a client-supplied value), Zod schema boundary cases (valid/invalid payloads per schema), password-reset token expiry and single-use enforcement.
- **Component (RTL):** signup/login/password-reset forms render client-side validation errors correctly.
- **E2E (Playwright):** signup β†’ verify-email (captured via a test inbox / intercepted send) β†’ login β†’ authed shell renders; wrong-password rejected; full password-reset round trip.
- A trivial passing `pnpm test` case lands first, before real tests, to prove the CI harness runs (explicit todo.md ask, and validates the Phase 0 CI pipeline actually executes real tests now instead of the placeholder).

## Observability

- **pino:** structured logs on every request and (future) job boundary.
- **Sentry:** captures unhandled exceptions and explicit `captureException` calls on real faults. Expected user errors (wrong password, invalid token) are logged at info/warn via pino, NOT sent to Sentry as errors β€” keeps Sentry signal meaningful per AGENTS.md's "fail loud, not silent" for real provider/system failures, without conflating that with expected auth rejections.

## `/health` endpoint

Checks `prisma.$queryRaw` (DB reachability) and a Redis `PING` (queue reachability). Returns `200` only if both succeed; `503` with a body identifying which dependency failed otherwise. This is what Railway's health check points at β€” a bare `200 OK` would violate "fail loud."

## UI shell

Tailwind + shadcn/ui. Two route groups:
- `(public)` β€” marketing placeholder homepage, `/login`, `/signup`, `/verify-email`, `/reset-password`.
- `(app)` β€” authed shell: sidebar nav placeholder, empty dashboard placeholder, verify-email banner slot (rendered when `emailVerified` is null).

## Deployment (Railway)

Provisioned via the Railway MCP: Postgres service, Redis service, app service, and a `staging` environment. Environment variables mirror production config, minus real Twilio/Stripe/Google credentials (unused until later phases). `railway.toml` (or Railway's dashboard equivalent) points the health check at `/health`. Phase 1 is done when `pnpm build` succeeds and `/health` returns 200 on the deployed staging environment.

## Explicitly out of scope for Phase 1

- Team roles beyond `OWNER` (Phase 6).
- Any Twilio/Stripe/Google Calendar integration (later phases) β€” env vars exist in `.env.example` already but are not wired to real provider calls yet.
- Notification queue jobs beyond a smoke-test proving BullMQ + Redis are reachable β€” no reminder/confirmation logic (Phase 3).
- Customer/Appointment data model (Phase 2).
Loading