Plan 004 — App foundation
Status: approved Written in plan mode from spec.md and research.md. Approved by the human before any code is written. Status is set by the human, never by the agent: proposed → approved.
Produced alone — tasks.md stays untouched until this plan is approved in turn. This is the plan's header half (architecture, file structure, task boundaries and interfaces); the bite-sized TDD steps are the task half, written to tasks.md at step 3 once this plan is approved.
Goal
Turn the empty apps/ into two running applications with one proven wire between them. After this plan, apps/api (NestJS on Fastify) boots and serves GET /health, its Zod schema generates an OpenAPI document, apps/web (Vite/React) generates its client from that document and renders a page that fetches /health and shows the result — and the whole thing sits under one root typecheck, test, lint, and build with CI green. No product feature is built; this is the frame every later vertical hangs on.
Approach
Seven deliverables in dependency order. The api is built first because it is the source of the contract (its OpenAPI document is the web app's codegen input); the web app is built against that committed document; the monorepo guardrails and CI are consolidated last, once there are two apps to cover.
- API skeleton (T1) — a minimal NestJS app on the Fastify adapter, compiled by the SWC CLI with an explicit
.swcrc(the run mechanismresearch.mdV1 verified:nest build -b swccollides with pnpm's build-approval gate, so we driveswcdirectly). AHealthServiceinjected into aHealthControllerproves DI resolves through SWC-emitted decorator metadata. - API contract (T2) — the
/healthresponse is declared once as a Zod schema, wired via nestjs-zod v5 (@ZodResponse+cleanupOpenApiDoc, perresearch.mdV2), and agenerate:openapiscript writes a committedapps/api/openapi.jsonfrom an app context without listening. - Web shell (T3) — a Vite + React 19 app with Panda CSS (PostCSS integration) and Base UI (
@base-ui/react), aTanStack Queryprovider andReact Router, and a Vite dev proxy/api →the api. Renders a styled placeholder to exercise the Panda + Base UI pipeline. - Web client (T4) — Orval generates the typed client, TanStack Query hooks, and Zod validators from
openapi.jsonintosrc/api/generated/(two config entries, perresearch.mdV3). A hand-writtensrc/apifacade re-exports the health hook wrapped with its validator; application code imports only the facade. - Health round-trip page (T5) — the page calls the facade's health hook and renders loading, error, and
okstates — the end-to-end path (SC1). - Unified guardrails + CI (T6) — each app carries its own
tsconfig.json; one roottypecheckruns per-projecttsc --noEmit(not project references — they force emit under TS 7, perresearch.mdV5); one roottestuses Vitesttest.projects(apinode, webjsdom); rootbuildbuilds both apps; CI gains apnpm buildstep. - Contract no-drift guard (T7) — a check that regenerating
openapi.jsonand the Orval client leaves the git tree clean, so a change on one side that is not regenerated on the other fails CI (SC4).
Architecture
apps/api (NestJS + Fastify, run via SWC) apps/web (Vite + React 19)
┌───────────────────────────────────┐ ┌──────────────────────────────────────┐
│ HealthController → HealthService │ │ routes/health-page ─┐ │
│ │ (Zod HealthResponse) │ │ │ imports only │
│ ▼ │ │ src/api (facade) │
│ nestjs-zod + @nestjs/swagger │ openapi │ │ wraps hook + validator │
│ ▼ cleanupOpenApiDoc │ .json │ src/api/generated (Orval) │
│ generate:openapi ───────────────┼────────────▶│ generate:api (Orval codegen) │
└───────────────────────────────────┘ (committed)│ TanStack Query · Panda CSS · Base UI │
▲ GET /health (Fastify) └──────────────────────────────────────┘
└───────────────── Vite dev proxy /api ────────────────┘
Root: tsconfig.base.json ─┬─ apps/api/tsconfig.json (decorators, nodenext, no DOM)
├─ apps/web/tsconfig.json (DOM + JSX, bundler)
└─ tsconfig.json (packages, scripts, tests) → one `pnpm typecheck` (3× tsc)
Root: vitest.config.ts (test.projects: api=node, web=jsdom, packages/tests=node) → one `pnpm test`
Root: biome.json (whole tree) · CI: lint → typecheck → test → build → docs:buildThe contract is one-directional: the api owns it, the web app consumes a generated artifact. The only seam the web app hand-writes is the src/api facade — the single place a generated hook and its Zod validator are composed, and the single import surface the rest of the app sees.
Tech stack
Every dependency below was verified working together on the current toolchain in research.md (2026-07-26); versions are pinned because this is bleeding-edge (TypeScript 7 native compiler, React 19).
- Runtime/toolchain: Node 26.5.0 (
engines.node ≥22.18), pnpm 11.15.1, TypeScript 7.0.2, Biome 2.5.5, Vitest 4.1.10, jsdom 29.1.1. - api:
@nestjs/core·common·platform-fastify11.1.28,fastify5.10.0,@nestjs/swagger11.4.6,nestjs-zod5.5.0,zod4.4.3,@swc/core1.15.46 +@swc/cli0.7.9,reflect-metadata0.2.2. Justification: NestJS + Fastify + Zod-as-single-source-of-truth are the spec's decided stack (R1–R3); SWC is the compile step decorator metadata requires (R5);@nestjs/swagger+ nestjs-zod emit the contract (R3). - web:
vite8.1.5,@vitejs/plugin-react6.0.4,react·react-dom19.2.8,@tanstack/react-query5.101.4,react-router(v7),@pandacss/dev1.11.5,@base-ui/react1.6.0,orval8.23.0 (dev),zod4.4.3. Justification: the spec's decided web stack (R6–R8); Orval consumes the OpenAPI document (R8); Panda + Base UI are the styling/primitive choices (R7). - No other new dependencies.
@swc/corejoinsesbuildon pnpm'sallowBuildslist.
Global Constraints
Copied verbatim from the architecture docs. Every task inherits these; do not summarise or reword them.
From docs/architecture/typescript.md:
- No
any. Not in app code, not in tests. Useunknownplus narrowing, or model the type properly. If a third-party type forces it, isolate it behind one typed adapter and comment why. - No non-null assertions (
!) to silence the compiler. - No
@ts-expect-errorwithout a comment explaining what is expected and when it can be removed. - Validate everything crossing a boundary (GW2 API responses, HTTP input) at runtime, not just at the type level.
- Prefer pure functions for domain logic. The optimizer must be testable without a network or a database.
- Match the style of surrounding code. No new dependency without justification in the spec or plan.
From docs/architecture/stack.md:
- Monorepo, pnpm workspaces.
apps/api— NestJS (TypeScript).apps/web— React (TypeScript).packages/*— shared code (domain types, the curated Mystic Forge dataset) when sharing is real, not speculative.- Postgres for persistence. In-memory cache for the MVP — no Redis until the caching story earns it.
- Vitest everywhere, both apps.
- Deploy: managed PaaS (Fly.io / Railway). CI: GitHub Actions — lint + typecheck + test + build.
- API keys are user secrets: encrypted at rest, never logged, never returned to the client.
From CLAUDE.md: typecheck clean, tests pass, every acceptance scenario and success criterion covered by a test whose name traces to it, no unexplained escape hatches, the human reviews the diff. Nothing is written under docs/superpowers/.
File Structure
Exact paths, and what each file is responsible for. A path here is a commitment; a task that touches a file not listed is a signal the plan missed something.
| Path | Change | Responsibility |
|---|---|---|
apps/api/package.json | new | @gw2priory/api, private; scripts build (swc), dev, start, generate:openapi; deps |
apps/api/tsconfig.json | new | extends base; moduleResolution: nodenext, paths, decorators + emitDecoratorMetadata, lib: ["ES2023"], noEmit |
apps/api/.swcrc | new | jsc.parser.decorators, jsc.transform.legacyDecorator + decoratorMetadata — the build that emits DI metadata |
apps/api/src/main.ts | new | reflect-metadata import; Fastify bootstrap with its native Pino logger enabled (R1); ZodValidationPipe; Swagger at /api-docs |
apps/api/src/app.module.ts | new | root module importing HealthModule |
apps/api/src/health/health.module.ts | new | wires controller + service |
apps/api/src/health/health.controller.ts | new | GET /health → HealthService, @ZodResponse(HealthResponseDto) |
apps/api/src/health/health.service.ts | new | getStatus(): { status: 'ok' } (the injected dependency) |
apps/api/src/health/health.schema.ts | new | Zod HealthResponse + createZodDto → HealthResponseDto |
apps/api/src/generate-openapi.ts | new | app-context document build → cleanupOpenApiDoc → write openapi.json (no listen) |
apps/api/openapi.json | new (committed) | the contract artifact; codegen input for the web app |
apps/api/src/health/health.controller.test.ts | new | integration: GET /health → 200 {status:'ok'} (node) |
apps/api/src/generate-openapi.test.ts | new | asserts openapi.json has GET /health + Zod-derived response |
apps/web/package.json | new | @gw2priory/web, private; scripts dev, build, generate:api, prepare (panda codegen); deps |
apps/web/tsconfig.json | new | extends base; DOM+JSX, lib: ["ES2023","DOM","DOM.Iterable"], jsx: react-jsx, bundler resolution |
apps/web/vite.config.ts | new | React plugin; dev proxy /api → api origin |
apps/web/postcss.config.cjs | new | { '@pandacss/dev/postcss': {} } (Panda has no Vite plugin) |
apps/web/panda.config.ts | new | jsxFramework: 'react', output styled-system |
apps/web/orval.config.ts | new | two entries — client: 'react-query' (fetch) and client: 'zod' |
apps/web/index.html | new | Vite entry |
apps/web/src/main.tsx | new | React root; QueryClientProvider; router |
apps/web/src/App.tsx | new | app shell + Panda styling + a Base UI primitive |
apps/web/src/routes/health-page.tsx | new | uses the facade health hook; renders loading/error/ok |
apps/web/src/api/index.ts | new | the facade: re-exports the health hook wrapped with its Zod validator |
apps/web/src/api/generated/** | new (committed) | Orval output — client, hooks, models, validators |
apps/web/src/App.test.tsx | new | RTL: shell renders, dev-proxy config present (jsdom) |
apps/web/src/routes/health-page.test.tsx | new | RTL: ok on resolve, error on reject, loading pending |
apps/web/src/api/boundary.test.ts | new | asserts no import of generated/ outside src/api/ |
tsconfig.base.json | modify | add lib: ["ES2023"] (non-DOM floor, so DOM stays isolated to web — F8) |
vitest.config.ts | modify | convert to test.projects (api node, web jsdom, packages/tests node) |
package.json (root) | modify | typecheck = 3× tsc --noEmit -p …; test; add dev, build; devDep jsdom |
pnpm-workspace.yaml | modify | add @swc/core to allowBuilds |
.github/workflows/ci.yml | modify | add a pnpm build step (after test, before docs:build); add contract no-drift check |
tests/ci/workflow.test.ts | modify | update the expected ordered-checks array to include pnpm build |
tests/monorepo/apps.test.ts | new | structural guards for P2: vitest env split, 3-project typecheck, biome covers apps, build builds both |
apps/web/.gitignore (or root) | modify | ignore styled-system/ (Panda-generated, rebuilt by prepare) |
Data & contracts
HealthResponse(Zod, api):z.object({ status: z.literal('ok') })→HealthResponseDtoviacreateZodDto. Emitted into OpenAPI as{ type: 'object', properties: { status: { type: 'string', enum: ['ok'] } }, required: ['status'] }(verified,research.mdV2). Under Zod 4 the schema is named with an_Outputsuffix; the response$refpoints at that variant.openapi.json(committed): OpenAPI 3 document withGET /healthand anoperationId(operationIdis what names the generated hook — Orval needs it).- Generated web client (committed,
src/api/generated/):useGetHealth(TanStack Query hook),getHealthResponse(Zod validator). Neither is imported directly by app code. src/apifacade: exportsuseHealth()= the generated hook composed with its validator, so a component receives runtime-checked data. This is the composition point Orval does not generate (research.mdF10).
Test strategy
Each of the spec's acceptance scenarios and success criteria becomes a named test (the spec's traceability table is filled as tasks land). Split:
- api integration (
health.controller.test.ts, node): boot a Nest app in-process, assertGET /health→ 200{status:'ok'}with DI resolved (P1 #1 / SC2). - contract emission (
generate-openapi.test.ts): run the emit, assert the document's/healthpath and Zod-derived response shape (P3 #1 / SC5). - web component (
health-page.test.tsx, jsdom): the facade hook is mocked at the facade boundary; assertokon resolve, error on reject, loading while pending (P1 #2, #3 / SC1).App.test.tsxasserts the shell renders and the dev-proxy config is present (P1 #4). - api boundary (
boundary.test.ts): static scan asserting nothing outsidesrc/api/imports fromgenerated/(P3 #3 / SC6). - monorepo structure (
apps.test.ts, mirroringtests/ci/workflow.test.tsstyle): assert the vitest projects carry the two environments, the typecheck script runs all three tsconfigs, Biome covers the apps, and the build script builds both (P2 #1–#4). Plus the existingdocs/superpowerszero-count invariant (P2 #5 / SC7). - contract no-drift (T7): a script (
verify:contract) regeneratesopenapi.json+ the Orval client and assertsgit diff --exit-codeon those paths; run in CI (P3 #2 / SC4).
What is not unit-tested and why: SC1's full browser round-trip against a live api is observational — the automated web tests mock at the facade; the real end-to-end is confirmed by running both apps (the spec's P1 independent test) and, ultimately, by CI's build + the first pnpm dev. This is called out rather than faked.
Task breakdown (boundaries and interfaces)
Bite-sized TDD steps for each are written to tasks.md at step 3. Here: scope, files, and the interfaces each task publishes to its neighbours.
T1 — API skeleton boots on Fastify (with its native Pino logger) via SWC, serves /health. (R1, R4, R5) Files: apps/api/{package.json, tsconfig.json, .swcrc, src/main.ts, src/app.module.ts, src/health/*.ts}; modify pnpm-workspace.yaml (allowBuilds @swc/core), tsconfig.base.json (lib floor), vitest.config.ts (convert to test.projects, add api node project), root package.json (typecheck includes api). Produces: HealthService.getStatus(): { status: 'ok' }; GET /health. Consumes: —.
T2 — API contract: Zod → OpenAPI document. (R2, R3) Files: apps/api/src/health/health.schema.ts, health.controller.ts (annotate), src/main.ts (ZodValidationPipe + Swagger), src/generate-openapi.ts, apps/api/openapi.json. Produces: HealthResponseDto; committed openapi.json with GET /health. Consumes: T1.
T3 — Web shell renders styled, with providers and dev proxy. (R6, R7, R12) Files: apps/web/{package.json, tsconfig.json, vite.config.ts, postcss.config.cjs, panda.config.ts, index.html, src/main.tsx, src/App.tsx}; modify vitest.config.ts (web jsdom project), root package.json (typecheck includes web), gitignore styled-system. Produces: the app shell + QueryClientProvider + router + /api proxy. Consumes: —.
T4 — Web client generated from OpenAPI, behind the facade. (R8, R9) Files: apps/web/orval.config.ts, src/api/generated/**, src/api/index.ts. Produces: useHealth() (facade hook, validated). Consumes: T2's openapi.json.
T5 — Health round-trip page. (R10; P1) Files: apps/web/src/routes/health-page.tsx, wire into App.tsx/router. Produces: the end-to-end path. Consumes: T3 shell, T4 useHealth().
T6 — Unified guardrails + CI. (R13, R14, R15, R16; P2) Files: root package.json (dev, build; final typecheck/test), vitest.config.ts (final), .github/workflows/ci.yml (+pnpm build), tests/ci/workflow.test.ts (updated array), tests/monorepo/apps.test.ts. Produces: one root typecheck/test/lint/build; green CI with both apps. Consumes: T1–T5.
T7 — Contract no-drift guard. (P3 #2 / SC4) Files: root package.json (verify:contract), .github/workflows/ci.yml (regen check step). Produces: CI failure on un-regenerated contract drift. Consumes: T2, T4.
Alternatives considered
- TypeScript project references for the one-root typecheck — rejected: they work under TS 7 but force emit (
.d.ts+.tsbuildinfo) and shift resolution off source, breaking the repo'snoEmitmodel (research.mdV5). Per-projecttsc --noEmitbehind one script gives the same guarantee. nest build -b swcfor the api compile — rejected: entangled with pnpm's build-approval gate (research.mdV1). The SWC CLI + explicit.swcrcis more robust.- A shared
packages/contractsof hand-written Zod schemas — rejected during brainstorming in favour of api-authoritative OpenAPI + generated client; the OpenAPI document is the shared artifact, so no hand-maintained package. - Orval's auto-generated runtime validation everywhere — not available: Orval emits validators decoupled from hooks (
research.mdF10), so the facade composes them where response integrity matters.
Risks
- Panda
styled-systemavailability in CI. It is gitignored and regenerated by the web app'spreparescript on install; typecheck/build run after install, so the types exist. Mitigation: the webbuildscript also runspanda codegenbeforevite build, so a missingstyled-systemcannot reach the build. - CI check-order test coupling. Adding
pnpm buildchanges the exact ordered-checks arraytests/ci/workflow.test.tsasserts. Mitigation: T6 updates that test in the same task as the CI change — they are one reviewable unit. - Committed generated artifacts drifting.
openapi.jsonandsrc/api/generated/**are committed; a hand-edit or an un-regenerated change would rot silently. Mitigation: T7's no-drift check fails CI. - TS 7 tsconfig migration. NestJS boilerplate assumes
moduleResolution: "node"andbaseUrl, both removed in TS 7 (research.mdF7). Mitigation: the apitsconfig.jsonis authored fresh withnodenext+paths, not copied from a generator.
Open questions
None blocking. research.md closed all six verification items; the three ratified spec amendments (R5, R13 mechanism + lib floor) are already reflected above. Two items are deferred by design, not open: the api's production run/build target (this plan compiles for local dev + CI typecheck/build only) and prod networking (CORS/deploy) — both are later specs, per the spec's Out of scope.