Skip to content

Spec 004 — App foundation ​

Status: implemented Branch: 004-app-foundation

Status is set by the human, never by the agent. It moves draft → approved → implemented.

Problem ​

docs/architecture/stack.md commits the project to two applications — apps/api (NestJS) and apps/web (React) — but apps/ is empty. Specs 001–003 built the workbench: the spec-driven workflow, a pnpm workspace with one root typecheck / test / lint, and a CI pipeline that runs them and guards main. What that workbench has never held is an application. There is nowhere to add a route, no process to boot, no path from a browser to a backend, and none of the "main tech choices" inside each app — the ones monorepo.md deliberately deferred to "the app specs' work" — have been made.

This spec stands up both applications as walking skeletons: each boots, and one real path works end to end — the web app renders a page that calls the api's health endpoint and shows the result. It pins the technology choices inside each app, wires the api-authoritative contract that connects them, and folds both apps into the repo's existing single typecheck / test / lint / build so CI stays green. It builds the frame every later feature hangs on; it deliberately builds no product feature.

User stories ​

Ordered by priority. Each story must be independently testable and shippable — if only P1 ships, there is still something usable.

P1 — Both apps run, and one path works end to end ​

As the developer, I want apps/api and apps/web to boot and to share one working request — the web page calls the api's /health endpoint through its generated client and displays the outcome (loading, error, or ok) — so that the project has a live frame: a backend that serves, a frontend that renders, and a proven wire between them to hang the first feature on.

Independent test: start both apps in dev, open the web page in a browser, and see it display ok fetched from the running api; with the api stopped, the same page shows its error state. Backed by automated tests: an api integration test that GET /health returns 200 {status:'ok'}, and a web component test that the page renders ok when its data hook resolves and an error state when it rejects.

Acceptance scenarios

  1. Given the apps/api skeleton, when the api process starts and a client issues GET /health, then it responds 200 with body { "status": "ok" }.
  2. Given the apps/web skeleton and a reachable api, when the web page mounts, then it issues the health request through the generated client's data hook (reached only via the src/api facade) and renders ok.
  3. Given the web page while the health request is in flight, when it has not yet resolved, then the page renders a loading state; and when the request fails (api unreachable), then the page renders an error state rather than crashing.
  4. Given both apps in dev, when the web app calls the api, then the call reaches the api through the Vite dev proxy under a same-origin /api path, with no CORS configuration required in dev.

P2 — Both apps fold into one set of root guardrails ​

As the developer, I want the two apps — despite each needing its own TypeScript profile (the web its DOM+JSX, the api its decorator metadata) — to be covered by a single root typecheck, test, lint, and build, so that the guardrails specs 001–003 built keep working with the apps present and CI stays green from one command each, not four-times-two.

Independent test: with both app skeletons committed, run pnpm typecheck, pnpm test, pnpm lint, and pnpm build at the repo root; each completes green and each covers both apps (a deliberately introduced type error, failing test, or lint violation inside either app fails the corresponding root command). The docs/superpowers/ file-count test still reports zero.

