Plan 034 — Client-direct ArenaNet (prices & account off our origin)
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.
Goal
The web client fetches its prices and account state (account, materials, wallet) directly from api.guildwars2.com, with the key held only in the browser and sent as ?access_token=; our origin stops proxying them. The pricing math and the GW2 wire contract live in one shared place each, imported by both apps, so nothing is duplicated across the boundary. Net effect on our API: removal — the account and commerce proxy routes are deleted, while the same underlying service capability stays for the MCP path (Wave 3).
Approach
Backend — delete the web-facing proxies (keep the MCP capability). Delete the whole commerce module (CommerceController/CommerceService/CommerceModule — no consumer outside commerce/, research.md V4/F2) and AccountController (keep AccountService, still used by ranking.service.ts and the MCP — V4). Regenerate apps/api/openapi.json; the /account* and /commerce/* paths drop out, and orval regeneration removes their generated web clients. verify:contract gates that both regenerated artifacts are committed and diff-clean (R12/SC8).
Shared — move the pricing math (R3). priceGraph, projectTree, collectPricedIds, and PriceEntry/PriceMap move from apps/api/src/recipe-graph/{pricing,project-tree}.ts into @gw2priory/recipe-graph (which both apps already runtime-import — V5). The package gains a dependency on @gw2priory/domain (for netSellPrice). recipe-graph.service.ts (resolvePriced, MCP path) switches its import to the package; behaviour is byte-identical (existing MCP tests guard it). The app-local copies are deleted.
Shared — the GW2 wire contract + enrichment joins (R13/R14), in a new @gw2priory/gw2 package. The web must validate ArenaNet responses and reproduce the materials/wallet enrichment the backend did (account.service.ts — F1). Rather than duplicate either, a new source-only workspace package @gw2priory/gw2 holds: (1) the GW2 v2 wire Zod schemas the web needs (Gw2Price/Item/Material/MaterialCategory/WalletEntry/Account/Currency), and (2) the pure enrichment joins (enrichMaterials, enrichWallet) plus their output types (Material, WalletEntry). To keep the api churn minimal, apps/api/src/gw2/gw2.schemas.ts re-exports the moved schemas from the package and keeps its api-only schemas (Gw2Build, Gw2ItemSlot, Gw2CharacterInventory, Gw2Recipe, …) local — so every existing from './gw2.schemas' import in apps/api keeps working unchanged. AccountService.getMaterials/getWallet are refactored to call the shared joins (behaviour-preserving).
Frontend — browser→ArenaNet fetch layer. A small module apps/web/src/shared/lib/gw2/ holds thin typed fetchers against https://api.guildwars2.com/v2: fetchPrices, fetchItems, fetchMaterialCategories, fetchCurrencies (keyless) and fetchAccount, fetchAccountMaterials, fetchAccountWallet (key as ?access_token=, from shared/lib/apiKey.ts). A chunk(199) helper batches ?ids= calls to the 199 cap (V3); each fetcher validates its response with a @gw2priory/gw2 schema at the boundary (the app's existing parse-at-the-edge pattern). No bespoke price cache — freshness is ArenaNet's public, max-age=120 via the browser HTTP cache plus a TanStack staleTime aligned to it (R10). No new failure layer (R11); errors fall to the existing boundaries.
Frontend — merge & enrich. useRecipeTree fetches the priceless graph (Surface A) and the prices for its ids, builds the PriceMap, and calls the shared priceGraph — replacing the null-filling project() and the local RecipeTree type with the package's PricedTree — restoring the cost/decision/profit UI (R2). useMaterials/useWallet/useAccount fetch browser-direct and run the shared joins (R4/R14). The raw key is kept out of the TanStack queryKey via the existing hashKey (research.md V1 caveat).
Key transport (V1). ?access_token=<key> — the Authorization header is CORS-blocked from the browser (proven in research.md V1). This supersedes the stack.md MVP note that the key is sent to our api as Authorization: Bearer (that note is in Global Constraints verbatim; the epic changes it for these flows). Updating stack.md is a step-6 graduation (see Open questions), not a code blocker.
Architecture
BEFORE AFTER
browser → our /api/commerce/prices browser ─┐
browser → our /api/account* (key) browser ─┼─→ api.guildwars2.com/v2/* (prices keyless;
│ (our origin IP, our budget) │ account via ?access_token=, user's IP)
▼ ▼
Gw2Service → api.guildwars2.com (our origin no longer proxies these)
Shared code (one implementation, two consumers):
@gw2priory/recipe-graph : priceGraph, projectTree, collectPricedIds ← apps/api resolvePriced (MCP)
← apps/web useRecipeTree (merge)
@gw2priory/gw2 (new) : GW2 wire schemas + enrichMaterials/enrichWallet
← apps/api gw2.schemas.ts (re-export) + AccountService
← apps/web shared/lib/gw2 fetchers + hooks
@gw2priory/domain : netSellPrice ← @gw2priory/recipe-graph (new dep)
Kept for Wave 3 (unchanged): AccountService, RankingService.rank, Gw2Client account+price methods,
/legendaries/ranking, /assistant, all MCP tools.Tech stack
Existing stack only — no new external dependency. One new internal workspace package (@gw2priory/gw2), justified by real two-consumer sharing of the GW2 wire contract + enrichment (not speculative — stack.md allows packages/* "when sharing is real").
- apps/api: NestJS,
nestjs-zod, Zod, Vitest. GW2 access viaGw2Client/Gw2Service(unchanged). - apps/web: React, TanStack Query, Zod, Vitest, MSW (tests).
orvalregenerates from the new OpenAPI. - packages:
@gw2priory/recipe-graph(+pricing),@gw2priory/gw2(new),@gw2priory/domain. - docs: VitePress (
docs:buildcompilesspecs/*.md).
Global Constraints
Copied verbatim from the architecture docs. Every task inherits these; do not summarise or reword them — a test asserts they appear here unchanged.
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.
moduleResolution: "node"andbaseUrlare removed in TS 7. Use"nodenext"(or"bundler"for Vite/bundler-resolved code) pluspaths: { "*": ["./*"] }in place ofbaseUrl.- TypeScript's default
libincludes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), ordocument/windowresolve everywhere, silently defeating profile isolation. A profile that needs DOM adds"DOM","DOM.Iterable"on top of that floor. experimentalDecoratorsandemitDecoratorMetadataare supported for typecheck only —tsc --noEmitaccepts decorator syntax and resolves metadata types, but--noEmitnever emitsdesign:paramtypesat runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; seestack.md's api build model.
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.
- Static data (items, station recipes) is immutable: cache hard.
- Prices are volatile: short TTL, recomputed live.
- GW2 API rate limit: per-IP token bucket, 300 burst, refill 5/sec,
429on overflow. Batch up to 200 ids per?ids=call. - All GW2 API access goes through the client that budgets this. Never call the GW2 API directly from a service.
- API keys are user secrets: never logged, never persisted server-side, never returned to the client. In the MVP the key is held client-side — the browser's
localStorage— and sent per request asAuthorization: Bearer; the api forwards it to GW2 and stores nothing at rest. Encryption at rest applies only if/when server-side key storage is introduced; no such storage exists today. Client-sidelocalStorageis plaintext and readable by any script on the origin (XSS) — a deliberate MVP limitation, established by spec 016 (client-custody, revisit before adding write-scoped or higher-value keys).
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.
Supersession note (not part of the verbatim block). This spec, per the gw2.app-alignment epic, moves prices + account fetching browser→ArenaNet with the key as
?access_token=— superseding the "sent per request asAuthorization: Bearer; the api forwards it" MVP posture for these flows only. Ranking + assistant (Wave 3) still follow the old posture.stack.mdis updated at step 6 (graduation).
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 |
|---|---|---|
packages/gw2/package.json | create | new workspace package @gw2priory/gw2 (mirror packages/domain config, runtime-loadable) |
packages/gw2/tsconfig.json | create | package tsconfig (mirror packages/domain) |
packages/gw2/src/index.ts | create | barrel: re-export schemas + enrich |
packages/gw2/src/schemas.ts | create | GW2 wire Zod schemas + types: Gw2Price/Item/Material/MaterialCategory/WalletEntry/Account/Currency (moved from apps/api/src/gw2/gw2.schemas.ts) |
packages/gw2/src/enrich.ts | create | pure enrichMaterials(rows, items, categories, prices)→Material[] + enrichWallet(rows, currencies)→WalletEntry[] + those output types (moved from account.service.ts/account.schema.ts) |
packages/gw2/src/enrich.test.ts | create | join unit tests (sorting, sellPrice fold, omit-unnamed rows, currency join) |
apps/api/src/gw2/gw2.schemas.ts | modify | re-export the moved schemas from @gw2priory/gw2; keep api-only schemas local (import path unchanged for callers) |
apps/api/src/account/account.service.ts | modify | getMaterials/getWallet call enrichMaterials/enrichWallet from @gw2priory/gw2 (behaviour-preserving) |
apps/api/src/account/account.schema.ts | modify | Material/WalletEntry now re-exported from @gw2priory/gw2; delete the DTO classes (AccountResponseDto/MaterialListDto/WalletListDto) used only by the deleted controller |
apps/api/src/account/account.controller.ts | delete | web-facing account proxy (3 routes) |
apps/api/src/account/account.controller.test.ts | delete | its test |
apps/api/src/account/account.module.ts | modify | drop controllers: [AccountController]; keep providers/exports: [AccountService] |
apps/api/src/commerce/** | delete | controller, service, module, schema, and all their tests (no external consumer — V4/F2) |
apps/api/src/app.module.ts | modify | remove CommerceModule from imports |
apps/api/src/mcp/mcp.shape.ts | modify | import Material/WalletEntry from @gw2priory/gw2 if the local import path changed |
apps/api/src/recipe-graph/pricing.ts | delete | moved to @gw2priory/recipe-graph |
apps/api/src/recipe-graph/project-tree.ts | delete | moved to @gw2priory/recipe-graph |
apps/api/src/recipe-graph/pricing.test.ts , project-tree.test.ts (+ recipe-graph.service.bifrost.test.ts if it imports the local copies) | modify/move | pricing/project-tree tests move into packages/recipe-graph; service tests import from the package |
apps/api/src/recipe-graph/recipe-graph.service.ts | modify | import priceGraph/PriceMap from @gw2priory/recipe-graph (was ./pricing); no logic change |
apps/api/src/recipe-graph/recipe-graph.controller.ts | modify | correct the stale spec 033→034 comment (R9) |
apps/api/openapi.json | regenerate | drop /account* + /commerce/* paths (committed) |
apps/api/package.json | modify | add @gw2priory/gw2 to deps |
packages/recipe-graph/src/pricing.ts | create | moved priceGraph, collectPricedIds, PriceEntry/PriceMap |
packages/recipe-graph/src/project-tree.ts | create | moved projectTree |
packages/recipe-graph/src/index.ts | modify | export pricing + project-tree |
packages/recipe-graph/src/__tests__/pricing.test.ts , project-tree.test.ts | create | the moved tests |
packages/recipe-graph/package.json | modify | add @gw2priory/domain dependency |
apps/web/src/shared/lib/gw2/client.ts | create | GW2_API_BASE, chunk(199), and typed fetchers (prices/items/materials/currencies keyless; account/materials/wallet via ?access_token=), each validating with a @gw2priory/gw2 schema |
apps/web/src/shared/lib/gw2/__tests__/client.test.ts | create | MSW: 199-batching (>199 → chunks), zod validation, ?access_token= present, base host is ArenaNet |
apps/web/src/api/useRecipeTree.ts | modify | fetch priceless graph + fetchPrices → priceGraph; drop local project()/RecipeTree; use package PricedTree; fix spec 033→034 comment |
apps/web/src/api/useMaterials.ts | modify | fetchAccountMaterials + fetchItems + fetchMaterialCategories + fetchPrices → enrichMaterials; hashKey in queryKey |
apps/web/src/api/useWallet.ts | modify | fetchAccountWallet + fetchCurrencies → enrichWallet; hashKey in queryKey |
apps/web/src/api/useAccount.ts | modify | fetchAccount browser-direct; hashKey in queryKey |
apps/web/src/api/generated/** | regenerate | orval drops endpoints/{account,commerce} |
apps/web/src/api/index.ts | modify | update facade exports (types now from hooks / @gw2priory/gw2) |
apps/web/src/api/__tests__/{useRecipeTree,useMaterials,useWallet,useAccount}.test.tsx | modify | MSW ArenaNet; assert no request to our origin and no key to our origin |
apps/web/src/features/legendaries/* (+ fixtures.ts) | modify | consume PricedTree; restore cost/decision/profit UI (spec 032 removed it) |
apps/web/src/features/materials/* , features/wallet/* | modify | consume the client-enriched Material/WalletEntry (shape unchanged) |
apps/web/package.json | modify | add @gw2priory/gw2 to deps |
docs/gaps/034-key-transport-and-shared-gw2.md | create | park: stack.md key-transport supersession + the stale "types-only recipe-graph" comments (spec Parked findings) |
Keep, explicitly (do not delete): AccountService, RankingService, Gw2Client/Gw2Service account+price methods, resolvePriced, all MCP + assistant code, /legendaries/ranking, /assistant.
Data & contracts
Removed from our OpenAPI: /account, /account/materials, /account/wallet, /commerce/prices.
New browser→ArenaNet contracts (documented in spec.md; validated client-side with @gw2priory/gw2 schemas). Shapes the client depends on:
// @gw2priory/gw2 — the subset the web validates (moved from apps/api gw2.schemas.ts)
Gw2Price = { id: number; buys: { quantity: number; unit_price: number };
sells: { quantity: number; unit_price: number } }
Gw2Item = { id: number; name: string; icon: string | null; rarity: string; /* … */ }
Gw2Material = { id: number; category: number; count: number } // raw account row
Gw2MaterialCategory = { id: number; name: string; order: number; items: number[] }
Gw2WalletEntry = { id: number; value: number } // raw account row
Gw2Currency = { id: number; name: string; icon: string; order: number }
Gw2Account = { name: string; /* … */ }
// enriched outputs (moved from apps/api account.schema.ts) — shape unchanged from today
Material = { id; count; category; categoryName; categoryOrder; name; icon: string|null; rarity;
sellPrice: number|null }
WalletEntry = { id; value; name; icon; order }
// pure joins (moved from AccountService)
enrichMaterials(rows: Gw2Material[], items: Gw2Item[], categories: Gw2MaterialCategory[],
prices: Gw2Price[]): Material[]
enrichWallet(rows: Gw2WalletEntry[], currencies: Gw2Currency[]): WalletEntry[]
// @gw2priory/recipe-graph — moved pricing (money math), maps Gw2Price → PriceEntry
PriceEntry = { buys: {quantity; unit_price}; sells: {quantity; unit_price} }
type PriceMap = Map<number, PriceEntry>
priceGraph(graph: ResolvedGraph, priceMap: PriceMap): PricedTree
collectPricedIds(graph: ResolvedGraph): number[]The client builds PriceMap directly from fetchPrices rows ({buys,sells} maps 1:1 to PriceEntry).
Test strategy
- Prices browser-direct + none to origin (P1 #1 / SC1 / SC2) —
useRecipeTree.test.tsxwith MSW: interceptapi.guildwars2.com/v2/commerce/prices, assert the tree renders prices and that no request hits our origin and no request carries the key. - 199 batching (P1 #2 / SC4) —
gw2/__tests__/client.test.ts:fetchPricesof >199 ids issues ceil(n/199) requests, each with ≤199 ids; results are reassembled. - Pricing parity (P1 #3 / SC3) —
packages/recipe-graph/__tests__/pricing.test.ts(the moved suite) provespriceGraph's decisions/summary; bothresolvePricedanduseRecipeTreeimport that one function, so parity is by construction — asserted by the existing MCPresolvePricedtests staying green plus a web test thatuseRecipeTreeoutput for a fixture graph+prices equalspriceGraph(graph, map). - Partial batch (P1 #4) —
pricing.test.ts: ids absent from thePriceMapresolve tounknown/null. - Account browser-direct + none to origin (P2 #1/#2 / SC1/SC2) —
useMaterials.test.tsx/useWallet.test.tsxwith MSW: ArenaNet called with?access_token=, our origin gets nothing, key never sent to origin; rendered rows equalenrichMaterials/enrichWalletoutput. - Enrichment join (R14) —
packages/gw2/src/enrich.test.ts: category sort,sellPricefold, unnamed-row omission, currency join — the assertions moved fromaccount.service.test.ts, which is refocused to "delegates to the shared join". - Invalid key (P2 #3) —
useMaterials.test.tsx: ArenaNet 401 surfaces through the existing boundary. - No account/commerce routes (P2 #4 / SC5) —
generate-openapi.test.tsasserts the paths are absent; a grep test asserts the controllers are gone; the MCP tool tests stay green (capability retained). - Single shared definitions (SC6) — grep test: exactly one
priceGraphand one set of the moved wire schemas, imported by both apps. - Contract clean (SC8) — CI
verify:contract(regenerateopenapi.json+generated/, diff-clean). - CI gate (SC7) —
pnpm lint && pnpm test && pnpm build && pnpm docs:build, green on this branch.pnpm buildspecifically exercises the React-Compiler gate for the moved runtime imports (V5 caveat).
Not tested directly: the per-IP budget behaviour (a GW2-side fact, research.md V2) and the browser HTTP-cache honouring max-age=120 (browser behaviour, not ours) — both are design assumptions with cited evidence, not CI assertions.
Alternatives considered
- Keep our
/commerce/prices(and/account*) proxy — rejected:research.mdV4/F2 shows the web never calls the commerce route, and keeping either puts prices/account back through our single origin IP, contradicting the epic invariant (the whole point of the spec). - Web-local minimal Zod schemas + inline joins (no
@gw2priory/gw2) — the ponytail-lighter option: smaller diff, no new package, but duplicates the GW2 wire contract + the enrichment across the boundary (the constitution's no-duplication rule; the spec's R13/R14 chose sharing). Rejected for that reason — but it is the lever to pull if the new package feels heavy (flagged for the human at approval). - Fold the shared GW2 schemas into
@gw2priory/domain— rejected:domainis money/domain logic; a single-purpose@gw2priory/gw2(wire contract + transforms) matches the existing one-purpose-per-package pattern and is easier to reason about. (Can still be merged later if either stays tiny.) - Move all of
gw2.schemas.tsto the package and rewrite every api import — rejected in favour of the re-export shim (moved schemas re-exported fromgw2.schemas.ts), which keeps api import paths stable and the diff small. - A client price cache — rejected (R10): the browser HTTP cache already honours
max-age=120; a TanStackstaleTimealigned to it is enough. No bespoke cache (ponytail).
Risks
- A shared package api can't load at runtime.
@gw2priory/gw2carries runtime Zod schemas thatapps/apiloads at runtime;@gw2priory/legendary-recipeshit exactly this (research.mdF12 — its root resolved to a.tsthe SWC api couldn't load). Mitigation: configurepackages/gw2identically to@gw2priory/domain/@gw2priory/recipe-graph, which api already runtime-loads (V5); a task step runspnpm --filter @gw2priory/api build+ a smoke import before wiring consumers. - React-Compiler build gate. The moved runtime imports only fail (if they will) in
vite build, not vitest (docs/architecture/react.md). Mitigation: the CI gate runspnpm build; the moved code is pure non-component logic, so risk is low. - Key-in-URL exposure (V1).
?access_token=puts the key in the URL. Mitigation:hashKeyin the TanStackqueryKey(existing pattern), never log the ArenaNet URL, HTTPS-only,Refereronly to ArenaNet. - OpenAPI ↔ orval drift. Mitigation: regenerate
openapi.jsonand the web client in the same task and commit both;verify:contractgates it (SC8). - Over-deletion.
AccountService/Gw2Clientmethods/resolvePricedmust survive for the MCP. The File Structure's explicit "keep" list + the MCP tool tests guard it. - MSW must intercept a cross-origin host. The web tests hit
api.guildwars2.com. Mitigation: MSW intercepts by absolute URL regardless of origin; handlers target the ArenaNet base.
Open questions
None blocking. All spec.md markers have verdicts in research.md (V1–V6). One non-blocking follow-up: docs/architecture/stack.md's "key sent to the api as Authorization: Bearer" and gw2-api.md's CORS/ transport notes are updated at step 6 (graduation candidates listed in research.md), not as a code prerequisite. The lighter "web-local schemas" alternative is offered to the human at approval as the one scope lever.