From 82ebe03fafdedcd48f5e7bd9ff25f3f6239ddf09 Mon Sep 17 00:00:00 2001 From: Tommy Hoang <107150100+tommyqhoang@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:47:45 -0500 Subject: [PATCH] Add Phase 1 (Foundation) design spec Brainstormed and approved: multi-tenant data model (Membership join table for many-to-many User<->Business, unblocking Phase 5 without a breaking migration), database sessions with activeBusinessId, Resend pulled forward for auth emails, Auth.js CSRF + origin-check pattern, centralized Zod schema modules, soft-gate unverified users. Co-Authored-By: Claude --- .../2026-07-28-phase1-foundation-design.md | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-28-phase1-foundation-design.md diff --git a/docs/superpowers/specs/2026-07-28-phase1-foundation-design.md b/docs/superpowers/specs/2026-07-28-phase1-foundation-design.md new file mode 100644 index 0000000..05c39a3 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-phase1-foundation-design.md @@ -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).