Spec 018 — Player holdings & material prices
Status: implemented Branch: 018-holdings-and-prices
Status is set by the human, never by the agent. It moves draft → approved → implemented.
Problem
Spec 016 opened the account door: a player connects a GW2 API key and the app confirms it by showing their account name. But the name is all we can see. The whole point of a legendary crafting planner is to answer "what do I already have, and what will the rest cost?" — and today the API surfaces neither. There is no way to read the player's material storage (the mats they've already banked), no way to read their wallet (the currencies legendary gifts and armor are paid in), and no public way to read Trading Post prices for a set of items (the client can fetch prices internally, but no endpoint exposes them). Every personalization the planner will do — subtract what you own, price what you still need — is blocked on these three reads. This spec adds them, thin and enriched for display, and nothing more: it does not reconcile holdings against any plan (that is a later feature that composes these reads).
HTTP endpoints (contract-first)
Three new endpoints, all served under the existing global /api prefix (spec 015 R1) and each contributing to apps/api/openapi.json via a Zod-first schema, so the generated web client and verify:contract cover them like every other route. Two are authenticated (per-player, Bearer key); one is public.
GET /api/account/materials (authenticated)
Request: header
Authorization: Bearer <api-key>(required), forwarded to GW2 — never read from a query string or body (a credential in a URL leaks into logs and caches). Requires the key to carry the GW2inventoriesscope.200: an array of the player's material-storage stacks, each enriched with item display data:
json[{ "id": 12134, "count": 250, "category": 5, "name": "Pile of Flax Seeds", "icon": "https://render.guildwars2.com/…", "rarity": "Basic" }]id/count/categorycome from/v2/account/materials(item id, quantity, material-category id);name/icon/rarityare joined from/v2/items. A stack whose item id/v2/itemsdoes not return is omitted (it cannot be displayed without a name) — see R4. Stacks the player holds zero of (GW2 returnscount: 0storage slots — research V2) are returned as-is, not filtered: this is a thin read and the consumer decides whether to hide them.400: the
Authorizationheader is missing or not a non-empty bearer token (never reaches GW2).401: the key was rejected as invalid or expired.
403: the key is valid but does not carry the
inventoriesscope — the message names the missing scope. (401/403 split confirmed live; the missing-scope403itself is confirmed by a dated manual record during implementation — research V1.)502: GW2 was unreachable or returned an unexpected status.
GET /api/account/wallet (authenticated)
Request: header
Authorization: Bearer <api-key>(required), as above. Requires thewalletscope.200: an array of the player's currency balances, each enriched with currency display data:
json[{ "id": 1, "value": 184320, "name": "Coin", "icon": "https://render.guildwars2.com/…" }]id/valuecome from/v2/account/wallet(currency id, amount);name/iconare joined from/v2/currencies. There is norarity— rarity is an item concept and currencies have none (confirmed — research V4). A currency id/v2/currenciesdoes not return is omitted (R4).400 / 401 / 502: as for materials.
403: the key is valid but does not carry the
walletscope (same mapping — research V1).
GET /api/commerce/prices?ids=… (public)
Request: query
ids— a comma-separated list of item ids (e.g.?ids=19721,19976,24295). No authentication. The list must be non-empty, all-numeric, and within a sane cap (R7).200: an array of Trading Post prices, in copper:
json[{ "id": 19721, "buys": { "quantity": 8123, "unit_price": 2710 }, "sells": { "quantity": 442, "unit_price": 2799 } }]Only TP-eligible ids appear. An id that is account-bound / not tradable (and so absent from
/v2/commerce/prices) is silently omitted, not errored — the caller diffs requested-vs-returned to learn which ids are non-tradable.whitelistedand other GW2 fields are stripped at the boundary.400:
idsis missing, empty, contains a non-numeric value, or exceeds the cap (R7). The request never reaches GW2.502: GW2 was unreachable or returned an unexpected status.
All three GW2 contracts are confirmed in research.md before approval (V1–V5).
User stories
Ordered by priority. Each story is independently testable and shippable — if only P1 ships, there is still something usable.
P1 — Read my material storage, ready to display
As a player, I want to fetch the materials I already have — each with its name, icon, rarity, and count — so that a planner (or I) can see my stock at a glance and later subtract it from what a legendary needs.
Independent test: call GET /api/account/materials with a valid, inventories-scoped key; the API reads /v2/account/materials, joins the stacks against /v2/items, and returns an array of { id, count, category, name, icon, rarity }. With no header it returns 400; with an invalid key 401; with a valid key lacking the inventories scope 403 naming the scope.
Acceptance scenarios
- Given a valid
inventories-scoped key, whenGET /api/account/materialsis called, then the API returns200with each storage stack enriched to{ id, count, category, name, icon, rarity }, the display fields joined from/v2/items. - Given a request with no
Authorizationheader, when the endpoint is called, then the API returns400without contacting GW2. - Given a key GW2 rejects as invalid/expired, when the endpoint is called, then the API returns
401. - Given a valid key that lacks the
inventoriesscope, when the endpoint is called, then the API returns403with a message naming theinventoriesscope. (confirmed — research V1) - Given a storage stack whose item id
/v2/itemsdoes not return, when the endpoint is called, then that stack is omitted from the response rather than returned without a name.
P2 — Read my wallet currencies, ready to display
As a player, I want to fetch my currency balances — each with its name, icon, and amount — so that a planner can account for the currencies legendary gifts and armor consume.
Independent test: call GET /api/account/wallet with a valid, wallet-scoped key; the API reads /v2/account/wallet, joins against /v2/currencies, and returns { id, value, name, icon } per currency (no rarity). Error contract mirrors P1 with the wallet scope.
Acceptance scenarios
- Given a valid
wallet-scoped key, whenGET /api/account/walletis called, then the API returns200with each balance enriched to{ id, value, name, icon }, name/icon joined from/v2/currencies, and norarityfield. - Given a valid key that lacks the
walletscope, when the endpoint is called, then the API returns403naming thewalletscope. (confirmed — research V1) - Given no
Authorizationheader, then400; given an invalid key, then401. - Given a currency id
/v2/currenciesdoes not return, when the endpoint is called, then that balance is omitted rather than returned without a name.
P3 — Look up Trading Post prices for a set of items
As a caller (the planner, or a curious user), I want to fetch current buy/sell prices for a list of item ids so that I can value the buyable materials in a plan.
Independent test: call GET /api/commerce/prices?ids=19721,19976 and receive [{ id, buys, sells }] in copper for the tradable ids; a mix that includes an account-bound id returns only the tradable ones; a bad ids value returns 400.
Acceptance scenarios
- Given
?ids=with tradable item ids, when the endpoint is called, then the API returns200with{ id, buys: { quantity, unit_price }, sells: { quantity, unit_price } }per id, in copper. - Given
?ids=mixing tradable and non-tradable (account-bound) ids, when the endpoint is called, then only the tradable ids appear; the non-tradable ids are omitted, not errored. - Given
idsmissing, empty, non-numeric, or over the cap, when the endpoint is called, then the API returns400and never contacts GW2.
P4 — A refresh within five minutes does not refetch (caching)
As a player refreshing my holdings, I want a repeat read within a short window to be served from cache, so that hammering refresh does not burn the GW2 rate budget or make me wait on a round-trip.
Independent test: call GET /api/account/materials twice with the same key inside five minutes; the second call is served from cache (no second GW2 /v2/account/materials fetch). After the TTL elapses, the next call refetches. The same holds for /api/account/wallet. Two different keys never share a cache entry.
Acceptance scenarios
- Given a materials read for key K, when the same key reads again within the TTL, then the second read returns without a new GW2 storage fetch (cache hit).
- Given two different keys K1 and K2, when each reads materials, then neither is served the other's data (the cache key folds a hash of the key, never the same URL for all users).
- Given a cached materials read, when the TTL has elapsed, then the next read refetches from GW2.
Requirements
- R1 — Extend the
accountfeature module (API). Add two routes toapps/api/src/account/—GET /account/materialsandGET /account/wallet— reusing the existing bearer-parse (400on a missing/blank token) and theAccountService. AddgetMaterials(key)/getWallet(key)to the service. The module stays in the standard shape (account.module.ts, thin controller, service,account.schema.ts), already wired intoapp.module.ts. - R2 — New
commercefeature module (API). A newapps/api/src/commerce/module in the standard shape (commerce.module.ts, thincommerce.controller.ts,commerce.service.ts,commerce.schema.ts), wired intoapp.module.ts, exposingGET /commerce/prices(served at/api/commerce/prices). The service wraps the already-wiredGw2Service.prices(). - R3 — Client reads through the budgeted
Gw2Service. All GW2 access goes through the one budgeted client (stack.md), never a directfetch. Add toGw2Client(and expose viaGw2Service):accountMaterials(apiKey)andaccountWallet(apiKey)(authenticated, bearer, header-aware — the same authenticated-GET path 016 added), andcurrencies(ids)(a new static, batched,?ids=-chunked read of/v2/currencies, cached like items/recipes with no expiry). New Zod schemasGw2MaterialSchema,Gw2WalletEntrySchema,Gw2CurrencySchema, each validating only the fields we consume and stripping the rest (shapes confirmed — research V2/V3/V4). - R4 — Enrichment join, in the service.
getMaterialscollects the storage item ids, reads them throughGw2Service.items(...)(batched, ≤199, static-cached), and joins name/icon/rarity onto each stack;getWalletdoes the same againstGw2Service.currencies(...)for name/icon. A stack/balance whose id the enrichment read does not return is omitted (it cannot be displayed without a name), and that omission is a tested behavior, not an incidental one. - R5 — Scope-aware error mapping. GW2 signals the two failure modes distinguishably (confirmed live, research V1): an invalid/expired key → upstream
401, returned by our endpoints as401; a valid key missing the required scope → upstream403, returned as403with a message naming the scope (inventoriesfor materials,walletfor wallet). Mapping switches on the upstream status; the scope name is read opportunistically from the403body'stext, falling back to the endpoint's statically-known required scope if the body is absent — so the message never depends on parsing the body. The client needs a403-specific error class distinct from 016'sGw2UnauthorizedError(which maps401). The raw key never appears in any error message (016 R4). (Amends the approved spec per research V1, which live-refuted 016's inherited "403for both, no body" premise — research Refuted claims #1; the missing-scope403is confirmed by a dated manual record during implementation.) - R6 — Per-user cache for authenticated reads, 5-minute TTL. Materials and wallet responses are cached per player, keyed by
<endpoint>:<hash(apiKey)>— a hash (e.g. SHA-256) of the key, never the raw secret as a map key, and never the bare URL (which is identical for every user and would leak one player's holdings to another — the reason 016 left/accountuncached). TTL is 5 minutes (ACCOUNT_TTL_MS = 300_000). The enrichment reads (/items,/currencies) keep their own static caching; prices keep their existing 60 s TTL;/account(name) stays uncached. Whether the cache lives inGw2Clientor the service is a plan decision, but the key must fold a hash of the API key (mechanism confirmed — research V5). - R7 — Prices
idsvalidation. Thecommerce/pricescontroller parsesidsinto a numeric array, rejecting with400when it is missing, empty, contains a non-numeric token, or exceeds a cap (a single request's worth — proposed 199, matching the client's per-batch cap, so one endpoint call is one GW2 batch; the exact cap is confirmed inplan.md). Valid ids pass toGw2Service.prices(), which already chunks internally. - R8 — Zod-first contract & OpenAPI. All three responses are defined by Zod schemas that contribute to
apps/api/openapi.json; the web client is regenerated andverify:contractstays green. This is a first-class deliverable, not deferred. - R9 — The key is never logged or persisted server-side. Inherited from 016 R4 and extended to the two new authenticated reads: the raw key is not logged, not persisted, and (R6) appears in a cache key only as a hash. Enriched responses carry no key material.
- R10 — No new
docs/superpowers/artifacts; prior specs' suites still pass, updated only where 018 changes their subject (e.g.app.module.tsgains thecommercemodule).
Mark anything unresolved inline rather than assuming an answer. Two markers, split by who can answer:
[NEEDS CLARIFICATION: specific question]— only the human can answer. Blocks step 1.5.[NEEDS VERIFICATION: specific question]— only reality can answer, resolved inresearch.mdwith cited evidence. Blocks the approval gate.
No [NEEDS CLARIFICATION] remains — the design (three thin enriched reads, name/icon/rarity for materials and name/icon for wallet, scope-aware 401/403, a public prices pass-through with an ids cap, and a 5-minute per-user cache) was agreed in brainstorming. The [NEEDS VERIFICATION] items below are resolved in research.md with verdicts — all five Confirmed:
- V1 — Invalid-key vs missing-scope on a scope-gated endpoint. Confirmed (research V1, live 2026-08-16): invalid key →
401{"text":"Invalid access token"}; missing scope →403. The two are distinguishable by status, so R5's split holds — and this live-refuted 016's inherited "403for both" premise (research Refuted claims #1). R5 amended above accordingly. - V2 —
/v2/account/materialsshape. Confirmed (research V2, wiki):{ id, category, count, binding? }, scopesaccount+inventories;bindingignored. Includescount: 0slots (returned as-is per the endpoint contract). - V3 —
/v2/account/walletshape. Confirmed (research V3, wiki):{ id, value }, scopewallet. - V4 —
/v2/currenciesshape. Confirmed (research V4, live 2026-08-16):{ id, name, icon }(+ strippeddescription/order),?ids=-batchable, static-cacheable, and no rarity — wallet legitimately omits it. - V5 — Per-user caching on the authenticated client path. Confirmed (research V5):
BoundedCachealready does TTL; key onsha256(apiKey)+ endpoint. Cache layer (client vs service) and storing the raw-vs-enriched body are plan decisions (research F4).
Success criteria
Measurable, outcome-focused. The stack (Nest, GW2 API) is named where the criterion is about that wiring.
- SC1 —
GET /api/account/materialswith a validinventories-scoped key returns200with every stack shaped{ id, count, category, name, icon, rarity }, display fields joined from/v2/items. (api tests, GW2 stubbed at the client boundary) - SC2 —
GET /api/account/walletwith a validwallet-scoped key returns200with every balance shaped{ id, value, name, icon }and norarityfield. (api tests) - SC3 — For both authenticated reads: no header →
400; invalid key →401; valid key missing the required scope →403naming the scope. (api tests; the403case gated on V1) - SC4 —
GET /api/commerce/prices?ids=…returns200 [{ id, buys, sells }]in copper for tradable ids; a mix including a non-tradable id omits it (not an error); a missing/empty/non-numeric/over-capidsreturns400. (api tests) - SC5 — A stack (or balance) whose id the enrichment read does not return is omitted from the response. (api test)
- SC6 — A second authenticated read with the same key inside 5 minutes issues no new GW2 storage/ wallet fetch (cache hit); after the TTL it refetches; two different keys never share a cache entry. (api tests over the cache with a fake clock / call-count assertion on the stubbed fetch)
- SC7 — The raw key never appears in server logs or any persisted store, and appears in a cache key only as a hash. (api test asserting log output / cache-key contents contain no raw key)
- SC8 —
openapi.jsoncontains/account/materials,/account/wallet, and/commerce/prices, andverify:contractpasses (no drift; the regenerated client includes all three). (CI step) - SC9 — The count of files under any
docs/superpowers/path stays zero, and prior specs' suites still pass. (existing invariants) - SC10 — Every acceptance scenario and success criterion maps to a named test or a dated manual record, with no gap; the automated portion passes.
Out of scope
- Reconciliation against a plan — "for legendary X, here's have-vs-need per material." That is the later planner feature that composes these three reads (the option-B shaping we explicitly deferred in brainstorming). This slice returns raw, enriched holdings and raw prices.
- Any holdings beyond material storage and the wallet — bank, character inventories, shared inventory slots, the legendary armory, known recipes. Each is its own later read. Precursors/gifts sitting in the bank are not surfaced this slice.
- Material-category name enrichment —
categoryis returned as the raw GW2 material-category id; joining/v2/materialsfor category names is deferred. - A force-refresh override for the 5-minute cache (e.g. "I just deposited mats, refetch now"). Noted in brainstorming as a future addition; this slice ships the TTL only.
- Any web UI — no page consumes these endpoints this slice; they are API + contract only. The frontend that renders holdings is a later feature.
- A per-API-key rate budget — the new authenticated reads share the one process-global GW2 token bucket with public reads (016 assumption stands); a per-key budget is deferred.
- A permissions/scope pre-check via
/v2/tokeninfo— scope failures are handled reactively from the read's own403(R5), not by inspecting scopes up front.
Assumptions
- 016 stands — the account module, the authenticated-GET path on
Gw2Client(bearer, header-aware, shared bucket), and theGw2UnauthorizedError→401mapping exist and are the foundation these two new authenticated reads extend. - The global
/apiprefix and CORS from spec 015 stand — the three endpoints are served at/api/account/materials,/api/account/wallet,/api/commerce/pricesand reached cross-origin like every other route. - The Zod-first contract → Orval pipeline stands (
stack.md) — adding endpoints means schemas, a regenerated client, andverify:contractguarding drift; no pipeline change. - The GW2 client facts hold (
docs/architecture/gw2-api.md) —?ids=batching to ≤199,200/206both success, missing ids omitted,/v2/commerce/pricesshape./v2/currenciesis assumed to follow the same batched-static pattern as items, confirmed inresearch.md(V4) rather than assumed. - Material storage and wallet fit one enrichment batch each in practice — a typical account's distinct material-storage item ids and currency ids are well under the 199 cap per enrichment read; the client chunks anyway, so correctness does not depend on this, only latency.
Traceability
Each acceptance scenario and success criterion maps to a named test or a dated manual-verification record (the latter only for the inherently observational — a live read against real GW2). SC10 asserts no empty cell. Filled in during implementation.
| Criterion | Test / verification |
|---|---|
| P1 #1 | apps/api/src/account/account.controller.test.ts (materials → enriched array) |
| P1 #2 | apps/api/src/account/account.controller.test.ts (no header → 400, service not called) |
| P1 #3 | apps/api/src/account/account.controller.test.ts (invalid key → 401) |
| P1 #4 | apps/api/src/account/account.controller.test.ts (missing inventories scope → 403) + manual record 2026-08-16: human-attested the live missing-scope 403 path works (accepted; live curl not captured — mapping deterministically covered by the stubbed-403 unit test in gw2-client.test.ts) |
| P1 #5 | apps/api/src/account/account.service.test.ts (stack with unjoinable id omitted) |
| P2 #1 | apps/api/src/account/account.controller.test.ts (wallet → enriched, no rarity) |
| P2 #2 | apps/api/src/account/account.controller.test.ts (missing wallet scope → 403) — live path human-attested 2026-08-16 (see P1 #4) |
| P2 #3 | apps/api/src/account/account.controller.test.ts (no header → 400; invalid key → 401) |
| P2 #4 | apps/api/src/account/account.service.test.ts (balance with unjoinable id omitted) |
| P3 #1 | apps/api/src/commerce/commerce.controller.test.ts (tradable ids → prices) |
| P3 #2 | apps/api/src/commerce/commerce.service.test.ts (non-tradable id omitted) |
| P3 #3 | apps/api/src/commerce/commerce.controller.test.ts (bad ids → 400) |
| P4 #1 | apps/api/src/gw2/gw2-client.test.ts (second read within TTL → no new fetch) |
| P4 #2 | apps/api/src/gw2/gw2-client.test.ts (two keys → distinct cache entries) |
| P4 #3 | apps/api/src/gw2/gw2-client.test.ts (read after TTL → refetch) |
| SC1 | apps/api/src/account/account.controller.test.ts + account.service.test.ts (materials enrichment) |
| SC2 | apps/api/src/account/account.controller.test.ts + account.service.test.ts (wallet enrichment, no rarity) |
| SC3 | apps/api/src/account/account.controller.test.ts (400/401/403 both reads) + apps/api/src/gw2/gw2-client.test.ts (401 invalid key / 403 scope-from-body); missing-scope live path human-attested 2026-08-16 (see P1 #4) |
| SC4 | apps/api/src/commerce/commerce.controller.test.ts + commerce.service.test.ts |
| SC5 | apps/api/src/account/account.service.test.ts (omit unjoinable id) |
| SC6 | apps/api/src/gw2/gw2-client.test.ts (per-user cache: hit, expiry, per-key isolation) |
| SC7 | apps/api/src/gw2/gw2-client.test.ts (cache key holds a hash, not the raw key; no key logged) |
| SC8 | pnpm verify:contract (CI) + apps/api/src/generate-openapi.test.ts (paths contains all three) |
| SC9 | tests/workflow/ repo-invariants (docs/superpowers/ count stays zero; prior suites pass) |
| SC10 | this table complete + every named test exists and passes |