Skip to content

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 via Gw2Client/Gw2Service (unchanged).
  • apps/web: React, TanStack Query, Zod, Vitest, MSW (tests). orval regenerates from the new OpenAPI.
  • packages: @gw2priory/recipe-graph (+pricing), @gw2priory/gw2 (new), @gw2priory/domain.
  • docs: VitePress (docs:build compiles specs/*.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. 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.
  • moduleResolution: "node" and baseUrl are removed in TS 7. Use "nodenext" (or "bundler" for Vite/bundler-resolved code) plus paths: { "*": ["./*"] } in place of baseUrl.
  • TypeScript's default lib includes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), or document/window resolve everywhere, silently defeating profile isolation. A profile that needs DOM adds "DOM", "DOM.Iterable" on top of that floor.
  • experimentalDecorators and emitDecoratorMetadata are supported for typecheck only — tsc --noEmit accepts decorator syntax and resolves metadata types, but --noEmit never emits design:paramtypes at runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; see stack.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, 429 on 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 as Authorization: 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-side localStorage is 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 as Authorization: Bearer; the api forwards it" MVP posture for these flows only. Ranking + assistant (Wave 3) still follow the old posture. stack.md is 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.

PathChangeResponsibility
packages/gw2/package.jsoncreatenew workspace package @gw2priory/gw2 (mirror packages/domain config, runtime-loadable)
packages/gw2/tsconfig.jsoncreatepackage tsconfig (mirror packages/domain)
packages/gw2/src/index.tscreatebarrel: re-export schemas + enrich
packages/gw2/src/schemas.tscreateGW2 wire Zod schemas + types: Gw2Price/Item/Material/MaterialCategory/WalletEntry/Account/Currency (moved from apps/api/src/gw2/gw2.schemas.ts)
packages/gw2/src/enrich.tscreatepure 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.tscreatejoin unit tests (sorting, sellPrice fold, omit-unnamed rows, currency join)
apps/api/src/gw2/gw2.schemas.tsmodifyre-export the moved schemas from @gw2priory/gw2; keep api-only schemas local (import path unchanged for callers)
apps/api/src/account/account.service.tsmodifygetMaterials/getWallet call enrichMaterials/enrichWallet from @gw2priory/gw2 (behaviour-preserving)
apps/api/src/account/account.schema.tsmodifyMaterial/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.tsdeleteweb-facing account proxy (3 routes)
apps/api/src/account/account.controller.test.tsdeleteits test
apps/api/src/account/account.module.tsmodifydrop controllers: [AccountController]; keep providers/exports: [AccountService]
apps/api/src/commerce/**deletecontroller, service, module, schema, and all their tests (no external consumer — V4/F2)
apps/api/src/app.module.tsmodifyremove CommerceModule from imports
apps/api/src/mcp/mcp.shape.tsmodifyimport Material/WalletEntry from @gw2priory/gw2 if the local import path changed
apps/api/src/recipe-graph/pricing.tsdeletemoved to @gw2priory/recipe-graph
apps/api/src/recipe-graph/project-tree.tsdeletemoved 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/movepricing/project-tree tests move into packages/recipe-graph; service tests import from the package
apps/api/src/recipe-graph/recipe-graph.service.tsmodifyimport priceGraph/PriceMap from @gw2priory/recipe-graph (was ./pricing); no logic change
apps/api/src/recipe-graph/recipe-graph.controller.tsmodifycorrect the stale spec 033→034 comment (R9)
apps/api/openapi.jsonregeneratedrop /account* + /commerce/* paths (committed)
apps/api/package.jsonmodifyadd @gw2priory/gw2 to deps
packages/recipe-graph/src/pricing.tscreatemoved priceGraph, collectPricedIds, PriceEntry/PriceMap
packages/recipe-graph/src/project-tree.tscreatemoved projectTree
packages/recipe-graph/src/index.tsmodifyexport pricing + project-tree
packages/recipe-graph/src/__tests__/pricing.test.ts , project-tree.test.tscreatethe moved tests
packages/recipe-graph/package.jsonmodifyadd @gw2priory/domain dependency
apps/web/src/shared/lib/gw2/client.tscreateGW2_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.tscreateMSW: 199-batching (>199 → chunks), zod validation, ?access_token= present, base host is ArenaNet
apps/web/src/api/useRecipeTree.tsmodifyfetch priceless graph + fetchPrices → priceGraph; drop local project()/RecipeTree; use package PricedTree; fix spec 033→034 comment
apps/web/src/api/useMaterials.tsmodifyfetchAccountMaterials + fetchItems + fetchMaterialCategories + fetchPrices → enrichMaterials; hashKey in queryKey
apps/web/src/api/useWallet.tsmodifyfetchAccountWallet + fetchCurrencies → enrichWallet; hashKey in queryKey
apps/web/src/api/useAccount.tsmodifyfetchAccount browser-direct; hashKey in queryKey
apps/web/src/api/generated/**regenerateorval drops endpoints/{account,commerce}
apps/web/src/api/index.tsmodifyupdate facade exports (types now from hooks / @gw2priory/gw2)
apps/web/src/api/__tests__/{useRecipeTree,useMaterials,useWallet,useAccount}.test.tsxmodifyMSW ArenaNet; assert no request to our origin and no key to our origin
apps/web/src/features/legendaries/* (+ fixtures.ts)modifyconsume PricedTree; restore cost/decision/profit UI (spec 032 removed it)
apps/web/src/features/materials/* , features/wallet/*modifyconsume the client-enriched Material/WalletEntry (shape unchanged)
apps/web/package.jsonmodifyadd @gw2priory/gw2 to deps
docs/gaps/034-key-transport-and-shared-gw2.mdcreatepark: 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:

ts
// @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.tsx with MSW: intercept api.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: fetchPrices of >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) proves priceGraph's decisions/summary; both resolvePriced and useRecipeTree import that one function, so parity is by construction — asserted by the existing MCP resolvePriced tests staying green plus a web test that useRecipeTree output for a fixture graph+prices equals priceGraph(graph, map).
  • Partial batch (P1 #4) — pricing.test.ts: ids absent from the PriceMap resolve to unknown/null.
  • Account browser-direct + none to origin (P2 #1/#2 / SC1/SC2) — useMaterials.test.tsx / useWallet.test.tsx with MSW: ArenaNet called with ?access_token=, our origin gets nothing, key never sent to origin; rendered rows equal enrichMaterials/enrichWallet output.
  • Enrichment join (R14) — packages/gw2/src/enrich.test.ts: category sort, sellPrice fold, unnamed-row omission, currency join — the assertions moved from account.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.ts asserts 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 priceGraph and one set of the moved wire schemas, imported by both apps.
  • Contract clean (SC8) — CI verify:contract (regenerate openapi.json + generated/, diff-clean).
  • CI gate (SC7) — pnpm lint && pnpm test && pnpm build && pnpm docs:build, green on this branch. pnpm build specifically 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.md V4/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: domain is 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.ts to the package and rewrite every api import — rejected in favour of the re-export shim (moved schemas re-exported from gw2.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 TanStack staleTime aligned to it is enough. No bespoke cache (ponytail).

Risks ​

  • A shared package api can't load at runtime. @gw2priory/gw2 carries runtime Zod schemas that apps/api loads at runtime; @gw2priory/legendary-recipes hit exactly this (research.md F12 — its root resolved to a .ts the SWC api couldn't load). Mitigation: configure packages/gw2 identically to @gw2priory/domain/@gw2priory/recipe-graph, which api already runtime-loads (V5); a task step runs pnpm --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 runs pnpm 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: hashKey in the TanStack queryKey (existing pattern), never log the ArenaNet URL, HTTPS-only, Referer only to ArenaNet.
  • OpenAPI ↔ orval drift. Mitigation: regenerate openapi.json and the web client in the same task and commit both; verify:contract gates it (SC8).
  • Over-deletion. AccountService/Gw2Client methods/resolvePriced must 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.