Skip to content

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 GW2 inventories scope.

  • 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/category come from /v2/account/materials (item id, quantity, material-category id); name/icon/rarity are joined from /v2/items. A stack whose item id /v2/items does not return is omitted (it cannot be displayed without a name) — see R4. Stacks the player holds zero of (GW2 returns count: 0 storage slots — research V2) are returned as-is, not filtered: this is a thin read and the consumer decides whether to hide them.

  • 400: the Authorization header 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 inventories scope — the message names the missing scope. (401/403 split confirmed live; the missing-scope 403 itself 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 the wallet scope.

  • 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/value come from /v2/account/wallet (currency id, amount); name/icon are joined from /v2/currencies. There is no rarity — rarity is an item concept and currencies have none (confirmed — research V4). A currency id /v2/currencies does not return is omitted (R4).

  • 400 / 401 / 502: as for materials.

  • 403: the key is valid but does not carry the wallet scope (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. whitelisted and other GW2 fields are stripped at the boundary.

  • 400: ids is 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

  1. Given a valid inventories-scoped key, when GET /api/account/materials is called, then the API returns 200 with each storage stack enriched to { id, count, category, name, icon, rarity }, the display fields joined from /v2/items.
  2. Given a request with no Authorization header, when the endpoint is called, then the API returns 400 without contacting GW2.
  3. Given a key GW2 rejects as invalid/expired, when the endpoint is called, then the API returns 401.
  4. Given a valid key that lacks the inventories scope, when the endpoint is called, then the API returns 403 with a message naming the inventories scope. (confirmed — research V1)
  5. Given a storage stack whose item id /v2/items does 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

  1. Given a valid wallet-scoped key, when GET /api/account/wallet is called, then the API returns 200 with each balance enriched to { id, value, name, icon }, name/icon joined from /v2/currencies, and no rarity field.
  2. Given a valid key that lacks the wallet scope, when the endpoint is called, then the API returns 403 naming the wallet scope. (confirmed — research V1)
  3. Given no Authorization header, then 400; given an invalid key, then 401.
  4. Given a currency id /v2/currencies does 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

  1. Given ?ids= with tradable item ids, when the endpoint is called, then the API returns 200 with { id, buys: { quantity, unit_price }, sells: { quantity, unit_price } } per id, in copper.
  2. 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.
  3. Given ids missing, empty, non-numeric, or over the cap, when the endpoint is called, then the API returns 400 and 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

  1. 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).
  2. 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).
  3. Given a cached materials read, when the TTL has elapsed, then the next read refetches from GW2.

