Skip to content

Spec 034 — Client-direct ArenaNet (prices & account off our origin) ​

Status: implemented Branch: 034-client-direct-anetEpic: gw2.app alignment — Wave 2, Spec 3. Surface: net removal (no new endpoints).

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

Numbering note. The epic and spec 032 refer to this work as "Spec 033" (a guess at the number before allocation); it was allocated 034. Where spec 032's merged text or code comments say "spec 033" for the client price merge, they mean this spec. The merged spec 032 text is left as-is (history); the misleading code comments are corrected here (R9).

Problem ​

The web client gets its volatile and per-user data through our own NestJS origin:

  • Prices — spec 032 made GET /recipe-graph/:itemId priceless and Surface-A cacheable, but the web detail view now renders a structure with every cost/decision/profit field null (the project() stub in useRecipeTree.ts). Nothing fills them yet, and the old fold went through /commerce/prices on our origin.
  • Account — materials and wallet are read through GET /account/materials and /account/wallet (and /account), proxied by AccountController. The user's API key is sent to our origin as Authorization: Bearer, and the response is per-user, so it can never be shared-cached.

Both make our backend spend its single origin IP's ArenaNet rate budget on per-user, volatile work, and keep the user's key flowing through our servers — the opposite of the epic's invariant (anything cacheable is user-agnostic and keyless; anything per-user is stateless and never cached). ArenaNet's API is CORS-open and rate-limited per source IP, so the browser can fetch this data itself: each user spends their own budget, the key never leaves the browser, and our origin stops proxying it.

User stories ​

Ordered by priority. Each is independently testable and shippable.

P1 — A priced recipe tree, computed in the browser ​

As a player, I want the legendary detail view to show live buy-vs-craft costs, decisions and profit again, so I can plan a craft — with the prices fetched by my own browser, not our server.

