Research 034 — Discovery
Status: complete Spec: 034 — Client-direct ArenaNetMethod: codebase reading (evidence as file:line) + one throwaway live-API/CORS spike run in a real cross-origin browser (Playwright at https://example.com, discarded after answering, per the constitution). Verified 2026-08-22 against branch 034-client-direct-anet (base b280164, spec 032 merged). ArenaNet responses measured the same day.
Outcome. The spec's core stands: prices and account state can be fetched browser → ArenaNet, our web-facing proxies dropped, the pricing math and wire schemas shared. Discovery resolved one open choice decisively and against the paper preference: the API key must travel as ?access_token= — the Authorization header is CORS-blocked from the browser (V1). No claim was refuted.
Marker resolutions
V1 — key transport from the browser: Authorization header vs ?access_token= → RESOLVED: query param (header is CORS-blocked)
Question. The spec left the transport open, preferring the header (keeps the key out of URLs) if it works cross-origin. Does /v2/account* accept an Authorization header from a browser, or must the key go in ?access_token=?
Verdict. Query param. The header is impossible from the browser; the client sends ?access_token=<key>. R4/R13/contract-table's deferred choice is settled — no spec change needed beyond recording the transport.
Evidence. Live cross-origin fetch from https://example.com (2026-08-22):
| Call | Result |
|---|---|
fetch('https://api.guildwars2.com/v2/account', { headers: { Authorization: 'Bearer <dummy>' } }) | TypeError: Failed to fetch — the CORS preflight for the non-safelisted Authorization header is not allowed; the request never leaves the browser |
fetch('https://api.guildwars2.com/v2/account?access_token=<dummy>') | reached the server → 401 (a "simple" GET, no preflight; CORS allowed it) |
This matches the measured behaviour of gw2.app, which uses ?access_token= for its browser→ArenaNet account calls (docs/gaps/gw2app-caching-comparison.md).
Caveat (security — carry into the plan). The key now appears in the request URL. Mitigations, mostly already the app's pattern: (1) keep the raw key out of the TanStack queryKey — reuse the existing hashKey(apiKey) (shared/lib/hashKey.ts, already used by useMaterials/useWallet/useAccount); (2) never log the ArenaNet URL; (3) transport is HTTPS and the Referer goes only to ArenaNet. This is the least-exposing option that works — the header, which would be cleaner, is not available.
V2 — is ArenaNet's rate limit per-IP (so browser-direct gives each user their own budget)? → CONFIRMED
Question. The epic's premise is that browser-direct calls spend the user's budget, not our shared origin's. That holds only if the limit is per source IP with no per-key component that still funnels.
Verdict. Confirmed — per IP. Browser-direct isolates each user's budget; proxying funnels all users through our one origin IP. This is the whole justification for the move.
Evidence. GW2 wiki, API:Best practices → Rate Limit (https://wiki.guildwars2.com/wiki/API:Best_practices): "The API is rate limited per IP." Max burst (bucket) size 300, refill 5 tokens/second (300/minute). No per-key component is documented. (This also matches our own server-side self-throttle: token-bucket.ts, capacity 300 / 5-per-sec — gw2.service.ts:19-20, sized under this shared limit precisely because our proxy shares one IP.)
V3 — does the 199-id ?ids= batch work from the browser, and what price-cache TTL? → CONFIRMED (batch); TTL = rely on max-age=120
Question. Can the browser issue the same 199-id batch the server does, and what freshness policy?
Verdict. 199-id batch works. Freshness: rely on ArenaNet's public, max-age=120 via the browser HTTP cache, with a TanStack staleTime aligned to ~120 s. No bespoke price cache (R10).
Evidence. Live cross-origin fetch (2026-08-22): GET /v2/commerce/prices?ids=<199 ids> — URL length 1243 chars (well under any limit), status 206, body readable, 177 priced rows returned (the rest were untradeable ids omitted — normal 206 semantics, exactly the client's merge-what-came-back path, P1 #4). The response carried cache-control: public,max-age=120 (readable — see V6). 199 mirrors the server cap (gw2-client.ts:43 MAX_IDS_PER_REQUEST).
V4 — which backend endpoints are safe to drop? → CONFIRMED
Question. Can the account/commerce proxies go without breaking the kept MCP path or any other consumer?
Verdict. Confirmed. Delete the whole commerce module and AccountController only; keep AccountService.
Evidence (codebase):
CommerceServicehas no consumer outsidecommerce/—rg "CommerceService" apps/api/src --glob '!**/commerce/**'is empty. The MCPgw2_pricestool callsGw2Service.pricesdirectly, not this service (mcp.tools.ts:143-148). →CommerceController+CommerceService+CommerceModuleare all droppable.AccountServiceis consumed byranking.service.ts:10,105(Wave 3) and the MCP (mcp.tools.ts:79,mcp.controller.ts:16,56), besidesAccountController. → keep the service; delete only the controller (and itsaccount.module.tscontrollers:entry).- The generated account client is imported only by the three hooks being rewritten (
useAccount.ts:4-5,useMaterials.ts:4-5,useWallet.ts:4-5); the generated commerce client is imported nowhere (see F2).
V5 — can @gw2priory/recipe-graph (with priceGraph) be runtime-imported by both apps? → CONFIRMED
Question. The plan moves priceGraph/projectTree/collectPricedIds into @gw2priory/recipe-graph. Can apps/web import it at runtime (React-Compiler build gate) and apps/api under SWC?
Verdict. Confirmed. Both already runtime-import that package; the moved code is pure functions, not React, so the compiler is unaffected. Only action: add @gw2priory/domain to recipe-graph's deps.
Evidence (codebase):
apps/webalready runtime-imports the package:mergeOwnVsNeed/aggregateNeed(features/legendaries/mergeOwnVsNeed.ts:8,aggregateNeed.ts:7). AddingpriceGraphis the same import channel.apps/apialready runtime-imports it:mergeOwnVsNeed,needRows(ranking.service.ts:1-7). SoresolvePricedswitching its./pricingimport to the package is consistent (the "types-only" comments are stale — see F3).priceGraph's only runtime dep isnetSellPrice, a one-linerMath.floor(gross * 0.85)(packages/domain/src/index.ts:7), currently imported bypricing.ts. Moving it means@gw2priory/recipe-graphgains a@gw2priory/domaindependency; both are source-export workspace packages (exports: ./src/index.ts), which web already builds from source.
Caveat. The React-Compiler gate is only exercised in vite build (docs/architecture/react.md), so the plan's verification must run pnpm build, not just tests — but the risk is low since the moved code is non-component pure logic.
V6 — can the browser read the cross-origin response body despite cache-control not being in access-control-expose-headers? → CONFIRMED
Question. The epic noted ArenaNet's access-control-expose-headers is minimal. Does that block reading the price body?
Verdict. Confirmed readable. expose-headers gates which response headers JS may read, never the body. The client reads prices fine.
Evidence. In the V3 probe the body parsed to a 177-element array cross-origin. Notably cache-control: public,max-age=120 was readable (it is a CORS-safelisted response header, so it needs no expose-headers entry), while access-control-allow-origin read as null — see F4.
Unprompted findings
F1 — /account/materials and /account/wallet are enriched server-side, not passthroughs
What. AccountService.getMaterials fetches raw account rows then joins gw2.items + gw2.materials() categories + gw2.prices and folds in sellPrice, sorting by category (account.service.ts:19-66, price fold at :55). getWallet joins gw2.currencies (account.service.ts:94-112). Raw /v2/account/materials returns only {id, category, count}.
Why it matters. The client must reproduce this join from browser-fetched keyless static data (/v2/items, /v2/materials, /v2/currencies) + prices. This is the basis of spec R14 (share the pure join, don't reimplement) and the extra keyless calls in the contract table.
F2 — the generated /api/commerce/prices web client is dead
What. useCommerceControllerList exists under generated/endpoints/commerce/ but is imported by nothing outside generated/, is not in the api facade (api/index.ts), and has no runtime caller. Prices reach the UI today only embedded (materials sellPrice, ranking).
Why it matters. Corroborates V4: dropping /commerce/prices breaks no web consumer; it also removes orphaned generated code. The recipe tree currently shows null prices — this spec is what first fetches prices on the web.
F3 — the "types-only @gw2priory/recipe-graph" comments are already stale
What. recipe-index.service.ts:1-2, pricing.ts:1, and recipe-graph.service.ts:1-2 say to import only types from @gw2priory/recipe-graph, yet ranking.service.ts:1-7 already runtime-imports from it. Separately, recipe-graph.controller.ts:28-29 and useRecipeTree.ts:16-17 attribute the client price merge to "spec 033" (this spec is 034).
Why it matters. R3 (moving priceGraph to the package) makes the runtime import explicit; R9 corrects the "spec 033" references. Broader doc reconciliation is parked (spec's Parked findings).
F4 — access-control-allow-origin reads as null in JS even though CORS succeeds
What. In the probe, the price fetch succeeded and the body was readable, but res.headers.get('access-control-allow-origin') returned null. That is expected: allow-origin is consumed by the browser and is not itself exposed to JS; only safelisted/expose-headers headers are readable.
Why it matters. A future reader debugging the client shouldn't treat the null as "CORS failed" — the body-readability (and the 206/cache-control read) is the real signal. Recorded so it isn't re-investigated.
Deletion / move blast radius (informs the plan)
Delete:
apps/api/src/commerce/— controller, service, module, schema, tests (no external consumer, V4/F2).apps/api/src/account/account.controller.ts+ its test; drop thecontrollers:entry inaccount.module.ts(keep the service export).apps/web/src/api/generated/endpoints/{account,commerce}/*— removed byorvalregen (R12), not by hand.
Move (into @gw2priory/recipe-graph, delete the app-local copies):
apps/api/src/recipe-graph/pricing.ts(priceGraph,collectPricedIds,PriceEntry/PriceMap) andproject-tree.ts(projectTree).recipe-graph.service.ts:18switches its import to the package;mcp.tools.tsbehaviour unchanged (R3).
Add / share:
- GW2 wire schemas the web needs (
Gw2Price/Material/WalletEntry/Account/Currency/Itemfromapps/api/src/gw2/gw2.schemas.ts) into a shared home bothgw2-clientand the web fetch layer import (R13; exact package decided in the plan). - The materials/wallet join (from
account.service.ts) as a shared pure function;AccountServicerefactored to call it, behaviour-preserving (R14).
Rewrite (web):
useAccount/useMaterials/useWallet→ browser→ArenaNet fetch + client-side join (F1).useRecipeTree→ fetch priceless graph + browser-fetch prices →priceGraph(replaces the null-fillingproject()), restoring cost/decision/profit UI (R2).
Keep (out-of-scope, Wave 3): AccountService, RankingService.rank, Gw2Client/Gw2Service account
- price methods,
/legendaries/ranking,/assistant, all MCP tools.
Coordination confirmations
- Wave 3 (Spec 5). Boundary confirmed by V4: HTTP surface (account/commerce routes) dropped; service capability (
AccountService,Gw2Clientmethods,resolvePriced) kept for the MCP + assistant. Ranking/assistant staying key-to-origin is Wave 3's to change. - Contract gate.
verify:contract(rootpackage.json) regeneratesapps/api/openapi.json+apps/web/src/api/generatedandgit diff --exit-codes them — the plan must regenerate and commit both after the controllers are deleted (R12/SC8).
Graduation candidates (step 6 → docs/architecture/)
- The key-transport rule: browser→ArenaNet authenticated calls use
?access_token=(theAuthorizationheader is CORS-blocked) — belongs indocs/architecture/gw2-api.md. - The per-IP rate-limit fact (300 burst / 5-per-sec, per IP) is already partly in
gw2-api.md; add the browser-direct consequence. - The CORS posture for browser-direct (open
allow-origin,cache-controlreadable,allow-originnot JS-readable, body always readable) — a short note ingw2-api.mdso the next spec doesn't re-probe.