Skip to content

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.

  1. GW2 client — static currencies read + new schemas/error (R3; research V4). In apps/api/src/gw2: add Gw2MaterialSchema, Gw2WalletEntrySchema, Gw2CurrencySchema (+ inferred types, extras stripped); add Gw2ForbiddenError (missing-scope, distinct from 016's Gw2UnauthorizedError, message carries the scope name, never the key). Add Gw2Client.currencies(ids) via the existing getByIds machinery (currency: prefix, no TTL — static game data, exactly like items()), surfaced on Gw2Service.currencies(ids).

  2. GW2 client — authenticated, per-user-cached reads (R3, R5, R6; research V1/V5, F4). Add ACCOUNT_TTL_MS = 300_000 and a per-user accountCache (BoundedCache, size-capped). Add a private authedCachedRead<T>(path, schema, apiKey, cachePrefix, fallbackScope): cache-hit short-circuit on <prefix>:<sha256(apiKey)>; else bucket.take(), header-aware fetchWithRetry with the bearer; 401 → Gw2UnauthorizedError, 403 → Gw2ForbiddenError(scope) (scope read from the body { text: "requires scope <name>" }, falling back to fallbackScope), 200/206 → parseOrThrow(z.array(schema)), then accountCache.set(key, parsed, ACCOUNT_TTL_MS). Expose accountMaterials(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).

  3. Account module — materials + wallet endpoints (R1, R4, R5; P1, P2). Extend apps/api/src/account/: account.schema.ts gains Material/MaterialListDto and WalletEntry/WalletListDto; account.service.ts gains getMaterials(apiKey) / getWallet(apiKey) that read the raw rows then enrich via gw2.items(...) / gw2.currencies(...), joining name/icon(/rarity) and omitting a row whose id the enrichment read didn't return; the thin account.controller.ts gains two @Get routes reusing a shared bearer-parse (missing/blank → 400) and a shared GW2-error map (Gw2UnauthorizedError → 401, Gw2ForbiddenError → 403 naming the scope, any other → 502). count: 0 stacks pass through unfiltered (spec, human decision).

  4. Commerce module — prices endpoint (R2, R7; P3). New apps/api/src/commerce/: commerce.schema.ts (CommercePricesQuery DTO — coerces the comma-separated ids string to a bounded number[], ≤ MAX_PRICE_IDS = 199; CommercePrice/CommercePriceListDto), commerce.service.ts (prices(ids) → gw2.prices(ids) mapped to { id, buys, sells }, whitelisted dropped), commerce.controller.ts (@Query() CommercePricesQuery → global ZodValidationPipe 400s bad ids; any GW2 failure → 502), commerce.module.ts. Wire CommerceModule into app.module.ts.

  5. Contract regen + OpenAPI + CORS assertion (R8; SC8). Regenerate openapi.json (gains the three paths) and the web client; generate-openapi.test.ts asserts all three paths; verify:contract stays green. Assert the Authorization CORS preflight on the two authenticated paths in main.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's createHash('sha256') for the cache-key hash — a Node builtin, no dependency.
  • Contract — Zod → nestjs-zod → openapi.json → Orval (react-query + zod) → src/api; verify:contract guards 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. Use unknown plus 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-error without 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, 429 on 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 the biome-ignore comment; import type silently disables DI or ZodValidationPipe. A new query route is guarded by an HTTP-level 400 test.
  • Zod-first (G1): no class-validator; shapes are Zod + createZodDto, @ZodResponse.
  • GW2 boundary (G4): api.guildwars2.com only inside src/gw2/.
  • HTTP exceptions (G5): new *Exception(...) only in *.controller.ts.
  • Module manifest (G6): every feature has a *.module.ts; app.module.ts declares only imports.
  • Colocated tests (G7): every @Injectable/@Controller file has a colocated *.test.ts.
  • Named constants for magic numbers, annotated with their source; domain errors as named Error subclasses 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.

PathChangeResponsibility
apps/api/src/gw2/gw2.errors.tsmodifiedAdd 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.tsmodifiedAdd 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.tsmodifiedAdd 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.tsmodifiedAdd currencies(ids), accountMaterials(apiKey), accountWallet(apiKey) passthroughs.
apps/api/src/account/account.schema.tsmodifiedAdd 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.tsmodifiedAdd 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.tsmodifiedAdd @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.tsunchangedAlready imports Gw2Module; no change (routes added to the existing controller).
apps/api/src/commerce/commerce.schema.tsnewCommercePricesQuery (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.tsnew@Injectable CommerceService.prices(ids: number[]) → gw2.prices(ids) mapped to { id, buys, sells } (drops whitelisted) (R2).
apps/api/src/commerce/commerce.controller.tsnew@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.tsnew@Module imports Gw2Module, controllers: [CommerceController], providers: [CommerceService] (R2, G6).
apps/api/src/app.module.tsmodifiedAdd CommerceModule to imports (G6).
apps/api/src/gw2/gw2.schemas.test.tsmodifiedGw2MaterialSchema/Gw2WalletEntrySchema/Gw2CurrencySchema parse valid rows and strip extras (binding, description, order).
apps/api/src/gw2/gw2-client.test.tsmodifiedcurrencies() 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.tsmodifiedcurrencies/accountMaterials/accountWallet passthroughs.
apps/api/src/account/account.service.test.tsmodifiedgetMaterials/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.tsmodifiedmaterials/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.tsnewprices 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.tsnewtradable 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.tsnewModule compiles; CommerceController resolves CommerceService (G7).
apps/api/src/generate-openapi.test.tsmodifiedAssert openapi.json contains /account/materials, /account/wallet, /commerce/prices (SC8).
apps/api/src/main.bootstrap.test.tsmodifiedPreflight OPTIONS on the two authed paths with Access-Control-Request-Headers: authorization → allowed (CORS confirming assertion).
apps/api/openapi.jsonmodified (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 Error with a readonly 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() }); + type infers.

  • Gw2Client.currencies(ids: number[]): Promise<Gw2Currency[]> — getByIds('currencies', Gw2CurrencySchema, ids, this.staticCache, 'currency:') (no TTL), mirroring items().

  • Gw2Client.accountMaterials(apiKey): Promise<Gw2Material[]> / accountWallet(apiKey): Promise<Gw2WalletEntry[]> —

    ts
    authedCachedRead('/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[]; then await 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); else assertSuccessStatus, 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: if body matches { text: string } and text matches /requires scope (\w+)/, return the capture, else null.

  • Account service (account.service.ts):

    ts
    async 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 }] : [];
      });
    }

    getWallet is the same shape against gw2.currencies, producing { id, value, name, icon } (currency icon is 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, BadRequestException on miss) and private mapGw2Error(e: unknown): never (Gw2UnauthorizedError→UnauthorizedException, Gw2ForbiddenError→ForbiddenException( key missing the ${e.scope} scope ), else BadGatewayException); get/materials/wallet each try { return await this.account.<m>(key) } catch (e) { this.mapGw2Error(e) }.

  • Commerce schema (commerce.schema.ts): MAX_PRICE_IDS = 199 (= MAX_IDS_PER_REQUEST);

    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)),
    });

    CommercePrice = z.object({ id: z.number().int(), buys: Quote, sells: Quote }) where Quote = 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); } (any gw2.prices failure propagates → mapped to 502 via the controller's catch).

Test strategy ​

CriterionHow it becomes a test
P1 #1, SC1account.controller.test.ts (materials → enriched array) + account.service.test.ts (join name/icon/rarity from stubbed gw2.items).
P1 #2account.controller.test.ts — no Authorization header → 400, service not called.
P1 #3account.controller.test.ts — service throws Gw2UnauthorizedError → 401.
P1 #4, SC3account.controller.test.ts — service throws Gw2ForbiddenError('inventories') → 403 naming the scope; gw2-client.test.ts — stubbed 403 {text:"requires scope inventories"} → Gw2ForbiddenError.
P1 #5, SC5account.service.test.ts — a stack whose id gw2.items omits is dropped; count:0 stack retained.
P2 #1, SC2account.controller.test.ts (wallet → enriched, no rarity key) + account.service.test.ts (join from stubbed gw2.currencies).
P2 #2account.controller.test.ts — Gw2ForbiddenError('wallet') → 403 naming wallet.
P2 #3account.controller.test.ts — no header → 400; Gw2UnauthorizedError → 401.
P2 #4account.service.test.ts — a balance whose id gw2.currencies omits is dropped.
P3 #1, SC4commerce.controller.test.ts (tradable ids → prices) + commerce.service.test.ts (map shape).
P3 #2commerce.service.test.ts — an id absent from stubbed gw2.prices is omitted (not errored).
P3 #3commerce.controller.test.ts — ids missing / "" / "1,x" / 200-count → 400 (HTTP-level via ZodValidationPipe).
P4 #1/#2/#3, SC6gw2-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).
SC7gw2-client.test.ts — Gw2ForbiddenError/Gw2UnauthorizedError messages contain no key; the cache key is sha256(key), never the raw key; account paths use no logger.
SC8verify:contract (CI) + generate-openapi.test.ts — the three paths present; client regenerated.
SC9existing tests/workflow repo-invariants — docs/superpowers/ count stays zero; prior suites pass.
SC10the 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:0 material 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-need endpoint. 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 — a CommercePricesQuery Zod DTO matches the established legendaries pattern and gets 400-on-bad-input for free from the global ZodValidationPipe (guard the G2 method-param trap with the value-import comment + an HTTP 400 test).
  • Reuse 016's Gw2UnauthorizedError for the 403. Rejected (R5) — the two must map to different HTTP statuses (401 vs 403); a distinct Gw2ForbiddenError carrying 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 mapGw2Error helper is clearer for three routes in one app; a filter earns its keep with more surface.
  • /v2/tokeninfo scope pre-check. Rejected (spec Out of scope) — scope failures are handled reactively from the read's own 403 (R5).

Risks ​

  • Missing-scope 403 not live-verified. The 401/403 split's invalid-key half is live-measured (research V1); the missing-scope 403 is documented only. Mitigation: the mapping is unit-tested with a stubbed 403 {text:"requires scope …"}, so the code path is fully covered deterministically; a live manual record during impl confirms reality (SC3 live row). scopeFromBody falls back to the endpoint's known scope if GW2's body ever differs, so the message degrades gracefully.
  • @Query() DTO as import type (G2 method-param trap). Would silently disable ZodValidationPipe for /commerce/prices with green lint/types/tests. Mitigation: value import + biome-ignore (per legendaries.controller.ts) and the HTTP-level 400 test asserting bad ids is rejected.
  • Comma-vs-repeated ids query parsing. ?ids=1,2 arrives 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 in apps/web beyond 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.
  • accountCache unbounded growth across many keys. Bounded by CACHE_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 in research.md; the human approved the R5 amendment and the raw-count:0 decision. The one residual — the live missing-scope 403 — 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.