Acceptance scenarios

  1. Given both apps with their own tsconfig.json, when pnpm typecheck runs at the root, then it type-checks packages/** and both apps in one invocation, and a type error planted in either app fails it.
  2. Given both apps with tests (api in a Node environment, web in a DOM environment), whenpnpm test runs at the root, then it discovers and runs the tests of both apps plus the existing packages/** and tests/** in one invocation, and a failing test in either app fails it.
  3. Given both apps, when pnpm lint runs, then Biome checks both apps under the existing single config, and a lint violation in either app fails it.
  4. Given both apps, when pnpm build runs at the root, then both apps build successfully; and the existing CI pipeline (lint + typecheck + test + build) runs green on a pull request that adds them.
  5. Given this spec's work is complete, when the docs/superpowers/ file-count test runs, then it still reports zero files there.

P3 — The web client is generated from the api's contract, and drift is caught ​

As the developer, I want the api to be the single source of truth for the contract — its Zod schemas generate an OpenAPI document, and the web client is generated from that document — so that the two apps cannot silently disagree on the shape of a request, and a change on one side that is not regenerated on the other is caught mechanically rather than at runtime.

Independent test: run the api's OpenAPI-emit step and the web's codegen step, then assert the git working tree is clean — regenerating both artifacts from committed source produces no diff. Separately, assert the emitted openapi.json describes GET /health with the response shape declared by the api's Zod schema.

Acceptance scenarios

  1. Given the api's health response declared once as a Zod schema, when the OpenAPI document is emitted, then it contains the /health path and a response schema derived from that Zod schema — the shape is written once, not duplicated in a hand-authored DTO.
  2. Given a committed openapi.json and generated web client, when the emit and codegen steps are re-run, then they produce byte-identical output — a clean working tree — so stale generated code is detectable in CI.
  3. Given the generated web client, when the web app consumes it, then application code imports only from the src/api facade; nothing outside src/api/ imports from the generated output directory.

Requirements ​

Requirements record the decisions this spec makes. Each [NEEDS VERIFICATION] marks a feasibility claim that only reality can confirm; every one must receive a verdict in research.md before the plan gate opens. A refuted claim sends the affected choice back to step 1 rather than being patched.

apps/api (NestJS)

  • R1 — The api runs on NestJS with the Fastify HTTP adapter, and logs via Pino.
  • R2 — Request/response schemas are defined once as Zod schemas and wired into NestJS via nestjs-zod (createZodDto + validation pipe); there are no class-validator DTOs.
  • R3 — An OpenAPI 3 document is generated from those Zod schemas via @nestjs/swagger plus nestjs-zod v5's integration — routes annotated with @ZodResponse, the document post-processed by cleanupOpenApiDoc(SwaggerModule.createDocument(...)) — served at /api-docs (and /api-docs-json) and written to a static openapi.json by a script that does not require the server to be running. Verified — research.md V2 (faithful enum/shape emission from Zod, offline; note Zod 4 emits _Output/_Input-suffixed schema names; the older patchNestjsSwagger API is gone in v5).
  • R4 — The api has a HealthModule exposing GET /health → { status: 'ok' }, with the response typed by a Zod schema so it appears in the OpenAPI document.
  • R5 — Because NestJS dependency injection requires decorator metadata that the repo's "Node runs TypeScript directly, tsconfig is noEmit" model does not emit, apps/api carries its own compile/run step — the SWC CLI driven by an explicit .swcrc (jsc.parser.decorators, jsc.transform.legacyDecorator, jsc.transform.decoratorMetadata), not nest build -b swc, which collides with pnpm's build-approval gate (see F9). A documented, deliberate divergence from the root no-build model, which monorepo.md anticipates. Verified — research.md V1 (SWC emits design:paramtypes; the app boots on Fastify under Node 26 with DI resolving; TS 7 typechecks the decorator code).

apps/web (React)

  • R6 — The web app builds and serves under Vite, routes with React Router, and manages server state with TanStack Query.
  • R7 — Styling is Panda CSS (zero-runtime, token-driven) and interactive primitives are Base UI (headless, accessible). Verified — research.md V4: install @base-ui/react 1.6.0 (the correct package — not @base-ui-components/react, not @mui/base), which covers Dialog/Popover/Tooltip/Tabs/Select on React 19; Panda integrates via PostCSS (it has no Vite plugin) with jsxFramework: "react", and its codegen typechecks under TS 7.
  • R8 — The web client is generated by Orval from openapi.json into src/api/generated/ — typed client, TanStack Query hooks, and Zod response validators. Verified — research.md V3. Two Orval config entries are required (one client: 'react-query', one client: 'zod'); the validators are not auto-wired into the hooks, so the src/api facade (R9) is where a hook and its validator are composed for runtime-checked responses.
  • R9 — Application code imports API access only from the src/api facade, which re-exports the generated hooks; nothing outside src/api/ imports from src/api/generated/. This keeps a later generator swap local and the data layer mockable in tests.
  • R10 — The skeleton page uses the generated health hook (through the facade) and renders loading, error, and ok states, using Panda styling and at least one Base UI primitive to exercise the UI pipeline.

Contract & connection

  • R11 — The contract flows in one direction: api Zod schemas → OpenAPI document → web generated client. openapi.json is committed and is the codegen input, so neither codegen nor CI needs a running server.
  • R12 — In development the web app reaches the api through a Vite dev proxy on a same-origin /api path; CORS is not configured in dev. Production networking is out of scope (see below).

Monorepo integration

  • R13 — Each app has its own tsconfig.json extending tsconfig.base.json with its profile (web: DOM + JSX; api: decorators + emitDecoratorMetadata). A single root pnpm typecheck covers packages/** and both apps by running per-project tsc --noEmit behind one script (tsc --noEmit -p packages/domain && … -p apps/web && … -p apps/api) — not TypeScript project references, which under TS 7 force emit (.d.ts + .tsbuildinfo) and break the repo's noEmit / source-resolution model. tsconfig.base.json pins a non-DOM lib (["es2023"]) and apps/web overrides to add dom, dom.iterable, so DOM globals stay isolated to the web profile; the api tsconfig uses moduleResolution: "nodenext" and paths (TS 7 removed moduleResolution: "node" and baseUrl). Verified — research.md V5, F7, F8.
  • R14 — Tests run through Vitest's test.projects key in a single vitest.config.ts (api in a node environment, web in a jsdom environment; not the deprecated vitest.workspace file) so one root pnpm test still discovers both apps alongside packages/** and tests/**, with jsdom as a root dev dependency. Verified — research.md V6.
  • R15 — Biome remains the sole linter/formatter over the whole tree (no per-app lint config), and root scripts gain a dev that runs both apps and a build that builds both; the existing CI pipeline runs them unchanged in shape.
  • R16 — Nothing is written under docs/superpowers/; the existing zero-count test stays satisfied.

Success criteria ​

Measurable outcomes, technology-agnostic where the outcome allows.

  • SC1 — Starting both apps and opening the web page shows ok fetched from the live api; stopping the api makes the same page show its error state instead of crashing.
  • SC2 — GET /health on the running api returns 200 { "status": "ok" }.
  • SC3 — A single root pnpm typecheck, pnpm test, pnpm lint, and pnpm build each complete successfully with both apps present, and each fails when a fault (type error / failing test / lint violation) is planted inside either app.
  • SC4 — Re-running the OpenAPI-emit and web-codegen steps from committed source leaves the git working tree clean (no diff) — regenerated artifacts match what is committed.
  • SC5 — The emitted openapi.json describes GET /health with a response schema derived from the api's Zod schema, and no hand-authored DTO duplicates that shape.
  • SC6 — No file outside src/api/ imports from the generated client directory, enforced by a test.
  • SC7 — The docs/superpowers/ file count remains zero, and CI (lint + typecheck + test + build) is green on the pull request that introduces both apps.

Out of scope ​

Excluded here so they don't creep in; each returns in its own later spec.

  • Postgres / ORM wiring. The stack decision (Postgres + in-memory cache) stands on paper, but no database connection, ORM, migration, or persisted entity is created until the first feature needs to persist something.
  • Config / secrets / auth posture — env validation, encrypted GW2-API-key handling, error-handling middleware beyond what the health path needs.
  • The GW2 API client, static-data sync, the profit engine, and any product feature — this is a frame, not a vertical.
  • Production networking and deployment — prod CORS/proxy, the PaaS deploy, container images.
  • Dense-data UI machinery — data tables, the crafting-tree graph library — feature-spec concerns, not the skeleton.
  • LLM features — as stack.md already excludes.

Assumptions ​

  • The workbench of specs 001–003 is in place and stays authoritative: pnpm 11.15.1, Node ≥22.18 (26.5.0 verified), TypeScript 7.0.2, Vitest 4.1.10, Biome 2.5.5, one root config per concern, CI on every PR and push to main.
  • packages/domain and the @gw2priory/* scope, workspace:* linking, and Biome house style (2-space, single quotes, semicolons) are followed by both apps.
  • The generated web client lives inside apps/web (only the web app consumes it); no shared workspace package is introduced for the contract — the OpenAPI document is the shared artifact.
  • Discovery (step 1.5) is complete: all six verification markers are confirmed and exact versions are pinned in research.md's version table (Node 26.5.0, pnpm 11.15.1, TypeScript 7.0.2, NestJS 11.1.28, nestjs-zod 5.5.0, zod 4.4.3, @swc/core 1.15.46, Fastify 5.10.0, Vite 8.1.5, React 19.2.8, Panda 1.11.5, @base-ui/react 1.6.0, Orval 8.23.0, @tanstack/react-query 5.101.4, Vitest 4.1.10, jsdom 29.1.1). @swc/core must join esbuild on pnpm's allowBuilds list (research.md F9).

Traceability ​

Each acceptance scenario and success criterion maps to a named test. Filled in during implementation; the intended target is named now so coverage is designed, not retrofitted.

CriterionTest
P1 #1apps/api/src/health/health.controller.test.ts — SC2 / P1 #1: returns 200 {status:"ok"} with DI resolved
P1 #2apps/web/src/routes/health-page.test.tsx — P1 #2 / SC1: shows ok when resolved
P1 #3apps/web/src/routes/health-page.test.tsx — P1 #3: shows an error state on failure, P1 #3: shows loading while pending
P1 #4apps/web/src/vite-proxy.test.ts — R12/P1 #4: proxies '/api' to the api's origin
P2 #1tests/monorepo/apps.test.ts — P2 #1/R13: typecheck runs tsc --noEmit for the root, apps/api, and apps/web
P2 #2tests/monorepo/apps.test.ts — P2 #2/R14: test.projects wires apps/api (node) and apps/web (jsdom)
P2 #3tests/monorepo/apps.test.ts — P2 #3/R15: no apps/**/biome.json exists
P2 #4tests/monorepo/apps.test.ts — P2 #4/R15: build filters both @gw2priory/api and @gw2priory/web through their own build script; tests/ci/workflow.test.ts — P1 #1/R3/SC4: runs the six checks in order
P2 #5tests/workflow/repo-invariants.test.ts — SC3: no workflow artifact is written outside specs/NNN-<slug>/ (the docs/superpowers zero-count assertion)
P3 #1apps/api/src/generate-openapi.test.ts — P3 #1 / SC5: documents GET /health with a Zod-derived response
P3 #2Amended by spec 012 — tests/contract/no-drift.test.ts was deleted. Its whole body shelled out to pnpm verify:contract, which .github/workflows/ci.yml already runs as its own step, so every CI run paid for the same SWC build + Orval codegen twice; the wrapped copy also failed a run on vitest's 5s default timeout (measured 4746ms green, then a slower runner) while the real step passed. The regeneration-and-diff now happens once, in that step, and tests/ci/workflow.test.ts — "P1 #1/R3/SC4: runs the six checks in order" — asserts the step is still there, in that order.
P3 #3Amended by spec 012 — boundary.test.ts's static scan was replaced by Biome's noRestrictedImports (biome.json), which fails pnpm lint, names the offending line, and also catches dynamic import() and a feature-local api/ folder that the scan's directory skip missed. The file was deleted; its R9: the facade exports a health hook case moved to apps/web/src/api/__tests__/useHealth.test.tsx.
SC1covered by P1 #2 + P1 #3 (resolve + error), apps/web/src/routes/health-page.test.tsx
SC2covered by P1 #1, apps/api/src/health/health.controller.test.ts
SC3covered by P2 #1–#4, tests/monorepo/apps.test.ts + tests/ci/workflow.test.ts
SC4covered by P3 #2 — see its amendment note; the owner is the CI pnpm verify:contract step, not a test
SC5covered by P3 #1, apps/api/src/generate-openapi.test.ts
SC6covered by P3 #3 — see its amendment note; the owner is now Biome, not a test
SC7covered by P2 #4 + P2 #5, tests/monorepo/apps.test.ts + tests/workflow/repo-invariants.test.ts