Requirements ​

  • R1 — Extend the account feature module (API). Add two routes to apps/api/src/account/ — GET /account/materials and GET /account/wallet — reusing the existing bearer-parse (400 on a missing/blank token) and the AccountService. Add getMaterials(key) / getWallet(key) to the service. The module stays in the standard shape (account.module.ts, thin controller, service, account.schema.ts), already wired into app.module.ts.
  • R2 — New commerce feature module (API). A new apps/api/src/commerce/ module in the standard shape (commerce.module.ts, thin commerce.controller.ts, commerce.service.ts, commerce.schema.ts), wired into app.module.ts, exposing GET /commerce/prices (served at /api/commerce/prices). The service wraps the already-wired Gw2Service.prices().
  • R3 — Client reads through the budgeted Gw2Service. All GW2 access goes through the one budgeted client (stack.md), never a direct fetch. Add to Gw2Client (and expose via Gw2Service): accountMaterials(apiKey) and accountWallet(apiKey) (authenticated, bearer, header-aware — the same authenticated-GET path 016 added), and currencies(ids) (a new static, batched, ?ids=-chunked read of /v2/currencies, cached like items/recipes with no expiry). New Zod schemas Gw2MaterialSchema, 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. getMaterials collects the storage item ids, reads them through Gw2Service.items(...) (batched, ≤199, static-cached), and joins name/icon/rarity onto each stack; getWallet does the same against Gw2Service.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 as 401; a valid key missing the required scope → upstream 403, returned as 403 with a message naming the scope (inventories for materials, wallet for wallet). Mapping switches on the upstream status; the scope name is read opportunistically from the 403 body's text, 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 a 403-specific error class distinct from 016's Gw2UnauthorizedError (which maps 401). The raw key never appears in any error message (016 R4). (Amends the approved spec per research V1, which live-refuted 016's inherited "403 for both, no body" premise — research Refuted claims #1; the missing-scope 403 is 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 /account uncached). 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 in Gw2Client or the service is a plan decision, but the key must fold a hash of the API key (mechanism confirmed — research V5).
  • R7 — Prices ids validation. The commerce/prices controller parses ids into a numeric array, rejecting with 400 when 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 in plan.md). Valid ids pass to Gw2Service.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 and verify:contract stays 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.ts gains the commerce module).

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 in research.md with 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 "403 for both" premise (research Refuted claims #1). R5 amended above accordingly.
  • V2 — /v2/account/materials shape. Confirmed (research V2, wiki): { id, category, count, binding? }, scopes account+inventories; binding ignored. Includes count: 0 slots (returned as-is per the endpoint contract).
  • V3 — /v2/account/wallet shape. Confirmed (research V3, wiki): { id, value }, scope wallet.
  • V4 — /v2/currencies shape. Confirmed (research V4, live 2026-08-16): { id, name, icon } (+ stripped description/order), ?ids=-batchable, static-cacheable, and no rarity — wallet legitimately omits it.
  • V5 — Per-user caching on the authenticated client path. Confirmed (research V5): BoundedCache already does TTL; key on sha256(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/materials with a valid inventories-scoped key returns 200 with 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/wallet with a valid wallet-scoped key returns 200 with every balance shaped { id, value, name, icon } and no rarity field. (api tests)
  • SC3 — For both authenticated reads: no header → 400; invalid key → 401; valid key missing the required scope → 403 naming the scope. (api tests; the 403 case gated on V1)
  • SC4 — GET /api/commerce/prices?ids=… returns 200 [{ 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-cap ids returns 400. (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.json contains /account/materials, /account/wallet, and /commerce/prices, and verify:contract passes (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 — category is returned as the raw GW2 material-category id; joining /v2/materials for 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 own 403 (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 the Gw2UnauthorizedError → 401 mapping exist and are the foundation these two new authenticated reads extend.
  • The global /api prefix and CORS from spec 015 stand — the three endpoints are served at /api/account/materials, /api/account/wallet, /api/commerce/prices and reached cross-origin like every other route.
  • The Zod-first contract → Orval pipeline stands (stack.md) — adding endpoints means schemas, a regenerated client, and verify:contract guarding drift; no pipeline change.
  • The GW2 client facts hold (docs/architecture/gw2-api.md) — ?ids= batching to ≤199, 200/206 both success, missing ids omitted, /v2/commerce/prices shape. /v2/currencies is assumed to follow the same batched-static pattern as items, confirmed in research.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.

CriterionTest / verification
P1 #1apps/api/src/account/account.controller.test.ts (materials → enriched array)
P1 #2apps/api/src/account/account.controller.test.ts (no header → 400, service not called)
P1 #3apps/api/src/account/account.controller.test.ts (invalid key → 401)
P1 #4apps/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 #5apps/api/src/account/account.service.test.ts (stack with unjoinable id omitted)
P2 #1apps/api/src/account/account.controller.test.ts (wallet → enriched, no rarity)
P2 #2apps/api/src/account/account.controller.test.ts (missing wallet scope → 403) — live path human-attested 2026-08-16 (see P1 #4)
P2 #3apps/api/src/account/account.controller.test.ts (no header → 400; invalid key → 401)
P2 #4apps/api/src/account/account.service.test.ts (balance with unjoinable id omitted)
P3 #1apps/api/src/commerce/commerce.controller.test.ts (tradable ids → prices)
P3 #2apps/api/src/commerce/commerce.service.test.ts (non-tradable id omitted)
P3 #3apps/api/src/commerce/commerce.controller.test.ts (bad ids → 400)
P4 #1apps/api/src/gw2/gw2-client.test.ts (second read within TTL → no new fetch)
P4 #2apps/api/src/gw2/gw2-client.test.ts (two keys → distinct cache entries)
P4 #3apps/api/src/gw2/gw2-client.test.ts (read after TTL → refetch)
SC1apps/api/src/account/account.controller.test.ts + account.service.test.ts (materials enrichment)
SC2apps/api/src/account/account.controller.test.ts + account.service.test.ts (wallet enrichment, no rarity)
SC3apps/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)
SC4apps/api/src/commerce/commerce.controller.test.ts + commerce.service.test.ts
SC5apps/api/src/account/account.service.test.ts (omit unjoinable id)
SC6apps/api/src/gw2/gw2-client.test.ts (per-user cache: hit, expiry, per-key isolation)
SC7apps/api/src/gw2/gw2-client.test.ts (cache key holds a hash, not the raw key; no key logged)
SC8pnpm verify:contract (CI) + apps/api/src/generate-openapi.test.ts (paths contains all three)
SC9tests/workflow/ repo-invariants (docs/superpowers/ count stays zero; prior suites pass)
SC10this table complete + every named test exists and passes