Skip to content

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.

  1. API skeleton (T1) — a minimal NestJS app on the Fastify adapter, compiled by the SWC CLI with an explicit .swcrc (the run mechanism research.md V1 verified: nest build -b swc collides with pnpm's build-approval gate, so we drive swc directly). A HealthService injected into a HealthController proves DI resolves through SWC-emitted decorator metadata.
  2. API contract (T2) — the /health response is declared once as a Zod schema, wired via nestjs-zod v5 (@ZodResponse + cleanupOpenApiDoc, per research.md V2), and a generate:openapi script writes a committed apps/api/openapi.json from an app context without listening.
  3. Web shell (T3) — a Vite + React 19 app with Panda CSS (PostCSS integration) and Base UI (@base-ui/react), a TanStack Query provider and React Router, and a Vite dev proxy /api → the api. Renders a styled placeholder to exercise the Panda + Base UI pipeline.
  4. Web client (T4) — Orval generates the typed client, TanStack Query hooks, and Zod validators from openapi.json into src/api/generated/ (two config entries, per research.md V3). A hand-written src/api facade re-exports the health hook wrapped with its validator; application code imports only the facade.
  5. Health round-trip page (T5) — the page calls the facade's health hook and renders loading, error, and ok states — the end-to-end path (SC1).
  6. Unified guardrails + CI (T6) — each app carries its own tsconfig.json; one root typecheck runs per-project tsc --noEmit (not project references — they force emit under TS 7, per research.md V5); one root test uses Vitest test.projects (api node, web jsdom); root build builds both apps; CI gains a pnpm build step.
  7. Contract no-drift guard (T7) — a check that regenerating openapi.json and 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:build

The 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-fastify 11.1.28, fastify 5.10.0, @nestjs/swagger 11.4.6, nestjs-zod 5.5.0, zod 4.4.3, @swc/core 1.15.46 + @swc/cli 0.7.9, reflect-metadata 0.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: vite 8.1.5, @vitejs/plugin-react 6.0.4, react·react-dom 19.2.8, @tanstack/react-query 5.101.4, react-router (v7), @pandacss/dev 1.11.5, @base-ui/react 1.6.0, orval 8.23.0 (dev), zod 4.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/core joins esbuild on pnpm's allowBuilds list.

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. Use unknown plus 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-error without 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.

PathChangeResponsibility
apps/api/package.jsonnew@gw2priory/api, private; scripts build (swc), dev, start, generate:openapi; deps
apps/api/tsconfig.jsonnewextends base; moduleResolution: nodenext, paths, decorators + emitDecoratorMetadata, lib: ["ES2023"], noEmit
apps/api/.swcrcnewjsc.parser.decorators, jsc.transform.legacyDecorator + decoratorMetadata — the build that emits DI metadata
apps/api/src/main.tsnewreflect-metadata import; Fastify bootstrap with its native Pino logger enabled (R1); ZodValidationPipe; Swagger at /api-docs
apps/api/src/app.module.tsnewroot module importing HealthModule
apps/api/src/health/health.module.tsnewwires controller + service
apps/api/src/health/health.controller.tsnewGET /health → HealthService, @ZodResponse(HealthResponseDto)
apps/api/src/health/health.service.tsnewgetStatus(): { status: 'ok' } (the injected dependency)
apps/api/src/health/health.schema.tsnewZod HealthResponse + createZodDto → HealthResponseDto
apps/api/src/generate-openapi.tsnewapp-context document build → cleanupOpenApiDoc → write openapi.json (no listen)
apps/api/openapi.jsonnew (committed)the contract artifact; codegen input for the web app
apps/api/src/health/health.controller.test.tsnewintegration: GET /health → 200 {status:'ok'} (node)
apps/api/src/generate-openapi.test.tsnewasserts openapi.json has GET /health + Zod-derived response
apps/web/package.jsonnew@gw2priory/web, private; scripts dev, build, generate:api, prepare (panda codegen); deps
apps/web/tsconfig.jsonnewextends base; DOM+JSX, lib: ["ES2023","DOM","DOM.Iterable"], jsx: react-jsx, bundler resolution
apps/web/vite.config.tsnewReact plugin; dev proxy /api → api origin
apps/web/postcss.config.cjsnew{ '@pandacss/dev/postcss': {} } (Panda has no Vite plugin)
apps/web/panda.config.tsnewjsxFramework: 'react', output styled-system
apps/web/orval.config.tsnewtwo entries — client: 'react-query' (fetch) and client: 'zod'
apps/web/index.htmlnewVite entry
apps/web/src/main.tsxnewReact root; QueryClientProvider; router
apps/web/src/App.tsxnewapp shell + Panda styling + a Base UI primitive
apps/web/src/routes/health-page.tsxnewuses the facade health hook; renders loading/error/ok
apps/web/src/api/index.tsnewthe 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.tsxnewRTL: shell renders, dev-proxy config present (jsdom)
apps/web/src/routes/health-page.test.tsxnewRTL: ok on resolve, error on reject, loading pending
apps/web/src/api/boundary.test.tsnewasserts no import of generated/ outside src/api/
tsconfig.base.jsonmodifyadd lib: ["ES2023"] (non-DOM floor, so DOM stays isolated to web — F8)
vitest.config.tsmodifyconvert to test.projects (api node, web jsdom, packages/tests node)
package.json (root)modifytypecheck = 3× tsc --noEmit -p …; test; add dev, build; devDep jsdom
pnpm-workspace.yamlmodifyadd @swc/core to allowBuilds
.github/workflows/ci.ymlmodifyadd a pnpm build step (after test, before docs:build); add contract no-drift check
tests/ci/workflow.test.tsmodifyupdate the expected ordered-checks array to include pnpm build
tests/monorepo/apps.test.tsnewstructural guards for P2: vitest env split, 3-project typecheck, biome covers apps, build builds both
apps/web/.gitignore (or root)modifyignore styled-system/ (Panda-generated, rebuilt by prepare)

Data & contracts ​

  • HealthResponse (Zod, api): z.object({ status: z.literal('ok') }) → HealthResponseDto via createZodDto. Emitted into OpenAPI as { type: 'object', properties: { status: { type: 'string', enum: ['ok'] } }, required: ['status'] } (verified, research.md V2). Under Zod 4 the schema is named with an _Output suffix; the response $ref points at that variant.
  • openapi.json (committed): OpenAPI 3 document with GET /health and an operationId (operationId is 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/api facade: exports useHealth() = the generated hook composed with its validator, so a component receives runtime-checked data. This is the composition point Orval does not generate (research.md F10).

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, assert GET /health → 200 {status:'ok'} with DI resolved (P1 #1 / SC2).
  • contract emission (generate-openapi.test.ts): run the emit, assert the document's /health path and Zod-derived response shape (P3 #1 / SC5).
  • web component (health-page.test.tsx, jsdom): the facade hook is mocked at the facade boundary; assert ok on resolve, error on reject, loading while pending (P1 #2, #3 / SC1). App.test.tsx asserts the shell renders and the dev-proxy config is present (P1 #4).
  • api boundary (boundary.test.ts): static scan asserting nothing outside src/api/ imports from generated/ (P3 #3 / SC6).
  • monorepo structure (apps.test.ts, mirroring tests/ci/workflow.test.ts style): 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 existing docs/superpowers zero-count invariant (P2 #5 / SC7).
  • contract no-drift (T7): a script (verify:contract) regenerates openapi.json + the Orval client and asserts git diff --exit-code on 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's noEmit model (research.md V5). Per-project tsc --noEmit behind one script gives the same guarantee.
  • nest build -b swc for the api compile — rejected: entangled with pnpm's build-approval gate (research.md V1). The SWC CLI + explicit .swcrc is more robust.
  • A shared packages/contracts of 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.md F10), so the facade composes them where response integrity matters.

Risks ​

  • Panda styled-system availability in CI. It is gitignored and regenerated by the web app's prepare script on install; typecheck/build run after install, so the types exist. Mitigation: the web build script also runs panda codegen before vite build, so a missing styled-system cannot reach the build.
  • CI check-order test coupling. Adding pnpm build changes the exact ordered-checks array tests/ci/workflow.test.ts asserts. Mitigation: T6 updates that test in the same task as the CI change — they are one reviewable unit.
  • Committed generated artifacts drifting. openapi.json and src/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" and baseUrl, both removed in TS 7 (research.md F7). Mitigation: the api tsconfig.json is authored fresh with nodenext + 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.