Skip to content

Research 018 — Player holdings & material prices ​

Status: complete

Step 1.5 output, written after the spec (which the human approved before discovery ran) and before the plan gate. Every [NEEDS VERIFICATION] marker in spec.md (V1–V5) has a verdict below, plus findings that turned up alongside. All five are Confirmed — none refuted a load-bearing spec claim — but one inherited background premise (016's "GW2 returns 403 for a bad key") is live-refuted and recorded under Refuted claims; its correction makes R5 more correct, not broken.

Verified against. The repo at this worktree's 018-holdings-and-prices HEAD; the live GW2 API (api.guildwars2.com, probed 2026-08-16 — currencies and the authenticated-error paths were exercised directly); and the official GW2 Wiki pages API:2/account/materials, API:2/account/wallet, API:2/currencies (fetched 2026-08-16). The two authenticated success shapes (materials, wallet) are documented, not live-verified — we have no real key with the inventories/wallet scopes, so a 200 body was never observed. The error paths (invalid key) were live-exercised. GW2 can drift from the wiki (the repo already records a documented 200-id cap that measured 400 live — apps/api/src/gw2/gw2-client.ts:22-27); re-verify the success shapes with a real key when one exists.

V1 — On a scope-gated endpoint, is an invalid key distinguishable from a valid key missing the scope? (spec R5, HTTP-endpoint 403, P1 #4, P2 #2, SC3) ​

Question. R5 maps an invalid/expired key to 401 and a valid key missing the required scope to 403 naming the scope. That needs GW2 to signal the two cases distinguishably. Spec 016 believed GW2 returns 403 for a bad key with no body and no invalid-vs-scope distinction — if that held on a scope-gated endpoint, R5 could not tell them apart and would bounce back to step 1.

Verdict. Confirmed — the two are distinguishable by status, and the split is even cleaner than R5 assumed: an invalid key → 401 (live-measured), a missing scope → 403 (documented). R5 stands as written; no bounce-back. The invalid-key half also refutes 016's inherited premise (see Refuted claims #1).

Evidence.

  • Live, 2026-08-16. GET /v2/account/materials, /v2/account/wallet, and /v2/account each, with a malformed key (deadbeef-0000) and a well-formed-but-fake GUID key (1A2B3C4D-…-8C9D0E1F2A3B), returned HTTP 401 with body { "text": "Invalid access token" }. No header at all → also 401 { "text": "Invalid access token" }. So a rejected key is 401, not 403, and it does carry a JSON body — on every one of the three endpoints, key-format-independent.
  • Missing scope → 403 (documented). API:2 states HTTP 403 is returned for "a valid API key without the necessary permissions," and the wiki's per-scope error convention is a 403 body { "text": "requires scope <name>" } (e.g. requires scope inventories). This is the case the two new endpoints hit when a valid key lacks inventories/wallet.
  • Repo already tolerant. Gw2Client.account() maps both 401 and 403 to Gw2UnauthorizedError today (apps/api/src/gw2/gw2-client.ts:140-142), so 016 works regardless of which status a bad key yields. For 018, R5 needs to split them: 401 → our 401; 403 → our 403 (name the scope). A second error class distinct from Gw2UnauthorizedError is the minimal add (see F1; error classes today: Gw2ValidationError, Gw2RateLimitError, Gw2RequestError, Gw2UnauthorizedError — apps/api/src/gw2/gw2.errors.ts).

Caveat → manual record during impl. The missing-scope 403 was not live-exercised — that needs a real, valid key that lacks inventories/wallet, which we don't have. It is documented only. Like 016's SC1 smoke, confirm it with a dated manual record during implementation (the human can create a scope-restricted key, or run ! curl -H "Authorization: Bearer <scoped-key>" …/v2/account/materials and paste the status+body). Mapping keys on status (401 vs 403); the scope name for the message is read from the 403 body's text opportunistically, falling back to the statically-known required scope per endpoint if the body is absent — so the message never depends on parsing the body.

V2 — What is the /v2/account/materials response shape? (spec R3, R4, P1 #1) ​

Verdict. Confirmed. Array of { id, category, count, binding? }; scopes account + inventories. Our endpoint surfaces id+count+category and joins name/icon/rarity from /v2/items; binding is ignored (not in our contract).

Evidence. GW2 Wiki API:2/account/materials (2026-08-16):

FieldTypeRequiredMeaning
idnumberyesitem id of the material (join target for /v2/items)
categorynumberyesmaterial-category id, resolvable against /v2/materials (we return the bare id — category-name join is out of scope)
countnumberyesquantity in the account vault
bindingstringno"Account" or omitted; not surfaced

Caveat. "All materials are included in the response, including those with a count of 0." So the raw read returns storage slots the player has zero of — see F2 (filter to count > 0).

V3 — What is the /v2/account/wallet response shape? (spec R3, R4, P2 #1) ​

Verdict. Confirmed. Array of { id, value }; scope wallet. Our endpoint surfaces id+value and joins name/icon from /v2/currencies; there is no rarity to join (V4).

Evidence. GW2 Wiki API:2/account/wallet (2026-08-16): each element is id (number — currency id, resolvable against /v2/currencies) and value (number — amount). Requires the wallet scope.

V4 — /v2/currencies shape: id/name/icon, batchable, static, no rarity? (spec R3, R4, P2 #1, SC2) ​

Verdict. Confirmed live. ?ids=-batchable, static (public, unauthenticated), returns name+icon, and has no rarity — so wallet legitimately omits rarity.

Evidence. Live, 2026-08-16: GET https://api.guildwars2.com/v2/currencies?ids=1,2 → 200 with [{ "id": 1, "name": "Coin", "description": "…", "order": 101, "icon": "https://render.guildwars2.com/file/…/619316.png" }, { "id": 2, "name": "Karma", … }]. The ?ids= batch is honored (two ids, two objects). Fields are id, name, description, order, icon — no rarity, no type, no flags. description/order are stripped at our Zod boundary (we consume id/name/icon). Being public, unauthenticated, and immutable game data, /v2/currencies caches hard like /v2/items — the new Gw2Client.currencies(ids) uses the no-expiry staticCache (apps/api/src/gw2/gw2-client.ts:73-76) with a currency: key prefix, same pattern as items() (:94-102).

V5 — Can the client key a per-user cache on a hash of the API key, with a TTL, without leaking across users? (spec R6) ​

Verdict. Confirmed. Mechanically trivial on the existing primitives; the only new ingredient is a key hash.

Evidence.

  • BoundedCache.set(key, value, ttlMs) already records expiresAt = Date.now() + ttlMs and expires lazily on get (apps/api/src/gw2/bounded-cache.ts:42-52, :28-40). The price cache already uses exactly this with a 60 s TTL (gw2-client.ts:114-123, PRICE_TTL_MS :34). A 5-minute TTL is the same mechanism with 300_000.
  • The authenticated GET path exists post-016: account(apiKey) takes a bucket token, calls the header-aware fetchWithRetry(url, path, init) with { headers: { Authorization: 'Bearer …' } }, and touches no cache (gw2-client.ts:131-145, :253-257). accountMaterials/accountWallet follow that shape, but add a cache keyed by <endpoint>:<hash(apiKey)>.
  • The key must fold the API key, not the URL. Every account read hits the same URL, and BoundedCache is a plain Map<string, …> with no user dimension (bounded-cache.ts:18) — a URL-keyed entry would serve one player's holdings to another (the exact reason 016 left /account uncached — 016 research V1). Hash with Node's built-in crypto.createHash('sha256').update(apiKey).digest('hex') (no dependency; the API runs on Node) so the raw secret is never a map key (SC7).

Caveat. The per-user cache is on the raw storage/wallet fetch only. The enrichment reads (items/currencies) have their own static caches, so even a per-user miss re-joins display data from the warm static cache cheaply — record which layer caches what in the plan (F4).

F1 — 016's inherited "bad key → 403, no body" premise is live-refuted (touches R5, docs) ​

Why it matters. 016's research.md (V2, and its graduation notes) and 016 spec.md's R2/SC1 state GW2 returns 403 for a rejected key, "no documented 401, no error-body shape," mapping "on status, not body." Live probing on 2026-08-16 shows the opposite: 401 with body { "text": "Invalid access token" }. 016's code is unaffected (it maps 401 and 403 alike), and its browser-facing 401 is still correct — but the documented rationale is wrong. This is a cross-cutting correction to a merged spec, so per the "park cross-cutting findings" rule it also warrants a docs/gaps/ record (candidate — surfaced to the human, not created unprompted). For 018 it is good news: invalid-key 401 and missing-scope 403 are cleanly distinct, so R5's split is real. Note 016 never graduated its auth claim into docs/architecture/gw2-api.md (that file has no auth section as of this HEAD), so no architecture doc is currently wrong — only 016's own artifacts.

F2 — Material storage returns count: 0 slots; filter to count > 0 (touches R4, P1 #1) ​

Why it matters. Per V2, /v2/account/materials lists every storage slot, including materials the player has zero of. "The materials the player already has" (the P1 intent) should not include zero-count noise. Recommend the service filter to count > 0 before enrichment (also trims the /v2/items join). This is an unspecified behavior the spec's P1 does not pin down — a small plan decision worth an explicit test either way; recommend filtering.

F3 — Prices endpoint needs no new client work (touches R2, R7) ​

Why it matters. Gw2Service.prices(ids) already exists, is ?ids=-chunked to ≤199, 60 s-TTL cached, and validated (gw2-client.ts:114-123, gw2.schemas.ts:52-63). The commerce module is only a controller (ids parse + 400) + a one-line service wrapper + a Zod response DTO stripping whitelisted. The 199 ids cap (R7) equals MAX_IDS_PER_REQUEST (gw2-client.ts:27) so one endpoint call maps to at most one GW2 batch — confirm the exact number in plan.md.

F4 — Cache the raw per-user fetch, not the enriched result (touches R6) ​

Why it matters. Two layers cache here: the per-user 5-min cache (R6) and the static item/currency caches (R3). Cleanest is to cache the raw /v2/account/materials array per user (the volatile, per-player part) and always run enrichment from the static caches on the way out — so a stale item name can't be pinned inside a per-user entry, and the per-user cache holds no display data to go stale. The plan should state the caching layer (client method vs service) and what exactly is stored.

Refuted claims ​

A refuted claim normally sends the spec back to step 1. Here the refuted claim is an inherited background premise from 016, not a load-bearing claim of 018 — and correcting it validates 018's design rather than undermining it — so 018 needs no structural rework, only a wording tidy proposed for human approval (the spec is already approved, so this is an amendment, not a silent patch).

  1. "GW2 returns 403 for a rejected key, with no body and no invalid-vs-scope distinction" (inherited from 016 research V2 / 016 spec R2; echoed in 018's R5 prose as the risk that could bounce R5). Believed: a bad key and a missing scope are indistinguishable, both 403, no body — so R5's 401/403 split might be impossible. True (live 2026-08-16): an invalid key → 401 with body { "text": "Invalid access token" }; a missing scope → 403 { "text": "requires scope …" } (documented). They are distinct. Proposed amendment to 018 spec.md: reword R5 (and the endpoint-contract note) to drop the "016 measured 403… if V1 is refuted, R5 bounces" hedge and state the confirmed mapping directly — upstream 401 (invalid key) → our 401; upstream 403 (missing scope) → our 403 naming the scope — mapping on status, with the scope name read opportunistically from the 403 body's text (fallback: the endpoint's statically-known scope). No user story, endpoint, or success criterion changes. Awaiting human approval of the amendment before the plan is written.

Graduation ​

Candidates to move to docs/architecture/gw2-api.md at step 6 (they outlive this feature; the next account-scoped spec should not re-derive them):

  • GW2 auth failure (corrects 016): an invalid/expired key → 401 { "text": "Invalid access token" } (live-measured, key-format-independent, on /account, /account/materials, /account/wallet); a valid key missing a scope → 403 { "text": "requires scope <name>" }. The two are distinguishable by status — supersede 016's "403 for both, no body, map on status not body."
  • /v2/currencies: public, immutable, ?ids=-batchable; fields id, name, description, order, icon; no rarity — cache like /v2/items.
  • /v2/account/materials: { id (item id), category, count, binding? }, scopes account + inventories, includes count: 0 slots.
  • /v2/account/wallet: { id (currency id), value }, scope wallet.
  • Per-user caching on the authenticated client path: key on sha256(apiKey) + endpoint, short TTL; cache the raw per-user body, enrich from the static caches on egress.
  • docs/gaps/ record correcting 016's auth-status claim (F1).