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 thatGw2MaterialSchemaparses{ id: 1, category: 5, count: 250, binding: 'Account', extra: 9 }→{ id, category, count, binding }(extras stripped,bindingoptional so{ id, category, count }also parses);Gw2WalletEntrySchemaparses{ id: 1, value: 100, extra: 1 }→{ id, value };Gw2CurrencySchemaparses{ id: 1, name: 'Coin', description: '…', order: 101, icon: 'u' }→{ id, name, icon }(stripsdescription/order). Watch them fail (schemas absent). - [ ] RED —
apps/api/src/gw2/gw2-client.test.ts: addcurrencies([1,2])cases with a stubfetchFn+ realTokenBucket: (a) fetches…/currencies?ids=1,2and resolves the parsed array; (b) a second call with the same ids ⇒ no newfetchFncall (static cache hit). Watch them fail (method absent). - [ ] GREEN —
gw2.errors.ts: addGw2ForbiddenError extends Errorwithreadonly scope: stringand message`GW2 rejected the API key: missing scope ${scope}`(never the key).gw2.schemas.ts: addGw2MaterialSchema({ id, category, count, binding: z.string().optional() }),Gw2WalletEntrySchema({ id, value }),Gw2CurrencySchema({ id, name, icon }) +typeinfers.gw2-client.ts: addcurrencies(ids: number[])=getByIds('currencies', Gw2CurrencySchema, ids, this.staticCache, 'currency:')(no TTL — mirrorsitems()). Import the new schema/type alongside the existing ones. - [ ] GREEN —
gw2.service.ts: addcurrencies(ids)→this.client.currencies(ids); updategw2.service.test.tsto assert the passthrough. - [ ] REFACTOR: only with the tests green.
- [ ] Confirm teeth — give
Gw2CurrencySchemaa requiredorder(the strip case fails); drop thecurrency:prefix socurrenciescollides withitemscache 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 stubfetchFn+ realTokenBucket: (a)fetchFncalled with…/account/materialsandinit.headers.Authorization === 'Bearer test-key', resolves the parsed rows; (b) a401rejects withGw2UnauthorizedErrorwhose message excludestest-key; (c) a403body{ text: 'requires scope inventories' }rejects withGw2ForbiddenErrorwhere.scope === 'inventories'and the message excludes the key; (d) a403with an unparseable/absent body rejects withGw2ForbiddenErrorwhere.scope === 'inventories'(the endpoint fallback for materials;walletfor wallet); (e) cache hit — two calls, same key ⇒ onefetchFncall; (f) per-key isolation — two calls, different keys ⇒ twofetchFncalls, and the cache key is not the raw key; (g) TTL expiry — withvi.useFakeTimers(), aftervi.advanceTimersByTime(ACCOUNT_TTL_MS)a third same-key call fetches again. Watch them fail (methods absent).[ ] GREEN —
gw2-client.ts: addconst ACCOUNT_TTL_MS = 300_000;(annotated: 5-min per-user hold, spec R6) andprivate readonly accountCache = new BoundedCache<unknown>({ maxEntries: CACHE_MAX_ENTRIES });. Add a purescopeFromBody(body: unknown): string | null(matches{ text: string }against/requires scope (\w+)/, elsenull). Addprivate 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: addaccountMaterials(apiKey)/accountWallet(apiKey)passthroughs; assert ingw2.service.test.ts.[ ] REFACTOR: only with the tests green.
[ ] Confirm teeth — map
403toGw2UnauthorizedError(case (c) fails); use the rawapiKeyas the cache key (case (f)'s "not the raw key" assertion fails); drop theaccountCache.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(fakeGw2Service):getMaterials('k')reads stubbed rows[{id:1,category:5,count:250},{id:2,category:5,count:0},{id:9,category:5,count:3}], joins stubbeditems[{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'}]— item9omitted (unjoinable),count:0kept,icon:undefined→null.getWallet('k')reads stubbed[{id:1,value:100},{id:7,value:5}], joinscurrencies[{id:1,name:'Coin',icon:'c1'}]→[{id:1,value:100,name:'Coin',icon:'c1'}](id7omitted, noraritykey). Watch them fail. - [ ] RED —
apps/api/src/account/account.controller.test.ts(fakeAccountService), for bothmaterialsandwallet: (a)('Bearer abc')→ the service array; (b)(undefined)→BadRequestException, service not called; (c)('Bearer ')→BadRequestException; (d) service throwsGw2UnauthorizedError→UnauthorizedException(401); (e) service throwsGw2ForbiddenError('inventories')→ForbiddenException(403) whose message namesinventories(walletfor the wallet route); (f) any other error →BadGatewayException(502). Watch them fail. - [ ] GREEN —
account.schema.ts: addMaterial/MaterialList/MaterialListDto({ id, count, category, name, icon: z.string().nullable(), rarity }, all ids/counts.int()) andWalletEntry/WalletList/WalletListDto({ id, value, name, icon: z.string() }).account.service.ts: addgetMaterials/getWalletperplan.mdData & contracts (raw rows →gw2.items/gw2.currencies→Mapjoin →flatMapomitting misses;icon: it.icon ?? null).account.controller.ts: extractprivate requireBearer(auth?: string): string(the existing^Bearer (.+)$trim →BadRequestException) andprivate mapGw2Error(e: unknown): never(Gw2UnauthorizedError→UnauthorizedException('invalid or expired key');Gw2ForbiddenError→ForbiddenException(key missing the ${e.scope} scope); elseBadGatewayException('GW2 upstream error')); refactor the existingget()onto these helpers, then add@Get('materials') @ZodResponse({status:200,type:MaterialListDto})and@Get('wallet') @ZodResponse({status:200,type:WalletListDto}), eachtry { return await this.account.<m>(this.requireBearer(auth)) } catch (e) { this.mapGw2Error(e) }.Gw2ForbiddenErroris a value import (used ininstanceof);ForbiddenExceptionadded to the@nestjs/commonimport. - [ ] 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(thecount:0-kept assertion fails); mapGw2ForbiddenErrorto 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(fakeGw2Service):prices([1,2])reads stubbedgw2.pricesreturning only[{id:1,whitelisted:true,buys:{quantity:8,unit_price:27},sells:{quantity:4,unit_price:28}}](id2absent — non-tradable) → returns[{id:1,buys:{quantity:8,unit_price:27},sells:{quantity:4,unit_price:28}}](id2omitted,whitelisteddropped). Watch it fail.[ ] RED —
apps/api/src/commerce/commerce.controller.test.ts: build the app with the globalZodValidationPipe(mirrorlegendaries.controller.test.ts) and a fakeCommerceService: (a)?ids=1,2→200with the service array; (b)idsmissing →400; (c)?ids=(empty) →400; (d)?ids=1,x→400; (e)?ids=<200 ids>→400(overMAX_PRICE_IDS); (f) service throws →502. Watch them fail (module absent).[ ] RED —
apps/api/src/commerce/commerce.module.test.ts:Test.createTestingModulecompiles the module (withGw2Module) and resolvesCommerceController→CommerceService(G7). Watch it fail.[ ] GREEN —
commerce.schema.ts:const MAX_PRICE_IDS = 199;(annotated: = clientMAX_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
CommercePricesQueryDtoanimport type(validation dies → case (d) returns200/500instead of400, 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 generatedpathscontains/account/materials,/account/wallet, and/commerce/prices(origin-relative, no/apiprefix — 015 R3). Watch it fail (routes not yet generated). - [ ] RED —
apps/api/src/main.bootstrap.test.ts: add preflight cases —OPTIONS /api/account/materialsandOPTIONS /api/account/walletwithOrigin: http://localhost:5173andAccess-Control-Request-Headers: authorizationreturn anaccess-control-allow-headerspermittingauthorization. Watch it (should pass if 016's globalenableCorsreflects the header; reveals it otherwise). - [ ] GREEN — regenerate and commit:
pnpm --filter @gw2priory/api generate:openapithenpnpm --filter @gw2priory/web generate:api; stageapps/api/openapi.jsonandapps/web/src/api/generated/**. If a preflight case fails, addallowedHeaders: ['Authorization', 'Content-Type']toapp.enableCors(...)inmain.ts(addapps/api/src/main.tsto the diff then). - [ ] Confirm
pnpm verify:contractis green (committed contract == fresh regen). - [ ] Confirm teeth — hand-delete
/commerce/pricesfromopenapi.json(the generate-openapi assertion andverify:contractboth 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 typecheckand the fullpnpm test(both apps +tests/**) — all green, noany, no skips. Record the actual command output; no success claim without it. - [ ]
pnpm verify:contractgreen;pnpm docs:buildgreen (specs compile through VitePress — [[vitepress-safe-spec-markdown]]). - [ ] Live missing-scope record (research V1 caveat, SC3 live row). The
401invalid-key path is already live-verified (research V1). For the403: if the human supplies a key lackinginventories/wallet, run! curl -sS -H "Authorization: Bearer <scoped-key>" <api>/api/account/materials -w '%{http_code}'and confirm403naming the scope; otherwise record the mapping as covered by the stubbed-403unit 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; confirmSC10(no empty cell) andSC9(tests/workflowrepo-invariants:docs/superpowers/count stays zero) pass. - [ ]
superpowers:requesting-code-reviewthensuperpowers:receiving-code-reviewon 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.mdstatusapproved→implementedinside 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 tomain. - [ ] 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 intodocs/architecture/gw2-api.md(GW2 auth-failure statuses correcting 016;/v2/currencies,/v2/account/materials,/v2/account/walletshapes; the per-user authed-cache pattern) — and create thedocs/gaps/record correcting 016's auth-status claim (research F1). As their owndocs: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-writtenapps/webcode this slice — only the committed regen — so a naming surprise is contained togenerated/**; record here if the regen names differ from expectation. - Missing-scope
403live shape (T2/T6): record the actual403body observed against a real scope-restricted key, if one becomes available — confirmsscopeFromBody's regex against reality (a fact worth graduating togw2-api.md). - CORS
allowedHeaders(T5): record whether 016's defaultenableCorsalready reflectedAuthorizationfor the new paths, or anallowedHeadersentry was needed.