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
- Given the
apps/apiskeleton, when the api process starts and a client issuesGET /health, then it responds200with body{ "status": "ok" }. - Given the
apps/webskeleton 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 thesrc/apifacade) and rendersok. - 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.
- 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
/apipath, 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
- Given both apps with their own
tsconfig.json, whenpnpm typecheckruns at the root, then it type-checkspackages/**and both apps in one invocation, and a type error planted in either app fails it. - Given both apps with tests (api in a Node environment, web in a DOM environment), when
pnpm testruns at the root, then it discovers and runs the tests of both apps plus the existingpackages/**andtests/**in one invocation, and a failing test in either app fails it. - Given both apps, when
pnpm lintruns, then Biome checks both apps under the existing single config, and a lint violation in either app fails it. - Given both apps, when
pnpm buildruns 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. - 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
- Given the api's health response declared once as a Zod schema, when the OpenAPI document is emitted, then it contains the
/healthpath and a response schema derived from that Zod schema — the shape is written once, not duplicated in a hand-authored DTO. - Given a committed
openapi.jsonand 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. - Given the generated web client, when the web app consumes it, then application code imports only from the
src/apifacade; nothing outsidesrc/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/swaggerplus nestjs-zod v5's integration — routes annotated with@ZodResponse, the document post-processed bycleanupOpenApiDoc(SwaggerModule.createDocument(...))— served at/api-docs(and/api-docs-json) and written to a staticopenapi.jsonby a script that does not require the server to be running. Verified —research.mdV2 (faithfulenum/shape emission from Zod, offline; note Zod 4 emits_Output/_Input-suffixed schema names; the olderpatchNestjsSwaggerAPI is gone in v5). - R4 — The api has a
HealthModuleexposingGET /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,
tsconfigisnoEmit" model does not emit,apps/apicarries its own compile/run step — the SWC CLI driven by an explicit.swcrc(jsc.parser.decorators,jsc.transform.legacyDecorator,jsc.transform.decoratorMetadata), notnest build -b swc, which collides with pnpm's build-approval gate (see F9). A documented, deliberate divergence from the root no-build model, whichmonorepo.mdanticipates. Verified —research.mdV1 (SWC emitsdesign: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.mdV4: install@base-ui/react1.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) withjsxFramework: "react", and its codegen typechecks under TS 7. - R8 — The web client is generated by Orval from
openapi.jsonintosrc/api/generated/— typed client, TanStack Query hooks, and Zod response validators. Verified —research.mdV3. Two Orval config entries are required (oneclient: 'react-query', oneclient: 'zod'); the validators are not auto-wired into the hooks, so thesrc/apifacade (R9) is where a hook and its validator are composed for runtime-checked responses. - R9 — Application code imports API access only from the
src/apifacade, which re-exports the generated hooks; nothing outsidesrc/api/imports fromsrc/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
okstates, 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.jsonis 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
/apipath; CORS is not configured in dev. Production networking is out of scope (see below).
Monorepo integration
- R13 — Each app has its own
tsconfig.jsonextendingtsconfig.base.jsonwith its profile (web: DOM + JSX; api: decorators +emitDecoratorMetadata). A single rootpnpm typecheckcoverspackages/**and both apps by running per-projecttsc --noEmitbehind 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'snoEmit/ source-resolution model.tsconfig.base.jsonpins a non-DOMlib(["es2023"]) andapps/weboverrides to adddom,dom.iterable, so DOM globals stay isolated to the web profile; the api tsconfig usesmoduleResolution: "nodenext"andpaths(TS 7 removedmoduleResolution: "node"andbaseUrl). Verified —research.mdV5, F7, F8. - R14 — Tests run through Vitest's
test.projectskey in a singlevitest.config.ts(api in anodeenvironment, web in ajsdomenvironment; not the deprecatedvitest.workspacefile) so one rootpnpm teststill discovers both apps alongsidepackages/**andtests/**, withjsdomas a root dev dependency. Verified —research.mdV6. - R15 — Biome remains the sole linter/formatter over the whole tree (no per-app lint config), and root scripts gain a
devthat runs both apps and abuildthat 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
okfetched from the live api; stopping the api makes the same page show its error state instead of crashing. - SC2 —
GET /healthon the running api returns200 { "status": "ok" }. - SC3 — A single root
pnpm typecheck,pnpm test,pnpm lint, andpnpm buildeach 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.jsondescribesGET /healthwith 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.mdalready 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/domainand 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/coremust joinesbuildon pnpm'sallowBuildslist (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.
| Criterion | Test |
|---|---|
| P1 #1 | apps/api/src/health/health.controller.test.ts — SC2 / P1 #1: returns 200 {status:"ok"} with DI resolved |
| P1 #2 | apps/web/src/routes/health-page.test.tsx — P1 #2 / SC1: shows ok when resolved |
| P1 #3 | apps/web/src/routes/health-page.test.tsx — P1 #3: shows an error state on failure, P1 #3: shows loading while pending |
| P1 #4 | apps/web/src/vite-proxy.test.ts — R12/P1 #4: proxies '/api' to the api's origin |
| P2 #1 | tests/monorepo/apps.test.ts — P2 #1/R13: typecheck runs tsc --noEmit for the root, apps/api, and apps/web |
| P2 #2 | tests/monorepo/apps.test.ts — P2 #2/R14: test.projects wires apps/api (node) and apps/web (jsdom) |
| P2 #3 | tests/monorepo/apps.test.ts — P2 #3/R15: no apps/**/biome.json exists |
| P2 #4 | tests/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 #5 | tests/workflow/repo-invariants.test.ts — SC3: no workflow artifact is written outside specs/NNN-<slug>/ (the docs/superpowers zero-count assertion) |
| P3 #1 | apps/api/src/generate-openapi.test.ts — P3 #1 / SC5: documents GET /health with a Zod-derived response |
| P3 #2 | Amended 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 #3 | Amended 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. |
| SC1 | covered by P1 #2 + P1 #3 (resolve + error), apps/web/src/routes/health-page.test.tsx |
| SC2 | covered by P1 #1, apps/api/src/health/health.controller.test.ts |
| SC3 | covered by P2 #1–#4, tests/monorepo/apps.test.ts + tests/ci/workflow.test.ts |
| SC4 | covered by P3 #2 — see its amendment note; the owner is the CI pnpm verify:contract step, not a test |
| SC5 | covered by P3 #1, apps/api/src/generate-openapi.test.ts |
| SC6 | covered by P3 #3 — see its amendment note; the owner is now Biome, not a test |
| SC7 | covered by P2 #4 + P2 #5, tests/monorepo/apps.test.ts + tests/workflow/repo-invariants.test.ts |