Tasks 016 — GW2 API key: connect & validate
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 — 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 or in any thrown error message (R4, SC3).
The task brief remains the source of requirements; this block only governs how much code answers it.
T1 — GW2 client: an authenticated account() GET (bearer, budgeted, uncached)
Satisfies: R2 (forwarding), R3, R4 (no cache / no key in errors), SC2 (client), SC3.
Extend the one budgeted client so a per-request bearer token can reach GW2 /v2/account, through the token bucket, caching nothing. The api.guildwars2.com reference stays inside gw2/ (guard F1).
[ ] RED —
apps/api/src/gw2/gw2.schemas.test.ts: add a case thatGw2AccountSchemaparses{ name: 'Account.1234', extra: 1 }to{ name: 'Account.1234' }(extras stripped). Watch it fail (schema absent).[ ] RED —
apps/api/src/gw2/gw2-client.test.ts: add cases foraccount(apiKey)with a stubfetchFnand a realTokenBucket: (a)fetchFnis called with…/accountandinit.headers.Authorization === 'Bearer test-key'; (b) a200returning{ name: 'A.1' }resolves to{ name: 'A.1' }; (c) two calls ⇒ twofetchFncalls (no caching); (d) a403response rejects withGw2UnauthorizedError, and the thrown message does not containtest-key(SC3); (e) a401also rejects withGw2UnauthorizedError. Watch them fail (method absent).[ ] GREEN —
gw2.errors.ts: addGw2UnauthorizedError extends Error(message must never carry the key).gw2.schemas.ts: addGw2AccountSchema = z.object({ name: z.string() })andtype Gw2Account = z.infer<typeof Gw2AccountSchema>.gw2-client.ts: givefetchWithRetry(url, path, init?)an optionalinitpassed tothis.fetchFn(url, init)(existing callers unchanged); addaccount(apiKey: string): Promise<Gw2Account>—await this.bucket.take(),```ts fetchWithRetry(`${this.baseUrl}/account`, 'account', { headers: { Authorization: `Bearer ${apiKey}` } }) if (res.status === 401 || res.status === 403) throw new Gw2UnauthorizedError('GW2 rejected the API key') ``` else `assertSuccessStatus(res, 'account')` + `parseOrThrow(Gw2AccountSchema, await res.json())`. No `cache.get`/`cache.set`.[ ] GREEN —
gw2.service.ts: addaccount(apiKey: string): Promise<Gw2Account>→this.client.account(apiKey); updategw2.service.test.tsto assert the passthrough.[ ] REFACTOR: only with the tests green.
[ ] Confirm teeth — drop the bearer header (assertion (a) fails); make
account()cache (assertion (c) fails); map403toGw2RequestError(assertion (d) fails). Restore.[ ] Commit (
api:).
Verified by: gw2-client.test.ts, gw2.schemas.test.ts, gw2.service.test.ts.
T2 — Account feature module: GET /api/account with status mapping
Satisfies: R1, R2 (mapping), P1 #1, P1 #3, P1 #4, SC2 (controller). Depends on T1.
- [ ] RED —
apps/api/src/account/account.service.test.ts:getAccount('k')delegates to a mockGw2Service.accountand returns its{ name }. Watch it fail (service absent). - [ ] RED —
apps/api/src/account/account.controller.test.ts, controller built with a fakeAccountService: (a)get('Bearer abc')→{ name }; (b)get(undefined)throwsBadRequestExceptionand the service is not called; (c)get('Bearer ')(blank token) →BadRequestException; (d) service throwsGw2UnauthorizedError⇒UnauthorizedException(401); (e) service throws any other error ⇒BadGatewayException(502). Watch them fail (module absent). - [ ] GREEN — add
account.schema.ts(AccountResponse = z.object({ name: z.string() }),AccountResponseDto extends createZodDto(AccountResponse));account.service.ts(@Injectable,getAccount(apiKey)→gw2.account(apiKey),Gw2Servicea value import per the DI rule);account.controller.ts(@Controller('account'),@Get()@ZodResponse({ status: 200, type: AccountResponseDto }),@Headers('authorization'); parse^Bearer (.+)$with a trimmed non-empty token elseBadRequestException;trygetAccount(key)catchmapGw2UnauthorizedError→UnauthorizedException('invalid or expired key'), elseBadGatewayException('GW2 upstream error'));account.module.ts(importsGw2Module, controller + provider). AddAccountModuletoapp.module.tsimports. - [ ] REFACTOR: only with the tests green.
- [ ] Confirm teeth — remove the blank-token guard (case (c) fails); map the unauthorized branch to 502 (case (d) fails). Restore.
- [ ] Commit (
api:).
Verified by: account.controller.test.ts, account.service.test.ts.
T3 — Contract regen + cross-origin Authorization preflight
Satisfies: R5, P1 #5, SC6, and the CORS-header risk. Depends on T2.
The endpoint must appear in openapi.json + the generated web client, and a cross-origin request carrying Authorization must survive preflight.
- [ ] RED —
apps/api/src/generate-openapi.test.ts: assert the generated document'spathscontains/account(origin-relative, no/apiprefix — spec 015 R3). Watch it fail (endpoint not generated). - [ ] RED —
apps/api/src/main.bootstrap.test.ts: add a preflight case —OPTIONS /api/accountwithOrigin: http://localhost:5173andAccess-Control-Request-Headers: authorizationreturns anaccess-control-allow-headersthat permitsauthorization. Watch it (it should reveal whether the defaultenableCorsreflects the header). - [ ] GREEN — regenerate and commit:
pnpm --filter @gw2priory/api generate:openapithenpnpm --filter @gw2priory/web generate:api; stageapps/api/openapi.jsonandapps/web/src/api/generated/**. If the preflight case fails, addallowedHeaders: ['Authorization', 'Content-Type']toapp.enableCors(...)inmain.ts(the only code change this task might need — addapps/api/src/main.tsto the diff then). - [ ] Confirm
pnpm verify:contractis green (committed contract == fresh regen). - [ ] Confirm teeth — hand-delete
/accountfromopenapi.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.
T4 — Web: key storage lib + queryKey hash
Satisfies: R7, the hash half of R8, storage half of SC4. Independent (no dependency).
- [ ] RED —
apps/web/src/shared/lib/__tests__/hashKey.test.ts:hashKey('abc')is deterministic,hashKey('abc') !== hashKey('abd'), andhashKey('abc') !== 'abc'(never the raw input). Watch it fail. - [ ] RED —
apps/web/src/features/account/__tests__/apiKeyStorage.test.ts(clearlocalStorageinbeforeEach):readApiKey()isnullinitially; afterwriteApiKey('k'),readApiKey() === 'k'andlocalStorageholds it under'gw2priory.apiKey';clearApiKey()makesreadApiKey()null. Watch it fail. - [ ] GREEN —
apps/web/src/shared/lib/hashKey.ts:hashKey(input: string): string— FNV-1a over the string, returned as 8-char hex.apps/web/src/features/account/apiKeyStorage.ts:STORAGE_KEY = 'gw2priory.apiKey';readApiKey()/writeApiKey(key)/clearApiKey(). A one-line comment onapiKeyStoragenotes the plaintext/XSS limitation (R10). - [ ] REFACTOR: only with the tests green.
- [ ] Confirm teeth — make
hashKeyreturn its input (the!== rawassertion fails). Restore. - [ ] Commit (
web:).
Verified by: hashKey.test.ts, apiKeyStorage.test.ts.
T5 — Web: useAccount facade hook
Satisfies: R8 (header + hashed queryKey). Depends on T3 (generated client) and T4 (hashKey).
[ ] RED —
apps/web/src/api/__tests__/useAccount.test.tsx(mirroruseHealth.test.tsx): renderuseAccount('test-key')under a testQueryClientProvider, stub the fetch layer to return200 { name: 'A.1' }; assert the outgoing request carriedAuthorization: Bearer test-key, the hook returns{ name: 'A.1' }, and the query'squeryKeyincludeshashKey('test-key')(and not the raw'test-key'). Watch it fail (hook absent).[ ] GREEN —
apps/web/src/api/useAccount.ts:type Account = z.infer<typeof AccountControllerGetResponse>;useAccount(apiKey: string): Account=```ts useSuspenseQuery(suspenseOptions( getAccountControllerGetQueryOptions({ request: { headers: { Authorization: `Bearer ${apiKey}` } }, query: { queryKey: ['/api/account', hashKey(apiKey)] as const } }))) ``` then `AccountControllerGetResponse.parse(query.data.data)`. Confirm the generated names against the T3 output; adjust imports if they differ (Risk: orval naming). Export `{ type Account, useAccount }` from `apps/web/src/api/index.ts`.[ ] REFACTOR: only with the test green.
[ ] Confirm teeth — drop the
requestheader (the Authorization assertion fails); put the raw key in the queryKey (the!== rawassertion fails). Restore.[ ] Commit (
web:).
Verified by: apps/web/src/api/__tests__/useAccount.test.tsx.
T6 — Web: the index page (input → conditional panel → connect/disconnect)
Satisfies: R6, R9, P1 #1/#2/#3, P2 #1/#2/#3/#4, SC1, SC4, SC5. Depends on T5.
The suspending useAccount is called only inside AccountPanel, which AccountPage mounts only when a key is present; the panel gets its own QueryBoundary + Suspense (reused from src/api) so validation never blanks the input and an invalid key shows inline (V4).
[ ] RED —
apps/web/src/features/account/__tests__/AccountPage.test.tsx(clearlocalStorageeach case; stub the fetch layer per case): (a) no stored key → the input renders, no request fires; (b) empty/whitespace submit → no request, a non-empty prompt shows (SC5); (c) submit a key with a200 { name: 'A.1' }stub → "Connected as A.1" andlocalStorageholds the key (SC1, P1 #1); (d) a key already inlocalStorageon mount → auto-validates → connected, no re-entry (P2 #1/SC4); (e) from connected, Disconnect →localStoragecleared and the input returns (P2 #2/SC4); (f) submit a key the stub answers401→ an "invalid or expired key" message with the input available (P1 #3, P2 #4); (g) submitting a second key replaces the stored one and re-validates (P2 #3). Watch them fail (components absent).[ ] RED —
apps/web/src/features/account/__tests__/routes.test.tsx: the feature'sroutestable maps/toAccountPage(mirrorhealth/__tests__/routes.test.tsx). Watch it fail.[ ] GREEN — add
ApiKeyInput.tsx(controlled field + Submit; trim; non-empty required;onSubmit(key)),AccountPanel.tsx(useAccount(apiKey); "Connected as {name}" + DisconnectonDisconnect),AccountPage.tsx(key inuseState(() => readApiKey());null→```tsx <ApiKeyInput onSubmit={k => { writeApiKey(k); setKey(k) }}/> ``` ; else ```tsx <QueryBoundary fallback={retry => …re-enter…}><Suspense fallback={…Validating…}><AccountPanel apiKey={key} onDisconnect={() => { clearApiKey(); setKey(null) }}/></Suspense></QueryBoundary> ``` ; the error fallback offers re-entry via the same clear-and-null path), `routes.tsx` (`{ path: '/', element: <AccountPage /> }`), `styles.ts` (Panda `css`, tokens not literals). Wire `routes as accountRoutes` into `main.tsx`'s router children; make the `App.tsx` brand a `NavLink` to `/`.[ ] REFACTOR: only with the tests green. Keep components branch-free of loading/error UI beyond the page's own boundary (react.md R10).
[ ] Confirm teeth — mount
AccountPanelunconditionally (case (a) fires a request → fails); share the one App-level boundary instead of the feature-local one (case (f) blanks the input → the "input available" assertion fails). Restore.[ ] Commit (
web:).
Verified by: AccountPage.test.tsx, routes.test.tsx.
T7 — Verify: suite green + local end-to-end smoke (step 5)
Satisfies: SC7, SC8, the whole-suite / typecheck gates. 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. - [ ] Local smoke: run the api and the
VITE_APP_ENV=devweb dev server; open/. The input renders; a deliberately invalid key shows "invalid or expired key" with the input still present. The live connected path needs a real GW2 key — if the human supplies one, confirm "Connected as Name", reload (auto-validates), Disconnect (input returns); otherwise record the connected path as covered byAccountPage.test.tsxand pending a keyed manual check. Record what was run, with today's date. - [ ] Fill
spec.md's traceability table cells from each task's Verified by; confirmSC8(no empty cell) andSC7(tests/workflowrepo-invariants:docs/superpowers/count stays zero) pass. - [ ]
superpowers:requesting-code-reviewthensuperpowers:receiving-code-reviewon the branch diff. - [ ] Commit any review fixes (
api:/web:/specs:as scoped).
Verified by: recorded command output; the dated smoke record; green review.
T8 — Close: status → implemented, merge (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 #18 green, merge tomain. - [ ] Worktree cleanup per
CLAUDE.md(from the main tree after merge):git worktree remove .claude/worktrees/016-account-api-key,git branch -D 016-account-api-key,git worktree prune. - [ ] Graduation (step 6): move the durable findings in
research.md's Graduation list intodocs/architecture/(GW2 auth facts →gw2-api.md; the authed-client + conditional-Suspense + hashed-queryKey patterns →nestjs.md/react.md) — as their owndocs:commit if done post-merge.
Verified by: merged PR; updated architecture docs.
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 (T3/T5): the generated
getAccountControllerGetQueryOptions/AccountControllerGetResponsenames are confirmed against the regenerated file in T3 — record here if they differ from the plan's assumption. - CORS
allowedHeaders(T3): record whether the defaultenableCorsreflectedAuthorizationor aallowedHeadersentry was needed — a fact worth graduating todeploy.md/stack.md.