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/:itemIdpriceless and Surface-A cacheable, but the web detail view now renders a structure with every cost/decision/profit fieldnull(theproject()stub inuseRecipeTree.ts). Nothing fills them yet, and the old fold went through/commerce/priceson our origin. - Account — materials and wallet are read through
GET /account/materialsand/account/wallet(and/account), proxied byAccountController. The user's API key is sent to our origin asAuthorization: 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
- Given a Gen-1 root and its live prices, when the detail view loads, then the browser issues the
/v2/commerce/pricesrequest(s) toapi.guildwars2.comand none to our/commerce/*. - 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.
- Given the fetched prices, when the tree is built, then each node's decision/cost fields and the root
summaryequalpriceGraph(graph, priceMap)— the same function the MCP tool uses. - 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/nullwithout 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/materialsand/account/walletwere not passthroughs: the backend joined/v2/items+/v2/materialscategories +/v2/commerce/prices(folding asellPrice) for materials, and/v2/currenciesfor 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
- Given a stored key, when the materials page loads, then the browser calls
api.guildwars2.com/v2/account/materialsdirectly and our origin receives no/account/materialsrequest and no request bearing the key. - Given a stored key, when the wallet page loads, then the browser calls
api.guildwars2.com/v2/account/walletdirectly (same origin/key guarantee). - 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).
- Given the running backend, when the OpenAPI document is generated, then it contains no
/account,/account/materials,/account/wallet, or/commerce/pricespath.
Endpoints & contract
Endpoints and OpenAPI are first-class spec content (epic cross-cutting rule).
Removed from our API (Surface B → deleted)
| Method & path | Was | Now |
|---|---|---|
GET /account | account display name (key via Authorization) | deleted — browser calls ArenaNet |
GET /account/materials | material storage (key via Authorization) | deleted |
GET /account/wallet | wallet (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.
| Call | Auth | ArenaNet cache-control | Response shape (subset used) | Used for |
|---|---|---|---|---|
GET /v2/commerce/prices?ids=<≤199> | none | public, max-age=120 | [{ id, buys:{quantity,unit_price}, sells:{quantity,unit_price} }] | price merge + materials sellPrice |
GET /v2/account | key | private | { name, … } | account identity |
GET /v2/account/materials | key (inventories) | private | [{ id, category, count }] | materials page |
GET /v2/account/wallet | key (wallet) | private | [{ id, value }] | wallet page |
GET /v2/items?ids=<≤199> | none | public, max-age=3600 | [{ id, name, icon, rarity, … }] | materials enrichment |
GET /v2/materials | none | static | [{ id, name, order, items[] }] | materials category join |
GET /v2/currencies?ids= | none | static | [{ 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:openapidrops the/account*and/commerce/*paths fromopenapi.json;orval(generate:api) then removesgenerated/endpoints/{account,commerce}. Both regenerated files are committed soverify:contractpasses. - 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
/v2is a third-party API and is not added to our OpenAPI. - Not duplicate the wire contract:
apps/api/src/gw2/gw2.schemas.tsalready 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
ResolvedGraphand renders the priced tree: per-nodeunitBuyPrice,craftCost,unitCost,lineCost,decision, and the rootsummary(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, andcollectPricedIdsare moved fromapps/api/src/recipe-graph/{pricing,project-tree}.tsinto@gw2priory/recipe-graph(besideaggregateNeed/mergeOwnVsNeed, whichapps/apialready runtime-imports).apps/apiresolvePricedandapps/web's merge both import it; the app-local copies are deleted. MCPpriory_recipe_treeoutput 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 inlocalStorage(shared/lib/apiKey.tsis 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/rankingand/assistantstill 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) andGw2Client/Gw2Serviceaccount + 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 mergedspec.mdtext 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 TanStackstaleTimealigned 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:openapidrops/account*and/commerce/*fromapps/api/openapi.json, andorvalremoves the correspondingapps/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'sgw2-client(which keeps using them for the MCP path), not duplicated — exact shared home decided inplan.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;AccountServiceis 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. TheAuthorizationheader is CORS-blocked from the browser (proven live); the key travels as a query param, kept out of query-keys viahashKey. - 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'spublic, max-age=120(browser HTTP cache + an aligned TanStackstaleTime), no bespoke cache. - V4 — safe to drop:
CommerceServicehas no consumer outsidecommerce/(drop the module);AccountServicestays 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-controlis a CORS-safelisted response header;expose-headersgates 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
resolvePricedoutput (samepriceGraph) — 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 —
priceGraphis 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:buildare green on this branch alone. - SC8 —
verify:contractpasses: regeneratingapps/api/openapi.jsonandapps/web/src/api/generatedyields no diff, and neither contains an/account*or/commerce/*path/client.
Out of scope
- Ranking client-side (Wave 3, Spec 5) —
/legendaries/rankingstays 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) —
/assistantstays key-to-origin; the MCP is unchanged beyond thepriceGraphmove (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/:itemIdreturns the complete, unpruned, pricelessResolvedGraph(Surface A) — verified onmain. - 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/domainand@gw2priory/recipe-graphare 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 keepsAccountServiceand theGw2Client/Gw2Serviceaccount + 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.tsand thepricing.ts/recipe-graph.service.tsheaders) predate any runtime import, yetranking.service.tsalready runtime-imports from that package. AddingpriceGraphmakes the runtime dependency explicit. This spec corrects the comments in files it edits; a broader doc reconciliation is recorded indocs/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.
| Criterion | Test |
|---|---|
| P1 #1 | apps/web/src/api/__tests__/useRecipeTree.test.tsx — prices fetched from the ArenaNet host, none to our /commerce/* |
| P1 #2 | apps/web/src/shared/lib/gw2/__tests__/client.test.ts (fetchPrices >199 → ≤199-id batches) + useRecipeTree.test.tsx |
| P1 #3 | packages/recipe-graph/src/__tests__/pricing.test.ts (priceGraph) + useRecipeTree.test.tsx (tree == priceGraph(graph, priceMap)) |
| P1 #4 | packages/recipe-graph/src/__tests__/pricing.test.ts + useRecipeTree.test.tsx — omitted ids resolve unknown/null, no throw |
| P2 #1 | apps/web/src/api/__tests__/useMaterials.test.tsx — ArenaNet /account/materials called, no origin/key request |
| P2 #2 | apps/web/src/api/__tests__/useWallet.test.tsx — ArenaNet /account/wallet called, no origin/key request |
| P2 #3 | apps/web/src/api/__tests__/useMaterials.test.tsx / useAccount.test.tsx — ArenaNet 401 surfaces through the boundary |
| P2 #4 | apps/api/src/generate-openapi.test.ts + apps/api/src/web-proxies-removed.test.ts — no /account* or /commerce/* |
| SC1 | the network assertions in useRecipeTree.test.tsx / useMaterials.test.tsx / useWallet.test.tsx |
| SC2 | key-never-to-origin assertions across useRecipeTree / useMaterials / useWallet / useAccount tests |
| SC3 | packages/recipe-graph/src/__tests__/pricing.test.ts + useRecipeTree.test.tsx (shared priceGraph) |
| SC4 | apps/web/src/shared/lib/gw2/__tests__/client.test.ts — batches ≤199 ids |
| SC5 | apps/api/src/web-proxies-removed.test.ts + generate-openapi.test.ts + MCP tool tests green |
| SC6 | single priceGraph (packages/recipe-graph) + single wire-schema set (packages/gw2), imported by both apps (grep) |
| SC7 | CI: pnpm lint && pnpm test && pnpm build && pnpm docs:build (all green on this branch) |
| SC8 | CI: pnpm verify:contract — regenerated openapi.json + generated/ diff-clean, no account/commerce paths |
| R14 | packages/gw2/src/__tests__/enrich.test.ts (joins) + useMaterials.test.tsx / useWallet.test.tsx + account.service.test.ts (delegation) |