Research 016 — GW2 API key: connect & validate
Status: complete
Step 1.5 output, written after the spec (which the human approved before discovery ran) and before the plan gate. Every [NEEDS VERIFICATION] marker in spec.md (V1–V4) has a verdict below, plus findings that turned up alongside. One sub-claim is Refuted (V2 status code) and three design points are left Open for the spec to decide — all folded into a proposed spec amendment (see Refuted claims and the Open notes), which the human approves before the plan is written.
Verified against. The repo at this worktree's 016-account-api-key HEAD, and the official GW2 Wiki pages API:2, API:2/account, API:API_key (fetched 2026-08-16). The GW2 findings are documented, not live-verified — we have no GW2 API key, so the authenticated /v2/account path was not exercised. GW2 can drift from the wiki (the repo already records a documented 200-id cap that measured 400 live — apps/api/src/gw2/gw2-client.ts:18-21); re-verify with a real key when one exists.
V1 — Can Gw2Service be minimally extended for an authenticated GET, through the bucket, without caching? (spec R2, R3, R4)
Question. R3 routes the authenticated /v2/account call through the one budgeted client, attaching a per-request Authorization: Bearer <key>, still passing the token bucket, and R4 forbids caching the per-account response. Does the client support this today, and what is the minimal extension?
Verdict. Confirmed. The client has no generic GET and passes no init/headers today, but a small, backward-compatible extension supports an authenticated GET that reuses the shared budget and skips the caches.
Evidence.
- No per-request headers today:
fetchWithRetry(url, path)callsconst res = await this.fetchFn(url);with no second argument —apps/api/src/gw2/gw2-client.ts:225-227. Public entry points are entity-specific (items():88,recipes():98,prices():108,searchRecipes():119) and build a bare URL string. - Every network path takes a token before fetching:
await this.bucket.take();atgw2-client.ts:191(ingetByIds, before the fetch at:192) and:142(insearchRecipes, before:143). The bucket is one instance on the client (gw2-client.ts:62, constructed once —gw2.service.ts:20-25, 300-token / 5-per-sec —gw2.service.ts:7-8). - Caches key on static ids only:
${keyPrefix}${id}(gw2-client.ts:174,:208);BoundedCacheis a plainMap<string, …>with no user/token dimension (bounded-cache.ts:18). An account response stored under a URL/id key would be served across users — so the account fetch must not call anycache.set.
Minimal change (for the plan). Make the retry helper header-aware — overload fetchWithRetry(url, path, init?) and pass init into this.fetchFn(url, init) at gw2-client.ts:227 (existing callers pass nothing → no behavior change), keeping the shared 429/backoff logic. Add a public account(apiKey) that: await this.bucket.take(), calls the header-aware helper with { headers: { Authorization: \Bearer ${apiKey}` } }, runs assertSuccessStatus (:259), parses via a **new** Gw2AccountSchema(none exists — imports atgw2-client.ts:4-13cover items/recipes/prices/ search only), and **returns without touching any cache**. Expose it onGw2Servicealongsideitems/recipes/prices (gw2.service.ts:28-42`).
Caveat. The token bucket is process-global and single-dimension (gw2.service.ts:19-26): one budget shared by all users and by both public and authenticated traffic. Routing account calls through it is correct for staying under GW2's per-IP limit, but one user's account polling draws down the same budget public item/price reads use. An accepted design consequence to record (see F3), not a blocker.
V2 — What is the GW2 /v2/account contract (auth scheme, name, scope, bad-key status)? (spec HTTP-endpoint, R2, P1 #3, SC2)
Question. The endpoint forwards a key to GW2 /v2/account and returns { name }. The spec assumed Authorization: Bearer auth, name as the display name, the account scope, and 401 for a rejected key (possibly 403 for missing scope). Confirm each against the GW2 docs.
Verdict. Confirmed on auth/field/scope; Refuted on the status code. Bearer auth, the name string, and the mandatory account scope all hold. But GW2 returns 403 — not 401 — for a bad key, and does not distinguish "invalid key" from "missing scope." See Refuted claims.
Evidence.
- Auth scheme:
API:2documents bothAuthorization: Bearer <API key>and?access_token=<key>— Bearer is officially supported, not query-only. name:API:2/accountreturnsname(string), the display name with numeric suffix (e.g."Account.1234"); other fields present and stripped includeid,age,world,guilds,guild_leader,created,access,commander,fractal_level,daily_ap,monthly_ap,wvw,last_modified,build_storage_slots.- Scope:
/v2/accountrequiresaccount;API:API_key— "This permission is mandatory for all keys." So any valid key can call it; there is effectively no missing-scope case for this endpoint. - Bad key:
API:2— HTTP403"will be returned if attempting to access an authenticated endpoint without a valid API key, or with a valid API key without the necessary permissions." One status for both cases; no documented401, and no documented error-body JSON shape. - Repo: no authenticated handling exists (
gw2-client.ts:225-236sets no auth); no account schema ingw2.schemas.ts;assertSuccessStatustreats only 200/206 as success and throws a genericGw2RequestErrorotherwise (gw2-client.ts:259-266) — a 403 currently surfaces undifferentiated, so 016 must add mapping. Error classes available:Gw2ValidationError,Gw2RateLimitError,Gw2RequestError(gw2.errors.ts).
Caveat. Documented, not live-verified (no key). Whether GW2 ever emits a bare 401, and the exact 403 error-body shape, are Open until testable with a real key — the mapping must key on status, not body.
V3 — Can a per-request Authorization header be threaded through the web data layer idiomatically? (spec R8)
Question. R8 sends Authorization: Bearer <key> on the account query through the Orval client + apiFetch mutator + react-query (Suspense). Is there a convention-compliant path?
Verdict. Confirmed. A facade hook passes the header through Orval's per-call request option — zero changes to apiFetch, the Orval config, or any generated file.
Evidence.
apiFetchforwards itsRequestInit(headers included) straight tofetch:apps/web/src/api/apiFetch.ts:5-9.- Generated endpoint fns spread a caller options arg into the fetch options (
generated/endpoints/health/health.ts:64-73); the query-options factory acceptsrequest?: SecondParameter<typeof apiFetch>and closes it intoqueryFn(health.ts:83-100; legendaries identicallegendaries.ts:81-124). Sorequest: { headers: { Authorization: 'Bearer …' } }→ factory →queryFn→apiFetch→fetch. suspenseOptionsnarrows to{ queryKey, queryFn }only (suspenseOptions.ts:8-17), butrequestis baked intoqueryFnbefore that narrowing — so it survives. It must be passed to the factory, not as a top-leveluseSuspenseQueryoption (which would be dropped).react.mdsanctions this: the facade insrc/apiis the home for wrappinguseSuspenseQueryviasuspenseOptions(react.md:96-112), and the@tanstack/react-query/generatedimport bans are exempted insidesrc/api(react.md:215,:222-228). The spec-015 R3/R4/R5 origin rules govern where requests go (apiFetch.ts:3,orval.config.ts:22-32), not headers — no conflict.
Minimal mechanism (for the plan). A useAccount(apiKey) facade hook mirroring useHealth.ts, passing request: { headers: { Authorization: \Bearer ${apiKey}` } }into the generatedgetAccount…QueryOptions(...)factory, thenuseSuspenseQuery(suspenseOptions(...)), returning the Zod-parsed `.
Caveat → Open (design decision). The generated queryKey is ['/api/account'] with no params, so a changed key would serve the stale cached account. The facade must fold a stable hash of the key (never the raw key — query keys are devtools-visible) into the queryKey. Endpoint/type names (getAccount…) are inferred from the operationId convention (orval.config.ts:9-11) until the endpoint is generated. (Ties to V4.)
V4 — Does the Suspense-only data flow support a query that runs only when a key is present and re-keys on change? (spec R8, R9)
Question. useSuspenseQuery can't be disabled (enabled: false isn't honored, and suspenseOptions strips it anyway). R8/R9 need the account fetch to run only when a key exists and re-fetch on change, within the Suspense-only flow. Is there an idiomatic pattern?
Verdict. Confirmed the pattern exists and is mechanically supported; Open only on where the feature-local Suspense/QueryBoundary boundary sits — which the spec should state.
Evidence.
- Facade hooks call
useSuspenseQuery, neveruseQuery(useHealth.ts:13,useLegendaries.ts:14); the onlyenabledis in generated code (recipe-graph.ts:110) and is stripped bysuspenseOptions(:9,17). Doctrine: no loading/error/fetch branch inside a data-needing component (react.md:97-99,:302-303). So a facade hook, once called, always fetches. - Idiomatic conditional fetch = conditional render: the page holds the key in
useState(the "ephemeral UI state" home —react.md:122,:283), renders an<Input/>when there's no key, and mounts the suspending child (<AccountPanel keyValue={k}/>calling the suspense hook) only once a key exists — so the query is never instantiated without a key. Pages already call the suspense hook unconditionally at top level (HealthPage.tsx:6,LegendariesPage.tsx:17); the prohibition is on branches inside that component, not on a parent conditionally rendering a child. - Key-in-queryKey re-fetch is standard: parameterized keys embed the value (
recipe-graph.ts:78), passed through untouched bysuspenseOptions— a changed key ⇒ changed queryKey ⇒ re-fetch, no special code. QueryBoundaryreset/retry (QueryBoundary.tsx:15-25, wired inApp.tsx:43-51) works unchanged; the panel's errors only reach a boundary when it's mounted.
Open (design decision for the spec). The single shared <Suspense fallback="Loading…"> andQueryBoundary are declared exactly once around the whole <Outlet/> (App.tsx:42-55; rule stated at App.tsx:16, react.md:27-28). If the key <Input/> and the <AccountPanel/> both sit under only that Outlet-level boundary, then while the panel validates, the entire page — including the input the user just typed — is replaced by "Loading…", and an invalid-key error blows the input away instead of showing inline. To keep the input visible and the error inline, the feature page needs its own local <Suspense> + QueryBoundary around just the panel. That is a second boundary, in mild tension with react.md's "declared once, in App.tsx" wording (whose target is boundaries inside the data-needing component, so a page-level boundary around a child is arguably a layout concern — but the spec should say so explicitly). Any feature-local boundary must reuse the exported QueryBoundary/Suspense from src/api, never a fresh @tanstack/react-query import (Biome-enforced — react.md:215).
F1 — The api.guildwars2.com architecture guard confines the new call to gw2/ (touches R3)
Why it matters. apps/api/src/conventions/guards.ts:129-134 flags any file referencing api.guildwars2.com outside apps/api/src/gw2/. The authenticated account fetch must live inside gw2/ (as the V1 extension already assumes); the account feature module (R1) calls Gw2Service, never GW2 directly. Confirms R3's boundary is enforced by a test, not just convention.
F2 — No 401 in GW2's vocabulary here; map on status, not body (touches R2, endpoint contract)
Why it matters. Beyond the refuted 401 (below), GW2 documents no error-body JSON shape for auth failures. The API's 403→(our response) mapping must switch on the status code only, never parse a body field. assertSuccessStatus (gw2-client.ts:259-266) throws a generic Gw2RequestError for a 403 today; 016 adds a dedicated mapping (e.g. a Gw2UnauthorizedError or reuse of an existing class) so the account controller can translate it to the browser-facing status.
F3 — The shared single-dimension rate budget (touches R3, Assumptions)
Why it matters. Per V1's caveat, authenticated account traffic shares the one process-global bucket with public reads. This is acceptable for MVP (stays under GW2's per-IP limit) but means account polling competes with catalog reads for budget. Record as an accepted consequence in the spec's Assumptions; a per-key budget is out of scope.
Refuted claims
Recorded rather than silently patched, per the workflow. The spec was approved before discovery, so unlike the usual draft-time fold, this refinement is proposed back to the human for approval before the plan is written — the discovery evidence, not enthusiasm, decides the change.
- "GW2 returns
401for a rejected key; possibly403for missing scope" (HTTP-endpoint section, R2, P1 #3, SC2). Believed: GW2 answers a bad key with401and a scope gap with403. True: GW2 returns403for both, with no documented401and no bad-key-vs-missing-scope distinction (V2). And since theaccountscope is mandatory on every key, this endpoint has effectively no missing-scope case — a 403 here means "invalid or expired key," full stop. Proposed change: the upstream signal is GW2403; ourGET /api/accountmaps it to a browser-facing401("invalid or expired key") — 401 is the correct semantics for our endpoint (the key is the caller's credential and it failed to authenticate), and it keeps P1 #3 and SC2 (which already assert our API returns 401) intact. The endpoint section and R2 are reworded to say "GW2403→ our401", the speculative "403 for missing scope" line is dropped, and mapping is specified on status, not body (F2). This is a boundary refinement — the feature is unchanged; only the code and its provenance are corrected. Awaiting human approval of the amendment.
Graduation
Candidates to move to docs/architecture/ at step 6 (they outlive this feature; the next account-scoped spec should not re-derive them). docs/architecture/gw2-api.md is the natural home for the GW2 facts:
- GW2 auth:
Authorization: Bearer <key>(or?access_token=) is accepted;/v2/accountneeds theaccountscope, mandatory on every key;nameis the display-name string. - GW2 auth failure:
403for both invalid key and missing scope — no401, no distinction, no documented error-body shape; map on status only. - Client extension: the budgeted client now supports an authenticated GET (per-request bearer, shared bucket, no caching of per-account responses) — for
nestjs.md/gw2-api.md. - Web pattern: conditional-Suspense = conditionally render the suspending child + fold a hash of the varying secret into the queryKey; per-request headers ride Orval's factory
requestoption — forreact.md.