Spec 016 — GW2 API key: connect & validate
Status: implemented Branch: 016-account-api-key
Status is set by the human, never by the agent. It moves draft → approved → implemented.
Problem
The site has no notion of whose account it is. Every endpoint so far serves public, static data (legendaries, recipes, prices); there is no way for a player to identify their own account, and the front page (/) renders nothing — the router only knows /legendaries and /health. Without an authenticated identity the whole class of account-scoped features (materials on hand, wallet, storage, …) is closed off. This spec opens the door and nothing more: a player pastes their GW2 API key on the index page, the app confirms the key works by fetching their account, and remembers the key for next time. It is the foundation every later account-scoped spec builds on.
HTTP endpoint (contract-first)
One new endpoint, served under the existing global /api prefix (spec 015 R1) and contributing to apps/api/openapi.json via a Zod-first schema, so the generated web client and verify:contract cover it like every other route.
GET /api/account- Request: header
Authorization: Bearer <api-key>(required). The key is the player's GW2 API key; the API forwards it to GW2 and does not read it from a query string or body (a credential in a URL leaks into logs and caches). - 200:
{ "name": string }— the GW2 account display name (e.g."Account.1234"). Onlynameis surfaced this slice; GW2's other account fields are stripped at the Zod boundary. - 400: the
Authorizationheader is missing or not a non-empty bearer token (the request never reaches GW2). - 401: the key was rejected (invalid or expired). GW2 signals this upstream as
403— it uses one status for both invalid-key and missing-scope and returns no error body — which our endpoint maps to a browser-facing401(the correct semantics for our API: the caller's credential, the key, failed to authenticate). Mapping switches on status, not body. The front end renders "invalid or expired key" and re-shows the input. - 502 (or the project's upstream-failure convention): GW2 was unreachable or returned an unexpected status — mapped through the existing error conventions, distinct from the 403→401 path.
- Request: header
The GW2 contract is confirmed in research.md (V2): Authorization: Bearer auth, name as the display-name string, the account scope (mandatory on every key), and 403-not-401 for a bad key — the last folded in via the amendment recorded in research.md's Refuted claims.
User stories
Ordered by priority. Each story must be independently testable and shippable — if only P1 ships, there is still something usable.
P1 — Connect with an API key and see it validated
As a player, I want to paste my GW2 API key on the index page and have the app confirm it works by showing my account name, so that I know the key is valid and the app can act on my behalf.
Independent test: on the index page (/) with no key stored, paste a valid key and submit; the app calls GET /api/account with Authorization: Bearer <key>, the API forwards it to GW2, and the page shows "Connected as Account.1234". Paste an invalid key and it shows an "invalid or expired key" error with the input still available. No localStorage persistence is needed for this story to pass.
Acceptance scenarios
- Given the index page with no key entered, when the player pastes a valid key and submits, then the app calls
GET /api/accountwith that key as a bearer token and renders "Connected as" the account name from the200response. - Given the input, when the player submits an empty (whitespace-only) value, then the app does not call the API and prompts for a non-empty key.
- Given a key GW2 rejects, when the player submits it, then the API returns
401, and the page shows an "invalid or expired key" message with the input available to try again. - Given a request with no
Authorizationheader, whenGET /api/accountis called, then the API returns400without contacting GW2. - Given the committed
openapi.json, whenverify:contractruns, then the/accountendpoint is present in the contract and the regenerated web client matches (no drift).
P2 — The key persists and can be disconnected
As a returning player, I want the app to remember my key and re-confirm it automatically, and to let me disconnect, so that I don't re-paste it every visit and can remove it when I want.
Independent test: with a valid key already in localStorage, load /; the page auto-validates and shows "Connected as Name" without re-entry. Click Disconnect; the key is removed from localStorage and the input returns. Paste a different key; it replaces the stored one.
Acceptance scenarios
- Given a valid key in
localStorage, when the index page loads, then the app validates it viaGET /api/accountand shows the connected state without the player re-entering it. - Given the connected state, when the player clicks Disconnect, then the key is removed from
localStorageand the page returns to the input state. - Given a key already stored, when the player submits a new key, then the new key replaces the old one in
localStorageand is validated. - Given a stored key that GW2 now rejects, when the index page loads, then the app shows the "invalid or expired key" state (the stale key is not silently treated as connected).
Requirements
- R1 — Account feature module (API). A new
apps/api/src/account/feature module in the standard shape (account.module.ts, a thinaccount.controller.ts,account.service.ts,account.schema.ts), wired intoapp.module.ts, exposingGET /account(served at/api/accountunder the global prefix). - R2 — Bearer forwarding & status mapping. The controller reads the
Authorization: Bearer <key>header; the service fetches GW2/v2/accountwith that key and returns{ name }, validated by a Zod schema that strips GW2's other fields. A missing/blank bearer token yields400before any GW2 call. GW2's403for a rejected key (invalid/expired — GW2 uses one status for invalid-key and missing-scope, with no error body) is mapped to a browser-facing401, switching on status, not body (V2, F2). - R3 — Through the budgeted client. The GW2 call goes through the one budgeted
Gw2Service(apps/api/src/gw2), never a directfetchto GW2 (stack.md). This requires an authenticated GET on the client that attaches the per-request bearer token while still passing through the token-bucket rate budget. The client has no per-request headers today but is minimally extensible — a header-aware retry path plus anaccount(apiKey)method that takes a bucket token and caches nothing (the existing caches key on URL/id, so caching account data would leak it across users) (V1). - R4 — The key is never logged or persisted server-side. The raw key is treated as a credential: it is not written to any log line (no request logging that includes the
Authorizationheader), not stored in any database, and not used as a persistent/shared cache key. Account responses are per-account, not static — they are not placed in the shared static-data caches (items/recipes), which assume immutable public data. (Any caching of authenticated responses, if added, is out of scope this slice.) - R5 — Zod-first contract & OpenAPI. The
{ name: string }response is defined by a Zod schema that contributes toapps/api/openapi.json; the web client is regenerated andverify:contractstays green. - R6 — Index route (web). A new
apps/web/src/features/account/feature owns the index route ({ path: '/', element: <…Page /> }), added to the router manifest inmain.tsx. The page is the first real content at/, which currently renders nothing. - R7 — Key storage lib (web). A small module under
apps/web/src/shared/lib/(or the feature's ownlib) owns alllocalStorageaccess for the key — read, write, clear — under one namespaced storage key. No component toucheslocalStoragedirectly. - R8 — Input & validation flow (web). The page has a paste field + Submit. A non-empty value is required client-side; GW2 remains the authority on validity (no brittle key-format regex). On submit the key is written to storage (R7) and the account query runs. The account request is a Suspense query (
suspenseOptions+QueryBoundary, perreact.md), instantiated only when a key is present (see R9), sending theAuthorization: Bearerheader through the generated client. The header rides Orval's per-call factoryrequestoption via auseAccount(apiKey)facade hook insrc/api— no generated-code orapiFetchmutator changes (V3). The account query'squeryKeyfolds a stable hash of the key — never the raw key, which would leak into a devtools-visible cache key — so re-entering a different key re-fetches rather than serving the stale prior account (V3). - R9 — States, conditional render & feature-local boundary (web). The page renders: no key → input; key present → auto-validate on load; valid → "Connected as Name" + a Disconnect button that clears storage (R7); invalid/expired → error message with the input to re-enter. Submitting a new key replaces the stored one. The account fetch runs only when a key is present, via conditional render: the page shows the input when there is no key and mounts the suspending account panel only once a key exists (a suspense query cannot be disabled — V4). The account panel carries its own feature-local
<Suspense>+QueryBoundary, reusing the exports fromsrc/api(never a fresh@tanstack/react-queryimport, which Biome forbids), so validation and errors stay scoped to the panel and never replace the input or the page chrome (V4). - R10 — Security limitation recorded.
localStorageis readable by any script on the origin, so an XSS bug would expose the key. This is accepted for now and recorded here as a known limitation (and in the feature's docs/notes) so a later spec can revisit (session-scoped storage or a server-side session). It is a documented trade-off, not an oversight. - R11 — No new
docs/superpowers/artifacts; prior specs' suites still pass, updated only where 016 changes their subject (e.g. the router now has a/route).
Mark anything unresolved inline rather than assuming an answer. Two markers, split by who can answer:
[NEEDS CLARIFICATION: specific question]— only the human can answer. Blocks step 1.5.[NEEDS VERIFICATION: specific question]— only reality can answer, resolved inresearch.mdwith cited evidence. Blocks the approval gate.
No [NEEDS CLARIFICATION] remains — the design (backend-forwarded key, index-page placement, minimal "connected as name" proof, full persist/disconnect lifecycle, localStorage "for now") was agreed in brainstorming. The four [NEEDS VERIFICATION] items below are all resolved with verdicts in research.md (discovery ran immediately after approval): V1/V3/V4 Confirmed, V2 Confirmed on auth/field/scope but Refuted on its status code (403, not 401) — folded into R2 and the endpoint contract via the amendment recorded in research.md's Refuted claims.
- V1 — Authenticated GET on
Gw2Service. Confirmed — minimally extensible (header-aware retry +account(apiKey), shared bucket, no caching). R3 updated. - V2 — GW2
/v2/accountcontract. Confirmed Bearer auth,namestring, mandatoryaccountscope; Refuted the status code — GW2 returns403(not401) for a bad key, no bad-key/missing-scope distinction. R2 + endpoint contract updated (GW2403→ our401). - V3 — Per-request
Authorizationheader through the web data layer. Confirmed — Orval's factoryrequestoption via auseAccountfacade hook; no generated-code changes. R8 updated (+ hashed queryKey). - V4 — Conditional Suspense query. Confirmed — conditionally render the suspending child + key in queryKey; feature-local
Suspense/QueryBoundarydecided. R9 updated.
Success criteria
Measurable, outcome-focused. The stack (Nest, Vite/react-query, GW2 API) is named where the criterion is about that wiring.
- SC1 — With a valid key, submitting on
/yields the connected state showing the account name returned byGET /api/account. (web test over the flow + local end-to-end smoke, recorded) - SC2 —
GET /api/accountwith a valid bearer token returns200 { name }; with no header returns400; with a key GW2 rejects (GW2403, mapped) returns401. (api tests, GW2 stubbed at the client boundary) - SC3 — The raw key never appears in server logs and is never persisted server-side. (api test asserting the log output / that no store receives the key)
- SC4 — A stored key auto-validates on page load and renders the connected state with no re-entry; Disconnect removes it from
localStorageand returns to the input. (web tests over the lifecycle) - SC5 — An empty/whitespace submit does not call the API and prompts for a non-empty key. (web test)
- SC6 —
openapi.jsoncontains/account, andverify:contractpasses (no drift between the committed contract and a fresh regeneration; the regenerated client includes the endpoint). (CI step) - SC7 — The count of files under any
docs/superpowers/path stays zero, and prior specs' suites still pass (updated only where 016 changes their subject, e.g. the new/route). (existing invariants) - SC8 — Every acceptance scenario and success criterion maps to a named test or a dated manual record, with no gap; the automated portion passes.
Out of scope
- Any account data beyond the name — materials, wallet, bank/inventory, characters, world, achievements. Each is its own later spec; this slice only proves the key works.
- A permissions/scope readout (
/v2/tokeninfo) — deferred; materials specs will need theinventoriesscope, but this slice does not inspect scopes. - Server-side key storage or sessions — the key lives only in the browser's
localStorage; the API is a per-request pass-through. Hardening storage is a future spec (see R10). - Multiple keys / key management UI — one key at a time; replacing it overwrites.
- Caching authenticated responses — the account call is not cached this slice.
- Client-side key-format validation — beyond non-empty; GW2 is the authority on validity.
- A per-API-key rate budget — account calls share the one process-global GW2 token bucket with public reads (research.md F3); a per-key budget is deferred.
Assumptions
- The global
/apiprefix and CORS from spec 015 stand — the new endpoint is served at/api/accountand the web app reaches it cross-origin exactly as it reaches/api/legendariestoday. - The Zod-first contract → Orval pipeline stands (
stack.md) — adding an endpoint means a schema, a regenerated client, andverify:contractguarding drift; no pipeline change. - The Suspense-only data flow (
react.md) applies — the account fetch suspends throughQueryBoundary/Suspenselike other reads; only the trigger (a user-supplied key) is new. - GW2 accepts
Authorization: Bearer <key>for/v2/account— the standard v2 auth scheme; confirmed inresearch.md(V2) rather than assumed. - The account call shares the one global GW2 rate budget — the authenticated fetch draws on the same process-global token bucket as public reads (research.md F3); accepted for MVP.
- No
[NEEDS CLARIFICATION]remains — placement, the minimal connected-state proof, the persist/ disconnect lifecycle, andlocalStorage-for-now were all decided in brainstorming.
Traceability
Each acceptance scenario and success criterion maps to a named test or a dated manual-verification record (the latter only for the inherently observational — a live validate against real GW2). SC8 asserts no empty cell. Filled in during implementation.
| Criterion | Test / verification |
|---|---|
| P1 #1 | apps/web/src/features/account/__tests__/AccountPage.test.tsx (case c: valid key → "Connected as") + apps/api/src/account/account.controller.test.ts (bearer → { name }) |
| P1 #2 | apps/web/src/features/account/__tests__/AccountPage.test.tsx (case b: empty/whitespace submit → no call, prompt) |
| P1 #3 | apps/web/src/features/account/__tests__/AccountPage.test.tsx (case f: rejected key → error fallback + re-entry) + apps/api/src/account/account.controller.test.ts (Gw2UnauthorizedError → 401) |
| P1 #4 | apps/api/src/account/account.controller.test.ts (no/blank Authorization → 400, service not called) |
| P1 #5 | pnpm verify:contract (CI) + apps/api/src/generate-openapi.test.ts (paths contains /account) |
| P2 #1 | apps/web/src/features/account/__tests__/AccountPage.test.tsx (case d: stored key on mount → auto-validate → connected) |
| P2 #2 | apps/web/src/features/account/__tests__/AccountPage.test.tsx (case e) + apps/web/src/features/account/__tests__/apiKeyStorage.test.ts (Disconnect clears localStorage, input returns) |
| P2 #3 | apps/web/src/features/account/__tests__/AccountPage.test.tsx (case g: new key replaces) + apiKeyStorage.test.ts (overwrite) |
| P2 #4 | apps/web/src/features/account/__tests__/AccountPage.test.tsx (case f: stored key rejected → error state) |
| SC1 | apps/web/src/features/account/__tests__/AccountPage.test.tsx (case c) + local smoke 2026-08-16: human confirmed "Connected as …" on / with a real key (dev servers, VITE_APP_ENV=dev); the api error paths (no header → 400; bad key → live GW2 403 → mapped 401 {"message":"invalid or expired key"}) and the Authorization CORS preflight were verified by curl the same session |
| SC2 | apps/api/src/account/account.controller.test.ts (200/400/401) + apps/api/src/gw2/gw2-client.test.ts (account(): bearer, no cache, 401/403 → Gw2UnauthorizedError) |
| SC3 | apps/api/src/gw2/gw2-client.test.ts (the Gw2UnauthorizedError message contains no key; the account path uses no logger) |
| SC4 | apps/web/src/features/account/__tests__/apiKeyStorage.test.ts + AccountPage.test.tsx (cases d, e) |
| SC5 | apps/web/src/features/account/__tests__/AccountPage.test.tsx (case b) |
| SC6 | pnpm verify:contract (CI) + apps/api/src/generate-openapi.test.ts |
| SC7 | tests/workflow/ repo-invariants (docs/superpowers/ count stays zero; prior suites pass) |
| SC8 | this table complete + every named test exists and passes (full suite: 364 passed, 1 expected-fail) |