Independent test: on the detail page, prices are fetched from api.guildwars2.com/v2/commerce/prices directly (a network trace shows the call to ArenaNet's host and no /commerce/* call to our origin), and the tree renders unitBuyPrice / craftCost / unitCost / decision and a root summary (totalCraftCost, netSell, profit) identical to what the MCP's resolvePriced computes for the same item and prices.

Acceptance scenarios

  1. Given a Gen-1 root and its live prices, when the detail view loads, then the browser issues the /v2/commerce/prices request(s) to api.guildwars2.com and none to our /commerce/*.
  2. Given a graph with more than 199 priceable ids, when prices are fetched, then the client splits them into batches of at most 199 ids per request.
  3. Given the fetched prices, when the tree is built, then each node's decision/cost fields and the root summary equal priceGraph(graph, priceMap) — the same function the MCP tool uses.
  4. Given a price response that omits some ids (206/404 drops untradeable ids), when the tree is built, then the returned ids are priced and the rest resolve to unknown/null without error.

P2 — Account state read straight from ArenaNet; the key never touches our origin ​

As a player, I want my materials and wallet read directly from ArenaNet with my stored key, so my key and my account data never pass through GW2Priory's servers.

Independent test: with a key in localStorage, the materials and wallet pages render their data from api.guildwars2.com/v2/account* calls made by the browser; a network trace to our origin shows no/account* request and no request carrying the key. The backend no longer exposes /account*.

Enrichment moves client-side. /account/materials and /account/wallet were not passthroughs: the backend joined /v2/items + /v2/materials categories + /v2/commerce/prices (folding a sellPrice) for materials, and /v2/currencies for wallet (account.service.ts:19-112). Browser-direct returns raw rows, so the client fetches that same keyless static data and prices and performs the join. The pure join stays server-side for the MCP, so it is shared, not duplicated (see R14).

Acceptance scenarios

  1. Given a stored key, when the materials page loads, then the browser calls api.guildwars2.com/v2/account/materials directly and our origin receives no /account/materials request and no request bearing the key.
  2. Given a stored key, when the wallet page loads, then the browser calls api.guildwars2.com/v2/account/wallet directly (same origin/key guarantee).
  3. Given an invalid or expired key, when an account call is made, then ArenaNet's 401 surfaces through the app's existing error/connect boundary (no new handling built).
  4. Given the running backend, when the OpenAPI document is generated, then it contains no /account, /account/materials, /account/wallet, or /commerce/prices path.

Endpoints & contract ​

Endpoints and OpenAPI are first-class spec content (epic cross-cutting rule).

Removed from our API (Surface B → deleted) ​

Method & pathWasNow
GET /accountaccount display name (key via Authorization)deleted — browser calls ArenaNet
GET /account/materialsmaterial storage (key via Authorization)deleted
GET /account/walletwallet (key via Authorization)deleted
GET /commerce/prices?ids=TP prices (keyless)deleted — browser calls ArenaNet

Deleting these routes removes their paths from the generated OpenAPI and, on orval regen, the generated web clients under apps/web/src/api/generated/endpoints/{account,commerce}.

New browser → ArenaNet calls (documented contracts — NOT our OpenAPI) ​

These are ArenaNet's own endpoints (https://api.guildwars2.com/v2), called from the browser. They are not added to our OpenAPI because they are not our surface; they are documented here as the contract the client codes against. Key transport (Authorization header vs ?access_token=) is V1 below.

CallAuthArenaNet cache-controlResponse shape (subset used)Used for
GET /v2/commerce/prices?ids=<≤199>nonepublic, max-age=120[{ id, buys:{quantity,unit_price}, sells:{quantity,unit_price} }]price merge + materials sellPrice
GET /v2/accountkeyprivate{ name, … }account identity
GET /v2/account/materialskey (inventories)private[{ id, category, count }]materials page
GET /v2/account/walletkey (wallet)private[{ id, value }]wallet page
GET /v2/items?ids=<≤199>nonepublic, max-age=3600[{ id, name, icon, rarity, … }]materials enrichment
GET /v2/materialsnonestatic[{ id, name, order, items[] }]materials category join
GET /v2/currencies?ids=nonestatic[{ id, name, icon, order }]wallet enrichment

The commerce/prices shape is exactly priceGraph's PriceEntry ({ buys, sells } with { quantity, unit_price }), so the client builds the PriceMap from the response with no reshaping. The last three (items / materials / currencies) are keyless static ArenaNet data that the backend used to fetch during enrichment; the browser fetches them directly instead.

Generated-contract handling (orval / OpenAPI) ​

apps/api/openapi.json and apps/web/src/api/generated/ are committed artifacts, and root verify:contract regenerates both and runs git diff --exit-code — CI fails if they drift. This spec must therefore:

  • Regenerate after deleting the controllers: generate:openapi drops the /account* and /commerce/* paths from openapi.json; orval (generate:api) then removes generated/endpoints/{account,commerce}. Both regenerated files are committed so verify:contract passes.
  • Replace the lost contract for the browser→ArenaNet calls with hand-written Zod schemas + thin fetch functions — the same boundary-validation pattern already used on orval responses. ArenaNet's /v2 is a third-party API and is not added to our OpenAPI.
  • Not duplicate the wire contract: apps/api/src/gw2/gw2.schemas.ts already defines the exact shapes (Gw2Price/Material/WalletEntry/Account/Currency/Item), still used by the kept MCP path, so the schemas the web needs are shared with it (R13), not re-declared.

Unchanged ​

GET /recipe-graph/{itemId} (Surface A, priceless — the client merges into it), GET /legendaries (Surface A), GET /legendaries/ranking and POST /assistant (Surface B, Wave 3 — still key-to-origin, out of scope here), and the MCP tools. No endpoint changes shape.

Requirements ​

  • R1 — The client fetches trading-post prices browser → ArenaNet (GET https://api.guildwars2.com/v2/commerce/prices?ids=…), keyless, batched to the 199-id cap. No price request is made to our origin.
  • R2 — The client merges those prices into spec 032's priceless ResolvedGraph and renders the priced tree: per-node unitBuyPrice, craftCost, unitCost, lineCost, decision, and the root summary (totalCraftCost, rootBuyPrice, netSell, profit). The detail-view cost/decision/profit UI that spec 032 removed (its R10) is restored.
  • R3 — The pricing math is one implementation, shared: priceGraph, projectTree, and collectPricedIds are moved from apps/api/src/recipe-graph/{pricing,project-tree}.ts into @gw2priory/recipe-graph (beside aggregateNeed/mergeOwnVsNeed, which apps/api already runtime-imports). apps/api resolvePriced and apps/web's merge both import it; the app-local copies are deleted. MCP priory_recipe_tree output is byte-identical to before the move.
  • R4 — The client fetches account state browser → ArenaNet for /v2/account, /v2/account/materials, and /v2/account/wallet, using the key already in localStorage (shared/lib/apiKey.ts is the only key-storage module — unchanged). The materials and wallet pages render from these direct fetches.
  • R5 — For the P1/P2 flows (prices, account, materials, wallet) the API key is never sent to our origin and account data never passes through it. (Ranking /legendaries/ranking and /assistant still send the key to our origin — that is Wave 3 and explicitly out of scope; see Coordination.)
  • R6 — The backend web-facing proxies are deleted: AccountController (all three routes) and the entire commerce module (CommerceController + CommerceService + CommerceModule, once V4 confirms the controller is its only consumer). The generated OpenAPI no longer contains /account* or /commerce/*; the orval-generated web clients for them are removed on regen. Their now-dead tests are removed.
  • R7 — MCP core capability is retained: AccountService (getAccount/getMaterials/getWallet) and Gw2Client/Gw2Service account + price methods stay and remain wired to the MCP tools (priory_account*, gw2_prices, priory_recipe_tree). Removing the controllers changes no MCP tool's behaviour (asserted by the existing MCP tests staying green).
  • R8 — Surface = net removal. This spec adds no new endpoint to our API. The dropped routes were Surface B; the new fetches are browser → ArenaNet (public prices, private account) — not our surface.
  • R9 — The stale forward-references to "spec 033" for the client price merge are corrected to 034 in the code comments that carry them (recipe-graph.controller.ts, useRecipeTree.ts). Spec 032's merged spec.md text is left unchanged (merged history).
  • R10 — Price freshness follows ArenaNet's public, max-age=120: the client relies on the browser HTTP cache plus a TanStack staleTime aligned to it, adding no bespoke price-cache layer. Exact mechanism confirmed in V3/V6.
  • R11 — No new failure-handling layer is built (human decision: the GW2 API does not fail in practice). Account/price fetch errors surface through the app's existing error/connect boundaries; a partial price batch (206/404) merges the returned ids and leaves the rest unpriced — normal GW2 semantics, not error handling.
  • R12 — The committed contract artifacts are regenerated and committed: generate:openapi drops /account* and /commerce/* from apps/api/openapi.json, and orval removes the corresponding apps/web/src/api/generated/endpoints/{account,commerce}. verify:contract (regenerate + git diff --exit-code) passes on this branch.
  • R13 — The browser→ArenaNet calls are contracted with hand-written Zod schemas + thin fetch functions (boundary validation, like the app's orval-response parses). The GW2 wire schemas the web needs are shared with apps/api's gw2-client (which keeps using them for the MCP path), not duplicated — exact shared home decided in plan.md. ArenaNet endpoints are not added to our OpenAPI.
  • R14 — The materials and wallet enrichment (the item/category/price join for materials, the currency join for wallet) moves client-side. The pure join is shared with the server-side AccountService (kept for the MCP), not reimplemented; AccountService is refactored to call the shared join, behaviour-preserving (its tests and the MCP tests stay green).

Discovery verdicts (all resolved in research.md — V1–V6; recorded here so the approved spec carries no open marker):

  • V1 — key transport → ?access_token= query param. The Authorization header is CORS-blocked from the browser (proven live); the key travels as a query param, kept out of query-keys via hashKey.
  • V2 — rate limit is per-IP (300 burst, 5/s), no per-key component — browser-direct spends each user's own budget.
  • V3 — the 199-id ?ids= batch works from the browser; price freshness rides ArenaNet's public, max-age=120 (browser HTTP cache + an aligned TanStack staleTime), no bespoke cache.
  • V4 — safe to drop: CommerceService has no consumer outside commerce/ (drop the module); AccountService stays for ranking + MCP (drop only the controller); the account generated client is used only by the three hooks being rewritten.
  • V5 — runtime-import confirmed: both apps already runtime-import @gw2priory/recipe-graph; @gw2priory/domain (netSellPrice) is added as a package dep.
  • V6 — the price body is readable cross-origin (cache-control is a CORS-safelisted response header; expose-headers gates header reads, never the body).

Success criteria ​

Measurable and technology-agnostic — outcomes, not implementation.

  • SC1 — Loading the legendary detail, materials, and wallet pages issues zero requests to our origin for account or commerce data; the corresponding api.guildwars2.com/v2/{commerce/prices,account, account/materials,account/wallet} calls are made by the browser.
  • SC2 — The API key appears in no request to our origin for the P1/P2 flows.
  • SC3 — For a Gen-1 item and a fixed price set, the client-built priced tree is equal to the MCP resolvePriced output (same priceGraph) — a shared-function parity test.
  • SC4 — Every browser price request carries at most 199 ids.
  • SC5 — The backend exposes no /account* and no /commerce/* route (asserted over controllers and the generated OpenAPI), while every MCP account/price tool still returns correct data (MCP tests green).
  • SC6 — priceGraph is defined exactly once in the repository (grep), imported by both apps; the GW2 wire schemas the web uses are likewise defined once and shared.
  • SC7 — Typecheck, lint, tests, both app builds, and docs:build are green on this branch alone.
  • SC8 — verify:contract passes: regenerating apps/api/openapi.json and apps/web/src/api/generated yields no diff, and neither contains an /account* or /commerce/* path/client.

Out of scope ​

  • Ranking client-side (Wave 3, Spec 5) — /legendaries/ranking stays server-side and still sends the key to our origin. Its deeper account reads (/characters, /account/bank, /account/inventory) are not moved here.
  • Assistant / MCP stateless-per-request reconcile (Wave 3) — /assistant stays key-to-origin; the MCP is unchanged beyond the priceGraph move (R3, behaviour-preserving).
  • The caching-header mechanism (Spec 031) — already merged; not touched.
  • Frontend perf / bundle / persistent client cache (Spec 033) — already merged; not touched beyond the new price/account queries.
  • Reconciling the "types-only @gw2priory/recipe-graph" doc/comments beyond the files this spec edits — parked (see below).

Assumptions ​

  • Spec 032 is merged: GET /recipe-graph/:itemId returns the complete, unpruned, priceless ResolvedGraph (Surface A) — verified on main.
  • ArenaNet's API is CORS-open (access-control-allow-origin: *, epic given), so browser-direct calls work; the exact key transport is V1.
  • The key already lives in localStorage (spec 016); no new storage is introduced.
  • @gw2priory/domain and @gw2priory/recipe-graph are workspace packages web can import (V5).

Known limitations (accepted) ​

  • Per-IP budget is now the user's own. A user who hammers the client spends their own ArenaNet rate budget and could rate-limit only their own browser — which is the point (blast radius is one user, not our shared origin). No server token-bucket guards the browser calls; browser volume is naturally low. ponytail: if a client ever fans out hard, add client-side throttling — not built now.

Coordination ​

  • With Wave 3 (Spec 5 / ai-ranking-reconcile). The keep/drop boundary is drawn at HTTP surface vs service capability: this spec deletes the web-facing /account* and /commerce/* routes but keeps AccountService and the Gw2Client/Gw2Service account + price methods. Wave 3 inherits that working service for the MCP's stateless path, and separately moves ranking + assistant off key-to-origin (the two flows this spec deliberately leaves untouched).

Parked findings ​

  • The "types-only @gw2priory/recipe-graph" comments (recipe-index.service.ts and the pricing.ts / recipe-graph.service.ts headers) predate any runtime import, yet ranking.service.ts already runtime-imports from that package. Adding priceGraph makes the runtime dependency explicit. This spec corrects the comments in files it edits; a broader doc reconciliation is recorded in docs/gaps/.
  • The Postgres-in-docs / no-DB-in-code divergence (epic open decision 6) is untouched here — this spec adds no persistence — and remains open.

Traceability ​

Each acceptance scenario and success criterion maps to a named test. Filled during implementation.

CriterionTest
P1 #1apps/web/src/api/__tests__/useRecipeTree.test.tsx — prices fetched from the ArenaNet host, none to our /commerce/*
P1 #2apps/web/src/shared/lib/gw2/__tests__/client.test.ts (fetchPrices >199 → ≤199-id batches) + useRecipeTree.test.tsx
P1 #3packages/recipe-graph/src/__tests__/pricing.test.ts (priceGraph) + useRecipeTree.test.tsx (tree == priceGraph(graph, priceMap))
P1 #4packages/recipe-graph/src/__tests__/pricing.test.ts + useRecipeTree.test.tsx — omitted ids resolve unknown/null, no throw
P2 #1apps/web/src/api/__tests__/useMaterials.test.tsx — ArenaNet /account/materials called, no origin/key request
P2 #2apps/web/src/api/__tests__/useWallet.test.tsx — ArenaNet /account/wallet called, no origin/key request
P2 #3apps/web/src/api/__tests__/useMaterials.test.tsx / useAccount.test.tsx — ArenaNet 401 surfaces through the boundary
P2 #4apps/api/src/generate-openapi.test.ts + apps/api/src/web-proxies-removed.test.ts — no /account* or /commerce/*
SC1the network assertions in useRecipeTree.test.tsx / useMaterials.test.tsx / useWallet.test.tsx
SC2key-never-to-origin assertions across useRecipeTree / useMaterials / useWallet / useAccount tests
SC3packages/recipe-graph/src/__tests__/pricing.test.ts + useRecipeTree.test.tsx (shared priceGraph)
SC4apps/web/src/shared/lib/gw2/__tests__/client.test.ts — batches ≤199 ids
SC5apps/api/src/web-proxies-removed.test.ts + generate-openapi.test.ts + MCP tool tests green
SC6single priceGraph (packages/recipe-graph) + single wire-schema set (packages/gw2), imported by both apps (grep)
SC7CI: pnpm lint && pnpm test && pnpm build && pnpm docs:build (all green on this branch)
SC8CI: pnpm verify:contract — regenerated openapi.json + generated/ diff-clean, no account/commerce paths
R14packages/gw2/src/__tests__/enrich.test.ts (joins) + useMaterials.test.tsx / useWallet.test.tsx + account.service.test.ts (delegation)