Plan 016 — GW2 API key: connect & validate
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
A player pastes their GW2 API key on the new index page (/), the app confirms it works by fetching the account and shows "Connected as Name", and remembers the key in localStorage. The key travels browser → our api → GW2 as Authorization: Bearer; the api forwards it through the one budgeted Gw2Service and stores/logs nothing. This is the foundation later account-scoped specs build on.
Approach
Six coordinated changes, each independently testable. Ordered so the contract exists before the web client that consumes it.
Extend the budgeted GW2 client for an authenticated GET (R2, R3; research V1). In
apps/api/src/gw2: addGw2AccountSchema({ name: string }, extras stripped); addGw2UnauthorizedError; makefetchWithRetry(url, path, init?)header-aware (passinitintothis.fetchFn(url, init)— existing callers pass nothing, no behaviour change); addGw2Client.account(apiKey)that takes a bucket token, fetches/accountwith the bearer header, maps GW2401/403→Gw2UnauthorizedError, otherwiseassertSuccessStatus(200) +parseOrThrow, and touches no cache (account data is per-user; the existing caches key on URL/id and would leak it across users). Surface it onGw2Service.account(apiKey)as a passthrough. No thrown message ever contains the key (SC3).Account feature module (R1, R2; F1). New
apps/api/src/account/:account.schema.ts(AccountResponseZod +AccountResponseDtoviacreateZodDto),account.service.ts(getAccount(apiKey)→gw2.account(apiKey)), a thinaccount.controller.ts(@Controller('account'),@Get()@ZodResponse({ status: 200, type: AccountResponseDto }), reads theAuthorizationheader, extracts the bearer token — missing/blank →400 BadRequestException— and mapsGw2UnauthorizedError→401 UnauthorizedException, any other GW2 failure →502 BadGatewayException), andaccount.module.tsimportingGw2Module. WireAccountModuleintoapp.module.ts. Theapi.guildwars2.comreference stays insidegw2/(guard F1) — the feature module only callsGw2Service.Regenerate the contract (R5).
openapi.jsongains/account; the web client + zod validator are regenerated (verify:contractstays green). Operation idAccountController_get→getAccountControllerGetQueryOptions/AccountControllerGetResponse(perorval.config.tsnaming).Web facade hook (R8; research V3).
apps/web/src/api/useAccount.tsmirrorsuseHealth.ts: passes the header via the generated factory'srequestoption and overrides thequeryKeyto fold a hash of the key (never the raw key), so a re-entered key re-fetches. A genericsrc/shared/lib/hashKey.tsprovides the hash (importable bysrc/apiwithout a layering inversion). ExportuseAccountfromsrc/api/index.ts. No generated-code, mutator, or orval-config change.Web account feature (R6, R7, R9; research V4). New
apps/web/src/features/account/:apiKeyStorage.ts(localStorage read/write/clear under one namespaced key),ApiKeyInput.tsx(paste field + Submit, non-empty required),AccountPanel.tsx(callsuseAccount, renders "Connected as Name" + Disconnect),AccountPage.tsx(the index page: holds the key inuseStateseeded from storage; renders the input when there's no key and mounts the suspending panel only once a key exists — a suspense query can't be disabled; the panel sits under its own feature-localQueryBoundary+Suspense, reusing thesrc/apiexports, so validating never blanks the input and an invalid key shows inline).routes.tsx({ path: '/', element: <AccountPage /> }),styles.ts. Wire the route intomain.tsx; make theApp.tsxbrand a link to/.CORS preflight for the header + docs (R11). A cross-origin request carrying
Authorizationtriggers a preflight; assert the api allows it (bootstrap test).docs/architecture/stack.md's API-key line is already updated to the client-custody model (this spec). Nodocs/superpowers/artifacts.
Architecture
apps/web (browser) apps/api
localStorage 'gw2priory.apiKey' (plaintext, R10) AccountController @Get /api/account
│ read/write/clear (apiKeyStorage.ts) ├─ parse "Bearer <key>" (missing → 400)
▼ ├─ Gw2UnauthorizedError → 401
AccountPage (useState key) └─ other GW2 failure → 502
├─ key === null → <ApiKeyInput onSubmit> │
└─ key !== null → QueryBoundary + Suspense (feature-local) ▼
└─ <AccountPanel apiKey> AccountService.getAccount(key)
│ useAccount(apiKey) │
▼ ▼
src/api/useAccount ──request: { Authorization: Gw2Service.account(key)
Bearer <key> } + queryKey [..., hashKey(key)] │
│ ▼
generated client → apiFetch Gw2Client.account(key)
│ fetch {origin}/api/account bucket.take() → fetchWithRetry(
└──────── CORS (Authorization ────── '/account', { headers: Bearer }) ,
preflight allowed) 401/403 → Gw2UnauthorizedError,
200 → parseOrThrow(Gw2AccountSchema),
NO cache → GW2 /v2/accountBoundaries: localStorage is touched only by apiKeyStorage.ts; the raw key reaches the network only via useAccount's per-request header; the queryKey carries a hash, never the key. The api's AccountController owns HTTP-status mapping; Gw2Client.account owns the GW2 call, and the api.guildwars2.com URL stays confined to gw2/ (guard F1).
Tech stack
apps/api— NestJS 11 on@nestjs/platform-fastify(existing).@Get,@Headers/request-header read,@ZodResponse,BadRequestException/UnauthorizedException/BadGatewayException,createZodDto(nestjs-zod), Zod. No new dependency.apps/web— React + Vite,@tanstack/react-query(Suspense), Orval-generated client (existing), Panda CSS (styled-system/css),react-router. No new dependency.- Contract — Zod →
nestjs-zod→openapi.json→ Orval (react-query + zod) →src/apifacade;verify:contractguards drift. No new dependency.
No new dependencies are introduced by this plan — the sole justification required by the Global Constraint below, met by "none added".
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.
moduleResolution: "node"andbaseUrlare removed in TS 7. Use"nodenext"(or"bundler"for Vite/bundler-resolved code) pluspaths: { "*": ["./*"] }in place ofbaseUrl.- TypeScript's default
libincludes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), ordocument/windowresolve everywhere, silently defeating profile isolation. A profile that needs DOM adds"DOM","DOM.Iterable"on top of that floor. experimentalDecoratorsandemitDecoratorMetadataare supported for typecheck only —tsc --noEmitaccepts decorator syntax and resolves metadata types, but--noEmitnever emitsdesign:paramtypesat runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; seestack.md's api build model.
From docs/architecture/stack.md:
- Monorepo, pnpm workspaces.
apps/api— NestJS (TypeScript).apps/web— React (TypeScript).packages/*— shared code when sharing is real, not speculative.- Postgres for persistence. In-memory cache for the MVP — no Redis until the caching story earns it.
- Vitest everywhere, both apps.
- 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 — the browser's
localStorage— and sent per request asAuthorization: Bearer; the api forwards it to GW2 and stores nothing at rest. Encryption at rest applies only if/when server-side key storage is introduced; no such storage exists today. Client-sidelocalStorageis plaintext and readable by any script on the origin (XSS) — a deliberate MVP limitation, established by spec 016 (client-custody, revisit before adding write-scoped or higher-value keys).
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 Gw2UnauthorizedError — a GW2 401/403 (rejected key). Its message must not contain the key (SC3, R4). |
apps/api/src/gw2/gw2.schemas.ts | modified | Add Gw2AccountSchema = z.object({ name: z.string() }) (extras stripped) and type Gw2Account. |
apps/api/src/gw2/gw2-client.ts | modified | fetchWithRetry(url, path, init?) passes init to this.fetchFn; new account(apiKey): bucket.take(), bearer header, 401/403 → Gw2UnauthorizedError, 200 → parseOrThrow(Gw2AccountSchema), no cache (R2/R3, V1). |
apps/api/src/gw2/gw2.service.ts | modified | Add account(apiKey: string): Promise<Gw2Account> passthrough to this.client.account. |
apps/api/src/account/account.schema.ts | new | AccountResponse = z.object({ name: z.string() }); AccountResponseDto extends createZodDto(AccountResponse) — drives runtime validation + OpenAPI (R5). |
apps/api/src/account/account.service.ts | new | @Injectable AccountService.getAccount(apiKey): Promise<{ name: string }> → gw2.account(apiKey). |
apps/api/src/account/account.controller.ts | new | @Controller('account') @Get() @ZodResponse(...); reads Authorization, extracts bearer (missing/blank → 400), maps Gw2UnauthorizedError → 401, other GW2 failure → 502 (R2, P1 #3/#4). |
apps/api/src/account/account.module.ts | new | @Module imports Gw2Module, controllers: [AccountController], providers: [AccountService] (R1). |
apps/api/src/app.module.ts | modified | Add AccountModule to imports. |
apps/api/src/account/account.controller.test.ts | new | Missing/blank header → 400; valid bearer → { name }; Gw2UnauthorizedError → 401; other GW2 error → 502 (SC2, P1 #3/#4). |
apps/api/src/account/account.service.test.ts | new | Delegates to Gw2Service.account and returns { name }. |
apps/api/src/gw2/gw2-client.test.ts | modified | account(): attaches Authorization: Bearer, calls bucket.take(), does not cache (two calls → two fetches), 401/403 → Gw2UnauthorizedError, key absent from the error message (SC2, SC3). |
apps/api/src/gw2/gw2.schemas.test.ts | modified | Gw2AccountSchema parses { name } and strips extra fields. |
apps/api/src/gw2/gw2.service.test.ts | modified | account() passthrough. |
apps/api/src/generate-openapi.test.ts | modified | Assert openapi.json contains the /account path (SC6). |
apps/api/src/main.bootstrap.test.ts | modified | Preflight OPTIONS /api/account with Access-Control-Request-Headers: authorization from an allowed origin → Access-Control-Allow-Headers permits authorization (CORS risk below). |
apps/api/openapi.json | modified (regen) | Gains GET /account. Committed; verify:contract re-diffs clean (R5, SC6). |
apps/web/src/api/generated/** | modified (regen) | Account endpoint hooks/validator generated. Committed; verify:contract clean (R5, SC6). |
apps/web/src/shared/lib/hashKey.ts | new | hashKey(input: string): string — a small non-crypto hash (FNV-1a → hex) for the queryKey, so the raw key never appears in a devtools-visible cache key (V3). |
apps/web/src/api/useAccount.ts | new | useAccount(apiKey): Account — request header + queryKey: ['/api/account', hashKey(apiKey)]; parse with AccountControllerGetResponse (R8, V3/V4). |
apps/web/src/api/index.ts | modified | Export { type Account, useAccount }. |
apps/web/src/features/account/apiKeyStorage.ts | new | readApiKey()/writeApiKey(k)/clearApiKey() over localStorage key 'gw2priory.apiKey' — the only localStorage toucher (R7). |
apps/web/src/features/account/ApiKeyInput.tsx | new | Controlled paste field + Submit; non-empty (trimmed) required; onSubmit(key) (R8, P1 #2/SC5). |
apps/web/src/features/account/AccountPanel.tsx | new | useAccount(apiKey); renders "Connected as name" + Disconnect (onDisconnect) — the suspending child (R9). |
apps/web/src/features/account/AccountPage.tsx | new | The index page: key in useState seeded from storage; input when no key, else feature-local QueryBoundary+Suspense around <AccountPanel>; error fallback offers re-entry; Disconnect clears storage + state (R6, R9, V4). |
apps/web/src/features/account/routes.tsx | new | { path: '/', element: <AccountPage /> } (R6). |
apps/web/src/features/account/styles.ts | new | Panda css styles for the page (tokens, not literals — design-system.md). |
apps/web/src/main.tsx | modified | Import routes as accountRoutes; add to the router children (R6). |
apps/web/src/App.tsx | modified | Make the brand (GW2 Priory) a NavLink to /. |
apps/web/src/shared/lib/__tests__/hashKey.test.ts | new | Deterministic; differs across keys; never equals the raw input (V3). |
apps/web/src/api/__tests__/useAccount.test.tsx | new | Sends Authorization: Bearer, hashed queryKey, returns parsed name (R8). |
apps/web/src/features/account/__tests__/apiKeyStorage.test.ts | new | read/write/clear round-trips; clear removes the key (SC4). |
apps/web/src/features/account/__tests__/AccountPage.test.tsx | new | No key → input; empty submit → no call (SC5); valid → connected (SC1); stored key auto-validates (P2 #1/SC4); Disconnect clears + returns to input (P2 #2/SC4); invalid key → error + re-enter (P1 #3); new key replaces (P2 #3). |
apps/web/src/features/account/__tests__/routes.test.tsx | new | / renders AccountPage (R6). |
docs/architecture/stack.md | modified (done) | API-key line updated to client-custody (this spec) — already applied. |
No file under any docs/superpowers/ path is created (R11, SC7).
Data & contracts
Gw2AccountSchema(gw2.schemas.ts):z.object({ name: z.string() });type Gw2Account = z.infer<…>. GW2 returns many fields; Zod strips the rest (gw2-api.md).Gw2Client.account(apiKey: string): Promise<Gw2Account>—await this.bucket.take(), thentsfetchWithRetry(`${baseUrl}/account`, 'account', { headers: { Authorization: `Bearer ${apiKey}` } });
res.status === 401 || 403→throw new Gw2UnauthorizedError('GW2 rejected the API key')(no key in the message); elseassertSuccessStatus(200) +parseOrThrow(Gw2AccountSchema, await res.json()). Nocache.get/cache.set.Gw2Service.account(apiKey: string): Promise<Gw2Account>→this.client.account(apiKey).AccountResponse(account.schema.ts):z.object({ name: z.string() });AccountResponseDto.AccountController.get(@Headers('authorization') authorization?: string): Promise<{ name: string }>— parse bearer (authorization?.match(/^Bearer (.+)$/), trimmed non-empty, elseBadRequestException);tstry { return await this.account.getAccount(key) } catch (e) { if (e instanceof Gw2UnauthorizedError) throw new UnauthorizedException('invalid or expired key'); throw new BadGatewayException('GW2 upstream error') }hashKey(input: string): string(shared/lib/hashKey.ts): FNV-1a over the string → 8-char hex. Deterministic, non-reversible-enough for a cache key, never equal to the input.useAccount(apiKey: string): Account—tsuseSuspenseQuery(suspenseOptions( getAccountControllerGetQueryOptions({ request: { headers: { Authorization: `Bearer ${apiKey}` } }, query: { queryKey: ['/api/account', hashKey(apiKey)] as const } }))); return AccountControllerGetResponse.parse(query.data.data).
type Account = z.infer<typeof AccountControllerGetResponse>.apiKeyStorage:STORAGE_KEY = 'gw2priory.apiKey';readApiKey(): string | null,writeApiKey(key: string): void,clearApiKey(): void.Wire request: generated URL builder yields
/api/account(orvalbaseUrl);apiFetchprependsAPI_ORIGIN; the wire request is{API_ORIGIN}/api/accountwith theAuthorizationheader, cross-origin under CORS.
Test strategy
| Criterion | How it becomes a test |
|---|---|
| P1 #1, SC1 | AccountPage.test.tsx (submit valid key → "Connected as") with useAccount fed a stubbed 200; account.controller.test.ts (bearer → { name }). |
| P1 #2, SC5 | AccountPage.test.tsx — empty/whitespace submit makes no request and prompts for a non-empty key. |
| P1 #3 | account.controller.test.ts (Gw2UnauthorizedError → 401) + AccountPage.test.tsx (401 → error + input available). |
| P1 #4 | account.controller.test.ts — no/blank Authorization → 400, no GW2 call. |
| P1 #5, SC6 | verify:contract (CI) + generate-openapi.test.ts — openapi.json contains /account; client regenerated. |
| P2 #1, SC4 | AccountPage.test.tsx — a key in localStorage auto-validates on mount → connected. |
| P2 #2, SC4 | AccountPage.test.tsx + apiKeyStorage.test.ts — Disconnect clears localStorage, returns to input. |
| P2 #3 | AccountPage.test.tsx — submitting a new key replaces the stored one (and re-fetches via the hashed queryKey). |
| P2 #4 | AccountPage.test.tsx — a stored key the api rejects renders the error state, not connected. |
| SC2 | account.controller.test.ts (200/400/401) + gw2-client.test.ts (account() bearer, no cache, 401/403 → Gw2UnauthorizedError). |
| SC3 | gw2-client.test.ts — the Gw2UnauthorizedError message does not contain the key; the account path uses no logger. |
| SC7 | existing tests/workflow repo-invariants — docs/superpowers/ count stays zero; prior suites pass. |
| SC8 | the traceability table in spec.md — every row maps to one of the tests above; no empty cell. |
| CORS (risk) | main.bootstrap.test.ts — preflight for /api/account allows the Authorization request header. |
Alternatives considered
- Return the key's granted scopes (
/v2/tokeninfo). Out of scope (spec) — this slice only proves the key works;accountscope is mandatory on every key (research V2), so a second call buys nothing here. - Pass the key as
?access_token=. Rejected — a credential in a URL leaks into logs/caches; the bearer header is the documented, safer scheme (research V2). - Thread an optional
headersparam throughgetByIds/all callers. Rejected (V1) —getByIdsis built around?ids=batching + caching, none of which apply to a single authed GET; a narrowaccountpath is the smaller, safer diff. - Put the raw key in the queryKey. Rejected (V3) — query keys are visible in devtools; a hash gives the same re-fetch-on-change without exposing the secret.
- One global
Suspense/QueryBoundaryfor the whole page. Rejected (V4) — validating would blank the input the user just typed; the feature-local boundary keeps it visible and errors inline. - An exception filter for the GW2→HTTP mapping. Deferred — one endpoint's mapping is clearer as a controller
try/catch; a filter earns its keep when a second authed endpoint arrives.
Risks
- CORS preflight for
Authorization. A cross-origin request with a customAuthorizationheader triggers a preflight; the api must answerAccess-Control-Allow-Headers: authorization.main.tscallsapp.enableCors({ origin: [...CORS_ALLOWLIST] })with no explicitallowedHeaders—@fastify/corsreflects the requested headers by default, so this likely already works. Mitigation: assert it inmain.bootstrap.test.ts; if the default doesn't reflect, addallowedHeaders: ['Authorization', 'Content-Type']toenableCors(a one-linemain.tschange, added to File Structure then). - Orval naming. The exact generated names (
getAccountControllerGetQueryOptions,AccountControllerGetResponse) depend on the controller method name (get) → operation idAccountController_get. Confirmed againsthealth(getHealth→HealthControllerGetHealth); re-confirm against the regenerated file in the contract task and adjustuseAccount.tsimports if needed. - Uncommitted regen fails
verify:contract. Mitigated by making regenerate-+-commit an explicit step in the contract task; CI re-diffs. useSuspenseQuery+ conditional render. The panel must be mounted only when a key exists (V4); a stray top-leveluseAccountcall with an empty key would fetch with a bad bearer. Mitigation: the hook is called only insideAccountPanel, whichAccountPagerenders only in the key-present branch — asserted byAccountPage.test.tsx.localStoragein tests. jsdom provideslocalStorage;apiKeyStorage.test.tsandAccountPage.test.tsxclear it between cases to avoid cross-test bleed.
Open questions
- None blocking. All four spec
[NEEDS VERIFICATION]markers have verdicts inresearch.md; the one refuted premise (403 vs 401) is folded into the spec and this plan. The CORS-header behaviour is the one residual, closed by an assertion inmain.bootstrap.test.ts(a test, not an open unknown).