From 4c894a15532e9d60ae433dba2f8f24baedbce536 Mon Sep 17 00:00:00 2001 From: Yonatan Hen Date: Thu, 16 Jul 2026 13:52:39 +0300 Subject: [PATCH 001/120] docs: pivot design from Next.js to Express + React MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebuild targeted Next.js 15; the target audience is fullstack/backend roles, and a Next.js app whose backend is a directory of Server Actions doesn't show what those interviews probe — HTTP semantics, an explicit API contract, middleware ordering, integration tests against real routes. New spec (2026-07-16-express-react-rebuild-design.md) supersedes and replaces the 2026-07-12 Next.js design, which is deleted here along with its P1 plan; both remain in git history on staging. Everything worth keeping was migrated into the new spec rather than left behind a stale reference: the data model, Redis design, regression checklist, free-tier constraints, and Compose/CI topology all carry forward. Substantive changes beyond the stack swap: - apps/api serves the built SPA from one origin — no CORS, no cross-origin cookie problem, one Render service. apps/realtime stays separate and therefore keeps the signed-ticket handshake. - Sessions: express-session + connect-redis, httpOnly cookie. SameSite=Lax suffices for CSRF *because* of the single origin — recorded as a dependency, not an assumption. - Content gating survives: it moves from "Server Component omits the field" to "the API never serializes it". Strictly stronger than before, since dropping SEO also drops the spoofed-crawler bypass the old design accepted. - SEO/JSON-LD paywall markup: dropped, now an explicit non-goal. - Cloudinary replaces S3 + CloudFront (free forever). - Phases resplit: P1 API (demoable via curl alone), P2 client, P3 comments, P4 realtime, P5 media, P6 oauth. - Supertest integration layer added — where the regression checklist is enforced against real routes through the real middleware chain. Also rewrites CLAUDE.md and the deployment architecture doc for the new stack. dev/web-app-scaffold and dev/ci-cd-pipeline (PR #8) are abandoned unmerged, retained in history. --- CLAUDE.md | 122 + docs/architecture/deployment-architecture.md | 163 + .../plans/2026-07-13-p1-nextjs-foundation.md | 3040 ----------------- .../2026-07-12-blog-chat-renewal-design.md | 539 --- ...2026-07-16-express-react-rebuild-design.md | 692 ++++ 5 files changed, 977 insertions(+), 3579 deletions(-) create mode 100644 CLAUDE.md create mode 100644 docs/architecture/deployment-architecture.md delete mode 100644 docs/superpowers/plans/2026-07-13-p1-nextjs-foundation.md delete mode 100644 docs/superpowers/specs/2026-07-12-blog-chat-renewal-design.md create mode 100644 docs/superpowers/specs/2026-07-16-express-react-rebuild-design.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..8e4f66ab7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,122 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +A from-scratch rebuild of a legacy MERN (CRA/Redux/Express/Socket.io) blog + chat app as a modern +**Express + React (Vite) + TypeScript** monorepo, done as a portfolio piece targeting fullstack/backend +roles. The legacy app still lives on `master` and is live on Render; the rebuild happens on `dev/*` branches +that merge into `staging` first, **never directly into `master`**. + +The full design rationale (REST surface, session auth model, content-gating mechanism, Redis usage, +production topology, and why the API and client are one deployed service) lives in +`docs/superpowers/specs/2026-07-16-express-react-rebuild-design.md` — read it before making architectural +changes, not just this file. `docs/architecture/deployment-architecture.md` is the living topology +reference. The task-by-task plan for the current phase is under `docs/superpowers/plans/`. + +The build is phased (P1–P6), each a dedicated branch, each independently deployable. + +> **History:** this rebuild was originally designed on Next.js 15, and Tasks 1–7 of that plan were built +> before the stack pivoted on 2026-07-16 (see spec §2 for why). `dev/web-app-scaffold` and +> `dev/ci-cd-pipeline` (PR #8) are **abandoned unmerged** — retained in git history. If you find Next.js +> code, Auth.js config, or Server Actions, you are on an abandoned branch. + +## Commands + +npm workspaces monorepo (`packages/*`, `apps/*`). Run from the repo root. + +```bash +npm run dev # docker compose watch — full stack (api, client, mongo, redis), hot reload +npm run typecheck # fans out to each workspace's own typecheck script (no root tsconfig.json — see below) +npm run lint # eslint . (flat config, typescript-eslint recommended + no-explicit-any as error) +npm run test # vitest run, across packages/**/*.test.ts and apps/**/*.test.ts +npm run test -- path/to/one.test.ts # single test file +npm run test:e2e # playwright, against compose.e2e.yaml (prod-target build) +npm run seed # seed the database +``` + +**Why `typecheck` fans out per-workspace instead of `tsc --build`:** apps that set `composite: false` are +incompatible with TypeScript's project-references build mode. There is no root `tsconfig.json`; each +workspace declares its own `typecheck` script and the root runs `npm run typecheck --workspaces +--if-present`. Don't reintroduce a root `tsc --build` — it's been tried and reverted. + +**Docker:** per-app multi-stage Dockerfiles (`base → deps → dev`/`builder → runner`). `compose.yaml` is the +dev stack (`target: dev`, hot reload); `compose.e2e.yaml` builds the `runner` target — the actual production +image — for CI/E2E, so a broken prod build is caught before Render is. If you're behind a TLS-intercepting +proxy/AV locally (Avast on this machine breaks all container TLS), the Dockerfiles accept an optional +`extra-ca` BuildKit secret (see `compose.override.yaml`, gitignored) — **never bake a CA cert into an image +or commit one**. + +## Architecture + +``` +apps/ + api/ # Express REST API; also serves the built SPA in prod → Render web service + client/ # React + Vite SPA → static bundle, served by apps/api + realtime/ # Socket.io service (P4) → separate Render web service +packages/ + shared/ # Mongoose models, Zod schemas, db/redis caches, errors — the cross-app package +``` + +**One origin, one service.** `apps/api` serves both `/api/v1/*` and the built SPA (catch-all → `index.html`). +In dev, Vite's `server.proxy` forwards `/api` to the API container, reproducing the same origin. This is +load-bearing: the httpOnly session cookie works identically in dev, CI, and prod, and **CORS is never needed +anywhere**. `apps/realtime` is the exception — it's a separate origin, which is exactly why it needs a signed +handshake ticket instead of the cookie (Render subdomains are on the Public Suffix List and cannot share +cookies). + +**Zod is the single source of truth for validation.** Schemas live in `packages/shared/src/schemas/`; +TypeScript types are inferred from them (`z.infer<...>`), never hand-declared. The same schema validates the +request on the server and drives the form on the client. + +**Mongoose models use explicit `Model` typing** (`packages/shared/src/models/*.ts`) — +`mongoose.models.X as Model ?? mongoose.model(...)` — because the untyped union return breaks +`.create()`'s overload resolution otherwise. + +**Mongo/Redis connections are cached on `globalThis`** (`packages/shared/src/db.ts`, `apps/api/src/lib/`). +A naive `connect()` opens a new connection per module reload until the pool is exhausted (Render's free Redis +caps at 50 connections). Never call `mongoose.connect()` / `new Redis()` outside these cached wrappers. + +**Sessions:** `express-session` + `connect-redis`. The cookie is httpOnly + Secure + SameSite=Lax and holds +only an opaque session ID; data lives in Redis. `SameSite=Lax` is sufficient CSRF protection **only because** +the SPA is same-origin — if the client ever moves to its own origin, CSRF tokens become mandatory. + +**Three-layer authorization**, all required, none sufficient alone: +1. `requireAuth` middleware — 401 for anonymous requests on protected routers. +2. `requireOwner(loadResource)` — 403 unless `req.session.userId` matches the resource author. Identity + **always** comes from the session, **never** from a body field. This is the fix for all five legacy + authorization holes. +3. Database constraints (e.g. the unique `(user, post)` index on `Like`) as the last line of defense. + +**Middleware order is load-bearing** and asserted by an integration test: +`helmet → json → session → routers → 404 → error handler`. The legacy app registered `cors()` *after* its +routers, so it never applied. The error handler is always last. + +**Content gating lives in the service layer, not the UI.** `postService.getPost(slug, session)` omits the +full `body` from its return value when a post is premium and there's no session — the API never serializes +it, so there is nothing to find in DevTools. Gating in a component would be cosmetic. See spec §6. + +## Project conventions + +- **Never write credentials, tokens, or connection strings into source.** Use `.env` (gitignored) locally; + `.env.example` documents every variable with no real values. In production, secrets are set in the Render + dashboard (`render.yaml` uses `sync: false`) — never committed, never hardcoded as a fallback. (A leaked + credential was found in this repo's git history on 2026-07-16 and scrubbed — this is not hypothetical.) +- **Business logic is isolated from request handling.** `lib/services/` holds the logic; routers and + middleware stay thin — authenticate, authorize, validate with Zod, delegate to a service. Never put + business logic in a route handler. +- **REST routes are versioned and grouped by prefix** — `/api/v1/auth/*`, `/api/v1/posts/*`, + `/api/v1/users/*` — never flat or unversioned. Each resource gets its own Router module. Prefer correct + HTTP semantics over convenience: like/unlike is idempotent `PUT`/`DELETE`, not `POST /toggle`; logout is + `POST`, not `GET`. +- **Errors are typed and translated once.** Services throw `UnauthorizedError`/`ForbiddenError`/ + `NotFoundError`/`ValidationError` from `packages/shared`; `middleware/error-handler.ts` maps them to + status codes and a consistent JSON shape. Handlers never build error responses ad hoc. +- **One component per `.tsx` file.** Follow the three-tier split under `apps/client/src/components/`: + `ui/` (styling primitives, `cva` variants — e.g. `Button`), `patterns/` (composed app-level components), + `layouts/` (page chrome, e.g. `PageShell`). If a component must be shared across apps, its prop-driven + base belongs in a shared location and the per-app version wraps/extends it — don't fork per app. +- **Server state belongs to TanStack Query, not a client store.** No Redux. Components never call `fetch` + directly — go through the typed wrappers in `apps/client/src/api/*`, which send `credentials: 'include'`. + Mutations invalidate query keys rather than hand-patching a cache. diff --git a/docs/architecture/deployment-architecture.md b/docs/architecture/deployment-architecture.md new file mode 100644 index 000000000..5b80e87db --- /dev/null +++ b/docs/architecture/deployment-architecture.md @@ -0,0 +1,163 @@ +# Deployment Architecture + +Living reference doc — update as the rebuild progresses. Unlike `docs/superpowers/specs` and +`docs/superpowers/plans` (dated, point-in-time planning artifacts), this file should stay current. + +**Status legend:** ✅ live today · 🚧 in progress · 📋 planned, not built yet + +**Last verified against reality:** 2026-07-16, at the Express + React stack pivot. + +--- + +## Current reality (read this first) + +`master` is **live production**, watched by an existing Render service that auto-deploys on every push +(this predates the rebuild — it's how the original 2021 legacy app has been hosted). On 2026-07-16, a +rebuild PR was merged into `master`, deleting the legacy app's `Dockerfile`/`server/`/`src/`; Render's +next build failed (`open Dockerfile: no such file or directory`) and the live site went down. It was +fixed by reverting the merge on `master` and moving all rebuild work to a `staging` branch instead. See +the `never-autonomous-merge-or-deploy` and `renewal-branch-structure` memory entries for the full story. + +**Consequence for this diagram:** `master` today runs the ✅ **legacy MERN app** (Express + CRA), not the +rebuild. Everything under "Target production topology" below is 📋 planned — it goes live only when +`staging` is deliberately promoted to `master`. + +**Stack pivot (2026-07-16):** the rebuild was originally designed on Next.js 15; Tasks 1–7 were built +before the direction changed to **Express + React (Vite)**, to make the project a better portfolio piece +for fullstack/backend roles. `dev/web-app-scaffold` and `dev/ci-cd-pipeline` (PR #8) are abandoned +unmerged — retained in git history, never deleted. See +`docs/superpowers/specs/2026-07-16-express-react-rebuild-design.md` §2 and §13. + +--- + +## Branch flow & CI/CD pipeline + +```mermaid +flowchart TD + Dev[Developer] -->|commits| FeatureBranch["dev/feature-branch"] + FeatureBranch -->|open PR| Staging["staging branch
(rebuild integration)"] + Staging -->|explicit approval only, never automatic| Master["master branch
PRODUCTION — Render watches this"] + + subgraph Pipeline["GitHub Actions CI/CD (on pull_request only)"] + direction TB + S1["1. Source
checkout"] --> S2["2. Build
typecheck + lint + api/client build"] + S2 --> S3["3. Test
Vitest unit + Supertest integration"] + S3 --> S4["4. Staging Deploy
ephemeral docker compose up
smoke test + Playwright, then torn down"] + S4 --> S5{{"5. Prod Deploy
MANUAL APPROVAL GATE"}} + end + + Staging -.PR triggers.-> Pipeline + S5 -->|user approves| Master +``` + +**Status:** branch model (`dev/*` → `staging` → `master`) is ✅ live. The 5-stage workflow was built on +`dev/ci-cd-pipeline` (PR #8) against the Next.js layout and is now 📋 to be rebuilt — its *shape* carries +forward verbatim (`pull_request`-only trigger, per-workspace typecheck fanout, ephemeral stage 4, manual +gate at stage 5, `permissions: contents: read`, `concurrency` cancel-superseded). Only the build/test +commands change. + +**Why the trigger is `pull_request` only:** a raw commit to a feature branch must never run CI — only +opening or updating a PR does. + +**Why staging deploy is ephemeral, not a persistent cloud environment:** Render's free tier allows only +one Key Value (Redis) instance per workspace, so a second always-on staging environment would either +have to share prod's Redis or cost money. Spinning up the full Docker Compose stack inside the CI runner +for the duration of the test run avoids that constraint entirely and costs nothing. + +**Stage 5 is a gate, not a deploy.** Render's own webhook performs the actual deploy when `master` +changes. The job exists to force a human approval step (GitHub Environment `production` + required +reviewer) and to make the pipeline stage explicit. + +--- + +## Target production topology (📋 planned — not live yet) + +```mermaid +flowchart TB + Users(("End Users")) -->|"HTTPS — SPA + /api/v1/*, one origin"| ApiSvc + Users -->|WebSocket, signed ticket| RealtimeSvc + + Master(["master branch"]) -->|auto-deploy webhook| ApiSvc + Master -->|auto-deploy webhook| RealtimeSvc + + subgraph RenderCloud["Render (free tier)"] + ApiSvc["apps/api
Express REST API
+ serves apps/client build"] + RealtimeSvc["apps/realtime
Socket.io service"] + Redis[("Render Key Value — Redis
sessions, chat buffer, rate limit")] + ApiSvc <-->|internal network| Redis + RealtimeSvc <-->|internal network| Redis + end + + ApiSvc -->|MONGODB_URI| Atlas[("MongoDB Atlas M0
production cluster")] + RealtimeSvc -->|MONGODB_URI| Atlas + + ApiSvc -->|signed upload params| Cloudinary[("Cloudinary
free-forever tier")] + Users -->|"direct upload / image GET"| Cloudinary +``` + +| Component | Status | Notes | +|---|---|---| +| `apps/api` Render service | 📋 planned | P1. Serves the REST API **and** the built SPA from one origin — no CORS, no cross-origin cookie problem | +| `apps/client` | 📋 planned | P2. Vite build; static assets baked into the `apps/api` image, not a separate service | +| `apps/realtime` Render service | 📋 planned | P4 — Socket.io, separate service, cold starts accepted | +| Render Key Value (Redis) | 📋 planned | P1 (sessions) → P4 (chat buffer, presence) → P6 (rate limiting). Ephemeral by design | +| MongoDB Atlas M0 | ✅ exists, 🚧 being re-secured | Credential was leaked and rotated on 2026-07-16; cluster will be wiped and reseeded before go-live | +| Cloudinary | 📋 planned | P5 — replaces the S3 + CloudFront plan; free forever, no shared-AWS-account hazard | +| Socket auth across origins | 📋 planned | P4 — short-lived signed JWT ticket. Render subdomains are on the Public Suffix List, so the two services **cannot** share a session cookie | + +**Why one service for API + client:** the session cookie is httpOnly and same-origin. Splitting the SPA +onto a Render Static Site would put it on a different `*.onrender.com` origin, forcing CORS plus +`SameSite=None` cookies — and would consume a second slice of the 750-hour free pool. `apps/realtime` +pays exactly that cost, which is why it needs the signed-ticket handshake instead of the cookie. + +--- + +## Local development (📋 planned — P1) + +```mermaid +flowchart LR + Browser(("Browser")) -->|":5173"| Vite + subgraph Laptop["Developer machine — docker compose watch"] + Vite["client container
Vite dev server, HMR"] + Vite -->|"proxy /api → :3000"| ApiDev + ApiDev["api container
dev target, hot reload"] + RealtimeDev["realtime container
dev target, hot reload"] + MongoDev[("mongo container
named volume
127.0.0.1 only")] + RedisDev[("redis container
127.0.0.1 only")] + ApiDev <--> MongoDev + ApiDev <--> RedisDev + RealtimeDev <--> MongoDev + RealtimeDev <--> RedisDev + end +``` + +`compose watch` syncs changed source files into the containers (not a bind mount) — avoids the +Windows `node_modules`/inotify problems a plain bind mount would hit. + +**Vite's `server.proxy` forwards `/api` to the api container**, so the browser sees a single origin in +dev exactly as it will in prod. The auth model is therefore identical across dev, CI, and prod — a cookie +bug cannot hide until deploy. + +**Mongo and Redis bind to `127.0.0.1`, never `0.0.0.0`** — they run unauthenticated locally, and +publishing them on all interfaces would expose an unauthenticated database to the LAN. + +## CI ephemeral staging (📋 planned — `compose.e2e.yaml` + smoke test in P1; Playwright added in P2) + +```mermaid +flowchart LR + subgraph Runner["GitHub Actions runner — ephemeral, torn down after"] + Compose["docker compose -f compose.e2e.yaml up --wait"] --> ApiE2E["api container
prod/runner target
serves the built SPA"] + Compose --> RealtimeE2E["realtime container
prod target"] + ApiE2E <--> MongoE2E[("mongo container")] + ApiE2E <--> RedisE2E[("redis container")] + ApiE2E --> PW["Playwright E2E tests"] + PW --> Down["docker compose down -v"] + end +``` + +This is the literal implementation of pipeline stage 4 above — it builds the same `runner` Docker target +that would ship to Render, so a broken production build fails here, not after a real deploy. + +**Note:** `compose.e2e.yaml` did not exist when stage 4 was first written, which is why PR #8's CI run +failed on it. In the new plan the file lands in P1 alongside the API, so the stage has something real to +stand up from the start. diff --git a/docs/superpowers/plans/2026-07-13-p1-nextjs-foundation.md b/docs/superpowers/plans/2026-07-13-p1-nextjs-foundation.md deleted file mode 100644 index 5611ef480..000000000 --- a/docs/superpowers/plans/2026-07-13-p1-nextjs-foundation.md +++ /dev/null @@ -1,3040 +0,0 @@ -# Phase 1 — Next.js Foundation: Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the legacy CRA + Express app with a deployed Next.js 15 monorepo that has secure credentials auth, posts CRUD with correct authorization, a white/blue design system, a containerized dev/E2E environment, and CI. - -**Architecture:** npm-workspaces monorepo. `packages/shared` owns Zod schemas (the single source of truth for validation *and* TypeScript types) plus Mongoose models. `apps/web` is a Next.js 15 App Router app where Server Components read the database directly and Server Actions perform mutations. Every mutating action re-derives identity from the session — never from the request body. Redux, react-router, and the Express server are deleted. - -**Tech Stack:** Next.js 15 (App Router, React 19), TypeScript, Mongoose 8, Zod, Auth.js v5 (`next-auth@beta`), Tailwind v4 + shadcn/ui, Vitest + `mongodb-memory-server`, Playwright, Docker Compose, GitHub Actions, Render. - -**Reference spec:** `docs/superpowers/specs/2026-07-12-blog-chat-renewal-design.md` - ---- - -## Global Constraints - -Every task's requirements implicitly include this section. - -- **Node 22 LTS.** Set in `.nvmrc`, `engines`, and all Dockerfiles. -- **TypeScript `strict: true`.** No `any`. No `as` casts to silence errors. -- **Zod is the single source of truth.** TypeScript types are always `z.infer` — never hand-written duplicates of a schema. -- **Identity comes from the session, never from client input.** No Server Action may read a user id, username, or ownership claim from `formData` or a request body. This is the rule that closes all five vulnerabilities in the legacy app. -- **`middleware.ts` is UX, not security.** Server Actions are public HTTP endpoints; each one re-authorizes independently. -- **Edge/Node split:** `auth.config.ts` must import **no** Mongoose and **no** bcrypt (it runs on Edge). Only `auth.ts` may. -- **Session strategy is `jwt`** (Auth.js requires this for the Credentials provider). The MongoDB adapter arrives in P5 with OAuth. -- **bcryptjs cost 12.** -- **Theme: light only.** White background, **blue** as the accent/secondary. Body text is near-black with a blue undertone (`--color-ink`), never blue itself — long-form blue text is hard to read. Blue (`--color-brand`) is reserved for actions, links, focus rings, and badges. No dark mode, no `dark:` variants. -- **No `alert()`.** Field errors render inline from Zod's `error.flatten().fieldErrors`; transient feedback uses `sonner`. -- **Free tier only.** Do not introduce any paid service. -- **Never run an `aws` CLI command** without asking the user first. -- **Commit after every task.** Conventional Commits. No `Co-Authored-By` trailer. -- **Branch:** all work lands on `dev/nextjs-foundation`. - ---- - -## File Structure - -``` -blog-chat-app/ -├── .github/workflows/ci.yml # typecheck → lint → unit → e2e → build -├── .nvmrc # 22 -├── package.json # npm workspaces root; scripts delegate to workspaces -├── tsconfig.base.json # strict TS, shared by all workspaces -├── eslint.config.mjs # flat config -├── vitest.config.ts # unit tests (node env) -├── playwright.config.ts # E2E, targets compose.e2e.yaml -├── compose.yaml # dev stack + `develop.watch` hot reload -├── compose.e2e.yaml # prod-target images, seeded, for E2E/CI -├── render.yaml # prod IaC: web + realtime + keyvalue -├── .env.example # every var, no secrets -├── packages/shared/ -│ ├── src/schemas/{user,post,comment}.ts # Zod — validation + inferred types -│ ├── src/models/{user,post,like,comment}.ts # Mongoose -│ ├── src/db.ts # globally cached Mongoose connection -│ ├── src/errors.ts # UnauthorizedError, ForbiddenError, NotFoundError -│ └── src/index.ts # public surface -└── apps/web/ - ├── Dockerfile # multi-stage: deps → dev / builder → runner - ├── next.config.ts - ├── middleware.ts # route guards (UX) - ├── app/ - │ ├── layout.tsx globals.css error.tsx not-found.tsx - │ ├── (auth)/login/page.tsx (auth)/signup/page.tsx - │ ├── blog/page.tsx blog/[slug]/page.tsx blog/new/page.tsx blog/[slug]/edit/page.tsx - │ └── api/auth/[...nextauth]/route.ts - ├── components/ - │ ├── ui/ # shadcn primitives (cva variants) - │ ├── patterns/ # PageHeader, EmptyState, SearchBar, PostCard, LikeButton - │ └── layouts/PageShell.tsx - ├── lib/ - │ ├── auth.config.ts # EDGE-SAFE. no mongoose, no bcrypt. - │ ├── auth.ts # NextAuth() + Credentials.authorize (node runtime) - │ ├── auth-guards.ts # requireAuth / requireOwner - │ ├── actions/{auth,posts,likes}.ts - │ └── services/{user,post,like}.ts - └── scripts/seed.ts -``` - -**Responsibility split that matters:** `services/*` contains all business logic and *all* authorization checks, and is unit-tested against `mongodb-memory-server` with no HTTP involved. `actions/*` is a thin boundary: parse with Zod → call the service → `revalidatePath` → return `{ ok }`. Keeping authorization in the service (not the action) is what makes the regression tests in Task 9 possible without spinning up Next.js. - ---- - -## Task 1: Monorepo skeleton and tooling - -**Files:** -- Create: `package.json`, `tsconfig.base.json`, `eslint.config.mjs`, `vitest.config.ts`, `.nvmrc`, `.env.example`, `.gitignore` -- Delete: `src/`, `server/`, `public/`, `Dockerfile`, `.dockerignore` (the entire legacy app) - -**Interfaces:** -- Consumes: nothing. -- Produces: `npm run typecheck`, `npm run lint`, `npm run test` at the repo root; workspaces `@blog/shared` and `@blog/web`. - -- [ ] **Step 1: Create the branch** - -```bash -git checkout master && git pull -git checkout -b dev/nextjs-foundation -``` - -- [ ] **Step 2: Delete the legacy app** - -The old code is preserved in git history and on `master`. Keeping it around would mean two apps in one repo. - -```bash -git rm -r --quiet src server public Dockerfile .dockerignore package-lock.json package.json -``` - -- [ ] **Step 3: Write the root workspace config** - -`package.json`: -```json -{ - "name": "blog-chat-app", - "private": true, - "workspaces": ["packages/*", "apps/*"], - "engines": { "node": ">=22" }, - "scripts": { - "dev": "docker compose watch", - "typecheck": "tsc --build --force", - "lint": "eslint .", - "test": "vitest run", - "test:e2e": "playwright test", - "seed": "npm run seed --workspace=@blog/web" - }, - "devDependencies": { - "@types/node": "^22.10.0", - "eslint": "^9.17.0", - "typescript": "^5.7.0", - "typescript-eslint": "^8.18.0", - "vitest": "^2.1.0" - } -} -``` - -`.nvmrc`: -``` -22 -``` - -`tsconfig.base.json` — `strict: true` is the whole point of this file: -```json -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "noUncheckedIndexedAccess": true, - "esModuleInterop": true, - "skipLibCheck": true, - "resolveJsonModule": true, - "isolatedModules": true, - "composite": true, - "declaration": true - } -} -``` - -`eslint.config.mjs`: -```js -import tseslint from 'typescript-eslint' - -export default tseslint.config( - { ignores: ['**/.next/**', '**/node_modules/**', '**/dist/**'] }, - ...tseslint.configs.recommended, - { rules: { '@typescript-eslint/no-explicit-any': 'error' } }, -) -``` - -`vitest.config.ts`: -```ts -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - environment: 'node', - include: ['packages/**/*.test.ts', 'apps/**/*.test.ts'], - testTimeout: 30_000, // mongodb-memory-server downloads a binary on first run - }, -}) -``` - -`.env.example` — every variable the app reads, with no real values: -``` -MONGODB_URI=mongodb://localhost:27017/blogchat -REDIS_URL=redis://localhost:6379 -AUTH_SECRET=generate-with-npx-auth-secret -AUTH_URL=http://localhost:3000 -``` - -`.gitignore`: -``` -node_modules/ -.next/ -dist/ -*.tsbuildinfo -.env -.env.local -coverage/ -test-results/ -playwright-report/ -``` - -- [ ] **Step 4: Verify the toolchain runs** - -```bash -npm install -npm run lint -``` -Expected: ESLint completes with no errors (there is no source code yet, so it has nothing to complain about). - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "chore: replace legacy CRA/Express app with monorepo skeleton - -Deletes the 5-year-old src/ and server/ trees (preserved on master). -Adds npm workspaces, strict TypeScript, ESLint flat config, Vitest." -``` - ---- - -## Task 2: Zod schemas in `packages/shared` - -This is the task that establishes the project's core idea: **define a shape once, get validation and types from it.** Everything downstream depends on it. - -**Files:** -- Create: `packages/shared/package.json`, `packages/shared/tsconfig.json` -- Create: `packages/shared/src/schemas/user.ts`, `packages/shared/src/schemas/post.ts` -- Test: `packages/shared/src/schemas/schemas.test.ts` - -**Interfaces:** -- Consumes: nothing. -- Produces: - - `SignupSchema`, `LoginSchema`, `type Signup`, `type Login` - - `CreatePostSchema`, `UpdatePostSchema`, `type CreatePost`, `type UpdatePost` - - `slugify(title: string): string` - - `deriveTeaser(body: string, paragraphs?: number): string` - -- [ ] **Step 1: Scaffold the workspace** - -`packages/shared/package.json`: -```json -{ - "name": "@blog/shared", - "version": "0.0.0", - "private": true, - "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { ".": "./src/index.ts" }, - "dependencies": { - "bcryptjs": "^2.4.3", - "mongoose": "^8.9.0", - "zod": "^3.24.0" - }, - "devDependencies": { - "@types/bcryptjs": "^2.4.6", - "mongodb-memory-server": "^10.1.0" - } -} -``` - -`packages/shared/tsconfig.json`: -```json -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { "outDir": "dist", "rootDir": "src" }, - "include": ["src/**/*"] -} -``` - -- [ ] **Step 2: Write the failing test** - -`packages/shared/src/schemas/schemas.test.ts`: -```ts -import { describe, expect, it } from 'vitest' -import { SignupSchema, CreatePostSchema, slugify, deriveTeaser } from './index.js' - -describe('SignupSchema', () => { - it('accepts a valid signup', () => { - const result = SignupSchema.safeParse({ - username: 'yonatan', - email: 'y@example.com', - password: 'correct-horse', - }) - expect(result.success).toBe(true) - }) - - it('rejects a password shorter than 8 characters', () => { - const result = SignupSchema.safeParse({ - username: 'yonatan', - email: 'y@example.com', - password: 'short', - }) - expect(result.success).toBe(false) - expect(result.error!.flatten().fieldErrors.password).toBeDefined() - }) - - it('rejects a malformed email', () => { - const result = SignupSchema.safeParse({ - username: 'yonatan', - email: 'not-an-email', - password: 'correct-horse', - }) - expect(result.success).toBe(false) - }) - - it('lowercases and trims the email', () => { - const result = SignupSchema.parse({ - username: 'yonatan', - email: ' Y@Example.COM ', - password: 'correct-horse', - }) - expect(result.email).toBe('y@example.com') - }) -}) - -describe('CreatePostSchema', () => { - it('rejects a title shorter than 3 characters', () => { - const result = CreatePostSchema.safeParse({ title: 'ab', body: 'hello', premium: false }) - expect(result.success).toBe(false) - }) - - it('defaults premium to false and tags to an empty array', () => { - const result = CreatePostSchema.parse({ title: 'A good title', body: 'hello' }) - expect(result.premium).toBe(false) - expect(result.tags).toEqual([]) - }) - - it('rejects an author field from client input', () => { - // The legacy app trusted req.body.author. The schema must strip it so it - // can never reach the database — identity comes from the session only. - const result = CreatePostSchema.parse({ - title: 'A good title', - body: 'hello', - author: 'attacker-controlled-id', - } as never) - expect('author' in result).toBe(false) - }) -}) - -describe('slugify', () => { - it('lowercases and hyphenates', () => { - expect(slugify('Hello World')).toBe('hello-world') - }) - - it('strips punctuation and collapses separators', () => { - expect(slugify('Redis: what is it, really?!')).toBe('redis-what-is-it-really') - }) - - it('trims leading and trailing hyphens', () => { - expect(slugify(' --Hello-- ')).toBe('hello') - }) -}) - -describe('deriveTeaser', () => { - it('returns the first two paragraphs', () => { - const body = 'One.\n\nTwo.\n\nThree.' - expect(deriveTeaser(body)).toBe('One.\n\nTwo.') - }) - - it('returns the whole body when it is shorter than the limit', () => { - expect(deriveTeaser('Only one.')).toBe('Only one.') - }) -}) -``` - -- [ ] **Step 3: Run the test and confirm it fails** - -```bash -npm run test -- packages/shared -``` -Expected: FAIL — `Failed to resolve import "./index.js"`. - -- [ ] **Step 4: Write the schemas** - -`packages/shared/src/schemas/user.ts`: -```ts -import { z } from 'zod' - -export const SignupSchema = z.object({ - username: z - .string() - .trim() - .min(3, 'Username must be at least 3 characters') - .max(30) - .regex(/^[a-zA-Z0-9_-]+$/, 'Letters, numbers, hyphens and underscores only'), - email: z.string().trim().toLowerCase().email('Enter a valid email address'), - password: z.string().min(8, 'Password must be at least 8 characters').max(200), -}) - -export const LoginSchema = z.object({ - username: z.string().trim().min(1, 'Enter your username'), - password: z.string().min(1, 'Enter your password'), -}) - -export type Signup = z.infer -export type Login = z.infer -``` - -`packages/shared/src/schemas/post.ts` — note there is **no `author` field**. Zod objects strip unknown keys by default, so an attacker-supplied `author` is silently dropped rather than trusted. That is the schema-level half of the fix for the legacy `req.body.author` bug. - -```ts -import { z } from 'zod' - -export const CreatePostSchema = z.object({ - title: z.string().trim().min(3, 'Title must be at least 3 characters').max(120), - body: z.string().trim().min(1, 'Body cannot be empty'), - premium: z.coerce.boolean().default(false), - tags: z.array(z.string().trim().min(1)).max(5).default([]), -}) - -export const UpdatePostSchema = CreatePostSchema.extend({ - postId: z.string().min(1), -}) - -export type CreatePost = z.infer -export type UpdatePost = z.infer - -export function slugify(title: string): string { - return title - .toLowerCase() - .normalize('NFKD') - .replace(/[̀-ͯ]/g, '') - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') -} - -export function deriveTeaser(body: string, paragraphs = 2): string { - return body.split(/\n{2,}/).slice(0, paragraphs).join('\n\n') -} -``` - -`packages/shared/src/index.ts`: -```ts -export * from './schemas/user.js' -export * from './schemas/post.js' -``` - -- [ ] **Step 5: Run the test and confirm it passes** - -```bash -npm run test -- packages/shared -``` -Expected: PASS — 12 tests. - -- [ ] **Step 6: Commit** - -```bash -git add packages/shared -git commit -m "feat(shared): add Zod schemas as the single source of truth - -Types are inferred via z.infer, so validation and types cannot drift. -CreatePostSchema deliberately omits 'author' — Zod strips unknown keys, so -client-supplied author ids are dropped rather than trusted (legacy bug)." -``` - ---- - -## Task 3: Mongoose models and the cached connection - -**Files:** -- Create: `packages/shared/src/db.ts`, `packages/shared/src/errors.ts` -- Create: `packages/shared/src/models/{user,post,like,comment}.ts` -- Test: `packages/shared/src/models/models.test.ts` - -**Interfaces:** -- Consumes: nothing from Task 2 (schemas and models are independent). -- Produces: `connectDb(uri: string): Promise`, `UserModel`, `PostModel`, `LikeModel`, `CommentModel`, and `UnauthorizedError`, `ForbiddenError`, `NotFoundError`. - -- [ ] **Step 1: Write the failing test** - -The two index tests are the ones that matter — they prove the database itself now prevents bugs the legacy code could produce. - -`packages/shared/src/models/models.test.ts`: -```ts -import { MongoMemoryServer } from 'mongodb-memory-server' -import mongoose from 'mongoose' -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' -import { LikeModel, PostModel, UserModel } from '../index.js' - -let mongod: MongoMemoryServer - -beforeAll(async () => { - mongod = await MongoMemoryServer.create() - await mongoose.connect(mongod.getUri()) - await mongoose.syncIndexes() -}) - -afterAll(async () => { - await mongoose.disconnect() - await mongod.stop() -}) - -beforeEach(async () => { - await Promise.all([UserModel.deleteMany({}), PostModel.deleteMany({}), LikeModel.deleteMany({})]) -}) - -describe('UserModel', () => { - it('enforces a unique username at the database level', async () => { - await UserModel.create({ username: 'yonatan', email: 'a@example.com', password: 'x' }) - await expect( - UserModel.create({ username: 'yonatan', email: 'b@example.com', password: 'x' }), - ).rejects.toThrow(/duplicate key/i) - }) - - it('enforces a unique email at the database level', async () => { - await UserModel.create({ username: 'a', email: 'same@example.com', password: 'x' }) - await expect( - UserModel.create({ username: 'b', email: 'same@example.com', password: 'x' }), - ).rejects.toThrow(/duplicate key/i) - }) - - it('allows a user with no password (OAuth users have none)', async () => { - const user = await UserModel.create({ username: 'oauth', email: 'o@example.com' }) - expect(user.password).toBeUndefined() - }) -}) - -describe('LikeModel', () => { - it('makes double-liking impossible via a compound unique index', async () => { - const user = await UserModel.create({ username: 'u', email: 'u@example.com', password: 'x' }) - const post = await PostModel.create({ - title: 'T', slug: 't', body: 'b', author: user._id, - }) - - await LikeModel.create({ user: user._id, post: post._id }) - // The legacy toggle did read-then-write, so two fast clicks could both push. - await expect(LikeModel.create({ user: user._id, post: post._id })).rejects.toThrow( - /duplicate key/i, - ) - expect(await LikeModel.countDocuments({ post: post._id })).toBe(1) - }) -}) - -describe('PostModel', () => { - it('enforces a unique slug', async () => { - const user = await UserModel.create({ username: 'u', email: 'u@example.com', password: 'x' }) - await PostModel.create({ title: 'T', slug: 'dup', body: 'b', author: user._id }) - await expect( - PostModel.create({ title: 'T2', slug: 'dup', body: 'b', author: user._id }), - ).rejects.toThrow(/duplicate key/i) - }) -}) -``` - -- [ ] **Step 2: Run the test and confirm it fails** - -```bash -npm run test -- packages/shared/src/models -``` -Expected: FAIL — `LikeModel` is not exported. - -- [ ] **Step 3: Write the models** - -`packages/shared/src/models/user.ts`: -```ts -import mongoose, { Schema, type InferSchemaType } from 'mongoose' - -const userSchema = new Schema( - { - username: { type: String, required: true, unique: true, trim: true }, - email: { type: String, required: true, unique: true, lowercase: true, trim: true }, - password: { type: String }, // absent for OAuth users - image: { type: String }, // S3 object key - bio: { type: String }, - }, - { timestamps: true }, -) - -export type User = InferSchemaType -export const UserModel = mongoose.models.User ?? mongoose.model('User', userSchema) -``` - -> `mongoose.models.User ?? mongoose.model(...)` is not optional. Next.js re-executes modules on hot reload, and calling `mongoose.model()` twice with the same name throws `OverwriteModelError`. Every model file needs this guard. - -`packages/shared/src/models/post.ts`: -```ts -import mongoose, { Schema, type InferSchemaType } from 'mongoose' - -const postSchema = new Schema( - { - title: { type: String, required: true, trim: true }, - slug: { type: String, required: true, unique: true }, - body: { type: String, required: true }, - premium: { type: Boolean, required: true, default: false }, - coverImage: { type: String }, - author: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true }, - tags: { type: [String], default: [], index: true }, - }, - { timestamps: true }, -) - -// Full-text search (used in P4). Replaces the legacy client-side .includes() filter. -postSchema.index({ title: 'text', body: 'text' }) - -export type Post = InferSchemaType -export const PostModel = mongoose.models.Post ?? mongoose.model('Post', postSchema) -``` - -`packages/shared/src/models/like.ts`: -```ts -import mongoose, { Schema, type InferSchemaType } from 'mongoose' - -const likeSchema = new Schema( - { - user: { type: Schema.Types.ObjectId, ref: 'User', required: true }, - post: { type: Schema.Types.ObjectId, ref: 'Post', required: true, index: true }, - }, - { timestamps: { createdAt: true, updatedAt: false } }, -) - -// The whole point of this collection: the database refuses a double-like. -likeSchema.index({ user: 1, post: 1 }, { unique: true }) - -export type Like = InferSchemaType -export const LikeModel = mongoose.models.Like ?? mongoose.model('Like', likeSchema) -``` - -`packages/shared/src/models/comment.ts`: -```ts -import mongoose, { Schema, type InferSchemaType } from 'mongoose' - -const commentSchema = new Schema( - { - body: { type: String, required: true, trim: true }, - author: { type: Schema.Types.ObjectId, ref: 'User', required: true }, - post: { type: Schema.Types.ObjectId, ref: 'Post', required: true, index: true }, - parent: { type: Schema.Types.ObjectId, ref: 'Comment' }, // self-ref → threading (P2) - }, - { timestamps: true }, -) - -export type Comment = InferSchemaType -export const CommentModel = mongoose.models.Comment ?? mongoose.model('Comment', commentSchema) -``` - -- [ ] **Step 4: Write the cached connection and error types** - -`packages/shared/src/db.ts` — the `globalThis` cache is what stops Next.js's dev hot-reload from opening a new connection on every file save until Atlas refuses them: - -```ts -import mongoose from 'mongoose' - -type Cache = { conn: typeof mongoose | null; promise: Promise | null } - -const globalCache = globalThis as typeof globalThis & { _mongoose?: Cache } -const cache: Cache = (globalCache._mongoose ??= { conn: null, promise: null }) - -export async function connectDb(uri: string): Promise { - if (cache.conn) return - cache.promise ??= mongoose.connect(uri, { bufferCommands: false }) - cache.conn = await cache.promise -} -``` - -`packages/shared/src/errors.ts`: -```ts -export class UnauthorizedError extends Error { - constructor(message = 'You must be signed in.') { - super(message) - this.name = 'UnauthorizedError' - } -} - -export class ForbiddenError extends Error { - constructor(message = 'You do not have permission to do that.') { - super(message) - this.name = 'ForbiddenError' - } -} - -export class NotFoundError extends Error { - constructor(message = 'Not found.') { - super(message) - this.name = 'NotFoundError' - } -} -``` - -Extend `packages/shared/src/index.ts`: -```ts -export * from './schemas/user.js' -export * from './schemas/post.js' -export * from './models/user.js' -export * from './models/post.js' -export * from './models/like.js' -export * from './models/comment.js' -export * from './db.js' -export * from './errors.js' -``` - -- [ ] **Step 5: Run the test and confirm it passes** - -```bash -npm run test -- packages/shared -``` -Expected: PASS — all schema and model tests. - -- [ ] **Step 6: Commit** - -```bash -git add packages/shared -git commit -m "feat(shared): add Mongoose models with database-level constraints - -- Unique indexes on username, email, slug (legacy had none — racy findOne) -- Compound unique (user, post) on Like makes double-liking impossible -- Cached connection prevents Next.js hot-reload connection exhaustion -- Drops the dead 'tasks' virtual and denormalized authorName/likes fields" -``` - ---- - -## Task 4: Next.js app scaffold with the white/blue design system - -**Files:** -- Create: `apps/web/package.json`, `apps/web/tsconfig.json`, `apps/web/next.config.ts` -- Create: `apps/web/app/{layout.tsx,globals.css,page.tsx}` -- Create: `apps/web/components/ui/button.tsx`, `apps/web/components/layouts/PageShell.tsx` -- Create: `apps/web/components/patterns/{PageHeader,EmptyState}.tsx` - -**Interfaces:** -- Consumes: nothing. -- Produces: ``, ``, ``, ` -} - -function Field({ label, name, type = 'text', errors }: { - label: string; name: string; type?: string; errors?: string[] -}) { - return ( - - ) -} - -export function AuthForm({ mode, action }: { - mode: 'login' | 'signup' - action: (prev: ActionState, fd: FormData) => Promise -}) { - const [state, formAction] = useActionState(action, idle) - - return ( -
- - {mode === 'signup' && ( - - )} - - {state.message ?

{state.message}

: null} - {mode === 'login' ? 'Sign in' : 'Create account'} - - ) -} -``` - -`apps/web/components/patterns/PostForm.tsx`: -```tsx -'use client' - -import { useActionState } from 'react' -import { useFormStatus } from 'react-dom' -import { Button } from '@/components/ui/button' -import { idle, type ActionState } from '@/lib/actions/types' - -function Submit({ label }: { label: string }) { - const { pending } = useFormStatus() - return -} - -export function PostForm({ action, initial, submitLabel }: { - action: (prev: ActionState, fd: FormData) => Promise - initial?: { postId: string; title: string; body: string; premium: boolean } - submitLabel: string -}) { - const [state, formAction] = useActionState(action, idle) - - return ( -
- {initial ? : null} - - - -