Plan 018 — Player holdings & material prices
Status: approved Written in plan mode from spec.md and research.md. Approved by the human before any code is written. Status is set by the human, never by the agent: proposed → approved.
Produced alone — tasks.md stays untouched until this plan is approved in turn.
Goal
Three thin, display-enriched reads that later personalize the legendary planner: GET /api/account/materials and GET /api/account/wallet (authenticated, Bearer key) return the player's material storage and wallet — each row joined to /v2/items (name/icon/rarity) or /v2/currencies (name/icon) — and GET /api/commerce/prices?ids=… (public) returns Trading Post buy/sell in copper. All GW2 access flows through the one budgeted Gw2Service; keys are never logged or persisted, and the two authenticated reads cache per player for 5 minutes, keyed by a hash of the key.
Approach
Five coordinated changes, each independently testable, ordered so the client boundary exists before the feature modules that consume it and the contract exists before it is asserted.
GW2 client — static currencies read + new schemas/error (R3; research V4). In
apps/api/src/gw2: addGw2MaterialSchema,Gw2WalletEntrySchema,Gw2CurrencySchema(+ inferred types, extras stripped); addGw2ForbiddenError(missing-scope, distinct from 016'sGw2UnauthorizedError, message carries the scope name, never the key). AddGw2Client.currencies(ids)via the existinggetByIdsmachinery (currency:prefix, no TTL — static game data, exactly likeitems()), surfaced onGw2Service.currencies(ids).GW2 client — authenticated, per-user-cached reads (R3, R5, R6; research V1/V5, F4). Add
ACCOUNT_TTL_MS = 300_000and a per-useraccountCache(BoundedCache, size-capped). Add a privateauthedCachedRead<T>(path, schema, apiKey, cachePrefix, fallbackScope): cache-hit short-circuit on<prefix>:<sha256(apiKey)>; elsebucket.take(), header-awarefetchWithRetrywith the bearer;401→Gw2UnauthorizedError,403→Gw2ForbiddenError(scope)(scope read from the body{ text: "requires scope <name>" }, falling back tofallbackScope),200/206→parseOrThrow(z.array(schema)), thenaccountCache.set(key, parsed, ACCOUNT_TTL_MS). ExposeaccountMaterials(apiKey)/accountWallet(apiKey)on client +Gw2Service. The cache stores the raw GW2 rows (F4) — enrichment happens on egress from the static caches. No thrown message and no cache key contains the raw key (SC7).Account module — materials + wallet endpoints (R1, R4, R5; P1, P2). Extend
apps/api/src/account/:account.schema.tsgainsMaterial/MaterialListDtoandWalletEntry/WalletListDto;account.service.tsgainsgetMaterials(apiKey)/getWallet(apiKey)that read the raw rows then enrich viagw2.items(...)/gw2.currencies(...), joining name/icon(/rarity) and omitting a row whose id the enrichment read didn't return; the thinaccount.controller.tsgains two@Getroutes reusing a shared bearer-parse (missing/blank →400) and a shared GW2-error map (Gw2UnauthorizedError→401,Gw2ForbiddenError→403naming the scope, any other →502).count: 0stacks pass through unfiltered (spec, human decision).Commerce module — prices endpoint (R2, R7; P3). New
apps/api/src/commerce/:commerce.schema.ts(CommercePricesQueryDTO — coerces the comma-separatedidsstring to a boundednumber[],≤ MAX_PRICE_IDS = 199;CommercePrice/CommercePriceListDto),commerce.service.ts(prices(ids)→gw2.prices(ids)mapped to{ id, buys, sells },whitelisteddropped),commerce.controller.ts(@Query() CommercePricesQuery→ globalZodValidationPipe400s badids; any GW2 failure →502),commerce.module.ts. WireCommerceModuleintoapp.module.ts.Contract regen + OpenAPI + CORS assertion (R8; SC8). Regenerate
openapi.json(gains the three paths) and the web client;generate-openapi.test.tsasserts all three paths;verify:contractstays green. Assert theAuthorizationCORS preflight on the two authenticated paths inmain.bootstrap.test.ts(016 established it for/account; CORS is origin/header-scoped, not path-scoped, so this is a confirming assertion).
Architecture
apps/api
GET /api/account/materials ─┐ GET /api/commerce/prices?ids=…
GET /api/account/wallet ────┤ │ @Query() CommercePricesQuery
AccountController │ │ (ZodValidationPipe → 400 bad ids)
├─ parse "Bearer <key>" │ (missing → 400) ▼
├─ Gw2UnauthorizedError → 401 CommerceController
├─ Gw2ForbiddenError → 403 (names scope) └─ other GW2 failure → 502
└─ other GW2 failure → 502 │
│ ▼
▼ CommerceService.prices(ids)
AccountService.getMaterials/getWallet(key) │ gw2.prices(ids) → {id,buys,sells}
│ raw rows enrich (name/icon/rarity) ▼
├─ gw2.accountMaterials(key) ─┐ gw2.items(ids) Gw2Service.prices (60s TTL cache)
└─ gw2.accountWallet(key) ────┤ gw2.currencies(ids)
│ │ │ static caches (no expiry)
▼ ▼ ▼
Gw2Client.accountMaterials/accountWallet(key) Gw2Client.currencies(ids) → GW2 /v2/currencies
accountCache['materials:'+sha256(key)] (5-min TTL) (getByIds, currency: prefix)
bucket.take() → fetchWithRetry('/account/…', { Bearer })
401 → Gw2UnauthorizedError
403 → Gw2ForbiddenError(scope from body.text ?? fallback)
200/206 → parseOrThrow(array) → cache raw rows → GW2 /v2/account/{materials,wallet}Boundaries: the raw key reaches the network only inside Gw2Client's bearer header and appears in a cache key only as sha256(key); api.guildwars2.com stays confined to gw2/ (guard G4); the controllers own HTTP-status mapping (guard G5), the services own enrichment, and the client owns the GW2 calls, caching, and rate budget.
Tech stack
apps/api— NestJS 11 on@nestjs/platform-fastify(existing).@Get,@Headers,@Query,@ZodResponse,BadRequestException/UnauthorizedException/ForbiddenException/BadGatewayException,createZodDto(nestjs-zod), Zod.node:crypto'screateHash('sha256')for the cache-key hash — a Node builtin, no dependency.- Contract — Zod →
nestjs-zod→openapi.json→ Orval (react-query + zod) →src/api;verify:contractguards drift.
No new dependencies are introduced by this plan — the sole justification required by the Global Constraint below, met by "none added" (node:crypto is a Node builtin).
Global Constraints
Copied verbatim from the architecture docs. Every task inherits these; do not summarise or reword them.
From docs/architecture/typescript.md:
- No
any. Not in app code, not in tests. Useunknownplus narrowing, or model the type properly. If a third-party type forces it, isolate it behind one typed adapter and comment why. - No non-null assertions (
!) to silence the compiler. - No
@ts-expect-errorwithout a comment explaining what is expected and when it can be removed. - Validate everything crossing a boundary (GW2 API responses, HTTP input) at runtime, not just at the type level.
- Prefer pure functions for domain logic. The optimizer must be testable without a network or a database.
- Match the style of surrounding code. No new dependency without justification in the spec or plan.
From docs/architecture/stack.md:
- Monorepo, pnpm workspaces.
apps/api— NestJS.apps/web— React.packages/*— shared code when sharing is real, not speculative. - In-memory cache for the MVP — no Redis until the caching story earns it.
- Static data (items, station recipes) is immutable: cache hard. Prices are volatile: short TTL, recomputed live.
- GW2 API rate limit: per-IP token bucket, 300 burst, refill 5/sec,
429on overflow. Batch up to 200 ids per?ids=call. - All GW2 API access goes through the client that budgets this. Never call the GW2 API directly from a service.
- API keys are user secrets: never logged, never persisted server-side, never returned to the client. In the MVP the key is held client-side and sent per request as
Authorization: Bearer; the api forwards it to GW2 and stores nothing at rest.
From docs/architecture/nestjs.md (code shape — enforced by the conventions/ arch tests):
- Thin controllers: HTTP-shaped work only; enrichment/logic in the service or a pure helper.
- DI value-import rule (G2): any constructor-injected class — and any
@Query()/@Body()DTO parameter — is a value import with thebiome-ignorecomment;import typesilently disables DI orZodValidationPipe. A new query route is guarded by an HTTP-level400test. - Zod-first (G1): no
class-validator; shapes are Zod +createZodDto,@ZodResponse. - GW2 boundary (G4):
api.guildwars2.comonly insidesrc/gw2/. - HTTP exceptions (G5):
new *Exception(...)only in*.controller.ts. - Module manifest (G6): every feature has a
*.module.ts;app.module.tsdeclares onlyimports. - Colocated tests (G7): every
@Injectable/@Controllerfile has a colocated*.test.ts. - Named constants for magic numbers, annotated with their source; domain errors as named
Errorsubclasses in*.errors.ts.
From CLAUDE.md: typecheck clean, tests pass, every acceptance scenario and success criterion covered by a test whose name traces to it, no unexplained escape hatches, the human reviews the diff.
File Structure
Exact paths, and what each file is responsible for. A path here is a commitment; a task that touches a file not listed is a signal the plan missed something.
| Path | Change | Responsibility |
|---|---|---|
apps/api/src/gw2/gw2.errors.ts | modified | Add Gw2ForbiddenError(scope: string) — a GW2 403 (valid key, missing scope). Message names the scope, never the key (SC7, R5). |
apps/api/src/gw2/gw2.schemas.ts | modified | Add Gw2MaterialSchema { id, category, count, binding? }, Gw2WalletEntrySchema { id, value }, Gw2CurrencySchema { id, name, icon } (+ type exports); extras stripped (research V2/V3/V4). |
apps/api/src/gw2/gw2-client.ts | modified | Add ACCOUNT_TTL_MS = 300_000, accountCache; currencies(ids) (static, getByIds, currency: prefix); authedCachedRead<T>(...); accountMaterials(apiKey) / accountWallet(apiKey) (bearer, 401→Unauthorized, 403→Gw2ForbiddenError(scope), per-user cache; R3/R5/R6, V1/V5/F4). |
apps/api/src/gw2/gw2.service.ts | modified | Add currencies(ids), accountMaterials(apiKey), accountWallet(apiKey) passthroughs. |
apps/api/src/account/account.schema.ts | modified | Add Material/MaterialListDto ({ id, count, category, name, icon: string | null, rarity }) and WalletEntry/WalletListDto ({ id, value, name, icon }) (R4, drives OpenAPI). |
apps/api/src/account/account.service.ts | modified | Add getMaterials(apiKey) / getWallet(apiKey): raw rows → enrich via gw2.items / gw2.currencies, join name/icon(/rarity), omit unjoinable rows, count:0 kept (R4). |
apps/api/src/account/account.controller.ts | modified | Add @Get('materials') / @Get('wallet'); extract a shared requireBearer(auth) (missing/blank → 400) and mapGw2Error(e) (Gw2UnauthorizedError→401, Gw2ForbiddenError→403 naming scope, else 502); reuse for all three routes (R5, P1/P2). |
apps/api/src/account/account.module.ts | unchanged | Already imports Gw2Module; no change (routes added to the existing controller). |
apps/api/src/commerce/commerce.schema.ts | new | CommercePricesQuery (ids string → bounded number[], ≤ MAX_PRICE_IDS = 199) + CommercePricesQueryDto; CommercePrice/CommercePriceListDto ({ id, buys{quantity,unit_price}, sells{...} }) (R7, R2). |
apps/api/src/commerce/commerce.service.ts | new | @Injectable CommerceService.prices(ids: number[]) → gw2.prices(ids) mapped to { id, buys, sells } (drops whitelisted) (R2). |
apps/api/src/commerce/commerce.controller.ts | new | @Controller('commerce') @Get('prices') @ZodResponse(...); @Query() CommercePricesQueryDto (value import + biome-ignore, G2); other GW2 failure → 502 (R2, R7, P3). |
apps/api/src/commerce/commerce.module.ts | new | @Module imports Gw2Module, controllers: [CommerceController], providers: [CommerceService] (R2, G6). |
apps/api/src/app.module.ts | modified | Add CommerceModule to imports (G6). |
apps/api/src/gw2/gw2.schemas.test.ts | modified | Gw2MaterialSchema/Gw2WalletEntrySchema/Gw2CurrencySchema parse valid rows and strip extras (binding, description, order). |
apps/api/src/gw2/gw2-client.test.ts | modified | currencies() batches/caches static; accountMaterials/accountWallet: bearer attached; 401→Gw2UnauthorizedError, 403→Gw2ForbiddenError with scope from body (& fallback); cache hit (2 calls→1 fetch), TTL expiry (fake timers), per-key isolation (2 keys→2 fetches); key absent from error message and cache key (SC6, SC7). |
apps/api/src/gw2/gw2.service.test.ts | modified | currencies/accountMaterials/accountWallet passthroughs. |
apps/api/src/account/account.service.test.ts | modified | getMaterials/getWallet enrich and omit a row whose id items/currencies didn't return; count:0 retained (SC1, SC2, SC5). |
apps/api/src/account/account.controller.test.ts | modified | materials/wallet: valid key → enriched array; no header → 400; Gw2UnauthorizedError→401; Gw2ForbiddenError→403 naming scope; other → 502 (SC1, SC2, SC3). |
apps/api/src/commerce/commerce.service.test.ts | new | prices maps to {id,buys,sells}; a non-tradable id (absent from gw2.prices) is omitted (SC4, P3 #2). |
apps/api/src/commerce/commerce.controller.test.ts | new | tradable ids → prices; missing/empty/non-numeric/over-cap ids → 400 (HTTP-level, G2 guard); GW2 failure → 502 (SC4, P3). |
apps/api/src/commerce/commerce.module.test.ts | new | Module compiles; CommerceController resolves CommerceService (G7). |
apps/api/src/generate-openapi.test.ts | modified | Assert openapi.json contains /account/materials, /account/wallet, /commerce/prices (SC8). |
apps/api/src/main.bootstrap.test.ts | modified | Preflight OPTIONS on the two authed paths with Access-Control-Request-Headers: authorization → allowed (CORS confirming assertion). |
apps/api/openapi.json | modified (regen) | Gains the three paths. Committed; verify:contract re-diffs clean (R8, SC8). |
apps/web/src/api/generated/** | modified (regen) | Endpoint hooks/validators generated + committed; verify:contract clean (R8, SC8). |
No file under any docs/superpowers/ path is created (R10, SC9). No apps/web feature code — this slice is API + contract only (spec Out of scope); the regenerated generated/** is the only web change.
Data & contracts
Gw2ForbiddenError(gw2.errors.ts):class Gw2ForbiddenError extends Errorwith areadonly scope: string; message`GW2 rejected the API key: missing scope ${scope}`— no key.Client schemas (
gw2.schemas.ts):Gw2MaterialSchema = z.object({ id: z.number(), category: z.number(), count: z.number(), binding: z.string().optional() });Gw2WalletEntrySchema = z.object({ id: z.number(), value: z.number() });Gw2CurrencySchema = z.object({ id: z.number(), name: z.string(), icon: z.string() });+ typeinfers.Gw2Client.currencies(ids: number[]): Promise<Gw2Currency[]>—getByIds('currencies', Gw2CurrencySchema, ids, this.staticCache, 'currency:')(no TTL), mirroringitems().Gw2Client.accountMaterials(apiKey): Promise<Gw2Material[]>/accountWallet(apiKey): Promise<Gw2WalletEntry[]>—tsauthedCachedRead('/account/materials', Gw2MaterialSchema, apiKey, 'materials', 'inventories') authedCachedRead('/account/wallet', Gw2WalletEntrySchema, apiKey, 'wallet', 'wallet')authedCachedRead<T extends { id: number } | object>(path, schema, apiKey, prefix, fallbackScope)—const key =${prefix}:${createHash('sha256').update(apiKey).digest('hex')}; const hit = this.accountCache.get(key); if (hit) return hit as T[];thenawait this.bucket.take(),fetchWithRetry(${baseUrl}${path}, path, { headers: { Authorization:Bearer ${apiKey}} });res.status === 401 → throw new Gw2UnauthorizedError('GW2 rejected the API key');res.status === 403 → throw new Gw2ForbiddenError(scopeFromBody(await res.json().catch(()=>null)) ?? fallbackScope); elseassertSuccessStatus,const parsed = parseOrThrow(z.array(schema), await res.json()),this.accountCache.set(key, parsed, ACCOUNT_TTL_MS),return parsed.scopeFromBody(body: unknown): string | null— pure helper: ifbodymatches{ text: string }andtextmatches/requires scope (\w+)/, return the capture, elsenull.Account service (
account.service.ts):tsasync getMaterials(apiKey: string): Promise<Material[]> { const rows = await this.gw2.accountMaterials(apiKey); const items = await this.gw2.items(rows.map((r) => r.id)); const byId = new Map(items.map((i) => [i.id, i])); return rows.flatMap((r) => { const it = byId.get(r.id); return it ? [{ id: r.id, count: r.count, category: r.category, name: it.name, icon: it.icon ?? null, rarity: it.rarity }] : []; }); }getWalletis the same shape againstgw2.currencies, producing{ id, value, name, icon }(currencyiconis always present — research V4).Account response DTOs (
account.schema.ts):Material = z.object({ id: z.number().int(), count: z.number().int(), category: z.number().int(), name: z.string(), icon: z.string().nullable(), rarity: z.string() }),MaterialList = z.array(Material),MaterialListDto;WalletEntry = z.object({ id: z.number().int(), value: z.number().int(), name: z.string(), icon: z.string() }),WalletList,WalletListDto.Account controller — a shared
private requireBearer(authorization?: string): string(the existing/^Bearer (.+)$/trim,BadRequestExceptionon miss) andprivate mapGw2Error(e: unknown): never(Gw2UnauthorizedError→UnauthorizedException,Gw2ForbiddenError→ForbiddenException(key missing the ${e.scope} scope), elseBadGatewayException);get/materials/walleteachtry { return await this.account.<m>(key) } catch (e) { this.mapGw2Error(e) }.Commerce schema (
commerce.schema.ts):MAX_PRICE_IDS = 199(=MAX_IDS_PER_REQUEST);tsexport 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)), });CommercePrice = z.object({ id: z.number().int(), buys: Quote, sells: Quote })whereQuote = z.object({ quantity: z.number().int(), unit_price: z.number().int() });CommercePriceListDto.Commerce service —
prices(ids: number[]): Promise<CommercePrice[]>→(await this.gw2.prices(ids)).map((p) => ({ id: p.id, buys: p.buys, sells: p.sells })).Commerce controller —
@Get('prices') @ZodResponse({ status: 200, type: CommercePriceListDto }) list(@Query() q: CommercePricesQueryDto): Promise<CommercePrice[]> { return this.commerce.prices(q.ids); }(anygw2.pricesfailure propagates → mapped to502via the controller's catch).
Test strategy
| Criterion | How it becomes a test |
|---|---|
| P1 #1, SC1 | account.controller.test.ts (materials → enriched array) + account.service.test.ts (join name/icon/rarity from stubbed gw2.items). |
| P1 #2 | account.controller.test.ts — no Authorization header → 400, service not called. |
| P1 #3 | account.controller.test.ts — service throws Gw2UnauthorizedError → 401. |
| P1 #4, SC3 | account.controller.test.ts — service throws Gw2ForbiddenError('inventories') → 403 naming the scope; gw2-client.test.ts — stubbed 403 {text:"requires scope inventories"} → Gw2ForbiddenError. |
| P1 #5, SC5 | account.service.test.ts — a stack whose id gw2.items omits is dropped; count:0 stack retained. |
| P2 #1, SC2 | account.controller.test.ts (wallet → enriched, no rarity key) + account.service.test.ts (join from stubbed gw2.currencies). |
| P2 #2 | account.controller.test.ts — Gw2ForbiddenError('wallet') → 403 naming wallet. |
| P2 #3 | account.controller.test.ts — no header → 400; Gw2UnauthorizedError → 401. |
| P2 #4 | account.service.test.ts — a balance whose id gw2.currencies omits is dropped. |
| P3 #1, SC4 | commerce.controller.test.ts (tradable ids → prices) + commerce.service.test.ts (map shape). |
| P3 #2 | commerce.service.test.ts — an id absent from stubbed gw2.prices is omitted (not errored). |
| P3 #3 | commerce.controller.test.ts — ids missing / "" / "1,x" / 200-count → 400 (HTTP-level via ZodValidationPipe). |
| P4 #1/#2/#3, SC6 | gw2-client.test.ts — 2 calls same key → 1 fetch (hit); after ACCOUNT_TTL_MS (fake timers) → refetch; 2 different keys → 2 fetches (per-key isolation). |
| SC7 | gw2-client.test.ts — Gw2ForbiddenError/Gw2UnauthorizedError messages contain no key; the cache key is sha256(key), never the raw key; account paths use no logger. |
| SC8 | verify:contract (CI) + generate-openapi.test.ts — the three paths present; client regenerated. |
| SC9 | existing tests/workflow repo-invariants — docs/superpowers/ count stays zero; prior suites pass. |
| SC10 | the traceability table in spec.md — every row maps to a test above (or the SC3 live manual record); no empty cell. |
| CORS (risk) | main.bootstrap.test.ts — preflight on /api/account/materials + /api/account/wallet allows authorization. |
| SC3 (live) | manual record during impl — a real key lacking inventories/wallet → live 403; the automated mapping is covered by the stubbed-403 unit test above (research V1 caveat). |
Alternatives considered
- Filter
count:0material stacks in the service. Rejected by the human (spec amendment) — the endpoint is a thin read; the consumer decides. Recorded as research F2 / spec Out-of-scope. - Planner-shaped
have-vs-needendpoint. Deferred (spec Out of scope) — this slice returns raw enriched holdings; reconciliation is the later composing feature. - Manual
@Query('ids')string parse in the controller. Rejected — aCommercePricesQueryZod DTO matches the establishedlegendariespattern and gets400-on-bad-input for free from the globalZodValidationPipe(guard the G2 method-param trap with the value-import comment + an HTTP400test). - Reuse 016's
Gw2UnauthorizedErrorfor the403. Rejected (R5) — the two must map to different HTTP statuses (401vs403); a distinctGw2ForbiddenErrorcarrying the scope is the minimal split. - Cache the enriched result per user. Rejected (research F4) — caching the raw rows and enriching from the static caches keeps display data from going stale inside a per-user entry.
- A shared exception filter for GW2→HTTP mapping. Deferred — a controller
mapGw2Errorhelper is clearer for three routes in one app; a filter earns its keep with more surface. /v2/tokeninfoscope pre-check. Rejected (spec Out of scope) — scope failures are handled reactively from the read's own403(R5).
Risks
- Missing-scope
403not live-verified. The401/403split's invalid-key half is live-measured (research V1); the missing-scope403is documented only. Mitigation: the mapping is unit-tested with a stubbed403 {text:"requires scope …"}, so the code path is fully covered deterministically; a live manual record during impl confirms reality (SC3 live row).scopeFromBodyfalls back to the endpoint's known scope if GW2's body ever differs, so the message degrades gracefully. @Query()DTO asimport type(G2 method-param trap). Would silently disableZodValidationPipefor/commerce/priceswith green lint/types/tests. Mitigation: value import +biome-ignore(perlegendaries.controller.ts) and the HTTP-level400test asserting badidsis rejected.- Comma-vs-repeated
idsquery parsing.?ids=1,2arrives as the string"1,2"; the schema splits on comma. A client sending?ids=1&ids=2(array) would bypass the.split— out of scope; the documented contract is comma-separated, asserted by the controller test using that form. - Orval naming for the new routes. Generated hook/response names derive from the controller method names (
materials,wallet,list) → operation ids. Confirm against the regenerated files in Task 5 and adjust nothing inapps/webbeyond the committed regen (no hand-written web code this slice). - Uncommitted regen fails
verify:contract. Mitigated by making regenerate-+-commit an explicit step in Task 5; CI re-diffs. accountCacheunbounded growth across many keys. Bounded byCACHE_MAX_ENTRIES(LRU-ish eviction, oldest-inserted) like the other caches; 5-min TTL also drops stale entries lazily.
Open questions
- None blocking. All five spec
[NEEDS VERIFICATION]markers have Confirmed verdicts inresearch.md; the human approved the R5 amendment and the raw-count:0decision. The one residual — the live missing-scope403— is closed by a dated manual record during implementation (a test/record, not an open unknown), with deterministic unit coverage of the mapping in the meantime.