Skip to content

Tasks 018 — Player holdings & material prices ​

Execution skill: superpowers:subagent-driven-development — one implementer per task, then a two-stage review (spec compliance, then code quality). superpowers:test-driven-development applies inside every task: no production code before a failing test that demands it. Reach for superpowers:systematic-debugging on any surprise rather than guessing.

Derived from plan.md (approved). Each task is small, independently verifiable, and reviewed as its own diff. Split where a reviewer could reject one task while approving its neighbour — not where the work merely changes subject. A task is done only when it satisfies the definition of done in CLAUDE.md.

Global Constraints in plan.md apply to every task and are not repeated per task. (Task steps use the project's compact RED/GREEN/REFACTOR prose — the established tasks.md shape, 015/016 — in place of writing-plans' "full code in every step"; the constitution's convention wins.)

Implementer constraints — restate in EVERY dispatch ​

Session modes don't reach subagents; the controller builds their context fresh. Every dispatch prompt must carry this block verbatim, or the implementer writes code without the ladder's constraints ([[propagate-ponytail-to-subagents]]):

You are a lazy senior developer — lazy means efficient, not careless; the best code is the code never written. Before writing code, stop at the first rung that holds: (1) does it need to exist at all (YAGNI); (2) already in this codebase — reuse the helper/util/pattern; (3) stdlib does it; (4) native platform feature covers it; (5) an already-installed dependency solves it; (6) can it be one line; (7) only then, the minimum code that works. Rules: no unrequested abstractions (no interface with one impl, no config for a value that never changes), no scaffolding "for later", deletion over addition, boring over clever, fewest files, shortest working diff — but only once you understand the problem (trace the real flow first; a small diff in the wrong place is a second bug). Never lazy about: understanding the problem, input validation at trust boundaries, error handling, security, accessibility, anything explicitly requested. Non-trivial logic leaves ONE runnable check behind (here: the task's test). Output: code first, then at most three short lines (what was skipped, when to add it); if the explanation is longer than the code, delete the explanation.

Also carry, verbatim, the security-critical rule for this feature: the raw API key must never appear in a log line, in any thrown error message, or in a cache key — a per-user cache key is sha256(key), never the key itself (R9, SC7).

The task brief remains the source of requirements; this block only governs how much code answers it.


T1 — GW2 client: new schemas, Gw2ForbiddenError, static currencies() ​

Satisfies: R3 (schemas, currencies read), part of R5 (the error class). Independent (no dependency).

Add the three raw-row schemas, the missing-scope error, and the static /v2/currencies read — all the non-authenticated groundwork the later tasks build on. api.guildwars2.com stays inside gw2/ (G4).

  • [ ] RED — apps/api/src/gw2/gw2.schemas.test.ts: add cases that Gw2MaterialSchema parses { id: 1, category: 5, count: 250, binding: 'Account', extra: 9 } → { id, category, count, binding } (extras stripped, binding optional so { id, category, count } also parses); Gw2WalletEntrySchema parses { id: 1, value: 100, extra: 1 } → { id, value }; Gw2CurrencySchema parses { id: 1, name: 'Coin', description: '…', order: 101, icon: 'u' } → { id, name, icon } (strips description/order). Watch them fail (schemas absent).
  • [ ] RED — apps/api/src/gw2/gw2-client.test.ts: add currencies([1,2]) cases with a stub fetchFn + real TokenBucket: (a) fetches …/currencies?ids=1,2 and resolves the parsed array; (b) a second call with the same ids ⇒ no new fetchFn call (static cache hit). Watch them fail (method absent).
  • [ ] GREEN — gw2.errors.ts: add Gw2ForbiddenError extends Error with readonly scope: string and message `GW2 rejected the API key: missing scope ${scope}` (never the key). gw2.schemas.ts: add Gw2MaterialSchema ({ id, category, count, binding: z.string().optional() }), Gw2WalletEntrySchema ({ id, value }), Gw2CurrencySchema ({ id, name, icon }) + type infers. gw2-client.ts: add currencies(ids: number[]) = getByIds('currencies', Gw2CurrencySchema, ids, this.staticCache, 'currency:') (no TTL — mirrors items()). Import the new schema/type alongside the existing ones.
  • [ ] GREEN — gw2.service.ts: add currencies(ids) → this.client.currencies(ids); update gw2.service.test.ts to assert the passthrough.
  • [ ] REFACTOR: only with the tests green.
  • [ ] Confirm teeth — give Gw2CurrencySchema a required order (the strip case fails); drop the currency: prefix so currencies collides with items cache keys (a cross-entity mix could pass the wrong shape — the cache-hit case still guards the round-trip). Restore.
  • [ ] Commit (api:).

Verified by: gw2.schemas.test.ts, gw2-client.test.ts, gw2.service.test.ts.


T2 — GW2 client: authenticated, per-user-cached accountMaterials / accountWallet ​

Satisfies: R3, R5 (401/403 split), R6 (5-min per-user cache), R9/SC7, SC6. Depends on T1.

The security-critical task. A per-request bearer reaches GW2 /v2/account/{materials,wallet} through the bucket; an invalid key → Gw2UnauthorizedError, a missing scope → Gw2ForbiddenError(scope); the raw rows cache per user under sha256(key) for 5 minutes. No key in any error or cache key.

  • [ ] RED — apps/api/src/gw2/gw2-client.test.ts, accountMaterials(apiKey) / accountWallet(apiKey) with a stub fetchFn + real TokenBucket: (a) fetchFn called with …/account/materials and init.headers.Authorization === 'Bearer test-key', resolves the parsed rows; (b) a 401 rejects with Gw2UnauthorizedError whose message excludes test-key; (c) a 403 body { text: 'requires scope inventories' } rejects with Gw2ForbiddenError where .scope === 'inventories' and the message excludes the key; (d) a 403 with an unparseable/absent body rejects with Gw2ForbiddenError where .scope === 'inventories' (the endpoint fallback for materials; wallet for wallet); (e) cache hit — two calls, same key ⇒ one fetchFn call; (f) per-key isolation — two calls, different keys ⇒ two fetchFn calls, and the cache key is not the raw key; (g) TTL expiry — with vi.useFakeTimers(), after vi.advanceTimersByTime(ACCOUNT_TTL_MS) a third same-key call fetches again. Watch them fail (methods absent).

  • [ ] GREEN — gw2-client.ts: add const ACCOUNT_TTL_MS = 300_000; (annotated: 5-min per-user hold, spec R6) and private readonly accountCache = new BoundedCache<unknown>({ maxEntries: CACHE_MAX_ENTRIES });. Add a pure scopeFromBody(body: unknown): string | null (matches { text: string } against /requires scope (\w+)/, else null). Add private async authedCachedRead<T>(path, schema, apiKey, prefix, fallbackScope):

    ```ts
    const key = `${prefix}:${createHash('sha256').update(apiKey).digest('hex')}`; // node:crypto
    const hit = this.accountCache.get(key); if (hit !== undefined) return hit as T[];
    await this.bucket.take();
    const res = await this.fetchWithRetry(`${this.baseUrl}${path}`, path,
      { headers: { Authorization: `Bearer ${apiKey}` } });
    if (res.status === 401) throw new Gw2UnauthorizedError('GW2 rejected the API key');
    if (res.status === 403) throw new Gw2ForbiddenError(
      scopeFromBody(await res.json().catch(() => null)) ?? fallbackScope);
    this.assertSuccessStatus(res, path);
    const parsed = parseOrThrow(z.array(schema), await res.json());
    this.accountCache.set(key, parsed, ACCOUNT_TTL_MS); return parsed;
    ```
    
    then `accountMaterials(apiKey)` = `authedCachedRead('/account/materials', Gw2MaterialSchema, apiKey, 'materials', 'inventories')`
    and `accountWallet(apiKey)` = `authedCachedRead('/account/wallet', Gw2WalletEntrySchema, apiKey, 'wallet', 'wallet')`.
    Import `createHash` from `node:crypto`.
    
  • [ ] GREEN — gw2.service.ts: add accountMaterials(apiKey) / accountWallet(apiKey) passthroughs; assert in gw2.service.test.ts.

  • [ ] REFACTOR: only with the tests green.

  • [ ] Confirm teeth — map 403 to Gw2UnauthorizedError (case (c) fails); use the raw apiKey as the cache key (case (f)'s "not the raw key" assertion fails); drop the accountCache.set (case (e) fails). Restore.

  • [ ] Commit (api:).

Verified by: gw2-client.test.ts (auth, 401/403, cache hit/expiry/isolation), gw2.service.test.ts.


T3 — Account module: GET /api/account/materials + GET /api/account/wallet (enriched) ​

Satisfies: R1, R4 (enrichment + omit), R5 (controller mapping), P1, P2, SC1, SC2, SC3, SC5. Depends on T2.

Two routes on the existing AccountController; the service enriches raw rows against items/currencies and omits any row it can't name; count:0 stacks pass through.

  • [ ] RED — apps/api/src/account/account.service.test.ts (fake Gw2Service): getMaterials('k') reads stubbed rows [{id:1,category:5,count:250},{id:2,category:5,count:0},{id:9,category:5,count:3}], joins stubbed items [{id:1,name:'Flax',icon:'u1',rarity:'Basic'},{id:2,name:'X',icon:undefined,rarity:'Fine'}] → returns [{id:1,count:250,category:5,name:'Flax',icon:'u1',rarity:'Basic'},{id:2,count:0,category:5,name:'X',icon:null,rarity:'Fine'}] — item 9 omitted (unjoinable), count:0 kept, icon:undefined→null. getWallet('k') reads stubbed [{id:1,value:100},{id:7,value:5}], joins currencies [{id:1,name:'Coin',icon:'c1'}] → [{id:1,value:100,name:'Coin',icon:'c1'}] (id 7 omitted, no rarity key). Watch them fail.
  • [ ] RED — apps/api/src/account/account.controller.test.ts (fake AccountService), for bothmaterials and wallet: (a) ('Bearer abc') → the service array; (b) (undefined) → BadRequestException, service not called; (c) ('Bearer ') → BadRequestException; (d) service throws Gw2UnauthorizedError → UnauthorizedException (401); (e) service throws Gw2ForbiddenError('inventories') → ForbiddenException (403) whose message names inventories (wallet for the wallet route); (f) any other error → BadGatewayException (502). Watch them fail.
  • [ ] GREEN — account.schema.ts: add Material/MaterialList/MaterialListDto ({ id, count, category, name, icon: z.string().nullable(), rarity }, all ids/counts .int()) and WalletEntry/WalletList/WalletListDto ({ id, value, name, icon: z.string() }). account.service.ts: add getMaterials/getWallet per plan.md Data & contracts (raw rows → gw2.items/gw2.currencies → Map join → flatMap omitting misses; icon: it.icon ?? null). account.controller.ts: extract private requireBearer(auth?: string): string (the existing ^Bearer (.+)$ trim → BadRequestException) and private mapGw2Error(e: unknown): never (Gw2UnauthorizedError→UnauthorizedException('invalid or expired key'); Gw2ForbiddenError→ForbiddenException( key missing the ${e.scope} scope ); else BadGatewayException('GW2 upstream error')); refactor the existing get() onto these helpers, then add @Get('materials') @ZodResponse({status:200,type:MaterialListDto}) and @Get('wallet') @ZodResponse({status:200,type:WalletListDto}), each try { return await this.account.<m>(this.requireBearer(auth)) } catch (e) { this.mapGw2Error(e) }. Gw2ForbiddenError is a value import (used in instanceof); ForbiddenException added to the @nestjs/common import.
  • [ ] REFACTOR: only with the tests green. Keep the two routes DRY over the shared helpers.
  • [ ] Confirm teeth — include unjoinable rows (service omit-case fails); filter count:0 (the count:0-kept assertion fails); map Gw2ForbiddenError to 502 (controller case (e) fails). Restore.
  • [ ] Commit (api:).

Verified by: account.service.test.ts, account.controller.test.ts.


T4 — Commerce module: GET /api/commerce/prices?ids=… ​

Satisfies: R2, R7, P3, SC4. Independent (gw2.prices already exists) — no dependency.

A new feature module wrapping the already-budgeted gw2.prices; a Zod query DTO bounds ids and the global pipe 400s bad input.

  • [ ] RED — apps/api/src/commerce/commerce.service.test.ts (fake Gw2Service): prices([1,2]) reads stubbed gw2.prices returning only [{id:1,whitelisted:true,buys:{quantity:8,unit_price:27},sells:{quantity:4,unit_price:28}}] (id 2 absent — non-tradable) → returns [{id:1,buys:{quantity:8,unit_price:27},sells:{quantity:4,unit_price:28}}] (id 2 omitted, whitelisted dropped). Watch it fail.

  • [ ] RED — apps/api/src/commerce/commerce.controller.test.ts: build the app with the global ZodValidationPipe (mirror legendaries.controller.test.ts) and a fake CommerceService: (a) ?ids=1,2 → 200 with the service array; (b) ids missing → 400; (c) ?ids= (empty) → 400; (d) ?ids=1,x → 400; (e) ?ids=<200 ids> → 400 (over MAX_PRICE_IDS); (f) service throws → 502. Watch them fail (module absent).

  • [ ] RED — apps/api/src/commerce/commerce.module.test.ts: Test.createTestingModule compiles the module (with Gw2Module) and resolves CommerceController → CommerceService (G7). Watch it fail.

  • [ ] GREEN — commerce.schema.ts: const MAX_PRICE_IDS = 199; (annotated: = client MAX_IDS_PER_REQUEST, one endpoint call = one GW2 batch);

    ```ts
    export const CommercePricesQuery = z.object({
      ids: z.string().transform((raw) => raw.split(',')).pipe(
        z.array(z.coerce.number().int().positive()).min(1).max(MAX_PRICE_IDS)),
    });
    export class CommercePricesQueryDto extends createZodDto(CommercePricesQuery) {}
    ```
    
    plus `Quote = z.object({ quantity: z.number().int(), unit_price: z.number().int() })`,
    `CommercePrice = z.object({ id: z.number().int(), buys: Quote, sells: Quote })`, `CommercePriceList`,
    `CommercePriceListDto`. `commerce.service.ts`: `@Injectable`, `prices(ids: number[])` →
    `(await this.gw2.prices(ids)).map(({ id, buys, sells }) => ({ id, buys, sells }))` (`Gw2Service` a
    **value** import, DI rule). `commerce.controller.ts`: `@Controller('commerce')`,
    `@Get('prices') @ZodResponse({status:200,type:CommercePriceListDto}) list(@Query() q: CommercePricesQueryDto)` →
    `try { return await this.commerce.prices(q.ids) } catch { throw new BadGatewayException('GW2 upstream error') }`.
    `CommercePricesQueryDto` is a **value** import with the `biome-ignore` + G2 method-param comment
    (per `legendaries.controller.ts`). `commerce.module.ts` imports `Gw2Module`. Add `CommerceModule` to
    `app.module.ts` imports.
    
  • [ ] REFACTOR: only with the tests green.

  • [ ] Confirm teeth — make CommercePricesQueryDto an import type (validation dies → case (d) returns 200/500 instead of 400, failing); drop .max(MAX_PRICE_IDS) (case (e) fails). Restore.

  • [ ] Commit (api:).

Verified by: commerce.service.test.ts, commerce.controller.test.ts, commerce.module.test.ts.


T5 — Contract regen + OpenAPI paths + Authorization preflight ​

Satisfies: R8, SC8, the CORS-header confirmation. Depends on T3 and T4.

  • [ ] RED — apps/api/src/generate-openapi.test.ts: assert the generated paths contains /account/materials, /account/wallet, and /commerce/prices (origin-relative, no /api prefix — 015 R3). Watch it fail (routes not yet generated).
  • [ ] RED — apps/api/src/main.bootstrap.test.ts: add preflight cases — OPTIONS /api/account/materials and OPTIONS /api/account/wallet with Origin: http://localhost:5173 and Access-Control-Request-Headers: authorization return an access-control-allow-headers permitting authorization. Watch it (should pass if 016's global enableCors reflects the header; reveals it otherwise).
  • [ ] GREEN — regenerate and commit: pnpm --filter @gw2priory/api generate:openapi then pnpm --filter @gw2priory/web generate:api; stage apps/api/openapi.json and apps/web/src/api/generated/**. If a preflight case fails, add allowedHeaders: ['Authorization', 'Content-Type'] to app.enableCors(...) in main.ts (add apps/api/src/main.ts to the diff then).
  • [ ] Confirm pnpm verify:contract is green (committed contract == fresh regen).
  • [ ] Confirm teeth — hand-delete /commerce/prices from openapi.json (the generate-openapi assertion and verify:contract both fail). Restore via regen.
  • [ ] Commit (api:).

Verified by: generate-openapi.test.ts; main.bootstrap.test.ts (preflight); pnpm verify:contract.


T6 — Verify: suite green + live missing-scope smoke (step 5) ​

Satisfies: SC7, SC9, SC10, the whole-suite / typecheck gates, and the research V1 live caveat. superpowers:verification-before-completion, then requesting-code-review + receiving-code-review.

  • [ ] Run pnpm typecheck and the full pnpm test (both apps + tests/**) — all green, no any, no skips. Record the actual command output; no success claim without it.
  • [ ] pnpm verify:contract green; pnpm docs:build green (specs compile through VitePress — [[vitepress-safe-spec-markdown]]).
  • [ ] Live missing-scope record (research V1 caveat, SC3 live row). The 401 invalid-key path is already live-verified (research V1). For the 403: if the human supplies a key lackinginventories/wallet, run ! curl -sS -H "Authorization: Bearer <scoped-key>" <api>/api/account/materials -w '%{http_code}' and confirm 403 naming the scope; otherwise record the mapping as covered by the stubbed-403 unit test (T2/T3) and pending a keyed manual check. Record what was run, with today's date. Never paste the key into the transcript.
  • [ ] Fill spec.md's traceability table cells from each task's Verified by; confirm SC10 (no empty cell) and SC9 (tests/workflow repo-invariants: docs/superpowers/ count stays zero) pass.
  • [ ] superpowers:requesting-code-review then superpowers:receiving-code-review on the branch diff.
  • [ ] Commit any review fixes (api:/specs: as scoped).

Verified by: recorded command output; the dated smoke record; green review.


T7 — Close: status → implemented, merge, graduation (step 6) ​

Satisfies: DoD. superpowers:finishing-a-development-branch.

  • [ ] The human moves spec.md status approved → implemented inside the branch (agent transcribes on instruction); this edit is part of the PR diff, before merge.
  • [ ] superpowers:finishing-a-development-branch — PR #20 green, merge to main.
  • [ ] Worktree cleanup per CLAUDE.md (from the main tree after merge): git worktree remove .claude/worktrees/018-holdings-and-prices, git branch -D 018-holdings-and-prices, git worktree prune.
  • [ ] Graduation (step 6): move the durable findings in research.md's Graduation list into docs/architecture/gw2-api.md (GW2 auth-failure statuses correcting 016; /v2/currencies, /v2/account/materials, /v2/account/wallet shapes; the per-user authed-cache pattern) — and create the docs/gaps/ record correcting 016's auth-status claim (research F1). As their own docs: commit if done post-merge.

Verified by: merged PR; updated architecture docs; the docs/gaps/ record.


Notes ​

Staging area for decisions and surprises found during implementation — including anything that turned out differently from what plan.md assumed. Move each into spec.md, research.md, or docs/ before closing the feature; this section is not a home.

  • Orval naming (T5): the generated hook/response names derive from the controller method names (materials, wallet, list). No hand-written apps/web code this slice — only the committed regen — so a naming surprise is contained to generated/**; record here if the regen names differ from expectation.
  • Missing-scope 403 live shape (T2/T6): record the actual 403 body observed against a real scope-restricted key, if one becomes available — confirms scopeFromBody's regex against reality (a fact worth graduating to gw2-api.md).
  • CORS allowedHeaders (T5): record whether 016's default enableCors already reflected Authorization for the new paths, or an allowedHeaders entry was needed.