diff --git a/apps/api/.env.example b/apps/api/.env.example index 1c3e01fa85..d7a32f06a3 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -912,6 +912,16 @@ INFOMANIAK_IP_POLL_INTERVAL_MS=3000 # MODEL_CATALOG_SOURCE_URL=https://models.dev/api.json # Dynamic OpenCode model catalog source # MODEL_CATALOG_CACHE_TTL_SECONDS=3600 # KV cache TTL for dynamic model catalogs # MODEL_CATALOG_FETCH_TIMEOUT_MS=5000 # Upstream model catalog fetch timeout + +# --- HTTP response cache budgets (seconds, clamped to [0, 86400]) --- +# Authenticated responses are always `private` + `Vary: Cookie`; only the +# unauthenticated /api/config/* endpoints may be marked `public`. +# PUBLIC_CONFIG_CACHE_MAX_AGE_SECONDS=60 # /api/config/* max-age +# PUBLIC_CONFIG_CACHE_SWR_SECONDS=300 # /api/config/* stale-while-revalidate +# MODEL_CATALOG_CACHE_MAX_AGE_SECONDS=60 # Model catalog response max-age (keep <= MODEL_CATALOG_CACHE_TTL_SECONDS above) +# MODEL_CATALOG_CACHE_SWR_SECONDS=300 # Model catalog response stale-while-revalidate +# PROJECT_REFERENCE_CACHE_MAX_AGE_SECONDS=0 # Agent profiles/skills max-age +# PROJECT_REFERENCE_CACHE_SWR_SECONDS=30 # Agent profiles/skills stale-while-revalidate # AI_PROXY_DAILY_INPUT_TOKEN_LIMIT=500000 # Per-user daily input token cap # AI_PROXY_DAILY_OUTPUT_TOKEN_LIMIT=200000 # Per-user daily output token cap # AI_PROXY_MAX_INPUT_TOKENS_PER_REQUEST=32000 # Max input tokens per single request diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 6d4983e90f..6cb59c03fd 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -891,6 +891,16 @@ export interface Env extends WebhookTriggerEnv, TaskRecoveryEnv { MODEL_CATALOG_SOURCE_URL?: string; // OpenCode model catalog source URL (default: https://models.dev/api.json) MODEL_CATALOG_CACHE_TTL_SECONDS?: string; // KV cache TTL for dynamic model catalogs (default: 3600) MODEL_CATALOG_FETCH_TIMEOUT_MS?: string; // Upstream model catalog fetch timeout (default: 5000) + + // HTTP response Cache-Control budgets for stable/semi-stable GETs. + // See apps/api/src/lib/cache-headers.ts. Values are seconds, clamped to [0, 86400]. + PUBLIC_CONFIG_CACHE_MAX_AGE_SECONDS?: string; // /api/config/* max-age (default: 60) + PUBLIC_CONFIG_CACHE_SWR_SECONDS?: string; // /api/config/* stale-while-revalidate (default: 300) + MODEL_CATALOG_CACHE_MAX_AGE_SECONDS?: string; // Model catalog response max-age (default: 60) + MODEL_CATALOG_CACHE_SWR_SECONDS?: string; // Model catalog response stale-while-revalidate (default: 300) + PROJECT_REFERENCE_CACHE_MAX_AGE_SECONDS?: string; // Agent profiles/skills max-age (default: 0) + PROJECT_REFERENCE_CACHE_SWR_SECONDS?: string; // Agent profiles/skills stale-while-revalidate (default: 30) + AI_PROXY_DAILY_INPUT_TOKEN_LIMIT?: string; // Per-user daily input token cap (default: 500000) AI_PROXY_DAILY_OUTPUT_TOKEN_LIMIT?: string; // Per-user daily output token cap (default: 200000) AI_PROXY_MAX_INPUT_TOKENS_PER_REQUEST?: string; // Max input tokens per request (default: 32000) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index aea8ec7723..b51a8d3d3f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -30,6 +30,7 @@ import { cors } from 'hono/cors'; import { createAuth } from './auth'; import * as schema from './db/schema'; import type { Env } from './env'; +import { applyCacheHeaders } from './lib/cache-headers'; import { resolveCredentialedCorsOrigin } from './lib/cors-origin'; import { log, serializeError } from './lib/logger'; import { resolvePagesProxyTarget } from './lib/pages-proxy'; @@ -623,12 +624,14 @@ app.get('/health', (c) => { // Public config — exposes feature flags the UI needs before auth app.get('/api/config/artifacts-enabled', (c) => { + applyCacheHeaders(c, 'public-config'); return c.json({ enabled: c.env.ARTIFACTS_ENABLED === 'true' && !!c.env.ARTIFACTS }); }); // The VAPID public key is runtime configuration: deploy-generated keys do not // exist when the web bundle is built. Never expose the corresponding private key. app.get('/api/config/vapid-public-key', (c) => { + applyCacheHeaders(c, 'public-config'); const publicKey = c.env.VAPID_PUBLIC_KEY?.trim() || null; return c.json({ publicKey }); }); @@ -644,6 +647,7 @@ app.get('/api/config/login-providers', async (c) => { getGoogleLoginOAuthConfig(c.env), getGitLabOAuthConfig(c.env), ]); + applyCacheHeaders(c, 'public-config'); return c.json({ github: github !== null, google: google !== null, gitlab: gitlab !== null }); }); diff --git a/apps/api/src/lib/cache-headers.ts b/apps/api/src/lib/cache-headers.ts new file mode 100644 index 0000000000..6947217620 --- /dev/null +++ b/apps/api/src/lib/cache-headers.ts @@ -0,0 +1,167 @@ +import { + DEFAULT_MODEL_CATALOG_RESPONSE_CACHE_MAX_AGE_SECONDS, + DEFAULT_MODEL_CATALOG_RESPONSE_CACHE_SWR_SECONDS, + DEFAULT_PROJECT_REFERENCE_CACHE_MAX_AGE_SECONDS, + DEFAULT_PROJECT_REFERENCE_CACHE_SWR_SECONDS, + DEFAULT_PUBLIC_CONFIG_CACHE_MAX_AGE_SECONDS, + DEFAULT_PUBLIC_CONFIG_CACHE_SWR_SECONDS, +} from '@simple-agent-manager/shared'; +import type { Context } from 'hono'; + +/** + * Conservative `Cache-Control` for stable / semi-stable API GETs. + * + * ## Where these headers actually take effect (read this first) + * + * **Only in the requesting browser's own cache.** They do nothing at the + * Cloudflare edge today: this Worker builds every response itself (`c.json(...)`) + * with no origin `fetch()` subrequest and no Cache API write, and Cloudflare's CDN + * cache is only consulted for responses that came from a subrequest. JSON is not + * a default-cacheable type either, `apps/api/wrangler.toml` has no `[cache]` + * block, and `infra/` defines no Cache Rules. So do not read this module as + * reducing Worker invocations, D1 load, or origin traffic — it reduces repeat + * *browser* fetches on reload and back-navigation, nothing more. + * + * The `private` discipline below is nonetheless written to stay correct if + * Workers Cache (`[cache] enabled = true`) or a Cache Rule is turned on later, at + * which point these responses WOULD become edge-cacheable. + * + * ## Two different threats, two different mechanisms + * + * These are easy to conflate; they are not interchangeable, so do not drop one + * believing the other covers it: + * + * - **A shared cache serving user A's body to user B** is prevented by + * **`private`**, on its own and unconditionally — Cloudflare documents that it + * will not cache a response marked `private`/`no-store`/`no-cache`/`max-age=0`, + * and that is true regardless of `Vary`. (Cloudflare's classic cache ignores + * `Vary` for anything but `Accept-Encoding` unless a Cache Rules Vary setting + * is configured, so `Vary: Cookie` must NOT be relied on for this threat.) + * - **A second login in the SAME browser profile reading the first login's + * entry** is prevented by **`Vary: Cookie`**. The browser's private cache is + * keyed per profile, not per session, so without it user B could be served + * user A's entry for the same URL after an account switch. Varying on the + * session cookie gives each login its own entry. + * + * The split is enforced by the type rather than by reviewer discipline: `public` + * is only reachable through {@link PUBLIC_CACHE_POLICIES}, whose members are + * unauthenticated endpoints whose body is byte-identical for every caller. + * + * Note that `Vary: Origin` is already present on every response — Hono's `cors()` + * appends it whenever `origin` is a function, which `index.ts` configures. Our + * `Vary: Cookie` composes with it (`Cookie, Origin`) rather than replacing it; + * `cache-headers.test.ts` pins that. + * + * Endpoints returning real-time data (chat messages, task status, session state, + * workspace status) are intentionally absent. This module is opt-in per handler + * precisely so nothing acquires caching by accident. + */ +export type CachePolicyName = + /** Unauthenticated, deploy-scoped config (`/api/config/*`). */ + | 'public-config' + /** Authenticated but globally identical: the agent model catalog. */ + | 'model-catalog' + /** Authenticated and per-user: project agent profiles, project skills. */ + | 'project-reference'; + +/** Policies allowed to emit `public`. Everything else is `private` + `Vary: Cookie`. */ +const PUBLIC_CACHE_POLICIES: ReadonlySet = new Set([ + 'public-config', +]); + +/** + * Env subset this module reads. Narrowed via a structural type rather than the + * full `Env` so the resolver stays unit-testable without a Worker binding + * (matches the `ModelCatalogEnv` pattern in `services/model-catalog.ts`). + */ +export interface CacheHeaderEnv { + PUBLIC_CONFIG_CACHE_MAX_AGE_SECONDS?: string; + PUBLIC_CONFIG_CACHE_SWR_SECONDS?: string; + MODEL_CATALOG_CACHE_MAX_AGE_SECONDS?: string; + MODEL_CATALOG_CACHE_SWR_SECONDS?: string; + PROJECT_REFERENCE_CACHE_MAX_AGE_SECONDS?: string; + PROJECT_REFERENCE_CACHE_SWR_SECONDS?: string; +} + +interface CachePolicyDefaults { + maxAgeEnvKey: keyof CacheHeaderEnv; + swrEnvKey: keyof CacheHeaderEnv; + defaultMaxAgeSeconds: number; + defaultSwrSeconds: number; +} + +const CACHE_POLICY_DEFAULTS: Record = { + 'public-config': { + maxAgeEnvKey: 'PUBLIC_CONFIG_CACHE_MAX_AGE_SECONDS', + swrEnvKey: 'PUBLIC_CONFIG_CACHE_SWR_SECONDS', + defaultMaxAgeSeconds: DEFAULT_PUBLIC_CONFIG_CACHE_MAX_AGE_SECONDS, + defaultSwrSeconds: DEFAULT_PUBLIC_CONFIG_CACHE_SWR_SECONDS, + }, + 'model-catalog': { + maxAgeEnvKey: 'MODEL_CATALOG_CACHE_MAX_AGE_SECONDS', + swrEnvKey: 'MODEL_CATALOG_CACHE_SWR_SECONDS', + defaultMaxAgeSeconds: DEFAULT_MODEL_CATALOG_RESPONSE_CACHE_MAX_AGE_SECONDS, + defaultSwrSeconds: DEFAULT_MODEL_CATALOG_RESPONSE_CACHE_SWR_SECONDS, + }, + 'project-reference': { + maxAgeEnvKey: 'PROJECT_REFERENCE_CACHE_MAX_AGE_SECONDS', + swrEnvKey: 'PROJECT_REFERENCE_CACHE_SWR_SECONDS', + defaultMaxAgeSeconds: DEFAULT_PROJECT_REFERENCE_CACHE_MAX_AGE_SECONDS, + defaultSwrSeconds: DEFAULT_PROJECT_REFERENCE_CACHE_SWR_SECONDS, + }, +}; + +/** Upper bound on any configured cache window (24h) — a fat-fingered env value + * must not pin a stale body in browsers for weeks. */ +const MAX_CACHE_SECONDS = 24 * 60 * 60; + +/** + * Parse a non-negative integer seconds value, clamped to `[0, MAX_CACHE_SECONDS]`. + * Anything unparseable, negative, or fractional falls back to the default — an + * operator typo degrades to the shipped policy rather than to "cache forever". + */ +function resolveCacheSeconds(raw: string | undefined, fallback: number): number { + if (raw === undefined || raw.trim() === '') return fallback; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < 0) return fallback; + return Math.min(parsed, MAX_CACHE_SECONDS); +} + +export interface ResolvedCachePolicy { + cacheControl: string; + /** `Cookie` for authenticated policies, `undefined` for public ones. */ + vary?: string; +} + +/** + * Resolve a policy name to the exact header values, applying env overrides. + * Exported so tests can assert the resolution independently of Hono. + */ +export function resolveCachePolicy( + policy: CachePolicyName, + env: CacheHeaderEnv | undefined +): ResolvedCachePolicy { + const config = CACHE_POLICY_DEFAULTS[policy]; + const maxAge = resolveCacheSeconds(env?.[config.maxAgeEnvKey], config.defaultMaxAgeSeconds); + const swr = resolveCacheSeconds(env?.[config.swrEnvKey], config.defaultSwrSeconds); + + const isPublic = PUBLIC_CACHE_POLICIES.has(policy); + const cacheControl = `${isPublic ? 'public' : 'private'}, max-age=${maxAge}, stale-while-revalidate=${swr}`; + + return isPublic ? { cacheControl } : { cacheControl, vary: 'Cookie' }; +} + +/** + * Apply a cache policy to the response being built. + * + * MUST be called before the terminal `c.json(...)` / `c.body(...)`, because Hono + * finalizes headers when the response is created. + */ +export function applyCacheHeaders( + c: Context, + policy: CachePolicyName +): void { + const resolved = resolveCachePolicy(policy, c.env); + c.header('Cache-Control', resolved.cacheControl); + if (resolved.vary) c.header('Vary', resolved.vary); +} diff --git a/apps/api/src/routes/agent-profiles.ts b/apps/api/src/routes/agent-profiles.ts index c0e3df3c80..c4165f94e8 100644 --- a/apps/api/src/routes/agent-profiles.ts +++ b/apps/api/src/routes/agent-profiles.ts @@ -3,6 +3,7 @@ import { Hono } from 'hono'; import * as schema from '../db/schema'; import type { Env } from '../env'; +import { applyCacheHeaders } from '../lib/cache-headers'; import { requireRouteParam } from '../lib/route-helpers'; import { getUserId, requireApproved,requireAuth } from '../middleware/auth'; import { requireProjectAccess, requireProjectCapability } from '../middleware/project-auth'; @@ -23,6 +24,11 @@ agentProfileRoutes.get('/', async (c) => { await requireProjectAccess(db, projectId, userId); const profiles = await agentProfileService.listProfiles(db, projectId, userId, c.env); + // Per-user body (project profiles OR this caller's global profiles), so this is + // strictly `private` + `Vary: Cookie`. max-age is 0 by default: the response is + // served stale-then-revalidated, so a user's own edit is masked for at most one + // request rather than held fresh. + applyCacheHeaders(c, 'project-reference'); return c.json({ items: profiles }); }); diff --git a/apps/api/src/routes/model-catalog.ts b/apps/api/src/routes/model-catalog.ts index cc04fdc211..ed2119401a 100644 --- a/apps/api/src/routes/model-catalog.ts +++ b/apps/api/src/routes/model-catalog.ts @@ -1,6 +1,7 @@ import { Hono } from 'hono'; import type { Env } from '../env'; +import { applyCacheHeaders } from '../lib/cache-headers'; import { requireApproved, requireAuth } from '../middleware/auth'; import { getModelCatalogForAgent } from '../services/model-catalog'; @@ -11,6 +12,10 @@ modelCatalogRoutes.use('*', requireAuth(), requireApproved()); modelCatalogRoutes.get('/:agentType', async (c) => { const agentType = c.req.param('agentType'); const catalog = await getModelCatalogForAgent(c.env, agentType); + // Authenticated, but the body depends only on `agentType` — identical for every + // caller. Still `private` + `Vary: Cookie` (see lib/cache-headers.ts): the + // request is credentialed, so a shared cache must never hold it. + applyCacheHeaders(c, 'model-catalog'); return c.json(catalog); }); diff --git a/apps/api/src/routes/skills.ts b/apps/api/src/routes/skills.ts index 3039a51d9f..0c470b595d 100644 --- a/apps/api/src/routes/skills.ts +++ b/apps/api/src/routes/skills.ts @@ -3,6 +3,7 @@ import { Hono } from 'hono'; import * as schema from '../db/schema'; import type { Env } from '../env'; +import { applyCacheHeaders } from '../lib/cache-headers'; import { requireRouteParam } from '../lib/route-helpers'; import { getUserId, requireApproved, requireAuth } from '../middleware/auth'; import { requireProjectAccess, requireProjectCapability } from '../middleware/project-auth'; @@ -19,6 +20,9 @@ skillRoutes.get('/', async (c) => { const db = drizzle(c.env.DATABASE, { schema }); await requireProjectAccess(db, projectId, userId); const skills = await skillService.listSkills(db, projectId, userId); + // Per-user body (project skills OR this caller's global skills) — see the + // agent-profiles list handler for the same reasoning. + applyCacheHeaders(c, 'project-reference'); return c.json({ items: skills }); }); diff --git a/apps/api/tests/unit/lib/cache-headers.test.ts b/apps/api/tests/unit/lib/cache-headers.test.ts new file mode 100644 index 0000000000..5dad452c8d --- /dev/null +++ b/apps/api/tests/unit/lib/cache-headers.test.ts @@ -0,0 +1,193 @@ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { describe, expect, it } from 'vitest'; + +import type { Env } from '../../../src/env'; +import { + applyCacheHeaders, + type CacheHeaderEnv, + type CachePolicyName, + resolveCachePolicy, +} from '../../../src/lib/cache-headers'; + +const ALL_POLICIES: CachePolicyName[] = ['public-config', 'model-catalog', 'project-reference']; + +/** Policies applied to endpoints that sit behind requireAuth(). */ +const AUTHENTICATED_POLICIES: CachePolicyName[] = ['model-catalog', 'project-reference']; + +function parseDirectives(header: string): Map { + return new Map( + header.split(',').map((part) => { + const [name, value] = part.trim().split('='); + return [name.toLowerCase(), value ?? true] as const; + }) + ); +} + +describe('resolveCachePolicy', () => { + it('emits the shipped defaults for public config', () => { + expect(resolveCachePolicy('public-config', {})).toEqual({ + cacheControl: 'public, max-age=60, stale-while-revalidate=300', + }); + }); + + it('emits the shipped defaults for the model catalog', () => { + expect(resolveCachePolicy('model-catalog', {})).toEqual({ + cacheControl: 'private, max-age=60, stale-while-revalidate=300', + vary: 'Cookie', + }); + }); + + it('emits always-revalidate SWR for per-user project reference data', () => { + // max-age=0 means a user's own edit is masked for at most one request. + expect(resolveCachePolicy('project-reference', {})).toEqual({ + cacheControl: 'private, max-age=0, stale-while-revalidate=30', + vary: 'Cookie', + }); + }); + + describe('cross-tenant safety invariants', () => { + it.each(AUTHENTICATED_POLICIES)('%s is never public', (policy) => { + // The API runs CORS with credentials:true. A `public` directive on a + // credentialed response lets a shared cache (CF edge, corporate proxy) + // serve one user's body to another. + const { cacheControl } = resolveCachePolicy(policy, {}); + expect(cacheControl).toMatch(/^private,/); + expect(cacheControl).not.toContain('public'); + }); + + it.each(AUTHENTICATED_POLICIES)('%s varies on Cookie', (policy) => { + // `private` alone is not enough: the browser HTTP cache is keyed per + // browser profile, not per login, so without Vary a second account in the + // same browser could be served the first account's entry. + expect(resolveCachePolicy(policy, {}).vary).toBe('Cookie'); + }); + + it('stays private even when an operator tries to widen it via env', () => { + // There is no env knob that can flip an authenticated policy to public — + // the split is structural, not configuration. + const hostile = { + MODEL_CATALOG_CACHE_MAX_AGE_SECONDS: '86400', + PROJECT_REFERENCE_CACHE_MAX_AGE_SECONDS: '86400', + } satisfies CacheHeaderEnv; + for (const policy of AUTHENTICATED_POLICIES) { + expect(resolveCachePolicy(policy, hostile).cacheControl).toMatch(/^private,/); + } + }); + + it.each(ALL_POLICIES)('%s never emits no-store or no-cache', (policy) => { + // Guards against a future edit accidentally turning a cache policy into a + // no-cache policy, which would silently remove the whole optimisation. + const directives = parseDirectives(resolveCachePolicy(policy, {}).cacheControl); + expect(directives.has('no-store')).toBe(false); + expect(directives.has('no-cache')).toBe(false); + }); + }); + + describe('env overrides', () => { + it('applies operator-supplied TTLs', () => { + expect( + resolveCachePolicy('public-config', { + PUBLIC_CONFIG_CACHE_MAX_AGE_SECONDS: '15', + PUBLIC_CONFIG_CACHE_SWR_SECONDS: '45', + }).cacheControl + ).toBe('public, max-age=15, stale-while-revalidate=45'); + }); + + it('accepts 0 as a real value rather than treating it as unset', () => { + // `0` is falsy — a naive `Number(raw) || fallback` would silently ignore an + // operator explicitly disabling freshness. + expect( + resolveCachePolicy('model-catalog', { + MODEL_CATALOG_CACHE_MAX_AGE_SECONDS: '0', + MODEL_CATALOG_CACHE_SWR_SECONDS: '0', + }).cacheControl + ).toBe('private, max-age=0, stale-while-revalidate=0'); + }); + + it('clamps an excessive value to 24h rather than caching indefinitely', () => { + expect( + resolveCachePolicy('public-config', { + PUBLIC_CONFIG_CACHE_MAX_AGE_SECONDS: '99999999', + }).cacheControl + ).toContain('max-age=86400'); + }); + + it.each([ + ['negative', '-1'], + ['fractional', '30.5'], + ['non-numeric', 'forever'], + ['empty', ' '], + ])('falls back to the default for a %s value', (_label, raw) => { + // A fat-fingered env value must degrade to the shipped policy, never to + // "cache forever". + expect( + resolveCachePolicy('public-config', { PUBLIC_CONFIG_CACHE_MAX_AGE_SECONDS: raw }) + .cacheControl + ).toBe('public, max-age=60, stale-while-revalidate=300'); + }); + + it('falls back to defaults when env is entirely absent', () => { + expect(resolveCachePolicy('public-config', undefined).cacheControl).toBe( + 'public, max-age=60, stale-while-revalidate=300' + ); + }); + }); +}); + +describe('applyCacheHeaders', () => { + function appFor(policy: CachePolicyName) { + const app = new Hono<{ Bindings: Env }>(); + app.get('/probe', (c) => { + applyCacheHeaders(c, policy); + return c.json({ ok: true }); + }); + return app; + } + + it('sets Cache-Control and Vary on the real response', async () => { + const res = await appFor('project-reference').request('/probe', {}, {} as Env); + expect(res.status).toBe(200); + expect(res.headers.get('cache-control')).toBe('private, max-age=0, stale-while-revalidate=30'); + expect(res.headers.get('vary')).toBe('Cookie'); + }); + + it('omits Vary for public policies so shared caches are not fragmented', async () => { + const res = await appFor('public-config').request('/probe', {}, {} as Env); + expect(res.headers.get('cache-control')).toBe('public, max-age=60, stale-while-revalidate=300'); + expect(res.headers.get('vary')).toBeNull(); + }); + + it('coexists with the CORS Vary: Origin instead of clobbering it', async () => { + // The global CORS middleware (src/index.ts) sets `Vary: Origin` AFTER the + // handler runs. If it used `.set()` rather than `.append()`, it would silently + // erase our `Vary: Cookie` and with it the cross-account protection — a change + // that would leave every other test in this file green. Pin the real + // interaction so a Hono upgrade cannot regress it unnoticed. + const app = new Hono<{ Bindings: Env }>(); + app.use('*', cors({ origin: () => 'https://app.example.com', credentials: true })); + app.get('/probe', (c) => { + applyCacheHeaders(c, 'project-reference'); + return c.json({ ok: true }); + }); + + const res = await app.request( + '/probe', + { headers: { Origin: 'https://app.example.com' } }, + {} as Env + ); + + const vary = res.headers.get('vary') ?? ''; + const values = vary.split(',').map((v) => v.trim()); + expect(values).toContain('Cookie'); + expect(values).toContain('Origin'); + }); + + it('honours env overrides through the Hono context', async () => { + const res = await appFor('model-catalog').request('/probe', {}, { + MODEL_CATALOG_CACHE_MAX_AGE_SECONDS: '5', + MODEL_CATALOG_CACHE_SWR_SECONDS: '10', + } as Env); + expect(res.headers.get('cache-control')).toBe('private, max-age=5, stale-while-revalidate=10'); + }); +}); diff --git a/apps/api/tests/unit/routes/agent-profiles.test.ts b/apps/api/tests/unit/routes/agent-profiles.test.ts index 751badb876..f97cade39a 100644 --- a/apps/api/tests/unit/routes/agent-profiles.test.ts +++ b/apps/api/tests/unit/routes/agent-profiles.test.ts @@ -117,6 +117,32 @@ describe('Agent Profiles Routes', () => { ); }); + it('marks the list private, always-revalidate, and varying on Cookie', async () => { + // Per-user body (project profiles OR this caller's global profiles). + mockService.listProfiles.mockResolvedValueOnce([makeProfile({ id: 'p1', name: 'default' })]); + + const res = await app.request(`${BASE_URL}${REQUEST_PATH}`, { method: 'GET' }, makeEnv()); + + expect(res.headers.get('cache-control')).toBe( + 'private, max-age=0, stale-while-revalidate=30' + ); + expect(res.headers.get('vary')).toBe('Cookie'); + expect(res.headers.get('cache-control')).not.toContain('public'); + }); + + it('does NOT cache a rejected (non-member) response', async () => { + // A 404 for a non-member must never be cacheable — otherwise a later + // successful membership check could be masked by the cached rejection. + mockProjectAuth.requireProjectAccess.mockRejectedValueOnce( + Object.assign(new Error('Project not found'), { statusCode: 404, error: 'NOT_FOUND' }) + ); + + const res = await app.request(`${BASE_URL}${REQUEST_PATH}`, { method: 'GET' }, makeEnv()); + + expect(res.status).toBe(404); + expect(res.headers.get('cache-control')).toBeNull(); + }); + it('rejects non-members before listing profiles', async () => { mockProjectAuth.requireProjectAccess.mockRejectedValueOnce( Object.assign(new Error('Project not found'), { diff --git a/apps/api/tests/unit/routes/model-catalog.test.ts b/apps/api/tests/unit/routes/model-catalog.test.ts index 1a652a1c3d..530cefaab4 100644 --- a/apps/api/tests/unit/routes/model-catalog.test.ts +++ b/apps/api/tests/unit/routes/model-catalog.test.ts @@ -33,6 +33,31 @@ describe('model catalog routes', () => { }); }); + it('marks the response private and varies on Cookie', async () => { + // The body is identical for every caller, but the request is credentialed + // (requireAuth) — so a shared cache must never hold it, and a second login in + // the same browser must not hit the first login's entry. + const res = await app.request('/api/model-catalog/opencode', { method: 'GET' }, { + KV: {}, + } as Env); + + expect(res.headers.get('cache-control')).toBe( + 'private, max-age=60, stale-while-revalidate=300' + ); + expect(res.headers.get('vary')).toBe('Cookie'); + expect(res.headers.get('cache-control')).not.toContain('public'); + }); + + it('honours the operator TTL override', async () => { + const res = await app.request('/api/model-catalog/opencode', { method: 'GET' }, { + KV: {}, + MODEL_CATALOG_CACHE_MAX_AGE_SECONDS: '5', + MODEL_CATALOG_CACHE_SWR_SECONDS: '10', + } as Env); + + expect(res.headers.get('cache-control')).toBe('private, max-age=5, stale-while-revalidate=10'); + }); + it('returns the model catalog for the requested agent type', async () => { const env = { KV: {} } as Env; diff --git a/apps/api/tests/unit/routes/skills.test.ts b/apps/api/tests/unit/routes/skills.test.ts index 7f2d925f4d..7c8d68c197 100644 --- a/apps/api/tests/unit/routes/skills.test.ts +++ b/apps/api/tests/unit/routes/skills.test.ts @@ -99,18 +99,62 @@ describe('Skill Routes', () => { expect(res.status).toBe(200); await expect(res.json()).resolves.toMatchObject({ items: [{ id: 'skill-1' }] }); - expect(mocks.requireProjectAccess).toHaveBeenCalledWith(expect.anything(), 'project-1', 'user-1'); + expect(mocks.requireProjectAccess).toHaveBeenCalledWith( + expect.anything(), + 'project-1', + 'user-1' + ); expect(mocks.listSkills).toHaveBeenCalledWith(expect.anything(), 'project-1', 'user-1'); }); + describe('cache headers', () => { + it('marks the list private, always-revalidate, and varying on Cookie', async () => { + // The body is per-user (project skills OR this caller's global skills), so + // it must never reach a shared cache, and a second login in the same + // browser must not be served the first login's entry. + mocks.listSkills.mockResolvedValueOnce([makeSkill()]); + + const res = await app.request(REQUEST_PATH, { method: 'GET' }, makeEnv()); + + expect(res.headers.get('cache-control')).toBe( + 'private, max-age=0, stale-while-revalidate=30' + ); + expect(res.headers.get('vary')).toBe('Cookie'); + expect(res.headers.get('cache-control')).not.toContain('public'); + }); + + it('does NOT cache the create response', async () => { + // Discriminating: proves the header is scoped to the GET handler and did + // not leak to sibling routes via router-level middleware. + mocks.createSkill.mockResolvedValueOnce(makeSkill({ id: 'skill-new' })); + + const res = await app.request( + REQUEST_PATH, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Release', prompt: 'ship it' }), + }, + makeEnv() + ); + + expect(res.headers.get('cache-control')).toBeNull(); + expect(res.headers.get('vary')).toBeNull(); + }); + }); + it('creates a skill and defaults to task mode through the service payload', async () => { mocks.createSkill.mockResolvedValueOnce(makeSkill({ id: 'skill-new', name: 'Release' })); - const res = await app.request(REQUEST_PATH, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'Release', resourceRequirementsJson: '{"cpu":4}' }), - }, makeEnv()); + const res = await app.request( + REQUEST_PATH, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Release', resourceRequirementsJson: '{"cpu":4}' }), + }, + makeEnv() + ); expect(res.status).toBe(201); await expect(res.json()).resolves.toMatchObject({ id: 'skill-new', name: 'Release' }); @@ -137,22 +181,30 @@ describe('Skill Routes', () => { }) ); - const res = await app.request(REQUEST_PATH, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'Release' }), - }, makeEnv()); + const res = await app.request( + REQUEST_PATH, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Release' }), + }, + makeEnv() + ); expect(res.status).toBe(404); expect(mocks.createSkill).not.toHaveBeenCalled(); }); it('rejects invalid create payloads before calling the service', async () => { - const res = await app.request(REQUEST_PATH, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ description: 'missing name' }), - }, makeEnv()); + const res = await app.request( + REQUEST_PATH, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ description: 'missing name' }), + }, + makeEnv() + ); expect(res.status).toBe(400); expect(mocks.createSkill).not.toHaveBeenCalled(); @@ -167,11 +219,15 @@ describe('Skill Routes', () => { expect(getRes.status).toBe(200); await expect(getRes.json()).resolves.toMatchObject({ id: 'skill-1' }); - const patchRes = await app.request(`${REQUEST_PATH}/skill-1`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ description: 'Updated' }), - }, makeEnv()); + const patchRes = await app.request( + `${REQUEST_PATH}/skill-1`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ description: 'Updated' }), + }, + makeEnv() + ); expect(patchRes.status).toBe(200); expect(mocks.updateSkill).toHaveBeenCalledWith( expect.anything(), diff --git a/apps/api/tests/workers/worker-smoke.test.ts b/apps/api/tests/workers/worker-smoke.test.ts index c62cc1b221..95ccb0660e 100644 --- a/apps/api/tests/workers/worker-smoke.test.ts +++ b/apps/api/tests/workers/worker-smoke.test.ts @@ -169,6 +169,61 @@ describe('Worker smoke tests (workerd runtime)', () => { }); }); + describe('response cache headers', () => { + // These endpoints are unauthenticated and byte-identical for every caller, + // which is the only condition under which `public` is safe (the API runs CORS + // with credentials: true — see src/lib/cache-headers.ts). + const PUBLIC_CONFIG_PATHS = [ + '/api/config/artifacts-enabled', + '/api/config/vapid-public-key', + '/api/config/login-providers', + ]; + + it.each(PUBLIC_CONFIG_PATHS)('serves %s with a public SWR policy', async (path) => { + const response = await SELF.fetch(`https://api.test.example.com${path}`); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe( + 'public, max-age=60, stale-while-revalidate=300' + ); + // The global CORS middleware contributes `Vary: Origin`. What matters is + // that we do NOT add `Cookie`: fragmenting a shared cache per session for a + // body that does not depend on the caller would defeat the point. + const vary = response.headers.get('vary') ?? ''; + expect(vary.split(',').map((v) => v.trim())).not.toContain('Cookie'); + }); + + // Discriminating controls: caching is opt-in per handler, so nothing else may + // pick it up. If someone converts this to blanket middleware, these fail. + it.each([ + ['a real-time authenticated list', '/api/projects'], + ['workspace runtime state', '/api/workspaces'], + ['node runtime state', '/api/nodes'], + ])('does not cache %s', async (_label, path) => { + const response = await SELF.fetch(`https://api.test.example.com${path}`); + expect(response.status).toBe(401); + expect(response.headers.get('cache-control')).toBeNull(); + }); + + it('does not cache the health endpoint', async () => { + const response = await SELF.fetch('https://api.test.example.com/health'); + expect(response.headers.get('cache-control')).toBeNull(); + }); + + it('never marks an authenticated response public', async () => { + // The invariant that matters most: a `public` directive on a credentialed + // response would let a shared cache serve one user's body to another. + for (const path of [ + '/api/projects', + '/api/workspaces', + '/api/nodes', + '/api/model-catalog/opencode', + ]) { + const response = await SELF.fetch(`https://api.test.example.com${path}`); + expect(response.headers.get('cache-control') ?? '').not.toContain('public'); + } + }); + }); + describe('Anthropic proxy route', () => { it('returns 401 for /ai/anthropic/v1/messages without x-api-key', async () => { const response = await SELF.fetch('https://api.test.example.com/ai/anthropic/v1/messages', { diff --git a/apps/web/.env.example b/apps/web/.env.example index 750ba053b4..1356b4d0d2 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -62,6 +62,18 @@ VITE_CMD_PALETTE_MAX_RESULTS_PER_CATEGORY=10 # (default: 180ms) # VITE_ROUTE_FALLBACK_REVEAL_DELAY_MS=180 +# Query-cache persistence (IndexedDB) +# An allowlisted slice of the TanStack Query cache is written to IndexedDB so a full +# page reload paints from cache instead of refetching. Records are namespaced by +# authenticated user and deleted on sign-out and account switch. +# How long a persisted record may be restored after it was written (default: 24h) +# VITE_QUERY_PERSIST_MAX_AGE_MS=86400000 +# Minimum gap between IndexedDB writes (default: 1s) +# VITE_QUERY_PERSIST_THROTTLE_MS=1000 +# Budget for the initial restore before failing open to an empty cache. Kept tight because +# rendering is gated on it and it can only add to first paint (default: 250ms) +# VITE_QUERY_PERSIST_RESTORE_TIMEOUT_MS=250 + # Maximum event pages fetched for one durable diagnosis timeline (default: 100) # VITE_DEBUG_DIAGNOSIS_EVENT_MAX_PAGES=100 diff --git a/apps/web/package.json b/apps/web/package.json index 6af3690c56..76a992c7ce 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,12 +26,14 @@ "@simple-agent-manager/terminal": "workspace:*", "@simple-agent-manager/ui": "workspace:*", "@tailwindcss/vite": "4.2.3", + "@tanstack/query-persist-client-core": "5.101.2", "@tanstack/react-query": "^5.101.2", "@xyflow/react": "12.10.2", "better-auth": "catalog:", "d3-scale": "4.0.2", "dagre": "0.8.5", "dompurify": "3.4.13", + "idb-keyval": "^6.2.1", "lucide-react": "catalog:", "mermaid": "11.14.0", "prism-react-renderer": "catalog:", @@ -64,6 +66,7 @@ "eslint": "catalog:", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "catalog:", + "fake-indexeddb": "^6.2.5", "jsdom": "catalog:", "typescript": "catalog:", "vite": "catalog:", diff --git a/apps/web/src/components/AuthProvider.tsx b/apps/web/src/components/AuthProvider.tsx index fbbeb63918..62c380f9e1 100644 --- a/apps/web/src/components/AuthProvider.tsx +++ b/apps/web/src/components/AuthProvider.tsx @@ -1,4 +1,5 @@ import type { UserRole, UserStatus } from '@simple-agent-manager/shared'; +import { Spinner } from '@simple-agent-manager/ui'; import { createContext, type ReactNode, @@ -10,6 +11,10 @@ import { useState, } from 'react'; +import { + discardPersistedQueryCache, + useQueryCachePersistence, +} from '../hooks/useQueryCachePersistence'; import { setUserId } from '../lib/analytics'; import { GITHUB_REAUTH_REQUIRED_EVENT } from '../lib/api/client'; import { signOut, useSession } from '../lib/auth'; @@ -110,6 +115,16 @@ export function AuthProvider({ children }: AuthProviderProps) { ? canResolveCacheNamespace : !canResolveCacheNamespace || activeCacheNamespace !== nextCacheNamespace; + // Rehydrate the persisted query cache for the RESOLVED namespace (never the + // in-flight one). Holding the spinner over this is what makes a reload paint + // from cache rather than from an empty state — the restore is a single + // IndexedDB read, bounded by a timeout that fails open, and a signed-out + // session never waits at all. + const isRestoringPersistedQueryCache = useQueryCachePersistence( + activeCacheNamespace, + enrichedUser?.id ?? '' + ); + useLayoutEffect(() => { if (!canResolveCacheNamespace || activeCacheNamespace === nextCacheNamespace) return; @@ -120,6 +135,11 @@ export function AuthProvider({ children }: AuthProviderProps) { cleanupTerminalSecrets(); broadcastAuthRevocation(); if (previousNamespace) clearLibraryCache(previousNamespace); + // The persisted query cache is namespaced by identity, so the next user + // cannot read this record — but leaving it on disk after the account it + // belongs to is gone serves no purpose. Drop it for the same reason + // clearLibraryCache runs here. + discardPersistedQueryCache(previousNamespace); } queryClient.clear(); @@ -187,7 +207,22 @@ export function AuthProvider({ children }: AuthProviderProps) { return ( - {isCacheNamespaceTransitioning ? null : children} + {isCacheNamespaceTransitioning ? null : isRestoringPersistedQueryCache ? ( + // Keep the SAME affordance ProtectedRoute was already showing while the + // session resolved, so restoring the persisted cache continues one + // uninterrupted spinner instead of unmounting it into a silent blank + // screen. Without this the sequence is spinner -> void -> content, and a + // screen reader hears "Verifying your session" and then nothing at all. +
+ +
+ ) : ( + children + )} {githubReauthMessage && (
(() => + namespace ? undefined : namespace + ); + + // `namespace` and `scope` both derive from `user.id`, so they always change + // together — listing both keeps exhaustive-deps happy without causing an extra + // resubscribe, and capturing `scope` in the effect closure (rather than a live + // ref) guarantees the dehydrate predicate can never check a scope that + // disagrees with the storage key it is writing to. + useEffect(() => { + // Identity not resolved yet — AuthProvider is still gating on its own state. + if (namespace === undefined) return; + + // Signed out: nothing to restore and nothing may be written. + if (!namespace) { + setRestoredFor(namespace); + return; + } + + let cancelled = false; + let settleTimer: ReturnType | null = null; + let teardown: (() => void) | null = null; + + const settle = () => { + if (cancelled) return; + if (settleTimer !== null) { + clearTimeout(settleTimer); + settleTimer = null; + } + setRestoredFor(namespace); + }; + + // Rendering is gated on the restore, so a hung or pathologically slow + // IndexedDB must not hang the app. Armed BEFORE awaiting the dynamic import so + // a stalled chunk fetch cannot hold the gate open either. + settleTimer = setTimeout(settle, QUERY_PERSIST_RESTORE_TIMEOUT_MS); + + void (async () => { + try { + const [{ persistQueryClient }, persistence] = await Promise.all([ + import('@tanstack/query-persist-client-core'), + import('../lib/query-persistence'), + ]); + if (cancelled) return; + + const storageKey = persistence.buildQueryPersistStorageKey(namespace); + if (!storageKey) { + settle(); + return; + } + + const persister = persistence.createIdbQueryPersister(storageKey); + const [unsubscribe, restored] = persistQueryClient({ + queryClient, + persister, + maxAge: persistence.QUERY_PERSIST_MAX_AGE_MS, + buster: persistence.QUERY_PERSIST_SCHEMA_VERSION, + dehydrateOptions: { + shouldDehydrateQuery: (query) => + persistence.shouldDehydratePersistedQuery(query, scope), + // Mutation state is on the "never persist" list. + shouldDehydrateMutation: () => false, + }, + }); + + teardown = () => { + unsubscribe(); + // AuthProvider's transition layout effect already ran + // `queryClient.clear()` while this subscription was still attached, so a + // write of the resulting empty snapshot may be queued against the + // OUTGOING identity's record. Drop it rather than let it blank a cache + // we may still want. + persister.cancelPendingWrites(); + }; + + // Torn down while the import was in flight. + if (cancelled) { + teardown(); + teardown = null; + return; + } + + void restored.then(settle, settle); + } catch { + // Chunk fetch failed (offline, or a redeploy moved the hash). Persistence + // is an optimisation — fail open to the normal in-memory cache. + settle(); + } + })(); + + return () => { + cancelled = true; + if (settleTimer !== null) clearTimeout(settleTimer); + teardown?.(); + }; + }, [namespace, scope]); + + return restoredFor !== namespace; +} + +/** + * Delete the persisted record for an identity that is no longer active. + * + * Called by `AuthProvider` on an account switch / sign-out transition, alongside + * the existing `clearLibraryCache(previousNamespace)`. Fire-and-forget, and + * dynamically imported for the same bundle reason as above: the record is already + * unreadable to the next user (namespaced key + scope-checked allowlist), so this + * is hygiene rather than the isolation boundary. + */ +export function discardPersistedQueryCache(namespace: string | null | undefined): void { + if (!namespace) return; + void import('../lib/query-persistence') + .then((m) => m.removePersistedQueryCache(namespace)) + .catch(() => { + // Nothing to do — see the doc comment: this is hygiene, not the boundary. + }); +} diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 2aadf3b194..f59ea94622 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -2,7 +2,11 @@ import { createAuthClient } from 'better-auth/react'; import { unsubscribeWebPush } from './api/notifications'; import { clearLegacyLibraryCache, clearLibraryCache } from './library-cache'; -import { broadcastAuthRevocation, cleanupTerminalSecrets, resetAuthRevoked } from './terminal-cleanup'; +import { + broadcastAuthRevocation, + cleanupTerminalSecrets, + resetAuthRevoked, +} from './terminal-cleanup'; const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8787'; @@ -62,6 +66,17 @@ export async function signOut() { } clearLibraryCache(); clearLegacyLibraryCache(); + // Best-effort, internally bounded sweep of the persisted query cache. Awaited so + // it normally completes before the redirect, and it runs even when the signOut + // request below fails — the case the archived cross-user cache incident cared + // about. It is NOT a hard guarantee: the sweep races an internal timeout so + // sign-out can never hang behind IndexedDB. That is safe because a surviving + // record is keyed to this same user and gated by the scope-checked allowlist, so + // it can never be read by the next account. + // Imported dynamically to keep idb-keyval out of the eager bundle. + await import('./query-persistence') + .then((m) => m.removeAllPersistedQueryCaches()) + .catch(() => undefined); try { await authClient.signOut({ fetchOptions: { diff --git a/apps/web/src/lib/query-options.ts b/apps/web/src/lib/query-options.ts index 4538b8eb4f..7fa01bd48c 100644 --- a/apps/web/src/lib/query-options.ts +++ b/apps/web/src/lib/query-options.ts @@ -1,6 +1,27 @@ import { queryOptions } from '@tanstack/react-query'; import { getProject, listGitHubInstallations, listProjects } from './api'; +import { QUERY_PERSIST_MAX_AGE_MS } from './query-persist-config'; + +/** + * `gcTime` for the ONE query the persister is allowed to write (`projects/list` — + * see `query-persist-config.ts`). + * + * Applied to that query alone, never as a QueryClient default: a global `gcTime` + * this long would pin node, workspace and admin-diagnosis data in memory too, + * which is both a memory regression and data the security review says to keep + * short-lived. + * + * Deliberately NOT applied to `projectDetail`. That query is not persisted, and + * `useProjectIntentPrefetch` populates it after a 120 ms hover — so a 24 h + * `gcTime` there would pin one full detail payload per project a user merely + * scrolled past, with no eviction bound. It keeps the default 5 min instead. + * + * Matching the persist `maxAge` keeps the two eviction clocks aligned: a query + * garbage-collected out of memory would be dropped from the next dehydrate, and a + * restored entry evicted sooner than `maxAge` would make persistence pointless. + */ +const PERSISTED_QUERY_GC_TIME_MS = QUERY_PERSIST_MAX_AGE_MS; export const projectQueryKeys = { all: (queryScope: string) => ['auth', queryScope, 'projects'] as const, @@ -22,6 +43,7 @@ export function projectListQueryOptions(queryScope: string, limit?: number) { return queryOptions({ queryKey: projectQueryKeys.list(queryScope, limit), queryFn: async () => (await listProjects(limit)).projects, + gcTime: PERSISTED_QUERY_GC_TIME_MS, }); } diff --git a/apps/web/src/lib/query-persist-config.ts b/apps/web/src/lib/query-persist-config.ts new file mode 100644 index 0000000000..460a2c1041 --- /dev/null +++ b/apps/web/src/lib/query-persist-config.ts @@ -0,0 +1,154 @@ +import { + DEFAULT_QUERY_PERSIST_MAX_AGE_MS, + DEFAULT_QUERY_PERSIST_RESTORE_TIMEOUT_MS, + DEFAULT_QUERY_PERSIST_THROTTLE_MS, +} from '@simple-agent-manager/shared'; +import type { Query } from '@tanstack/react-query'; + +/** + * Pure configuration and policy for query-cache persistence. + * + * Deliberately free of any IndexedDB import. `AuthProvider` is statically imported + * by `App.tsx`, so anything it can reach at module scope ships in the eager + * bundle; keeping the key builder, the timing constants and the allowlist here + * lets those callers stay cheap while `query-persistence.ts` (which pulls in + * `idb-keyval`) is loaded only for signed-in sessions. + * + * Persistence for an allowlisted slice of the TanStack Query cache. + * + * Why IndexedDB and not localStorage: `lib/library-cache.ts` already competes for + * the ~5 MB localStorage budget hard enough to need its own LRU eviction + * (`findOldestLibraryKey`). Putting the query cache in a separate IDB store means + * it can never evict library index entries, and gives headroom as the allowlist + * grows. + * + * ## Security model + * + * Two independent layers, either of which alone prevents cross-user leakage: + * + * 1. **Storage key namespacing** — the IDB key embeds the authenticated user + * namespace (`buildLibraryCacheNamespace`), so two accounts never share a + * record. `AuthProvider` additionally deletes the previous namespace's record + * on every identity transition. + * 2. **Dehydration allowlist** — {@link shouldDehydratePersistedQuery} only ever + * persists keys shaped `['auth', , ]`. + * A key belonging to another scope cannot be written even if it is somehow + * resident in the cache. + * + * The allowlist is deliberately narrow. `tasks/backlog/2026-08-07-expand-frontend- + * query-cache-and-persistence.md` bans persisting chat messages/agent output, + * credentials/tokens, admin errors and diagnoses, node/workspace runtime details, + * file contents/signed URLs, and mutation state without a separate security + * review. + * + * ## The allowlist matches on CONTENT, not just key shape + * + * An earlier revision allowlisted the whole `projects` domain, reasoning that every + * banned surface uses an *unscoped* key (`['nodes',…]`, `['workspaces',…]`, + * `['admin-diagnosis',…]`, `['notification-preferences']`) and is therefore + * excluded structurally. That reasoning was incomplete and shipped a real leak: + * `projectQueryKeys.detail` is ALSO `['auth', scope, 'projects', …]`, and + * `GET /api/projects/:id` returns `recentSessions[].topic` — literally the first 97 + * characters of the user's first chat message (`project-data/messages.ts`) — plus + * `recentActivity[].payload.message`, free-text agent output. Both are on the + * banned list. The response type hides it: `projects/crud.ts` returns those fields + * through an `as ProjectDetailResponse & {…}` cast, so they are invisible to + * TypeScript. + * + * So the allowlist keys on the full `domain/operation` pair, and every entry must + * be justified by what the endpoint actually RETURNS. Read the handler's real + * response body before adding one; the query key will not tell you. + */ + +/** Query-key `domain/operation` pairs (`key[2]/key[3]`) approved for persistence. + * + * Adding an entry writes that endpoint's response to the user's disk and requires + * the security review described above. + * + * Deliberately excluded: + * - `projects/detail` — `getProject` embeds chat-derived session topics and + * agent-authored activity text (see above). + * - `github/installations` — installation identifiers are connection + * configuration, which the backlog task lists as review-gated. + * + * `projects/list` is included because `ProjectSummary` is names, counts and + * timestamps only — no free-text user or agent content. */ +export const PERSISTED_QUERY_OPERATIONS: ReadonlySet = new Set(['projects/list']); + +/** Prefix for every persisted query-cache record. */ +export const QUERY_PERSIST_KEY_PREFIX = 'sam-query-cache'; + +/** + * Generation marker for the persisted payload. Bump whenever the dehydrated shape + * or the allowlist changes so previously written records are discarded instead of + * hydrated into a cache that no longer understands them. + * + * Hand-maintained on purpose, following the `sam-shell-v3` precedent in + * `src/sw.ts`: no build hash or version string reaches the web bundle today + * (`vite.config.ts` has no `define` block and CI injects no SHA), so a derived + * buster would have to be invented rather than read. + */ +export const QUERY_PERSIST_SCHEMA_VERSION = 'v1'; + +function readPositiveIntEnv(raw: string | undefined, fallback: number): number { + const parsed = raw ? Number(raw) : Number.NaN; + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +/** How long a persisted record may be restored after it was written. */ +export const QUERY_PERSIST_MAX_AGE_MS = readPositiveIntEnv( + import.meta.env?.VITE_QUERY_PERSIST_MAX_AGE_MS, + DEFAULT_QUERY_PERSIST_MAX_AGE_MS +); + +/** Minimum gap between IndexedDB writes. */ +export const QUERY_PERSIST_THROTTLE_MS = readPositiveIntEnv( + import.meta.env?.VITE_QUERY_PERSIST_THROTTLE_MS, + DEFAULT_QUERY_PERSIST_THROTTLE_MS +); + +/** Upper bound on the initial restore before we fail open to an empty cache. */ +export const QUERY_PERSIST_RESTORE_TIMEOUT_MS = readPositiveIntEnv( + import.meta.env?.VITE_QUERY_PERSIST_RESTORE_TIMEOUT_MS, + DEFAULT_QUERY_PERSIST_RESTORE_TIMEOUT_MS +); + +/** + * IndexedDB key for a user namespace. Returns `null` for an absent namespace, so + * an unauthenticated session persists nothing at all. + * + * @param namespace the value from `buildLibraryCacheNamespace(userId)` + */ +export function buildQueryPersistStorageKey(namespace: string | null | undefined): string | null { + if (!namespace) return null; + return `${QUERY_PERSIST_KEY_PREFIX}:${QUERY_PERSIST_SCHEMA_VERSION}:${namespace}`; +} + +/** + * The dehydration allowlist. + * + * A query is persisted only when ALL hold: + * - it succeeded and actually has data (never persist errors or in-flight state); + * - its key is `['auth', scope, domain, operation, …]`; + * - `scope` is the *currently authenticated* scope, not merely some scope; + * - `domain/operation` is in {@link PERSISTED_QUERY_OPERATIONS}. + * + * @param scope the active `queryScope` (`user.id`) — an empty scope persists nothing + */ +export function shouldDehydratePersistedQuery(query: Query, scope: string): boolean { + if (!scope) return false; + if (query.state.status !== 'success' || query.state.data === undefined) return false; + + const key = query.queryKey; + if (!Array.isArray(key) || key.length < 4) return false; + + const [prefix, keyScope, domain, operation] = key; + return ( + prefix === 'auth' && + typeof keyScope === 'string' && + keyScope === scope && + typeof domain === 'string' && + typeof operation === 'string' && + PERSISTED_QUERY_OPERATIONS.has(`${domain}/${operation}`) + ); +} diff --git a/apps/web/src/lib/query-persistence.ts b/apps/web/src/lib/query-persistence.ts new file mode 100644 index 0000000000..7829083b1d --- /dev/null +++ b/apps/web/src/lib/query-persistence.ts @@ -0,0 +1,200 @@ +import type { PersistedClient, Persister } from '@tanstack/query-persist-client-core'; +import { del, delMany, get, keys, set, type UseStore } from 'idb-keyval'; + +import { + buildQueryPersistStorageKey, + QUERY_PERSIST_KEY_PREFIX, + QUERY_PERSIST_RESTORE_TIMEOUT_MS, + QUERY_PERSIST_THROTTLE_MS, +} from './query-persist-config'; + +/** + * The IndexedDB half of query-cache persistence. + * + * Split from `query-persist-config.ts` so that `idb-keyval` and this module's + * machinery stay OUT of the eager bundle: only `useQueryCachePersistence` loads + * this, and only once a signed-in namespace exists. See that hook's doc comment. + * + * Re-exports the pure policy surface so existing importers (and tests) can keep + * treating `query-persistence` as the single entry point. + */ +export { + buildQueryPersistStorageKey, + PERSISTED_QUERY_OPERATIONS, + QUERY_PERSIST_MAX_AGE_MS, + QUERY_PERSIST_RESTORE_TIMEOUT_MS, + QUERY_PERSIST_SCHEMA_VERSION, + QUERY_PERSIST_THROTTLE_MS, + shouldDehydratePersistedQuery, +} from './query-persist-config'; + +/** + * A `Persister` backed by IndexedDB. + * + * Every method is fail-open: IndexedDB is unavailable in some private-browsing + * modes and can reject on quota. A storage failure must degrade to "no persisted + * cache", never to a broken app, so failures resolve rather than throw and a + * failed write disables further writes for the lifetime of the persister. + */ +export interface CancellablePersister extends Persister { + /** + * Drop any throttled write that has not landed yet. + * + * Needed because `AuthProvider` calls `queryClient.clear()` during an identity + * transition while the *previous* identity's subscription is still attached + * (React runs effect cleanup on the following commit). Without this, the + * resulting empty snapshot could land on the previous user's record after we + * have already detached, blanking a cache they would otherwise get back on + * their next sign-in. + */ + cancelPendingWrites(): void; +} + +export function createIdbQueryPersister( + storageKey: string, + options: { throttleMs?: number; store?: UseStore } = {} +): CancellablePersister { + const throttleMs = options.throttleMs ?? QUERY_PERSIST_THROTTLE_MS; + const store = options.store; + + let disabled = false; + let pendingClient: PersistedClient | null = null; + let flushTimer: ReturnType | null = null; + let lastWriteAt = 0; + let lastWrittenPayload: string | null = null; + + const flush = async (): Promise => { + flushTimer = null; + const client = pendingClient; + pendingClient = null; + if (!client || disabled) return; + + // Skip a write that would not change anything on disk. + // + // `persistQueryClientSubscribe` subscribes to the WHOLE query cache and + // re-dehydrates on every event anywhere in the app — a screen polling two + // unrelated queries every 10s therefore enqueues a write every throttle + // window even though nothing in the persisted allowlist changed. Comparing + // the serialized payload skips the expensive part (structured clone + IDB + // transaction) for those no-op churn events. + let payload: string; + try { + payload = JSON.stringify(client); + } catch { + // Non-serializable snapshot: let idb-keyval's structured clone decide. + payload = ''; + } + if (payload !== '' && payload === lastWrittenPayload) return; + + lastWriteAt = Date.now(); + try { + await set(storageKey, client, store); + lastWrittenPayload = payload === '' ? null : payload; + } catch { + // Quota exceeded, private mode, or a closed connection. Stop writing — + // retrying on every cache event would only burn cycles. + disabled = true; + lastWrittenPayload = null; + } + }; + + return { + /** + * Throttled write. TanStack v5 removed `throttleTime` from + * `persistQueryClient`, and the subscriber fires on every cache event, so + * without this a busy screen would issue an IDB write per keystroke-ish + * update. Always coalesces to the most recent snapshot. + */ + persistClient(client: PersistedClient): void { + if (disabled) return; + pendingClient = client; + if (flushTimer !== null) return; + const elapsed = Date.now() - lastWriteAt; + const delay = elapsed >= throttleMs ? 0 : throttleMs - elapsed; + flushTimer = setTimeout(() => void flush(), delay); + }, + + async restoreClient(): Promise { + if (disabled) return undefined; + try { + return await get(storageKey, store); + } catch { + disabled = true; + return undefined; + } + }, + + async removeClient(): Promise { + pendingClient = null; + if (flushTimer !== null) { + clearTimeout(flushTimer); + flushTimer = null; + } + try { + await del(storageKey, store); + } catch { + // Nothing recoverable — the record either never existed or IDB is gone. + } + }, + + cancelPendingWrites(): void { + pendingClient = null; + if (flushTimer !== null) { + clearTimeout(flushTimer); + flushTimer = null; + } + }, + }; +} + +/** + * Delete the persisted record for a namespace without constructing a persister. + * + * Used by sign-out and by identity transitions, where the goal is only to make + * the previous user's record unreadable. + */ +export async function removePersistedQueryCache( + namespace: string | null | undefined +): Promise { + const storageKey = buildQueryPersistStorageKey(namespace); + if (!storageKey) return; + try { + await del(storageKey); + } catch { + // Best effort: the record is already unreachable to the next user because the + // storage key is namespaced and the allowlist re-checks the active scope. + } +} + +/** + * Delete every persisted query-cache record, for every identity and every schema + * version. + * + * Sign-out has no user context to namespace by (mirroring the no-argument + * `clearLibraryCache()` it runs beside), and it is the one moment where sweeping + * records from *all* generations — including ones written by an older + * {@link QUERY_PERSIST_SCHEMA_VERSION} — is unambiguously correct. + * + * Bounded by `timeoutMs`: sign-out must not be able to hang behind IndexedDB. + * Leaving a record behind is not a leak (the key is namespaced, and the allowlist + * re-checks the active scope on write), so failing open here is safe. + */ +export async function removeAllPersistedQueryCaches( + timeoutMs: number = QUERY_PERSIST_RESTORE_TIMEOUT_MS, + store?: UseStore +): Promise { + const sweep = async (): Promise => { + const allKeys = await keys(store); + const ours = allKeys.filter( + (key): key is string => + typeof key === 'string' && key.startsWith(`${QUERY_PERSIST_KEY_PREFIX}:`) + ); + if (ours.length > 0) await delMany(ours, store); + }; + + try { + await Promise.race([sweep(), new Promise((resolve) => setTimeout(resolve, timeoutMs))]); + } catch { + // IndexedDB unavailable or rejected — see the doc comment; failing open is safe. + } +} diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index df67c54903..cfae8323eb 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -13,6 +13,9 @@ interface ImportMetaEnv { readonly VITE_CHUNK_LOAD_RETRY_DELAY_MS?: string; readonly VITE_CHUNK_RELOAD_COOLDOWN_MS?: string; readonly VITE_ROUTE_FALLBACK_REVEAL_DELAY_MS?: string; + readonly VITE_QUERY_PERSIST_MAX_AGE_MS?: string; + readonly VITE_QUERY_PERSIST_THROTTLE_MS?: string; + readonly VITE_QUERY_PERSIST_RESTORE_TIMEOUT_MS?: string; readonly DEV: boolean; readonly PROD: boolean; readonly MODE: string; diff --git a/apps/web/tests/setup.ts b/apps/web/tests/setup.ts index 8183fd1855..16fbca24ae 100644 --- a/apps/web/tests/setup.ts +++ b/apps/web/tests/setup.ts @@ -1,4 +1,8 @@ import '@testing-library/jest-dom/vitest'; +// jsdom does not implement IndexedDB. The query-cache persister +// (src/lib/query-persistence.ts) is IDB-backed, and AuthProvider drives it on +// every identity transition, so any test that mounts AuthProvider touches it. +import 'fake-indexeddb/auto'; import { vi } from 'vitest'; diff --git a/apps/web/tests/unit/components/auth-provider-persistence-failure.test.tsx b/apps/web/tests/unit/components/auth-provider-persistence-failure.test.tsx new file mode 100644 index 0000000000..da78ddf0c5 --- /dev/null +++ b/apps/web/tests/unit/components/auth-provider-persistence-failure.test.tsx @@ -0,0 +1,144 @@ +/** + * AuthProvider must still render when IndexedDB misbehaves. + * + * Separate from `auth-provider.test.tsx` because `vi.mock('idb-keyval')` is + * hoisted and file-wide: that suite exercises the real (fake-indexeddb) store, so + * the storage-failure cases cannot share a file with it. ESM namespace objects are + * not configurable, so `vi.spyOn(idbModule, 'get')` is not an option either. + * + * Query-cache persistence is an optimisation. Rendering is gated on its restore, + * so every failure mode here must fail OPEN — degrade to the normal in-memory + * cache and paint — never hold the app blank. + */ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AuthProvider, useAuth } from '../../../src/components/AuthProvider'; + +const { mockUseSession, idbGet } = vi.hoisted(() => ({ + mockUseSession: vi.fn(), + idbGet: vi.fn(), +})); + +vi.mock('idb-keyval', () => ({ + get: idbGet, + set: vi.fn().mockResolvedValue(undefined), + del: vi.fn().mockResolvedValue(undefined), + delMany: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockResolvedValue([]), +})); + +vi.mock('../../../src/lib/auth', () => ({ + signOut: vi.fn(), + useSession: () => mockUseSession(), +})); + +vi.mock('../../../src/lib/library-cache', async (importOriginal) => ({ + ...(await importOriginal()), + clearLibraryCache: vi.fn(), + clearLegacyLibraryCache: vi.fn(), +})); + +vi.mock('../../../src/lib/terminal-cleanup', () => ({ + broadcastAuthRevocation: vi.fn(), + cleanupTerminalSecrets: vi.fn(), + initAuthBroadcastListener: vi.fn(), + resetAuthRevoked: vi.fn(), + teardownAuthBroadcastListener: vi.fn(), +})); + +function AuthConsumer() { + const auth = useAuth(); + return {String(auth.isAuthenticated)}; +} + +const validSession = { + user: { id: 'u1', email: 'test@test.com', name: 'Test User', role: 'user', status: 'active' }, + session: { id: 's1' }, +}; + +describe('AuthProvider — persisted query cache failure modes', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseSession.mockReturnValue({ + data: validSession, + isPending: false, + error: null, + isRefetching: false, + }); + }); + + it('renders children when the restore never settles', async () => { + // A hung IndexedDB open. Without the fail-open settle timer in + // useQueryCachePersistence, children never mount and this test times out — + // which is exactly the blank-screen failure the timer exists to bound. + idbGet.mockImplementation(() => new Promise(() => {})); + + render( + + + + ); + + expect(await screen.findByTestId('authenticated')).toHaveTextContent('true'); + }); + + it('shows an accessible status affordance while the restore is in flight', async () => { + // ui-ux review: unmounting ProtectedRoute's "Verifying your session" spinner + // into a silent blank screen regressed both state clarity and accessibility — + // a screen reader heard that label and then nothing. The gate must carry the + // same affordance so the spinner is continuous. + idbGet.mockImplementation(() => new Promise(() => {})); + + render( + + + + ); + + // Asserted SYNCHRONOUSLY: the affordance is on screen the moment the gate + // opens, with no blank frame between the session spinner and this one. An + // awaited query here would let the fail-open budget elapse first and prove + // nothing. + expect(screen.getByLabelText('Verifying your session')).toBeInTheDocument(); + expect(screen.queryByTestId('authenticated')).not.toBeInTheDocument(); + + // ...and it yields to real content once the fail-open budget elapses. + expect(await screen.findByTestId('authenticated')).toHaveTextContent('true'); + expect(screen.queryByLabelText('Verifying your session')).not.toBeInTheDocument(); + }); + + it('renders children when IndexedDB rejects outright', async () => { + // Private browsing, or a disabled/blocked store. + idbGet.mockRejectedValue(new Error('IndexedDB unavailable')); + + render( + + + + ); + + expect(await screen.findByTestId('authenticated')).toHaveTextContent('true'); + }); + + it('renders children immediately for a signed-out session and never reads storage', async () => { + // Anonymous visitors (landing page, /try, /device) have no identity to key a + // record by, so they must not pay for the gate at all. + mockUseSession.mockReturnValue({ + data: null, + isPending: false, + error: null, + isRefetching: false, + }); + + render( + + + + ); + + // Synchronous: present on the very first assertion, with no `find`/await gap. + expect(screen.getByTestId('authenticated')).toHaveTextContent('false'); + expect(idbGet).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/tests/unit/components/auth-provider.test.tsx b/apps/web/tests/unit/components/auth-provider.test.tsx index d753dd18da..2454fcb7f6 100644 --- a/apps/web/tests/unit/components/auth-provider.test.tsx +++ b/apps/web/tests/unit/components/auth-provider.test.tsx @@ -1,12 +1,17 @@ import type { ProjectSummary } from '@simple-agent-manager/shared'; import { QueryClientProvider, useQuery } from '@tanstack/react-query'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { clear as idbClear, get as idbGet, keys as idbKeys, set as idbSet } from 'idb-keyval'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AuthProvider, useAuth } from '../../../src/components/AuthProvider'; import { GITHUB_REAUTH_REQUIRED_EVENT } from '../../../src/lib/api/client'; import { queryClient } from '../../../src/lib/query-client'; import { projectQueryKeys } from '../../../src/lib/query-options'; +import { + buildQueryPersistStorageKey, + QUERY_PERSIST_SCHEMA_VERSION, +} from '../../../src/lib/query-persistence'; const { mockUseSession, @@ -116,7 +121,13 @@ describe('AuthProvider', () => { cacheRenderLog.length = 0; }); - it('shows authenticated when session is valid', () => { + // NOTE: an authenticated render is asynchronous. AuthProvider gates children + // until the identity namespace resolves AND the persisted query cache has been + // restored for it (useQueryCachePersistence), and that restore is an IndexedDB + // read. Tests that expect children for a signed-in user must therefore `find` + // rather than `get`. Signed-out / pending renders stay synchronous because no + // record is ever read for a null namespace. + it('shows authenticated when session is valid', async () => { mockUseSession.mockReturnValue({ data: validSession, isPending: false, @@ -124,7 +135,7 @@ describe('AuthProvider', () => { isRefetching: false, }); renderWithAuth(); - expect(screen.getByTestId('authenticated')).toHaveTextContent('true'); + expect(await screen.findByTestId('authenticated')).toHaveTextContent('true'); expect(screen.getByTestId('user-name')).toHaveTextContent('Test User'); }); @@ -140,7 +151,7 @@ describe('AuthProvider', () => { expect(screen.getByTestId('authenticated')).toHaveTextContent('false'); }); - it('preserves session when refetch error occurs after valid session', () => { + it('preserves session when refetch error occurs after valid session', async () => { // First render: valid session mockUseSession.mockReturnValue({ data: validSession, @@ -149,7 +160,7 @@ describe('AuthProvider', () => { isRefetching: false, }); const { rerender } = renderWithAuth(); - expect(screen.getByTestId('authenticated')).toHaveTextContent('true'); + expect(await screen.findByTestId('authenticated')).toHaveTextContent('true'); // Second render: refetch error wipes session data (BetterAuth behavior) mockUseSession.mockReturnValue({ @@ -181,7 +192,7 @@ describe('AuthProvider', () => { expect(screen.getByTestId('user-name')).toHaveTextContent('none'); }); - it('exposes isRefetching from BetterAuth', () => { + it('exposes isRefetching from BetterAuth', async () => { mockUseSession.mockReturnValue({ data: validSession, isPending: false, @@ -189,10 +200,10 @@ describe('AuthProvider', () => { isRefetching: true, }); renderWithAuth(); - expect(screen.getByTestId('refetching')).toHaveTextContent('true'); + expect(await screen.findByTestId('refetching')).toHaveTextContent('true'); }); - it('clears cached session on clean null (intentional signout)', () => { + it('clears cached session on clean null (intentional signout)', async () => { // Start with valid session mockUseSession.mockReturnValue({ data: validSession, @@ -201,7 +212,7 @@ describe('AuthProvider', () => { isRefetching: false, }); const { rerender } = renderWithAuth(); - expect(screen.getByTestId('authenticated')).toHaveTextContent('true'); + expect(await screen.findByTestId('authenticated')).toHaveTextContent('true'); // Server returns clean null — no error, not pending (signout or session expiry) mockUseSession.mockReturnValue({ @@ -221,7 +232,7 @@ describe('AuthProvider', () => { expect(screen.getByTestId('user-name')).toHaveTextContent('none'); }); - it('recovers when refetch succeeds after transient error', () => { + it('recovers when refetch succeeds after transient error', async () => { // Start with valid session mockUseSession.mockReturnValue({ data: validSession, @@ -244,7 +255,7 @@ describe('AuthProvider', () => { ); // Cached session used - expect(screen.getByTestId('authenticated')).toHaveTextContent('true'); + expect(await screen.findByTestId('authenticated')).toHaveTextContent('true'); // Refetch succeeds with new session const newSession = { @@ -266,7 +277,7 @@ describe('AuthProvider', () => { expect(screen.getByTestId('user-name')).toHaveTextContent('Updated User'); }); - it('does not clear the same user namespace during transient refetch errors', () => { + it('does not clear the same user namespace during transient refetch errors', async () => { mockUseSession.mockReturnValue({ data: validSession, isPending: false, @@ -274,6 +285,7 @@ describe('AuthProvider', () => { isRefetching: false, }); const { rerender } = renderWithAuth(); + await screen.findByTestId('authenticated'); expect(mockClearLegacyLibraryCache).toHaveBeenCalledTimes(1); clearQueryCacheSpy.mockClear(); mockBroadcastAuthRevocation.mockClear(); @@ -337,7 +349,7 @@ describe('AuthProvider', () => { expect(mockResetAuthRevoked).not.toHaveBeenCalled(); }); - it('clears the previous user namespace on account switch without clearing the new user cache', () => { + it('clears the previous user namespace on account switch without clearing the new user cache', async () => { mockUseSession.mockReturnValue({ data: validSession, isPending: false, @@ -345,6 +357,7 @@ describe('AuthProvider', () => { isRefetching: false, }); const { rerender } = renderWithAuth(); + await screen.findByTestId('user-name'); mockClearLibraryCache.mockClear(); mockClearLegacyLibraryCache.mockClear(); clearQueryCacheSpy.mockClear(); @@ -367,7 +380,7 @@ describe('AuthProvider', () => { ); - expect(screen.getByTestId('user-name')).toHaveTextContent('Other User'); + expect(await screen.findByTestId('user-name')).toHaveTextContent('Other User'); expect(mockClearLibraryCache).toHaveBeenCalledTimes(1); expect(mockClearLibraryCache).toHaveBeenCalledWith('user:u1'); expect(mockClearLibraryCache).not.toHaveBeenCalledWith('user:u2'); @@ -485,4 +498,216 @@ describe('AuthProvider', () => { expect(mockSignOut).toHaveBeenCalledTimes(1); }); + + describe('persisted query cache', () => { + beforeEach(async () => { + vi.restoreAllMocks(); + await idbClear(); + }); + + it('restores the persisted cache for the signed-in user before children render', async () => { + // The whole point of item #3: a reload paints from cache, not a spinner. + // Children must not appear until the restore has landed, otherwise the + // first frame is an empty state and the cache is pointless. + const storageKey = buildQueryPersistStorageKey('user:u1')!; + await idbSet(storageKey, { + timestamp: Date.now(), + buster: QUERY_PERSIST_SCHEMA_VERSION, + clientState: { + mutations: [], + queries: [ + { + queryKey: projectQueryKeys.list('u1', 50), + queryHash: JSON.stringify(projectQueryKeys.list('u1', 50)), + state: { + data: [PRIVATE_PROJECT], + dataUpdateCount: 1, + dataUpdatedAt: Date.now(), + error: null, + errorUpdateCount: 0, + errorUpdatedAt: 0, + fetchFailureCount: 0, + fetchFailureReason: null, + fetchMeta: null, + isInvalidated: false, + status: 'success', + fetchStatus: 'idle', + }, + }, + ], + }, + }); + + mockUseSession.mockReturnValue({ + data: validSession, + isPending: false, + error: null, + isRefetching: false, + }); + render( + + + + + + ); + + expect(await screen.findByTestId('cached-project')).toHaveTextContent(PRIVATE_PROJECT.name); + // Discriminating: the very first render of the consumer already had the + // cached project. If the gate were removed, the log would start with a + // `u1:none` frame. + expect(cacheRenderLog[0]).toBe(`u1:${PRIVATE_PROJECT.name}`); + expect(cacheRenderLog).not.toContain('u1:none'); + }); + + it("never hydrates one user's persisted record into another user's session", async () => { + // The cross-user leak this whole design exists to prevent — the shape of + // the incident in tasks/archive/2026-08-05-namespace-library-cache-by-user.md. + const storageKey = buildQueryPersistStorageKey('user:u1')!; + await idbSet(storageKey, { + timestamp: Date.now(), + buster: QUERY_PERSIST_SCHEMA_VERSION, + clientState: { + mutations: [], + queries: [ + { + // Deliberately keyed to u2 INSIDE u1's record: even a poisoned + // record must not surface, because the record is only ever read + // under its owner's storage key. + queryKey: projectQueryKeys.list('u2', 50), + queryHash: JSON.stringify(projectQueryKeys.list('u2', 50)), + state: { + data: [PRIVATE_PROJECT], + dataUpdateCount: 1, + dataUpdatedAt: Date.now(), + error: null, + errorUpdateCount: 0, + errorUpdatedAt: 0, + fetchFailureCount: 0, + fetchFailureReason: null, + fetchMeta: null, + isInvalidated: false, + status: 'success', + fetchStatus: 'idle', + }, + }, + ], + }, + }); + + // Sign in as u2 — a DIFFERENT user, so a different storage key. + mockUseSession.mockReturnValue({ + data: { + ...validSession, + user: { ...validSession.user, id: 'u2', email: 'other@test.com', name: 'Other User' }, + }, + isPending: false, + error: null, + isRefetching: false, + }); + render( + + + + + + ); + + expect(await screen.findByTestId('cache-user')).toHaveTextContent('u2'); + expect(screen.getByTestId('cached-project')).toHaveTextContent('none'); + expect(screen.queryByText(PRIVATE_PROJECT.name)).not.toBeInTheDocument(); + expect(cacheRenderLog.some((entry) => entry.includes(PRIVATE_PROJECT.name))).toBe(false); + // u1's record is untouched — we never even opened it. + expect(await idbGet(storageKey)).toBeDefined(); + }); + + it("deletes the previous user's persisted record on account switch", async () => { + const previousKey = buildQueryPersistStorageKey('user:u1')!; + await idbSet(previousKey, { + timestamp: Date.now(), + buster: QUERY_PERSIST_SCHEMA_VERSION, + clientState: { mutations: [], queries: [] }, + }); + + mockUseSession.mockReturnValue({ + data: validSession, + isPending: false, + error: null, + isRefetching: false, + }); + const { rerender } = renderWithAuth(); + await screen.findByTestId('user-name'); + + mockUseSession.mockReturnValue({ + data: { + ...validSession, + user: { ...validSession.user, id: 'u2', email: 'other@test.com', name: 'Other User' }, + }, + isPending: false, + error: null, + isRefetching: false, + }); + rerender( + + + + ); + + expect(await screen.findByTestId('user-name')).toHaveTextContent('Other User'); + await waitFor(async () => expect(await idbGet(previousKey)).toBeUndefined()); + }); + + it('renders normally when there is no persisted record', async () => { + mockUseSession.mockReturnValue({ + data: validSession, + isPending: false, + error: null, + isRefetching: false, + }); + renderWithAuth(); + expect(await screen.findByTestId('authenticated')).toHaveTextContent('true'); + }); + + it('survives a rapid A -> B -> C identity switch without leaking either predecessor', async () => { + const { rerender } = renderWithAuth(); + + for (const id of ['u1', 'u2', 'u3']) { + mockUseSession.mockReturnValue({ + data: { ...validSession, user: { ...validSession.user, id, name: `User ${id}` } }, + isPending: false, + error: null, + isRefetching: false, + }); + rerender( + + + + + + ); + } + + await waitFor(() => expect(screen.getByTestId('cache-user')).toHaveTextContent('u3')); + // Every rendered frame belongs to the identity that owns it: no frame ever + // paired a later scope with an earlier identity's project data. + expect(cacheRenderLog).not.toContain(`u2:${PRIVATE_PROJECT.name}`); + expect(cacheRenderLog).not.toContain(`u3:${PRIVATE_PROJECT.name}`); + expect(mockClearLibraryCache).toHaveBeenCalledWith('user:u1'); + expect(mockClearLibraryCache).toHaveBeenCalledWith('user:u2'); + }); + + it('persists nothing and renders synchronously for a signed-out session', async () => { + mockUseSession.mockReturnValue({ + data: null, + isPending: false, + error: null, + isRefetching: false, + }); + renderWithAuth(); + expect(await screen.findByTestId('authenticated')).toHaveTextContent('false'); + + const remaining = await idbKeys(); + expect(remaining.filter((k) => String(k).startsWith('sam-query-cache:'))).toHaveLength(0); + }); + }); }); diff --git a/apps/web/tests/unit/lib/auth-signout.test.ts b/apps/web/tests/unit/lib/auth-signout.test.ts index eb94027278..5eb1feaf34 100644 --- a/apps/web/tests/unit/lib/auth-signout.test.ts +++ b/apps/web/tests/unit/lib/auth-signout.test.ts @@ -22,6 +22,13 @@ vi.mock('../../../src/lib/api/notifications', () => ({ unsubscribeWebPush: mockUnsubscribeWebPush, })); +// signOut dynamically imports this module to keep idb-keyval out of the eager +// bundle; mocking it here proves the wiring survives that indirection. +const mockRemoveAllPersistedQueryCaches = vi.fn(); +vi.mock('../../../src/lib/query-persistence', () => ({ + removeAllPersistedQueryCaches: mockRemoveAllPersistedQueryCaches, +})); + describe('signOut', () => { beforeEach(() => { vi.resetAllMocks(); @@ -36,6 +43,7 @@ describe('signOut', () => { writable: true, }); mockUnsubscribeWebPush.mockResolvedValue(undefined); + mockRemoveAllPersistedQueryCaches.mockResolvedValue(undefined); Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: { @@ -104,4 +112,37 @@ describe('signOut', () => { mockSignOut.mock.invocationCallOrder[0]! ); }); + + it('sweeps the persisted query cache before the sign-out request', async () => { + // Nothing previously proved signOut() actually calls this — the sweep function + // was unit-tested in isolation, so deleting the call site would have been + // invisible. That is precisely the regression this whole feature guards against. + const { signOut } = await import('../../../src/lib/auth'); + + await signOut(); + + expect(mockRemoveAllPersistedQueryCaches).toHaveBeenCalledOnce(); + expect(mockRemoveAllPersistedQueryCaches.mock.invocationCallOrder[0]!).toBeLessThan( + mockSignOut.mock.invocationCallOrder[0]! + ); + }); + + it('sweeps the persisted query cache even when the sign-out request fails', async () => { + mockSignOut.mockRejectedValueOnce(new Error('network down')); + const { signOut } = await import('../../../src/lib/auth'); + + await expect(signOut()).rejects.toThrow('network down'); + + expect(mockRemoveAllPersistedQueryCaches).toHaveBeenCalledOnce(); + }); + + it('completes sign-out even when the persisted-cache sweep rejects', async () => { + // The sweep is best-effort: a storage failure must not block sign-out. + mockRemoveAllPersistedQueryCaches.mockRejectedValueOnce(new Error('IndexedDB gone')); + const { signOut } = await import('../../../src/lib/auth'); + + await signOut(); + + expect(mockSignOut).toHaveBeenCalledOnce(); + }); }); diff --git a/apps/web/tests/unit/lib/query-persistence.test.ts b/apps/web/tests/unit/lib/query-persistence.test.ts new file mode 100644 index 0000000000..eb76f4d005 --- /dev/null +++ b/apps/web/tests/unit/lib/query-persistence.test.ts @@ -0,0 +1,621 @@ +import { persistQueryClient } from '@tanstack/query-persist-client-core'; +import { QueryClient } from '@tanstack/react-query'; +import { clear, get, keys, set, type UseStore } from 'idb-keyval'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + buildQueryPersistStorageKey, + createIdbQueryPersister, + QUERY_PERSIST_SCHEMA_VERSION, + removeAllPersistedQueryCaches, + removePersistedQueryCache, + shouldDehydratePersistedQuery, +} from '../../../src/lib/query-persistence'; + +const USER_A = 'user-a'; +const USER_B = 'user-b'; +const NAMESPACE_A = `user:${USER_A}`; +const NAMESPACE_B = `user:${USER_B}`; + +/** A project payload used as the cross-user canary. */ +const USER_A_PRIVATE_PROJECT = [{ id: 'proj-1', name: 'user-a-confidential-project' }]; + +/** Stand-ins for the two banned content classes that ride inside GET /api/projects/:id. */ +const CHAT_CONTENT_CANARY = 'user-a-secret-chat-prompt-do-not-persist'; +const AGENT_OUTPUT_CANARY = 'agent-authored-activity-message-do-not-persist'; + +function makeClient(): QueryClient { + return new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); +} + +/** Seed a successful query so it is eligible for dehydration. */ +function seed(client: QueryClient, key: readonly unknown[], data: unknown): void { + client.setQueryData(key, data); +} + +/** The single query object the cache holds for `key`. */ +function queryFor(client: QueryClient, key: readonly unknown[]) { + const query = client.getQueryCache().find({ queryKey: key }); + if (!query) throw new Error(`no query cached for ${JSON.stringify(key)}`); + return query; +} + +/** + * A `UseStore` whose every transaction rejects — models private-browsing mode or + * a quota failure. idb-keyval takes an optional custom store on every call, so + * this needs no global IndexedDB patching (patching `indexedDB.open` would wedge + * idb-keyval's memoized default store for the rest of the file). + */ +function rejectingStore(): UseStore { + return (() => Promise.reject(new Error('IndexedDB unavailable'))) as unknown as UseStore; +} + +/** A `UseStore` that never settles — models a hung IndexedDB. */ +function hangingStore(): UseStore { + return (() => new Promise(() => {})) as unknown as UseStore; +} + +describe('query-persistence', () => { + beforeEach(async () => { + await clear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('buildQueryPersistStorageKey', () => { + it('namespaces the record by identity and schema version', () => { + expect(buildQueryPersistStorageKey(NAMESPACE_A)).toBe( + `sam-query-cache:${QUERY_PERSIST_SCHEMA_VERSION}:${NAMESPACE_A}` + ); + }); + + it('gives two identities two different records', () => { + expect(buildQueryPersistStorageKey(NAMESPACE_A)).not.toBe( + buildQueryPersistStorageKey(NAMESPACE_B) + ); + }); + + it('returns null when there is no authenticated identity', () => { + // A null key is what stops a signed-out session persisting anything at all. + expect(buildQueryPersistStorageKey(null)).toBeNull(); + expect(buildQueryPersistStorageKey(undefined)).toBeNull(); + expect(buildQueryPersistStorageKey('')).toBeNull(); + }); + }); + + describe('shouldDehydratePersistedQuery — the allowlist', () => { + it('persists an allowlisted query owned by the active scope', () => { + const client = makeClient(); + seed(client, ['auth', USER_A, 'projects', 'list', { limit: 50 }], USER_A_PRIVATE_PROJECT); + expect( + shouldDehydratePersistedQuery( + queryFor(client, ['auth', USER_A, 'projects', 'list', { limit: 50 }]), + USER_A + ) + ).toBe(true); + }); + + it('refuses a query belonging to a DIFFERENT scope', () => { + // The load-bearing cross-user assertion: even if user B's cache somehow + // holds a user-A-scoped entry, it must never reach disk. + const client = makeClient(); + seed(client, ['auth', USER_A, 'projects', 'list'], USER_A_PRIVATE_PROJECT); + expect( + shouldDehydratePersistedQuery( + queryFor(client, ['auth', USER_A, 'projects', 'list']), + USER_B + ) + ).toBe(false); + }); + + it('refuses everything when there is no active scope', () => { + const client = makeClient(); + seed(client, ['auth', USER_A, 'projects', 'list'], USER_A_PRIVATE_PROJECT); + expect( + shouldDehydratePersistedQuery(queryFor(client, ['auth', USER_A, 'projects', 'list']), '') + ).toBe(false); + }); + + // Every one of these is on the "never persist without a separate security + // review" list in tasks/backlog/2026-08-07-expand-frontend-query-cache-and- + // persistence.md. They are excluded structurally: none carries the + // ['auth', , …] shape. + it.each([ + ['node runtime details', ['nodes', 'list']], + ['node provider catalog', ['nodes', 'catalog']], + ['workspace runtime details', ['workspaces', 'list', '']], + ['admin diagnosis output', ['admin-diagnosis', 'run-1']], + ['per-user notification preferences', ['notification-preferences']], + ])('refuses %s', (_label, key) => { + const client = makeClient(); + seed(client, key, { secret: 'do-not-persist' }); + expect(shouldDehydratePersistedQuery(queryFor(client, key), USER_A)).toBe(false); + }); + + it('refuses an auth-scoped domain that is not on the allowlist', () => { + // `github` installations are deliberately excluded pending security review. + const client = makeClient(); + seed(client, ['auth', USER_A, 'github', 'installations'], [{ id: 1 }]); + expect( + shouldDehydratePersistedQuery( + queryFor(client, ['auth', USER_A, 'github', 'installations']), + USER_A + ) + ).toBe(false); + }); + + it('refuses projects/detail even though projects/list is allowlisted', () => { + // The regression this allowlist shape exists for. `projects/detail` shares the + // `projects` domain with the allowlisted `projects/list`, but GET + // /api/projects/:id returns recentSessions[].topic — the first 97 chars of the + // user's first CHAT MESSAGE — and recentActivity[].payload.message, free-text + // agent output. Both are on the "never persist" list. A domain-level allowlist + // accepted them; the operation-pair allowlist must not. + const client = makeClient(); + seed(client, ['auth', USER_A, 'projects', 'detail', 'proj-1'], { + id: 'proj-1', + recentSessions: [{ id: 's1', topic: CHAT_CONTENT_CANARY }], + recentActivity: [{ id: 'a1', payload: { message: AGENT_OUTPUT_CANARY } }], + }); + expect( + shouldDehydratePersistedQuery( + queryFor(client, ['auth', USER_A, 'projects', 'detail', 'proj-1']), + USER_A + ) + ).toBe(false); + }); + + it('refuses a key whose first element is not the auth prefix', () => { + // Discriminating for the `prefix === 'auth'` conjunct, which had no coverage. + const client = makeClient(); + seed(client, ['public', USER_A, 'projects', 'list'], USER_A_PRIVATE_PROJECT); + expect( + shouldDehydratePersistedQuery( + queryFor(client, ['public', USER_A, 'projects', 'list']), + USER_A + ) + ).toBe(false); + }); + + it('refuses a domain-only key with no operation segment', () => { + const client = makeClient(); + seed(client, ['auth', USER_A, 'projects'], USER_A_PRIVATE_PROJECT); + expect( + shouldDehydratePersistedQuery(queryFor(client, ['auth', USER_A, 'projects']), USER_A) + ).toBe(false); + }); + + it('refuses a query that failed rather than persisting an error state', async () => { + const client = makeClient(); + await client + .fetchQuery({ + queryKey: ['auth', USER_A, 'projects', 'list'], + queryFn: () => Promise.reject(new Error('boom')), + retry: false, + }) + .catch(() => undefined); + expect( + shouldDehydratePersistedQuery( + queryFor(client, ['auth', USER_A, 'projects', 'list']), + USER_A + ) + ).toBe(false); + }); + }); + + describe('persist → restore across a simulated page load', () => { + it('restores allowlisted data into a brand-new QueryClient', async () => { + const storageKey = buildQueryPersistStorageKey(NAMESPACE_A)!; + const key = ['auth', USER_A, 'projects', 'list', { limit: 50 }] as const; + + // --- page load 1: populate and persist --- + const first = makeClient(); + seed(first, key, USER_A_PRIVATE_PROJECT); + const [unsubscribe, restored] = persistQueryClient({ + queryClient: first, + persister: createIdbQueryPersister(storageKey, { throttleMs: 0 }), + buster: QUERY_PERSIST_SCHEMA_VERSION, + dehydrateOptions: { + shouldDehydrateQuery: (q) => shouldDehydratePersistedQuery(q, USER_A), + }, + }); + await restored; + // Nudge the cache so the subscriber writes, then let the 0ms flush land. + seed(first, key, USER_A_PRIVATE_PROJECT); + await vi.waitFor(async () => expect(await get(storageKey)).toBeDefined()); + unsubscribe(); + + // --- page load 2: fresh client, nothing in memory --- + const second = makeClient(); + expect(second.getQueryData(key)).toBeUndefined(); + + const [unsubscribe2, restored2] = persistQueryClient({ + queryClient: second, + persister: createIdbQueryPersister(storageKey, { throttleMs: 0 }), + buster: QUERY_PERSIST_SCHEMA_VERSION, + }); + await restored2; + unsubscribe2(); + + expect(second.getQueryData(key)).toEqual(USER_A_PRIVATE_PROJECT); + }); + + it('never writes chat-derived or agent-authored content to disk', async () => { + // End-to-end version of the allowlist canary: drive the REAL dehydrate path + // (not shouldDehydratePersistedQuery in isolation) with a cache holding both + // an allowlisted list query and a detail query carrying the banned content, + // then read the raw bytes back out of IndexedDB. + const storageKey = buildQueryPersistStorageKey(NAMESPACE_A)!; + const listKey = ['auth', USER_A, 'projects', 'list', { limit: 50 }] as const; + const detailKey = ['auth', USER_A, 'projects', 'detail', 'proj-1'] as const; + + const client = makeClient(); + seed(client, listKey, USER_A_PRIVATE_PROJECT); + seed(client, detailKey, { + id: 'proj-1', + recentSessions: [{ id: 's1', topic: CHAT_CONTENT_CANARY }], + recentActivity: [{ id: 'a1', payload: { message: AGENT_OUTPUT_CANARY } }], + }); + + const [unsubscribe, restored] = persistQueryClient({ + queryClient: client, + persister: createIdbQueryPersister(storageKey, { throttleMs: 0 }), + buster: QUERY_PERSIST_SCHEMA_VERSION, + dehydrateOptions: { + shouldDehydrateQuery: (q) => shouldDehydratePersistedQuery(q, USER_A), + }, + }); + await restored; + seed(client, listKey, USER_A_PRIVATE_PROJECT); + await vi.waitFor(async () => expect(await get(storageKey)).toBeDefined()); + unsubscribe(); + + const written = JSON.stringify(await get(storageKey)); + // The allowlisted list survived... + expect(written).toContain('user-a-confidential-project'); + // ...and neither banned content class is anywhere in the bytes on disk. + expect(written).not.toContain(CHAT_CONTENT_CANARY); + expect(written).not.toContain(AGENT_OUTPUT_CANARY); + expect(written).not.toContain('recentSessions'); + expect(written).not.toContain('recentActivity'); + }); + + it('gives two users separate records even for the SAME project id', async () => { + // The acceptance criterion's "including colliding project IDs" case: the user + // scope is embedded in BOTH the storage key and the query key, so an + // identical projectId across accounts cannot collide. + const sharedProjectId = 'identical-project-id'; + const keyA = buildQueryPersistStorageKey(NAMESPACE_A)!; + const keyB = buildQueryPersistStorageKey(NAMESPACE_B)!; + expect(keyA).not.toBe(keyB); + + const listA = ['auth', USER_A, 'projects', 'list', { project: sharedProjectId }] as const; + const clientA = makeClient(); + seed(clientA, listA, [{ id: sharedProjectId, name: 'user-a-view-of-shared-id' }]); + const [unsubA, restoredA] = persistQueryClient({ + queryClient: clientA, + persister: createIdbQueryPersister(keyA, { throttleMs: 0 }), + buster: QUERY_PERSIST_SCHEMA_VERSION, + dehydrateOptions: { shouldDehydrateQuery: (q) => shouldDehydratePersistedQuery(q, USER_A) }, + }); + await restoredA; + seed(clientA, listA, [{ id: sharedProjectId, name: 'user-a-view-of-shared-id' }]); + await vi.waitFor(async () => expect(await get(keyA)).toBeDefined()); + unsubA(); + + const clientB = makeClient(); + const [unsubB, restoredB] = persistQueryClient({ + queryClient: clientB, + persister: createIdbQueryPersister(keyB, { throttleMs: 0 }), + buster: QUERY_PERSIST_SCHEMA_VERSION, + }); + await restoredB; + unsubB(); + + expect(clientB.getQueryCache().getAll()).toHaveLength(0); + expect(JSON.stringify(clientB.getQueryData(listA) ?? null)).not.toContain( + 'user-a-view-of-shared-id' + ); + }); + + it("does not restore user A's record into user B's session", async () => { + const keyA = buildQueryPersistStorageKey(NAMESPACE_A)!; + const keyB = buildQueryPersistStorageKey(NAMESPACE_B)!; + const queryKey = ['auth', USER_A, 'projects', 'list'] as const; + + const a = makeClient(); + seed(a, queryKey, USER_A_PRIVATE_PROJECT); + const [unsubA, restoredA] = persistQueryClient({ + queryClient: a, + persister: createIdbQueryPersister(keyA, { throttleMs: 0 }), + buster: QUERY_PERSIST_SCHEMA_VERSION, + dehydrateOptions: { shouldDehydrateQuery: (q) => shouldDehydratePersistedQuery(q, USER_A) }, + }); + await restoredA; + seed(a, queryKey, USER_A_PRIVATE_PROJECT); + await vi.waitFor(async () => expect(await get(keyA)).toBeDefined()); + unsubA(); + + // User B signs in: different storage key, so the restore finds nothing. + const b = makeClient(); + const [unsubB, restoredB] = persistQueryClient({ + queryClient: b, + persister: createIdbQueryPersister(keyB, { throttleMs: 0 }), + buster: QUERY_PERSIST_SCHEMA_VERSION, + }); + await restoredB; + unsubB(); + + expect(b.getQueryData(queryKey)).toBeUndefined(); + expect(b.getQueryCache().getAll()).toHaveLength(0); + }); + }); + + describe('eviction', () => { + it('discards a record older than maxAge', async () => { + const storageKey = buildQueryPersistStorageKey(NAMESPACE_A)!; + const queryKey = ['auth', USER_A, 'projects', 'list'] as const; + + await set(storageKey, { + timestamp: Date.now() - 60_000, + buster: QUERY_PERSIST_SCHEMA_VERSION, + clientState: { + mutations: [], + queries: [ + { + queryKey, + queryHash: JSON.stringify(queryKey), + state: { + data: USER_A_PRIVATE_PROJECT, + dataUpdateCount: 1, + dataUpdatedAt: Date.now() - 60_000, + error: null, + errorUpdateCount: 0, + errorUpdatedAt: 0, + fetchFailureCount: 0, + fetchFailureReason: null, + fetchMeta: null, + isInvalidated: false, + status: 'success', + fetchStatus: 'idle', + }, + }, + ], + }, + }); + + const client = makeClient(); + const [unsubscribe, restored] = persistQueryClient({ + queryClient: client, + persister: createIdbQueryPersister(storageKey, { throttleMs: 0 }), + buster: QUERY_PERSIST_SCHEMA_VERSION, + maxAge: 1_000, // record is 60s old — well past + }); + await restored; + unsubscribe(); + + expect(client.getQueryData(queryKey)).toBeUndefined(); + // Expired records are deleted, not merely skipped. + expect(await get(storageKey)).toBeUndefined(); + }); + + it('discards a record written under a different schema version', async () => { + const storageKey = buildQueryPersistStorageKey(NAMESPACE_A)!; + const queryKey = ['auth', USER_A, 'projects', 'list'] as const; + + await set(storageKey, { + timestamp: Date.now(), + buster: 'v0-previous-schema', + clientState: { mutations: [], queries: [] }, + }); + + const client = makeClient(); + const [unsubscribe, restored] = persistQueryClient({ + queryClient: client, + persister: createIdbQueryPersister(storageKey, { throttleMs: 0 }), + buster: QUERY_PERSIST_SCHEMA_VERSION, + }); + await restored; + unsubscribe(); + + expect(client.getQueryData(queryKey)).toBeUndefined(); + expect(await get(storageKey)).toBeUndefined(); + }); + }); + + describe('failure resilience', () => { + it('treats an unreadable store as a cache miss instead of throwing', async () => { + const storageKey = buildQueryPersistStorageKey(NAMESPACE_A)!; + await set(storageKey, { poisoned: true }); + + const persister = createIdbQueryPersister(storageKey, { throttleMs: 0 }); + const client = makeClient(); + + // A malformed record must not reject the restore or crash hydration. + const [unsubscribe, restored] = persistQueryClient({ + queryClient: client, + persister, + buster: QUERY_PERSIST_SCHEMA_VERSION, + }); + await expect(restored).resolves.toBeUndefined(); + unsubscribe(); + expect(client.getQueryCache().getAll()).toHaveLength(0); + }); + + it('degrades to a cache miss when the store itself rejects', async () => { + // Models private-browsing / disabled-IndexedDB: every store access throws. + const storageKey = buildQueryPersistStorageKey(NAMESPACE_A)!; + const persister = createIdbQueryPersister(storageKey, { + throttleMs: 0, + store: rejectingStore(), + }); + + await expect(persister.restoreClient()).resolves.toBeUndefined(); + await expect(persister.removeClient()).resolves.toBeUndefined(); + + // A rejected write must not throw out of the void-returning persistClient. + expect(() => + persister.persistClient({ + timestamp: Date.now(), + buster: QUERY_PERSIST_SCHEMA_VERSION, + clientState: { mutations: [], queries: [] }, + }) + ).not.toThrow(); + + // A healthy persister is unaffected — failure is per-persister, not global. + const healthy = createIdbQueryPersister(storageKey, { throttleMs: 0 }); + await expect(healthy.restoreClient()).resolves.toBeUndefined(); + }); + + it('latches off after a WRITE failure so it stops retrying every cache event', async () => { + // test-engineer found the `disabled = true` inside flush()'s own catch was + // never executed: the existing failure test tripped the latch via a READ, so + // persistClient short-circuited before flush ever ran. This drives the write + // path directly with a store that only fails on write. + let writeAttempts = 0; + const writeOnlyFailingStore = ((_mode: unknown, callback: (store: unknown) => unknown) => { + // idb-keyval calls the store fn for both get and set; count and reject. + writeAttempts += 1; + void callback; + return Promise.reject(new Error('QuotaExceededError')); + }) as unknown as UseStore; + + const persister = createIdbQueryPersister('sam-query-cache:v1:user:latch', { + throttleMs: 0, + store: writeOnlyFailingStore, + }); + + const snapshot = (n: number) => ({ + timestamp: n, + buster: QUERY_PERSIST_SCHEMA_VERSION, + clientState: { mutations: [], queries: [] }, + }); + + persister.persistClient(snapshot(1)); + await vi.waitFor(() => expect(writeAttempts).toBeGreaterThan(0)); + const afterFirstFailure = writeAttempts; + + // Every subsequent event must be dropped by the latch, not retried. + for (let i = 2; i < 8; i += 1) persister.persistClient(snapshot(i)); + await new Promise((resolve) => setTimeout(resolve, 40)); + + expect(writeAttempts).toBe(afterFirstFailure); + }); + + it('skips an IndexedDB write when the snapshot is unchanged', async () => { + // persistQueryClientSubscribe re-dehydrates on every cache event app-wide, so + // unrelated polling (Nodes/Workspaces, 10s) would otherwise write an + // identical payload every throttle window forever. + let writes = 0; + const countingStore = ((mode: unknown, callback: (store: unknown) => unknown) => { + if (mode === 'readwrite') writes += 1; + void callback; + return Promise.resolve(); + }) as unknown as UseStore; + + const persister = createIdbQueryPersister('sam-query-cache:v1:user:dedupe', { + throttleMs: 0, + store: countingStore, + }); + const identical = () => ({ + timestamp: 1, + buster: QUERY_PERSIST_SCHEMA_VERSION, + clientState: { mutations: [], queries: [] }, + }); + + persister.persistClient(identical()); + await vi.waitFor(() => expect(writes).toBe(1)); + + for (let i = 0; i < 5; i += 1) { + persister.persistClient(identical()); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + expect(writes).toBe(1); + }); + + it('coalesces throttled writes to the latest snapshot', async () => { + const storageKey = buildQueryPersistStorageKey(NAMESPACE_A)!; + const persister = createIdbQueryPersister(storageKey, { throttleMs: 20 }); + + const snapshot = (n: number) => ({ + timestamp: n, + buster: QUERY_PERSIST_SCHEMA_VERSION, + clientState: { mutations: [], queries: [] }, + }); + + // Three cache events inside one throttle window must yield one write. + persister.persistClient(snapshot(1)); + persister.persistClient(snapshot(2)); + persister.persistClient(snapshot(3)); + + await vi.waitFor(async () => + expect((await get<{ timestamp: number }>(storageKey))?.timestamp).toBe(3) + ); + }); + + it('cancelPendingWrites drops a queued snapshot so it cannot blank a record', async () => { + // Guards the AuthProvider transition ordering: queryClient.clear() runs + // while the outgoing identity is still subscribed, queuing an empty + // snapshot. That write must not land after we detach. + const storageKey = buildQueryPersistStorageKey(NAMESPACE_A)!; + await set(storageKey, { + timestamp: 1, + buster: QUERY_PERSIST_SCHEMA_VERSION, + clientState: { mutations: [], queries: [] }, + }); + + const persister = createIdbQueryPersister(storageKey, { throttleMs: 30 }); + persister.persistClient({ + timestamp: 999, + buster: QUERY_PERSIST_SCHEMA_VERSION, + clientState: { mutations: [], queries: [] }, + }); + persister.cancelPendingWrites(); + + // Wait past the throttle window; the cancelled write must never land. + await new Promise((resolve) => setTimeout(resolve, 80)); + + expect((await get<{ timestamp: number }>(storageKey))?.timestamp).toBe(1); + }); + }); + + describe('teardown', () => { + it('removePersistedQueryCache deletes only the given identity', async () => { + const keyA = buildQueryPersistStorageKey(NAMESPACE_A)!; + const keyB = buildQueryPersistStorageKey(NAMESPACE_B)!; + await set(keyA, { a: true }); + await set(keyB, { b: true }); + + await removePersistedQueryCache(NAMESPACE_A); + + expect(await get(keyA)).toBeUndefined(); + expect(await get(keyB)).toBeDefined(); + }); + + it('removeAllPersistedQueryCaches sweeps every identity and schema version', async () => { + await set(buildQueryPersistStorageKey(NAMESPACE_A)!, { a: true }); + await set(buildQueryPersistStorageKey(NAMESPACE_B)!, { b: true }); + await set(`sam-query-cache:v0:${NAMESPACE_A}`, { legacy: true }); + await set('sam-library:user:other', { unrelated: true }); + + await removeAllPersistedQueryCaches(); + + const remaining = await keys(); + expect(remaining.filter((k) => String(k).startsWith('sam-query-cache:'))).toHaveLength(0); + // Only our own records are swept — the library cache is untouched. + expect(remaining).toContain('sam-library:user:other'); + }); + + it('removeAllPersistedQueryCaches resolves even when the sweep never settles', async () => { + // Sign-out awaits this, so it must not be able to hang behind IndexedDB. + // Discriminating: without the internal timeout race this never resolves and + // the test times out. + const started = Date.now(); + await expect(removeAllPersistedQueryCaches(25, hangingStore())).resolves.toBeUndefined(); + expect(Date.now() - started).toBeLessThan(2_000); + }); + }); +}); diff --git a/apps/www/src/content/docs/docs/reference/configuration.md b/apps/www/src/content/docs/docs/reference/configuration.md index db47a2d5ff..43659c9141 100644 --- a/apps/www/src/content/docs/docs/reference/configuration.md +++ b/apps/www/src/content/docs/docs/reference/configuration.md @@ -161,7 +161,7 @@ Sleeping and reclaimed Instant and VM sessions are restored from a snapshot of t | `SESSION_SLEEP_MAX_ATTEMPTS` | `9` | Automatic sleep attempts before SAM preserves compute and records an operator-visible failure. Raising the configured budget re-arms previously exhausted rows that are still below the new limit. | | `SESSION_SLEEP_CLAIM_LEASE_MS` | `600000` (10 min) | Time after which an interrupted automatic-sleep claim can be safely reclaimed. | | `HARNESS_BACKGROUND_WORK_LEASE_MS` | `300000` (5 min) | Finite sleep-protection lease renewed by normalized harness background-work lifecycle signals. Expiry fails open to ordinary idle-sleep eligibility so a missing terminal signal cannot pin compute forever. | -| `HARNESS_BACKGROUND_WORK_MAX_DURATION_MS` | `1800000` (30 min) | Absolute ceiling, measured from the last harness lifecycle **progress** edge rather than the last heartbeat, on how long background work may defer sleep. The sliding lease above is refreshed by periodic re-reports, so an adapter faithfully re-reporting a stale task set (for example an abandoned `run_in_background` dev server) would otherwise pin compute awake indefinitely. | +| `HARNESS_BACKGROUND_WORK_MAX_DURATION_MS` | `1800000` (30 min) | Absolute ceiling, measured from the last harness lifecycle **progress** edge rather than the last heartbeat, on how long background work may defer sleep. The sliding lease above is refreshed by periodic re-reports, so an adapter faithfully re-reporting a stale task set (for example an abandoned `run_in_background` dev server) would otherwise pin compute awake indefinitely. | | `SESSION_SNAPSHOT_RECOVERY_CLAIM_LEASE_MS` | `600000` (10 min) | Time after which an interrupted replacement-runtime wake claim can be reconciled or reclaimed. | | `SESSION_LIFECYCLE_ERROR_MAX_LENGTH` | `2048` | Maximum sleep/recovery diagnostic detail stored in lifecycle records. | | `SESSION_SNAPSHOT_PURGE_ENABLED` | `true` | Enables bounded expiry cleanup: terminalizes the sleeping chat, deletes its R2 objects, then removes D1 metadata. | @@ -365,6 +365,27 @@ SAM loads OpenCode Zen and OpenCode Go model choices through the authenticated m | `MODEL_CATALOG_CACHE_TTL_SECONDS` | `3600` | KV cache TTL for normalized dynamic model catalog payloads | | `MODEL_CATALOG_FETCH_TIMEOUT_MS` | `5000` | Timeout for the upstream catalog fetch before static fallback | +## HTTP Response Caching + +Conservative `Cache-Control` budgets for stable and semi-stable API `GET`s, letting the browser +serve a cached body instantly while it revalidates in the background. All values are **seconds** and +are clamped to `[0, 86400]`; an unparseable or negative value falls back to the default rather than +caching for longer. + +Authenticated responses are always emitted as `private` with `Vary: Cookie`, so neither a shared +cache nor a second account in the same browser can be served another user's body. Only the +unauthenticated `/api/config/*` endpoints are marked `public`. Endpoints returning real-time data +(chat messages, task status, session and workspace state) are deliberately excluded. + +| Variable | Default | Description | +| ----------------------------------------- | ------- | --------------------------------------------------------------------------- | +| `PUBLIC_CONFIG_CACHE_MAX_AGE_SECONDS` | `60` | `max-age` for the unauthenticated `/api/config/*` endpoints | +| `PUBLIC_CONFIG_CACHE_SWR_SECONDS` | `300` | `stale-while-revalidate` for `/api/config/*` | +| `MODEL_CATALOG_CACHE_MAX_AGE_SECONDS` | `60` | `max-age` for `GET /api/model-catalog/:agentType` | +| `MODEL_CATALOG_CACHE_SWR_SECONDS` | `300` | `stale-while-revalidate` for the model catalog response | +| `PROJECT_REFERENCE_CACHE_MAX_AGE_SECONDS` | `0` | `max-age` for project agent-profile and skill lists (0 = always revalidate) | +| `PROJECT_REFERENCE_CACHE_SWR_SECONDS` | `30` | `stale-while-revalidate` for project agent-profile and skill lists | + ## Warm Node Pooling | Variable | Default | Description | @@ -722,28 +743,28 @@ ProjectData stores a single prompt-delivery queue and checkpoint episodes keyed ## Durable Object Limits -| Variable | Default | Description | -| ------------------------------------- | ---------------- | ---------------------------------------------------------------------- | -| `MAX_SESSIONS_PER_PROJECT` | `10000` | Max chat sessions per project | -| `MAX_MESSAGES_PER_SESSION` | `100000` | Max messages per chat session | -| `DOCUMENT_CARD_RAW_OUTPUT_MAX_BYTES` | `16384` | Max compact metadata bytes preserved for library document cards | -| `MESSAGE_SIZE_THRESHOLD` | `102400` | Max message size in bytes | -| `ACTIVITY_RETENTION_DAYS` | `90` | Days to retain activity events | -| `SESSION_IDLE_TIMEOUT_MINUTES` | `60` | Idle session timeout | -| `SESSION_ACTIVITY_STALE_THRESHOLD_MS` | `300000` (5 min) | Evidence threshold before stale working activity can be healed to idle | -| `SESSION_ACTIVITY_PROBE_TIMEOUT_MS` | `5000` (5 s) | Timeout for the vm-agent session-activity probe. Background control-loop budget — deliberately far below the interactive node-agent timeout | -| `SESSION_ACTIVITY_PROBE_MAX_ATTEMPTS` | `3` | Consecutive unreachable probes after which a stale working state is terminalized as dead | -| `SESSION_ACTIVITY_PROBE_MAX_CANDIDATES` | `10` | Stale-activity candidates probed per ProjectData alarm pass | -| `DO_SUMMARY_SYNC_DEBOUNCE_MS` | `5000` | Debounce for DO-to-D1 summary sync | +| Variable | Default | Description | +| --------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `MAX_SESSIONS_PER_PROJECT` | `10000` | Max chat sessions per project | +| `MAX_MESSAGES_PER_SESSION` | `100000` | Max messages per chat session | +| `DOCUMENT_CARD_RAW_OUTPUT_MAX_BYTES` | `16384` | Max compact metadata bytes preserved for library document cards | +| `MESSAGE_SIZE_THRESHOLD` | `102400` | Max message size in bytes | +| `ACTIVITY_RETENTION_DAYS` | `90` | Days to retain activity events | +| `SESSION_IDLE_TIMEOUT_MINUTES` | `60` | Idle session timeout | +| `SESSION_ACTIVITY_STALE_THRESHOLD_MS` | `300000` (5 min) | Evidence threshold before stale working activity can be healed to idle | +| `SESSION_ACTIVITY_PROBE_TIMEOUT_MS` | `5000` (5 s) | Timeout for the vm-agent session-activity probe. Background control-loop budget — deliberately far below the interactive node-agent timeout | +| `SESSION_ACTIVITY_PROBE_MAX_ATTEMPTS` | `3` | Consecutive unreachable probes after which a stale working state is terminalized as dead | +| `SESSION_ACTIVITY_PROBE_MAX_CANDIDATES` | `10` | Stale-activity candidates probed per ProjectData alarm pass | +| `DO_SUMMARY_SYNC_DEBOUNCE_MS` | `5000` | Debounce for DO-to-D1 summary sync | ## Durable Object Retry -| Variable | Default | Description | -| ------------------------ | ------- | -------------------------------------------------------------------------- | -| `DO_RETRY_MAX_ATTEMPTS` | `8` | Max attempts for transient Durable Object RPC reset/overload errors | -| `DO_RETRY_BASE_DELAY_MS` | `100` | Base retry delay in milliseconds for transient Durable Object RPC failures | -| `DO_RETRY_MAX_DELAY_MS` | `250` | Max per-attempt retry delay for transient Durable Object RPC failures | -| `PROJECT_DATA_ENSURE_MEMO_MAX_ENTRIES` | `2000` | Max ProjectData Durable Objects one Worker isolate remembers as already having a persisted `projectId`, so `ensureProjectId` costs one RPC per isolate instead of one before every DO call | +| Variable | Default | Description | +| -------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `DO_RETRY_MAX_ATTEMPTS` | `8` | Max attempts for transient Durable Object RPC reset/overload errors | +| `DO_RETRY_BASE_DELAY_MS` | `100` | Base retry delay in milliseconds for transient Durable Object RPC failures | +| `DO_RETRY_MAX_DELAY_MS` | `250` | Max per-attempt retry delay for transient Durable Object RPC failures | +| `PROJECT_DATA_ENSURE_MEMO_MAX_ENTRIES` | `2000` | Max ProjectData Durable Objects one Worker isolate remembers as already having a persisted `projectId`, so `ensureProjectId` costs one RPC per isolate instead of one before every DO call | ## Runtime Config Limits @@ -854,22 +875,37 @@ Applied via cloud-init on each node: ## Web UI (Build-Time) -| Variable | Default | Description | -| --------------------------------------- | ------------------ | --------------------------------------------------------------------- | -| `VITE_FILE_PREVIEW_INLINE_MAX_BYTES` | `10485760` (10 MB) | Images below this size render inline automatically | -| `VITE_FILE_PREVIEW_LOAD_MAX_BYTES` | `52428800` (50 MB) | Images below this size show click-to-load; above shows download link | -| `VITE_ANALYTICS_MAX_QUEUE_SIZE` | `100` | Max client-side analytics events retained before oldest events drop | -| `VITE_ANALYTICS_FLUSH_THRESHOLD` | `10` | Client event count that triggers an immediate analytics flush | -| `VITE_ANALYTICS_FLUSH_INTERVAL_MS` | `5000` | Client analytics background flush interval in milliseconds | -| `VITE_DEBUG_DIAGNOSIS_EVENT_MAX_PAGES` | `100` | Max paginated diagnosis-event pages loaded per browser request | -| `VITE_PROJECT_LIST_LIMIT` | `50` | Projects loaded into each shared list-cache entry | -| `VITE_PROJECT_POLL_INTERVAL_MS` | `30000` | Project-list page refresh cadence in milliseconds; `0` disables | -| `VITE_SIDEBAR_PROJECT_POLL_INTERVAL_MS` | `60000` | App-shell project-list refresh cadence in milliseconds; `0` disables | -| `VITE_PROJECT_PREFETCH_DELAY_MS` | `120` | Mouse dwell before project-detail prefetch; focus/touch are immediate | -| `VITE_BACKGROUND_FETCH_DELAY_MS` | `150` | Delay before background query activity is shown and announced | -| `VITE_CHUNK_LOAD_RETRY_DELAY_MS` | `350` | Wait before retrying a failed lazy route-chunk import | +| Variable | Default | Description | +| --------------------------------------- | ------------------ | ------------------------------------------------------------------------ | +| `VITE_FILE_PREVIEW_INLINE_MAX_BYTES` | `10485760` (10 MB) | Images below this size render inline automatically | +| `VITE_FILE_PREVIEW_LOAD_MAX_BYTES` | `52428800` (50 MB) | Images below this size show click-to-load; above shows download link | +| `VITE_ANALYTICS_MAX_QUEUE_SIZE` | `100` | Max client-side analytics events retained before oldest events drop | +| `VITE_ANALYTICS_FLUSH_THRESHOLD` | `10` | Client event count that triggers an immediate analytics flush | +| `VITE_ANALYTICS_FLUSH_INTERVAL_MS` | `5000` | Client analytics background flush interval in milliseconds | +| `VITE_DEBUG_DIAGNOSIS_EVENT_MAX_PAGES` | `100` | Max paginated diagnosis-event pages loaded per browser request | +| `VITE_PROJECT_LIST_LIMIT` | `50` | Projects loaded into each shared list-cache entry | +| `VITE_PROJECT_POLL_INTERVAL_MS` | `30000` | Project-list page refresh cadence in milliseconds; `0` disables | +| `VITE_SIDEBAR_PROJECT_POLL_INTERVAL_MS` | `60000` | App-shell project-list refresh cadence in milliseconds; `0` disables | +| `VITE_PROJECT_PREFETCH_DELAY_MS` | `120` | Mouse dwell before project-detail prefetch; focus/touch are immediate | +| `VITE_BACKGROUND_FETCH_DELAY_MS` | `150` | Delay before background query activity is shown and announced | +| `VITE_CHUNK_LOAD_RETRY_DELAY_MS` | `350` | Wait before retrying a failed lazy route-chunk import | | `VITE_CHUNK_RELOAD_COOLDOWN_MS` | `15000` | Minimum gap between chunk-recovery reloads; guards against a reload loop | -| `VITE_ROUTE_FALLBACK_REVEAL_DELAY_MS` | `180` | Delay before the route loading spinner fades in, avoiding a flash | +| `VITE_ROUTE_FALLBACK_REVEAL_DELAY_MS` | `180` | Delay before the route loading spinner fades in, avoiding a flash | +| `VITE_QUERY_PERSIST_MAX_AGE_MS` | `86400000` (24 h) | How long a persisted query-cache record may be restored after writing | +| `VITE_QUERY_PERSIST_THROTTLE_MS` | `1000` | Minimum gap between IndexedDB writes of the query cache | +| `VITE_QUERY_PERSIST_RESTORE_TIMEOUT_MS` | `250` | Budget for the initial cache restore before failing open to no cache | + +### Query cache persistence + +The control-plane UI writes an allowlisted slice of its query cache to IndexedDB so a full page +reload paints from cache instead of refetching. Only bounded project reference data is persisted; +chat messages, agent output, credentials, admin diagnostics, node and workspace runtime details, and +file contents are never written to disk. + +Records are namespaced by authenticated user and by a schema version, and are deleted on sign-out +and on account switch, so one account can never be shown another account's cached data. If +IndexedDB is unavailable — private browsing, a storage quota failure, or a disabled store — the app +degrades silently to its normal in-memory cache. ## Analytics diff --git a/packages/shared/src/constants/defaults.ts b/packages/shared/src/constants/defaults.ts index 76d97fca3c..9b2cc8a454 100644 --- a/packages/shared/src/constants/defaults.ts +++ b/packages/shared/src/constants/defaults.ts @@ -167,3 +167,71 @@ export const DEFAULT_REPORT_ISSUE_DESCRIPTION_MAX_LENGTH = 5_000; /** Max length for stored report content (description + technical references). Override via REPORT_ISSUE_CONTENT_MAX_LENGTH env var. */ export const DEFAULT_REPORT_ISSUE_CONTENT_MAX_LENGTH = 65_536; + +// ============================================================================= +// Client Query Cache Persistence (apps/web) +// ============================================================================= +// The web app persists an allowlisted slice of its TanStack Query cache to +// IndexedDB so a full page reload paints from cache instead of refetching the +// world. See apps/web/src/lib/query-persistence.ts. + +/** How long a persisted query cache entry may be restored after it was written. + * `persistQueryClientRestore` discards (and deletes) anything older. Matches + * TanStack's own documented default. Override via VITE_QUERY_PERSIST_MAX_AGE_MS. */ +export const DEFAULT_QUERY_PERSIST_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours + +/** Minimum gap between IndexedDB writes. TanStack v5 removed `throttleTime` from + * persistQueryClient, so the persister throttles its own writes to avoid an IDB + * write per cache event. Override via VITE_QUERY_PERSIST_THROTTLE_MS. */ +export const DEFAULT_QUERY_PERSIST_THROTTLE_MS = 1_000; + +/** Upper bound on the initial cache restore for a signed-in user. + * + * Rendering is gated on this restore, and that gate can only ever ADD latency to + * first paint: it runs after the session round trip (it needs the identity to + * pick the right record), and it suppresses the spinner `ProtectedRoute` would + * otherwise show, because that spinner lives inside the gated subtree. So the + * budget is deliberately tight — a healthy IndexedDB read is single-digit + * milliseconds, and past this point failing open to an empty in-memory cache + * beats holding a blank screen. + * Override via VITE_QUERY_PERSIST_RESTORE_TIMEOUT_MS. */ +export const DEFAULT_QUERY_PERSIST_RESTORE_TIMEOUT_MS = 250; + +// ============================================================================= +// HTTP Response Cache-Control (apps/api) +// ============================================================================= +// Conservative stale-while-revalidate budgets for stable/semi-stable GETs. See +// apps/api/src/lib/cache-headers.ts. Authenticated responses are ALWAYS `private` +// and carry `Vary: Cookie`; `public` is reserved for unauthenticated, globally +// identical responses. + +/** `max-age` for unauthenticated deploy-scoped config GETs (/api/config/*). + * Override via PUBLIC_CONFIG_CACHE_MAX_AGE_SECONDS. */ +export const DEFAULT_PUBLIC_CONFIG_CACHE_MAX_AGE_SECONDS = 60; + +/** `stale-while-revalidate` for unauthenticated deploy-scoped config GETs. + * Override via PUBLIC_CONFIG_CACHE_SWR_SECONDS. */ +export const DEFAULT_PUBLIC_CONFIG_CACHE_SWR_SECONDS = 300; + +/** `max-age` for the authenticated but globally identical model catalog. + * + * Deliberately far below the KV catalog TTL (`MODEL_CATALOG_CACHE_TTL_SECONDS`, + * default 3600) so a refreshed catalog is not masked for long. The two are + * independent env vars and nothing enforces the ordering — raising this above the + * KV TTL is a staleness footgun, not a caught error. + * Override via MODEL_CATALOG_CACHE_MAX_AGE_SECONDS. */ +export const DEFAULT_MODEL_CATALOG_RESPONSE_CACHE_MAX_AGE_SECONDS = 60; + +/** `stale-while-revalidate` for the model catalog response. + * Override via MODEL_CATALOG_CACHE_SWR_SECONDS. */ +export const DEFAULT_MODEL_CATALOG_RESPONSE_CACHE_SWR_SECONDS = 300; + +/** `max-age` for per-user project-scoped reference GETs (agent profiles, skills). + * Deliberately 0: the response is served stale-then-revalidated rather than held + * fresh, so a user's own edit is never masked by more than one request. + * Override via PROJECT_REFERENCE_CACHE_MAX_AGE_SECONDS. */ +export const DEFAULT_PROJECT_REFERENCE_CACHE_MAX_AGE_SECONDS = 0; + +/** `stale-while-revalidate` for per-user project-scoped reference GETs. + * Override via PROJECT_REFERENCE_CACHE_SWR_SECONDS. */ +export const DEFAULT_PROJECT_REFERENCE_CACHE_SWR_SECONDS = 30; diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index 000adefa79..00398f0db8 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -54,7 +54,16 @@ export { DEFAULT_MAX_TASKS_PER_PROJECT, DEFAULT_MCP_TOKEN_MAX_LIFETIME_SECONDS, DEFAULT_MCP_TOKEN_TTL_SECONDS, + DEFAULT_MODEL_CATALOG_RESPONSE_CACHE_MAX_AGE_SECONDS, + DEFAULT_MODEL_CATALOG_RESPONSE_CACHE_SWR_SECONDS, DEFAULT_NODE_HEARTBEAT_STALE_SECONDS, + DEFAULT_PROJECT_REFERENCE_CACHE_MAX_AGE_SECONDS, + DEFAULT_PROJECT_REFERENCE_CACHE_SWR_SECONDS, + DEFAULT_PUBLIC_CONFIG_CACHE_MAX_AGE_SECONDS, + DEFAULT_PUBLIC_CONFIG_CACHE_SWR_SECONDS, + DEFAULT_QUERY_PERSIST_MAX_AGE_MS, + DEFAULT_QUERY_PERSIST_RESTORE_TIMEOUT_MS, + DEFAULT_QUERY_PERSIST_THROTTLE_MS, DEFAULT_REPORT_ISSUE_CONTENT_MAX_LENGTH, DEFAULT_REPORT_ISSUE_DESCRIPTION_MAX_LENGTH, DEFAULT_REPORT_ISSUE_TITLE_MAX_LENGTH, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5036dd878..ee2903c12d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,7 +236,7 @@ importers: version: 0.6.1(hono@4.12.31)(valibot@1.3.1(typescript@5.9.3)) '@mastra/core': specifier: 1.9.0 - version: 1.9.0(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(valibot@1.3.1(typescript@5.9.3))(zod@4.3.6))(@types/json-schema@7.0.15)(openapi-types@12.1.3)(zod@4.3.6) + version: 1.9.0(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(valibot@1.3.1(typescript@5.9.3))(zod@4.4.3))(@types/json-schema@7.0.15)(openapi-types@12.1.3)(zod@4.4.3) '@simple-agent-manager/cloud-init': specifier: workspace:* version: link:../../packages/cloud-init @@ -248,13 +248,13 @@ importers: version: link:../../packages/shared ai: specifier: 7.0.9 - version: 7.0.9(zod@4.3.6) + version: 7.0.9(zod@4.4.3) better-auth: specifier: 'catalog:' version: 1.6.11(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.26.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.28.17))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.5) better-auth-cloudflare: specifier: 0.3.0 - version: 0.3.0(@better-auth/drizzle-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.28.17)))(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-auth@1.6.11(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.26.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.28.17))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.5))(better-sqlite3@12.11.1)(kysely@0.28.17) + version: 0.3.0(@better-auth/drizzle-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.28.17)))(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-auth@1.6.11(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.26.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.28.17))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.5))(better-sqlite3@12.11.1)(kysely@0.28.17) diff: specifier: ^9.0.0 version: 9.0.0 @@ -278,7 +278,7 @@ importers: version: 1.3.1(typescript@5.9.3) workers-ai-provider: specifier: 3.1.11 - version: 3.1.11(@ai-sdk/provider@4.0.1)(ai@7.0.9(zod@4.3.6)) + version: 3.1.11(@ai-sdk/provider@4.0.1)(ai@7.0.9(zod@4.4.3)) yaml: specifier: 2.9.0 version: 2.9.0 @@ -373,6 +373,9 @@ importers: '@tailwindcss/vite': specifier: 4.2.3 version: 4.2.3(vite@8.1.3(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0)) + '@tanstack/query-persist-client-core': + specifier: 5.101.2 + version: 5.101.2 '@tanstack/react-query': specifier: ^5.101.2 version: 5.101.2(react@19.2.7) @@ -391,6 +394,9 @@ importers: dompurify: specifier: 3.4.13 version: 3.4.13 + idb-keyval: + specifier: ^6.2.1 + version: 6.3.0 lucide-react: specifier: 'catalog:' version: 0.460.0(react@19.2.7) @@ -482,6 +488,9 @@ importers: eslint-plugin-react-hooks: specifier: 'catalog:' version: 7.1.1(eslint@9.39.5(jiti@2.6.1)) + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 jsdom: specifier: 'catalog:' version: 29.1.1(@noble/hashes@2.0.1) @@ -505,10 +514,10 @@ importers: version: 3.7.3 '@astrojs/starlight': specifier: 0.40.0 - version: 0.40.0(astro@6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@5.9.3) + version: 0.40.0(astro@6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(idb-keyval@6.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@5.9.3) astro: specifier: 6.4.8 - version: 6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) + version: 6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(idb-keyval@6.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) mermaid: specifier: 11.14.0 version: 11.14.0 @@ -4025,6 +4034,9 @@ packages: '@tanstack/query-core@5.101.2': resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} + '@tanstack/query-persist-client-core@5.101.2': + resolution: {integrity: sha512-rgMsbvBVpSPDUsprC9FYxojdG0Q1LH6yq1i3DXz2aps4VEqv2l4Msj3YDqIifU3xkl/DrYe9YWntZAJLrM6xTg==} + '@tanstack/react-query@5.101.2': resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==} peerDependencies: @@ -6129,6 +6141,10 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-check@4.9.0: resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} engines: {node: '>=12.17.0'} @@ -6599,6 +6615,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + idb-keyval@6.3.0: + resolution: {integrity: sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -9759,41 +9778,41 @@ snapshots: dependencies: zod: 4.4.3 - '@ai-sdk/gateway@4.0.7(zod@4.3.6)': + '@ai-sdk/gateway@4.0.7(zod@4.4.3)': dependencies: '@ai-sdk/provider': 4.0.1 - '@ai-sdk/provider-utils': 5.0.2(zod@4.3.6) + '@ai-sdk/provider-utils': 5.0.2(zod@4.4.3) '@vercel/oidc': 3.2.0 - zod: 4.3.6 + zod: 4.4.3 - '@ai-sdk/provider-utils@2.2.8(zod@4.3.6)': + '@ai-sdk/provider-utils@2.2.8(zod@4.4.3)': dependencies: '@ai-sdk/provider': 1.1.3 nanoid: 3.3.18 secure-json-parse: 2.7.0 - zod: 4.3.6 + zod: 4.4.3 - '@ai-sdk/provider-utils@3.0.20(zod@4.3.6)': + '@ai-sdk/provider-utils@3.0.20(zod@4.4.3)': dependencies: '@ai-sdk/provider': 2.0.1 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.1.1 - zod: 4.3.6 + zod: 4.4.3 - '@ai-sdk/provider-utils@4.0.0(zod@4.3.6)': + '@ai-sdk/provider-utils@4.0.0(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.0 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.1.1 - zod: 4.3.6 + zod: 4.4.3 - '@ai-sdk/provider-utils@5.0.2(zod@4.3.6)': + '@ai-sdk/provider-utils@5.0.2(zod@4.4.3)': dependencies: '@ai-sdk/provider': 4.0.1 '@standard-schema/spec': 1.1.0 '@workflow/serde': 4.1.0 eventsource-parser: 3.1.0 - zod: 4.3.6 + zod: 4.4.3 '@ai-sdk/provider@1.1.3': dependencies: @@ -9815,12 +9834,12 @@ snapshots: dependencies: json-schema: 0.4.0 - '@ai-sdk/ui-utils@1.2.11(zod@4.3.6)': + '@ai-sdk/ui-utils@1.2.11(zod@4.4.3)': dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@4.3.6) - zod: 4.3.6 - zod-to-json-schema: 3.25.2(zod@4.3.6) + '@ai-sdk/provider-utils': 2.2.8(zod@4.4.3) + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) '@antfu/install-pkg@1.1.0': dependencies: @@ -9922,13 +9941,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/mdx@6.0.3(astro@6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0))': + '@astrojs/mdx@6.0.3(astro@6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(idb-keyval@6.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0))': dependencies: '@astrojs/internal-helpers': 0.10.0 '@astrojs/markdown-remark': 7.2.0 '@mdx-js/mdx': 3.1.1 acorn: 8.17.0 - astro: 6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) + astro: 6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(idb-keyval@6.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) es-module-lexer: 2.0.0 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -9952,17 +9971,17 @@ snapshots: stream-replace-string: 2.0.0 zod: 4.4.3 - '@astrojs/starlight@0.40.0(astro@6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@5.9.3)': + '@astrojs/starlight@0.40.0(astro@6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(idb-keyval@6.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0))(typescript@5.9.3)': dependencies: '@astrojs/markdown-remark': 7.2.0 - '@astrojs/mdx': 6.0.3(astro@6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0)) + '@astrojs/mdx': 6.0.3(astro@6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(idb-keyval@6.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0)) '@astrojs/sitemap': 3.7.3 '@pagefind/default-ui': 1.4.0 '@types/hast': 3.0.4 '@types/js-yaml': 4.0.9 '@types/mdast': 4.0.4 - astro: 6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) - astro-expressive-code: 0.43.1(astro@6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0)) + astro: 6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(idb-keyval@6.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) + astro-expressive-code: 0.43.1(astro@6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(idb-keyval@6.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0)) bcp-47: 2.1.0 hast-util-from-html: 2.0.3 hast-util-select: 6.0.4 @@ -10337,6 +10356,21 @@ snapshots: '@cloudflare/workers-types': 5.20260707.1 '@opentelemetry/api': 1.9.0 + '@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)': + dependencies: + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + '@opentelemetry/semantic-conventions': 1.40.0 + '@standard-schema/spec': 1.1.0 + better-call: 1.3.5(zod@4.4.3) + jose: 6.2.3 + kysely: 0.28.17 + nanostores: 1.3.0 + zod: 4.4.3 + optionalDependencies: + '@cloudflare/workers-types': 5.20260707.1 + '@opentelemetry/api': 1.9.0 + '@better-auth/drizzle-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.28.17))': dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) @@ -10344,6 +10378,13 @@ snapshots: optionalDependencies: drizzle-orm: 0.45.2(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.28.17) + '@better-auth/drizzle-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.28.17))': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + optionalDependencies: + drizzle-orm: 0.45.2(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.28.17) + '@better-auth/kysely-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)': dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) @@ -10351,27 +10392,55 @@ snapshots: optionalDependencies: kysely: 0.28.17 + '@better-auth/kysely-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + optionalDependencies: + kysely: 0.28.17 + '@better-auth/memory-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 + '@better-auth/memory-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + '@better-auth/mongo-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 + '@better-auth/mongo-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + '@better-auth/prisma-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 + '@better-auth/prisma-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + '@better-auth/telemetry@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 + '@better-auth/telemetry@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + '@better-auth/utils@0.4.0': dependencies: '@noble/hashes': 2.0.1 @@ -11442,24 +11511,24 @@ snapshots: dependencies: '@lukeed/csprng': 1.1.0 - '@mastra/core@1.9.0(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(valibot@1.3.1(typescript@5.9.3))(zod@4.3.6))(@types/json-schema@7.0.15)(openapi-types@12.1.3)(zod@4.3.6)': + '@mastra/core@1.9.0(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(valibot@1.3.1(typescript@5.9.3))(zod@4.4.3))(@types/json-schema@7.0.15)(openapi-types@12.1.3)(zod@4.4.3)': dependencies: '@a2a-js/sdk': 0.2.5 - '@ai-sdk/provider-utils-v5': '@ai-sdk/provider-utils@3.0.20(zod@4.3.6)' - '@ai-sdk/provider-utils-v6': '@ai-sdk/provider-utils@4.0.0(zod@4.3.6)' + '@ai-sdk/provider-utils-v5': '@ai-sdk/provider-utils@3.0.20(zod@4.4.3)' + '@ai-sdk/provider-utils-v6': '@ai-sdk/provider-utils@4.0.0(zod@4.4.3)' '@ai-sdk/provider-v5': '@ai-sdk/provider@2.0.0' '@ai-sdk/provider-v6': '@ai-sdk/provider@3.0.0' - '@ai-sdk/ui-utils-v5': '@ai-sdk/ui-utils@1.2.11(zod@4.3.6)' + '@ai-sdk/ui-utils-v5': '@ai-sdk/ui-utils@1.2.11(zod@4.4.3)' '@isaacs/ttlcache': 2.1.4 '@lukeed/uuid': 2.0.1 - '@mastra/schema-compat': 1.1.3(zod@4.3.6) - '@modelcontextprotocol/sdk': 1.27.1(zod@4.3.6) + '@mastra/schema-compat': 1.1.3(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.27.1(zod@4.4.3) '@sindresorhus/slugify': 2.2.1 dotenv: 17.3.1 execa: 9.6.1 gray-matter: 4.0.3 hono: 4.12.31 - hono-openapi: 1.3.0(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(valibot@1.3.1(typescript@5.9.3))(zod@4.3.6))(@types/json-schema@7.0.15)(hono@4.12.31)(openapi-types@12.1.3) + hono-openapi: 1.3.0(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(valibot@1.3.1(typescript@5.9.3))(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.31)(openapi-types@12.1.3) ignore: 7.0.5 js-tiktoken: 1.0.21 json-schema: 0.4.0 @@ -11470,7 +11539,7 @@ snapshots: radash: 12.1.1 ws: 8.19.0 xxhash-wasm: 1.1.0 - zod: 4.3.6 + zod: 4.4.3 transitivePeerDependencies: - '@cfworker/json-schema' - '@hono/standard-validator' @@ -11482,13 +11551,13 @@ snapshots: - supports-color - utf-8-validate - '@mastra/schema-compat@1.1.3(zod@4.3.6)': + '@mastra/schema-compat@1.1.3(zod@4.4.3)': dependencies: json-schema-to-zod: 2.7.0 - zod: 4.3.6 + zod: 4.4.3 zod-from-json-schema: 0.5.2 zod-from-json-schema-v3: zod-from-json-schema@0.0.5 - zod-to-json-schema: 3.25.1(zod@4.3.6) + zod-to-json-schema: 3.25.1(zod@4.4.3) '@mdx-js/mdx@3.1.1': dependencies: @@ -11530,7 +11599,7 @@ snapshots: dependencies: langium: 4.2.2 - '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': + '@modelcontextprotocol/sdk@1.27.1(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.9(hono@4.12.31) ajv: 8.20.0 @@ -11547,8 +11616,8 @@ snapshots: json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 4.3.6 - zod-to-json-schema: 3.25.1(zod@4.3.6) + zod: 4.4.3 + zod-to-json-schema: 3.25.1(zod@4.4.3) transitivePeerDependencies: - supports-color @@ -12436,24 +12505,24 @@ snapshots: '@speed-highlight/core@1.2.15': {} - '@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6)': + '@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3)': dependencies: '@standard-schema/spec': 1.1.0 '@types/json-schema': 7.0.15 quansync: 0.2.11 optionalDependencies: valibot: 1.3.1(typescript@5.9.3) - zod: 4.3.6 - zod-to-json-schema: 3.25.2(zod@4.3.6) + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) - '@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(valibot@1.3.1(typescript@5.9.3))(zod@4.3.6)': + '@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(valibot@1.3.1(typescript@5.9.3))(zod@4.4.3)': dependencies: - '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6) + '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3) '@standard-schema/spec': 1.1.0 openapi-types: 12.1.3 optionalDependencies: valibot: 1.3.1(typescript@5.9.3) - zod: 4.3.6 + zod: 4.4.3 '@standard-schema/spec@1.1.0': {} @@ -12634,6 +12703,10 @@ snapshots: '@tanstack/query-core@5.101.2': {} + '@tanstack/query-persist-client-core@5.101.2': + dependencies: + '@tanstack/query-core': 5.101.2 + '@tanstack/react-query@5.101.2(react@19.2.7)': dependencies: '@tanstack/query-core': 5.101.2 @@ -13423,12 +13496,12 @@ snapshots: agent-base@7.1.4: {} - ai@7.0.9(zod@4.3.6): + ai@7.0.9(zod@4.4.3): dependencies: - '@ai-sdk/gateway': 4.0.7(zod@4.3.6) + '@ai-sdk/gateway': 4.0.7(zod@4.4.3) '@ai-sdk/provider': 4.0.1 - '@ai-sdk/provider-utils': 5.0.2(zod@4.3.6) - zod: 4.3.6 + '@ai-sdk/provider-utils': 5.0.2(zod@4.4.3) + zod: 4.4.3 ajv-draft-04@1.0.0(ajv@8.20.0): optionalDependencies: @@ -13585,12 +13658,12 @@ snapshots: transitivePeerDependencies: - supports-color - astro-expressive-code@0.43.1(astro@6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0)): + astro-expressive-code@0.43.1(astro@6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(idb-keyval@6.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: - astro: 6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) + astro: 6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(idb-keyval@6.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) rehype-expressive-code: 0.43.1 - astro@6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0): + astro@6.4.8(@types/node@25.9.1)(aws4fetch@1.0.20)(idb-keyval@6.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.56.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0): dependencies: '@astrojs/compiler': 4.0.0 '@astrojs/internal-helpers': 0.10.0 @@ -13640,7 +13713,7 @@ snapshots: ultrahtml: 1.6.0 unifont: 0.7.4 unist-util-visit: 5.1.0 - unstorage: 1.17.5(aws4fetch@1.0.20) + unstorage: 1.17.5(aws4fetch@1.0.20)(idb-keyval@6.3.0) vfile: 6.0.3 vite: 7.3.6(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) vitefu: 1.1.3(vite@7.3.6(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0)) @@ -13736,9 +13809,9 @@ snapshots: is-alphanumerical: 2.0.1 is-decimal: 2.0.1 - better-auth-cloudflare@0.3.0(@better-auth/drizzle-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.28.17)))(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-auth@1.6.11(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.26.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.28.17))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.5))(better-sqlite3@12.11.1)(kysely@0.28.17): + better-auth-cloudflare@0.3.0(@better-auth/drizzle-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.28.17)))(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-auth@1.6.11(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.26.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.28.17))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.5))(better-sqlite3@12.11.1)(kysely@0.28.17): dependencies: - '@better-auth/drizzle-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.28.17)) + '@better-auth/drizzle-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.28.17)) '@cloudflare/workers-types': 5.20260707.1 better-auth: 1.6.11(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.26.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.28.17))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.5) drizzle-orm: 0.45.2(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.28.17) @@ -13776,13 +13849,13 @@ snapshots: better-auth@1.6.11(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.26.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.28.17))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.5): dependencies: - '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) - '@better-auth/drizzle-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.28.17)) - '@better-auth/kysely-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17) - '@better-auth/memory-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/mongo-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/prisma-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/telemetry': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/drizzle-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.28.17)) + '@better-auth/kysely-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17) + '@better-auth/memory-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/mongo-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/prisma-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/telemetry': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 @@ -13842,6 +13915,15 @@ snapshots: optionalDependencies: zod: 4.3.6 + better-call@1.3.5(zod@4.4.3): + dependencies: + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + rou3: 0.7.12 + set-cookie-parser: 3.1.0 + optionalDependencies: + zod: 4.4.3 + better-sqlite3@12.11.1: dependencies: bindings: 1.5.0 @@ -15257,6 +15339,8 @@ snapshots: extend@3.0.2: {} + fake-indexeddb@6.2.5: {} + fast-check@4.9.0: dependencies: pure-rand: 8.4.2 @@ -15776,10 +15860,10 @@ snapshots: hex-rgb@4.3.0: {} - hono-openapi@1.3.0(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(valibot@1.3.1(typescript@5.9.3))(zod@4.3.6))(@types/json-schema@7.0.15)(hono@4.12.31)(openapi-types@12.1.3): + hono-openapi@1.3.0(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(valibot@1.3.1(typescript@5.9.3))(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.31)(openapi-types@12.1.3): dependencies: - '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6) - '@standard-community/standard-openapi': 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(valibot@1.3.1(typescript@5.9.3))(zod@4.3.6) + '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3) + '@standard-community/standard-openapi': 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(valibot@1.3.1(typescript@5.9.3))(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(valibot@1.3.1(typescript@5.9.3))(zod@4.4.3) '@types/json-schema': 7.0.15 openapi-types: 12.1.3 optionalDependencies: @@ -15867,6 +15951,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + idb-keyval@6.3.0: {} + ieee754@1.2.1: {} ignore-walk@8.0.0: @@ -19049,7 +19135,7 @@ snapshots: picomatch: 4.0.4 webpack-virtual-modules: 0.6.2 - unstorage@1.17.5(aws4fetch@1.0.20): + unstorage@1.17.5(aws4fetch@1.0.20)(idb-keyval@6.3.0): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -19061,6 +19147,7 @@ snapshots: ufo: 1.6.3 optionalDependencies: aws4fetch: 1.0.20 + idb-keyval: 6.3.0 upath@1.2.0: {} @@ -19557,10 +19644,10 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260730.1 '@cloudflare/workerd-windows-64': 1.20260730.1 - workers-ai-provider@3.1.11(@ai-sdk/provider@4.0.1)(ai@7.0.9(zod@4.3.6)): + workers-ai-provider@3.1.11(@ai-sdk/provider@4.0.1)(ai@7.0.9(zod@4.4.3)): dependencies: '@ai-sdk/provider': 4.0.1 - ai: 7.0.9(zod@4.3.6) + ai: 7.0.9(zod@4.4.3) wrangler@4.106.0(@cloudflare/workers-types@5.20260707.1): dependencies: @@ -19710,13 +19797,13 @@ snapshots: dependencies: zod: 4.4.3 - zod-to-json-schema@3.25.1(zod@4.3.6): + zod-to-json-schema@3.25.1(zod@4.4.3): dependencies: - zod: 4.3.6 + zod: 4.4.3 - zod-to-json-schema@3.25.2(zod@4.3.6): + zod-to-json-schema@3.25.2(zod@4.4.3): dependencies: - zod: 4.3.6 + zod: 4.4.3 zod-validation-error@4.0.2(zod@4.4.3): dependencies: diff --git a/tasks/active/2026-08-18-query-cache-persistence-and-http-cache-headers.md b/tasks/active/2026-08-18-query-cache-persistence-and-http-cache-headers.md new file mode 100644 index 0000000000..1210459a21 --- /dev/null +++ b/tasks/active/2026-08-18-query-cache-persistence-and-http-cache-headers.md @@ -0,0 +1,175 @@ +# Query Cache Persistence + HTTP Cache-Control Headers (UI Perf Workstream E) + +Program: SAM UI Performance Plan (idea `01M09SKVNJGJNJY2WGCZ6D89XZ`), plan items **#3** and **#7**. +SAM task: `01M0B812TYM8FN3479HCDAZ6QD`. + +Base branch: `sam/read-idea-01m09skvnjgjnjy2wgcz6d89xz-using-bmbgfz` (program integration branch), **not** `main`. + +## Problem + +**#3 — no query-cache persistence.** `apps/web/src/lib/query-client.ts:25-33` builds an in-memory-only +`QueryClient`. Every full page reload re-fetches the entire world: parse bundle → auth hop → refetch. +Nothing survives a reload or a browser restart. + +**#7 — no HTTP caching on API GETs.** Every browser navigation re-fetches from origin even for data +that changes on deploy or once a month. The API sets `Cache-Control` in 22 places, but every one is +`no-store` / `no-cache` / `private` except four unauthenticated static resources +(`index.ts:655` JWKS, `index.ts:664` OIDC discovery, `binary-artifacts.ts:32`, `cli.ts`). + +## Research findings + +### Endpoint reality check — the brief's candidate list is mostly wrong + +The dispatch brief said "verify in code first". Verified; most named paths do not exist: + +| Brief said | Reality | Decision | +|---|---|---| +| `GET /api/ai/models` | Does not exist. Web UI uses `GET /api/model-catalog/:agentType` (`routes/model-catalog.ts:12`, session auth, **global** payload, KV-backed w/ 3600s TTL). `GET /ai/v1/models` also exists (`ai-proxy.ts:482`) but is agent-facing w/ callback-token auth. | Cache `/api/model-catalog/:agentType`. Skip `/ai/v1/models` (not a browser surface). | +| `GET /api/platform-config` | Does not exist. Real path is `GET /api/admin/platform-config` (`admin-platform-config.ts:18`) — **superadmin-only**. Public config lives at `/api/config/artifacts-enabled` (`index.ts:625`), `/api/config/vapid-public-key` (`index.ts:631`), `/api/config/login-providers` (`index.ts:639`). | Cache the three **public** `/api/config/*` endpoints. Skip the superadmin one (rare, and admin surfaces should read through). | +| `GET /api/projects/:id` | Exists (`projects/crud.ts:535`) but embeds `recentSessions` (5) + `recentActivity` (10) + live task/workspace counts from the ProjectData DO. **This is semi-real-time.** | **Excluded.** Caching it would show stale chat/activity. Documented as a deliberate exclusion. | +| `GET /api/projects/:id/settings` | **Does not exist at all.** Closest is `GET /api/projects/:id/runtime-config` (`crud.ts:603`), gated on the `secret:read` capability and containing **secrets metadata**. | **Excluded** — credential-adjacent. | +| `GET /api/projects/:id/profiles` | Real path `GET /api/projects/:projectId/agent-profiles` (`agent-profiles.ts:17`). D1-only. Payload is **user-specific**: `services/agent-profiles.ts:84-88` returns project profiles `OR` the *calling user's* global profiles. | Cache, `private` + `Vary: Cookie`. | +| `GET /api/projects/:id/skills` | Real path `GET /api/projects/:projectId/skills` (`skills.ts:16`). Same user-specific `or(project, user-global)` shape at `services/skills.ts:96-100`. | Cache, `private` + `Vary: Cookie`. | + +### The cross-tenant hazard that shapes item #7 + +- `apps/api/src/index.ts:586-602` runs global CORS with **`credentials: true`**. +- `grep -rn "Vary" apps/api/src` → **zero hits**. The API has never emitted a `Vary` header. +- Therefore `Cache-Control: public` on any *authenticated* response is unsafe: a shared cache + (Cloudflare edge, corporate proxy) could serve user A's `agent-profiles` to user B. +- Even `private` is not sufficient on its own: the browser HTTP cache is per *profile*, not per + *login*. User A signs out, user B signs in in the same browser → B could be served A's cached + entry for the same URL. + +**Rules adopted:** authenticated GET ⇒ `private` **and** `Vary: Cookie` (session cookie differs per +user ⇒ different cache entry). `public` is permitted **only** on unauthenticated, globally identical +responses, matching the existing JWKS precedent at `index.ts:655`. + +### Web-side facts that shape item #3 + +- `QueryClientProvider` is mounted at `App.tsx:233`, **outside** `AuthProvider` (`App.tsx:235`). + AuthProvider reaches the client through the module singleton import, not context. So a root-level + `PersistQueryClientProvider` cannot know the user identity — persistence must be driven from + inside `AuthProvider`. +- `AuthProvider.tsx:113-131` already owns the identity-transition `useLayoutEffect`: it calls + `cleanupTerminalSecrets()`, `broadcastAuthRevocation()`, `clearLibraryCache(previousNamespace)`, + then **`queryClient.clear()` (:125)**, then `setActiveCacheNamespace(...)`. `AuthProvider.tsx:190` + gates children on `isCacheNamespaceTransitioning`. **Extend this; do not build a parallel + auth-transition listener** (rules 24 + 59). +- `buildLibraryCacheNamespace(userId)` (`lib/library-cache.ts:49`) → `user:` + is the established identity-namespace helper. Reuse it. +- Only **4 of 11** `useQuery` call sites are identity-scoped, all via `lib/query-options.ts:5-19` + which prefixes `['auth', queryScope, …]` where `queryScope = user?.id ?? ''`. The other 7 are + **not**: `['nodes','list']`, `['nodes','catalog']`, `['workspaces','list',…]`, + `['admin-diagnosis', runId]`, `['notification-preferences']`. +- `tasks/backlog/2026-08-07-expand-frontend-query-cache-and-persistence.md` is the design spec for + this item and carries a hard **"Never Persist Without Separate Security Review"** list: chat + messages/agent output, credentials/tokens, admin errors/diagnoses/logs, **node/workspace runtime + details**, file contents/signed URLs, mutation state. + → The 7 unscoped keys map almost exactly onto that banned list. An allowlist keyed on + `['auth', , ]` therefore excludes all of them *structurally*. +- `tasks/archive/2026-08-05-namespace-library-cache-by-user.md` — the real cross-user browser-cache + leak incident this must not repeat. +- jsdom has **no IndexedDB** and there is no polyfill installed; `apps/web/tests/setup.ts` mocks only + `matchMedia` + `ResizeObserver`. IDB tests need `fake-indexeddb`. +- **No app version / build hash reaches the bundle.** `vite.config.ts` has no `define` block; CI + (`deploy-reusable.yml:622-635`) injects no SHA. The codebase precedent for a cache generation + marker is the hand-bumped `sam-shell-v3` in `src/sw.ts:8-9`. +- `persistQueryClient` (v5.101.2) returns `[unsubscribe, restorePromise]`; `persistQueryClientRestore` + already discards persisted data that is expired (`maxAge`), busted (`buster`), or throws. + `PersistQueryClientRootOptions` has **no** `throttleTime` in v5 — writes must be throttled by the + persister itself. + +### Decisions (documented per the "agents decide best-practice questions autonomously" policy) + +1. **IndexedDB, not localStorage.** The brief prefers IDB for size, and `lib/library-cache.ts` already + competes for the 5 MB localStorage budget hard enough to need LRU eviction + (`library-cache.ts:findOldestLibraryKey`). Query cache goes in a separate IDB store so it cannot + evict library index entries. Costs `idb-keyval` + `fake-indexeddb` (dev). +2. **Allowlist = `projects` domain only for v1.** `github.installations` is deliberately excluded as + connection-configuration-adjacent, pending the separate security review the backlog task demands. + Project list/detail is the highest-leverage read anyway (dashboard + sidebar + project page). +3. **Buster = hand-bumped `QUERY_PERSIST_SCHEMA_VERSION`**, following the `sw.ts` precedent, rather + than inventing build-time SHA plumbing the deploy pipeline does not supply. +4. **`GET /api/projects/:id` excluded** from cache headers — it carries DO-sourced recent + sessions/activity, which is the real-time data the brief says not to cache. + +## Implementation checklist + +### Item #3 — query cache persistence (apps/web) + +- [x] Add deps: `@tanstack/query-persist-client-core@5.101.2` (exact match to `react-query@5.101.2`), + `idb-keyval`, dev `fake-indexeddb`. +- [x] Shared defaults in `packages/shared/src/constants/defaults.ts`: + `DEFAULT_QUERY_PERSIST_MAX_AGE_MS`, `DEFAULT_QUERY_PERSIST_THROTTLE_MS`, + `DEFAULT_QUERY_PERSIST_RESTORE_TIMEOUT_MS`. +- [x] `apps/web/src/lib/query-persistence.ts`: + - `buildQueryPersistStorageKey(namespace)` → per-identity IDB key. + - `createIdbPersister(key)` implementing `Persister` with throttled writes and + **fail-open** try/catch on every method (private mode / disabled IDB ⇒ cache miss, never throw). + - `shouldDehydratePersistedQuery(query, scope)` — the allowlist. Requires + `key[0] === 'auth' && key[1] === scope && ALLOWED_DOMAINS.has(key[2])` **and** a successful + query state. + - `QUERY_PERSIST_SCHEMA_VERSION` buster; `VITE_*` env overrides for all three timings. +- [x] `apps/web/src/hooks/useQueryCachePersistence.ts` — drives + `persistQueryClient()` per active namespace; unsubscribes + removes the previous namespace's + store on transition; bounded restore with timeout; returns `isRestoring`. +- [x] Wire into `AuthProvider` — extend the existing gate so children do not render until restore + settles; must not regress the existing `queryClient.clear()` transition semantics. +- [x] Clear the persisted store on sign-out in `apps/web/src/lib/auth.ts` (alongside + `clearLibraryCache()`), deterministically, before navigation. +- [x] Set `gcTime` on the persisted query options only (not globally — global `gcTime` would retain + admin-diagnosis / node / workspace data in memory). +- [x] Document the new `VITE_*` vars in `apps/web/.env.example` + `vite-env.d.ts`. + +### Item #7 — HTTP cache headers (apps/api) + +- [x] Shared defaults: `DEFAULT_CACHE_TTL_*` / SWR constants in `packages/shared`. +- [x] `apps/api/src/lib/cache-headers.ts` — named policies + `applyCacheHeaders(c, policy)`. + `private` policies always emit `Vary: Cookie`. `public` is structurally unavailable to + authenticated policies. +- [x] Apply to: `/api/config/artifacts-enabled`, `/api/config/vapid-public-key`, + `/api/config/login-providers` (public); `/api/model-catalog/:agentType` (private, global); + `/api/projects/:projectId/agent-profiles`, `/api/projects/:projectId/skills` (private, per-user). +- [x] Env vars in `apps/api/src/env.ts` + `.env.example` + public config reference docs. + +### Tests + +- [x] Persist/restore across a simulated page load (fresh `QueryClient`, same store key). +- [x] Identity isolation: user A's store key ≠ user B's; restoring under B yields nothing. +- [x] Allowlist: `nodes`/`workspaces`/`admin-diagnosis`/`notification-preferences` and a + foreign-scope `['auth','other-user','projects']` key are never dehydrated. +- [x] `maxAge` expiry and `buster` mismatch both evict. +- [x] IDB failure (throwing store) degrades to in-memory, app still renders. +- [x] AuthProvider account switch removes the previous namespace's persisted store and never renders + A's data as B. +- [x] Sign-out removes the persisted store. +- [x] API: each targeted endpoint emits the expected `Cache-Control` (+ `Vary: Cookie` where private). +- [x] API: env override changes the emitted TTL. +- [x] **Discriminating guard**: real-time endpoints (chat messages, task status, session/workspace + state) emit no `Cache-Control`, and **no authenticated endpoint emits `public`**. + +## Acceptance criteria + +- Persisted query keys are namespaced by authenticated user **and** schema version. +- Logout / expiry / account switch cannot render the previous user's data, including colliding + project IDs. +- Only allowlisted queries are dehydrated; every entry on the "never persist" list is excluded. +- Persistence failure (private mode, quota, parse error) degrades silently to in-memory cache. +- Targeted API GETs carry conservative, env-configurable SWR cache headers. +- No authenticated response is ever marked `public`; every `private` response carries `Vary: Cookie`. +- Real-time endpoints are untouched. + +## Program constraints + +- PR base = the integration branch. **Do not merge** — the coordinator merges. +- **Staging intentionally skipped** by explicit instruction (project policy: "Skip staging when + explicitly requested for /do work"). Verification consolidated at the primary integration PR. +- `performance-reviewer` + `cloudflare-specialist` local reviews required. + +## References + +- `.claude/rules/48-stale-while-revalidate-ui.md`, `.claude/rules/60-request-io-and-bundle-budgets.md` +- `.claude/rules/24-no-duplicate-ui-controls.md`, `.claude/rules/59-understand-before-adding.md` +- `.claude/rules/20-cross-origin-cors.md`, `.claude/rules/03-constitution.md` (Principle XI) +- TanStack persistence: https://tanstack.com/query/v5/docs/framework/react/plugins/persistQueryClient