Skip to content

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):

CallResult
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):

  • CommerceService has no consumer outside commerce/ — rg "CommerceService" apps/api/src --glob '!**/commerce/**' is empty. The MCP gw2_prices tool calls Gw2Service.prices directly, not this service (mcp.tools.ts:143-148). → CommerceController + CommerceService + CommerceModule are all droppable.
  • AccountService is consumed by ranking.service.ts:10,105 (Wave 3) and the MCP (mcp.tools.ts:79, mcp.controller.ts:16,56), besides AccountController. → keep the service; delete only the controller (and its account.module.ts controllers: 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/web already runtime-imports the package: mergeOwnVsNeed/aggregateNeed (features/legendaries/mergeOwnVsNeed.ts:8, aggregateNeed.ts:7). Adding priceGraph is the same import channel.
  • apps/api already runtime-imports it: mergeOwnVsNeed, needRows (ranking.service.ts:1-7). So resolvePriced switching its ./pricing import to the package is consistent (the "types-only" comments are stale — see F3).
  • priceGraph's only runtime dep is netSellPrice, a one-liner Math.floor(gross * 0.85) (packages/domain/src/index.ts:7), currently imported by pricing.ts. Moving it means @gw2priory/recipe-graph gains a @gw2priory/domain dependency; 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 the controllers: entry in account.module.ts (keep the service export).
  • apps/web/src/api/generated/endpoints/{account,commerce}/* — removed by orval regen (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) and project-tree.ts (projectTree). recipe-graph.service.ts:18 switches its import to the package; mcp.tools.ts behaviour unchanged (R3).

Add / share:

  • GW2 wire schemas the web needs (Gw2Price/Material/WalletEntry/Account/Currency/Item from apps/api/src/gw2/gw2.schemas.ts) into a shared home both gw2-client and 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; AccountService refactored 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-filling project()), 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, Gw2Client methods, resolvePriced) kept for the MCP + assistant. Ranking/assistant staying key-to-origin is Wave 3's to change.
  • Contract gate. verify:contract (root package.json) regenerates apps/api/openapi.json + apps/web/src/api/generated and git 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= (the Authorization header is CORS-blocked) — belongs in docs/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-control readable, allow-origin not JS-readable, body always readable) — a short note in gw2-api.md so the next spec doesn't re-probe.