Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
});
Expand All @@ -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 });
});

Expand Down
167 changes: 167 additions & 0 deletions apps/api/src/lib/cache-headers.ts
Original file line number Diff line number Diff line change
@@ -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<CachePolicyName> = new Set<CachePolicyName>([
'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<CachePolicyName, CachePolicyDefaults> = {
'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<E extends { Bindings: CacheHeaderEnv }>(
c: Context<E>,
policy: CachePolicyName
): void {
const resolved = resolveCachePolicy(policy, c.env);
c.header('Cache-Control', resolved.cacheControl);
if (resolved.vary) c.header('Vary', resolved.vary);
}
6 changes: 6 additions & 0 deletions apps/api/src/routes/agent-profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 });
});

Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/routes/model-catalog.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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);
});

Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/routes/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 });
});

Expand Down
Loading