Epic — gw2.app alignment (cacheable, user-agnostic, browser-direct)
Status: brainstorm output, not yet spec'd. This document is the shared base for a set of parallel feature specs. It is context, not a spec — it sets no status and crosses no gate. Each spec below is a normal feature under the project constitution: its own specs/NNN-<slug>/ with spec.md → research.md → plan.md → tasks.md, its own worktree, its own approval gates.
How to use this doc (for a parallel session)
You are one of several Claude sessions, each owning one spec below. Before touching your spec:
- Read this whole file — it is the shared architecture the specs assume.
- Read
docs/gaps/gw2app-caching-comparison.md— the measured evidence this epic rests on. - Read the constitution (
CLAUDE.md) and follow the workflow: scaffold a worktree offorigin/main, writespec.mdfirst, get it approved, thenresearch.md(verify every[NEEDS VERIFICATION]), thenplan.md, thentasks.md. HTTP endpoints + OpenAPI are first-class spec content. - Honour your wave's dependencies (below). Do not start a spec whose upstream has not merged, unless you are working only its independent half and say so.
Ponytail is in force: the smallest change that satisfies the invariant wins. Do not add a dependency, an abstraction, or a persistence layer that the invariant does not require.
Why this epic exists (one paragraph)
gw2.app is far faster than GW2Priory, and the cause is caching + data placement + infra, not the algorithm. GW2Priory already computes a correct, batched, waterfall-free recipe tree — then discards it: recomputed per request, no HTTP cache, no CDN, on a cold-startable free origin, with volatile prices and per-user account data folded through the same path so nothing is cacheable. gw2.app's backend, by contrast, serves only static, user-agnostic data cached for hours and shared across all users, and pushes everything per-user or volatile to direct browser↔ArenaNet calls. Full evidence and numbers: docs/gaps/gw2app-caching-comparison.md.
The target model — the hybrid contract
The backend splits into two surfaces with a hard rule between them. This invariant is the acceptance lens for every endpoint any spec adds or changes.
- Surface A — public data API (the cacheable core). User-agnostic, no API key ever. Serves item metadata, priceless recipe trees, search, reference tables, curated forge recipes. Responses carry
Cache-Control: public, max-age=…and are CDN-cacheable, shared across all users. Data is sourced either from committed static data or from ArenaNet (server-side, cached), never per-user. - Surface B — per-user compute (thin, stateless). The assistant / MCP, and optionally ranking. Receives the user's API key or an account snapshot per request, computes, returns. Responses are
private, no-store. Persists nothing — the key never lands at rest. - Client. Public data ← Surface A (cached hard). Account state + trading-post prices ← browser →
api.guildwars2.comdirectly, with the key held only in the browser (localStorage). Per-user features compute client-side where feasible, else call Surface B with a per-request key/snapshot.
The invariant, one line: anything cacheable is user-agnostic and keyless; anything per-user is stateless and never cached. Every new or changed endpoint must declare which surface it belongs to.
Givens — facts every spec can rely on
Sourced from the measured analysis (docs/gaps/gw2app-caching-comparison.md) and the codebase. Items marked [NEEDS VERIFICATION] must be re-confirmed in that spec's research.md before its spec is approved — do not treat them as settled.
- ArenaNet rate limit is per source IP (≈600 req/min, token-bucket burst), not per key. So browser-direct calls give every user their own budget; proxying funnels all users through our one origin IP.
[NEEDS VERIFICATION: per-IP vs any per-key component, exact numbers, authenticated-endpoint behaviour.] - ArenaNet CORS is open (
access-control-allow-origin: *), so browser-direct calls work. Butaccess-control-expose-headersis minimal (nocache-control), so the browser cannot read cache metadata.[NEEDS VERIFICATION: does /v2 accept an Authorization header cross-origin, or must the key go in the ?access_token= query param?] - ArenaNet cache-control:
/v2/items/{id}public, max-age=3600(1h);/v2/commerce/pricespublic, max-age=120(2min); authenticated/v2/account*areprivate(per-user, browser cache only). The?v=2025-08-29T…param is ArenaNet's schema-version selector, a pinned constant — NOT a cache-buster. - GW2 API batching:
/v2/{items,recipes,commerce/prices}?ids=caps at 199 ids per request; a 206/404 drops unknown ids. Existing client already batches (apps/api/src/gw2/gw2-client.ts). - Recipe data split: station recipes (craftable at a station) ARE in
/v2/recipes+/v2/recipes/search→ resolvable live + cacheable. Mystic Forge / legendary precursor recipes are NOT in the API → must stay curated by hand. - Icons load browser-direct from
render.guildwars2.com(URL comes from the data); this already matchesgw2.appand needs no change. - Current GW2Priory state: NestJS backend, no database (docs claim Postgres; code has none — reconcile when a spec touches persistence). In-memory
BoundedCache(static = no expiry, prices = 60s, account = 5min). Token bucket (300 cap, 5/s) self-throttling under the shared IP limit./recipe-graph/:itemId(recipe-graph.service.tsresolvePriced) returns one recursive priced tree; prices are folded in server-side. Ranking (legendaries/ranking.service.ts) overlaps a ~40-request account scan with graph resolution. Frontend is a Vite SPA, TanStack Query withstaleTime: 0, no route code-splitting.
The specs (merged) and the dependency graph
Six original axes merged into five specs, grouped into three parallel waves. Slugs are suggestions; allocate the NNN when you scaffold.
Wave 1 — parallel, no cross-dependencies
Spec 1 · backend-edge-caching (the biggest perceived-latency win, smallest diff)
- Add
Cache-Controlto Surface-A endpoints and put a CDN in front of the API; remove the cold start (paid tier or keep-warm). - Owns the caching mechanism — a header policy other specs annotate their endpoints against (e.g. an interceptor keyed by a per-route
public, max-agevsprivate, no-storedeclaration). Other specs do not invent their own header scheme; they use this one. - Out of scope: changing any response body. Purely headers + infra.
- Open:
[NEEDS VERIFICATION: CDN choice — Cloudflare in front of Render vs Render's own edge; does the free/paid tier expose the controls needed.]
Spec 2 · recipe-graph-cacheable (backend)
- Resolve station recipes live from ArenaNet + cache; delete the committed
station-recipes.tsand thegenerate-recipe-indexsync script; keep the curated forge/precursor dataset app-side and single-consumer (not a workspace package, no duplicated data across boundaries). - Make
/recipe-graph/:itemIdpriceless — remove the server-side price fold so the structure is user-agnostic and cacheable for hours. (Client merges prices; see Spec 3.) - Coordinate with Spec 1 only on the header declaration for the now-cacheable endpoint.
- Open:
[NEEDS CLARIFICATION: live-recipe cache TTL/eviction; do we keep any committed station data as a cold-start warmup or go fully live.]
Spec 4 · frontend-perf (frontend, independent)
- Route-level code-splitting; a sane TanStack
staleTime; a persistent client cache (IndexedDB) so repeat visits survive a reload. Mind the React-Compiler build gate (docs/architecture/react.md): it only runs invite build, so runpnpm buildto catch it. - SSR is explicitly deferred to a spike, not assumed: only adopt it if, after Spec 1 lands, the SPA's first paint is measured too slow. Do not build SSR speculatively (YAGNI).
- Out of scope: how data is fetched (that is Spec 3); this is bundle + cache-config only.
Wave 2 — after Spec 2 merges
Spec 3 · client-direct-anet (frontend + backend removal)
- Client fetches prices browser→ArenaNet and merges them into the priceless tree from Spec 2.
- Client fetches account state browser→ArenaNet with the key from
localStorage; backend stops proxying account data; the key stops being sent to our origin for these flows. - The account half is independent of Spec 2; the price-merge half needs Spec 2's priceless tree. If you must start early, do the account half first and say so.
- Open:
[NEEDS VERIFICATION: Authorization header vs ?access_token= query param from the browser];[NEEDS CLARIFICATION: do we drop the backend commerce/account proxy endpoints or keep them for the MCP/assistant path.]
Wave 3 — after Spec 3 merges
Spec 5 · ai-ranking-reconcile (depends on Spec 3)
- Assistant + MCP become stateless-per-request under the contract: they receive the key or an account snapshot per call and persist nothing.
- Ranking runs client-side (decided) — compute in the browser from cached trees + browser-fetched account + prices, exactly like
gw2.app's logic. Backend stays user-agnostic; the existing server-sideranking.service.tsaccount-scan path is retired. Optimise later only if measured too heavy (Surface B is the fallback, not the starting point). - Open:
[NEEDS CLARIFICATION: split into ai-assistant and ranking if the two prove independent.]
Dependency summary
Wave 1 (parallel): Spec 1 (caching/CDN) Spec 2 (recipe priceless) Spec 4 (frontend perf)
Wave 2: Spec 3 (client-direct ArenaNet) [needs Spec 2]
Wave 3: Spec 5 (AI/ranking) [needs Spec 3]Cross-cutting rules for every spec
- Declare the surface. Every endpoint you add or change states Surface A (
public, max-age, keyless, user-agnostic) or Surface B (private, no-store, stateless, per-request key/snapshot). A reviewer can reject on this alone. - The key never persists. No spec writes an API key to disk, a DB, or a log. Surface B holds it only for the duration of one request.
- Endpoints + OpenAPI are spec content, not an afterthought — every feature spells out its HTTP endpoints and OpenAPI up front.
- No
any, no unexplained escape hatches (docs/architecture/typescript.md). - Park out-of-scope findings in
docs/gaps/as records, do not fix them in passing. - Run
pnpm lint,pnpm test,pnpm build,pnpm docs:buildbefore pushing — the literal-colour guard, React-compiler build gate, and VitePress compile all bite late otherwise.
Open decisions carried across the epic (resolve in the owning spec's discovery)
Ranking home— decided: client-side, likegw2.app; Surface B is only a later fallback if browser compute is measured too heavy (Spec 5).- CDN choice + whether the paid tier is required for the controls we need (Spec 1).
- Key transport from the browser: Authorization header vs
?access_token=query param (Spec 3). - Whether SSR is needed at all once Spec 1 removes the cold start (Spec 4 spike).
- Fully-live recipes vs a committed cold-start warmup set (Spec 2).
- The Postgres-in-docs / no-DB-in-code divergence — reconcile in whichever spec first needs persistence (likely none of these; flag if so).