Skip to content

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:

  1. Read this whole file — it is the shared architecture the specs assume.
  2. Read docs/gaps/gw2app-caching-comparison.md — the measured evidence this epic rests on.
  3. Read the constitution (CLAUDE.md) and follow the workflow: scaffold a worktree off origin/main, write spec.md first, get it approved, then research.md (verify every [NEEDS VERIFICATION]), then plan.md, then tasks.md. HTTP endpoints + OpenAPI are first-class spec content.
  4. 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.com directly, 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. But access-control-expose-headers is minimal (no cache-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* are private (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 matches gw2.app and 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.ts resolvePriced) 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 with staleTime: 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-Control to 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-age vs private, no-store declaration). 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.ts and the generate-recipe-index sync script; keep the curated forge/precursor dataset app-side and single-consumer (not a workspace package, no duplicated data across boundaries).
  • Make /recipe-graph/:itemId priceless — 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 in vite build, so run pnpm build to 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-side ranking.service.ts account-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:build before 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) ​

  1. Ranking home — decided: client-side, like gw2.app; Surface B is only a later fallback if browser compute is measured too heavy (Spec 5).
  2. CDN choice + whether the paid tier is required for the controls we need (Spec 1).
  3. Key transport from the browser: Authorization header vs ?access_token= query param (Spec 3).
  4. Whether SSR is needed at all once Spec 1 removes the cold start (Spec 4 spike).
  5. Fully-live recipes vs a committed cold-start warmup set (Spec 2).
  6. The Postgres-in-docs / no-DB-in-code divergence — reconcile in whichever spec first needs persistence (likely none of these; flag if so).