From 21a1172a808bf23b4da1f333068a6e1b461141dc Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Thu, 9 Jul 2026 15:14:32 -0600 Subject: [PATCH 1/5] Cache-Control/Vary hardening and shared-cache defaults (#1518, #1565) - Add Vary: Origin to CORS-enabled responses and preflight (#1518) - Stamp identity-dependent responses (authenticated or 401) with Cache-Control: private, no-cache + Vary: Authorization/Cookie, unless the app opted into shared caching with public/s-maxage (#1565) - New @table(cacheControl: "...") directive / resource static emits a default Cache-Control on anonymous reads; caching tables fall back to public, s-maxage= (#1565 converse); persisted on the primary-key attribute like expiration - Fix initSystemSchemaPaths mutating process.env with CLI args (this clobbered real env vars, e.g. AUTHENTICATION_AUTHORIZELOCAL) - Parse boolean-ish env strings for AUTHENTICATION_AUTHORIZELOCAL so 'false' no longer enables the local auth bypass Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lmdbBridge/lmdbUtility/initializePaths.js | 7 +- dataLayer/schemaDescribe.ts | 1 + .../fixtures/http-cache-headers/config.yaml | 5 + .../fixtures/http-cache-headers/resources.js | 45 +++++ .../http-cache-headers/schema.graphql | 14 ++ .../server/http-cache-headers.test.ts | 165 ++++++++++++++++++ resources/Table.ts | 17 +- resources/databases.ts | 13 ++ resources/graphql.ts | 8 + security/auth.ts | 64 ++++++- server/DESIGN.md | 10 ++ server/REST.ts | 17 ++ server/serverHelpers/Headers.ts | 21 +++ unitTests/security/auth.test.js | 9 +- .../server/serverHelpers/Headers.test.js | 41 ++++- 15 files changed, 415 insertions(+), 22 deletions(-) create mode 100644 integrationTests/server/fixtures/http-cache-headers/config.yaml create mode 100644 integrationTests/server/fixtures/http-cache-headers/resources.js create mode 100644 integrationTests/server/fixtures/http-cache-headers/schema.graphql create mode 100644 integrationTests/server/http-cache-headers.test.ts diff --git a/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js b/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js index 52f30417d2..e72c472698 100644 --- a/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js +++ b/dataLayer/harperBridge/lmdbBridge/lmdbUtility/initializePaths.js @@ -86,9 +86,10 @@ function initSystemSchemaPaths(schema, table) { schema = schema.toString(); table = table.toString(); - // Check to see if there are any CLI or env args related to schema/table path - const args = process.env; - Object.assign(args, minimist(process.argv)); + // Check to see if there are any CLI or env args related to schema/table path. + // Merge CLI args over env vars WITHOUT mutating process.env: assigning into process.env + // permanently clobbered real env vars (e.g. AUTHENTICATION_AUTHORIZELOCAL) with CLI values. + const args = Object.assign({}, process.env, minimist(process.argv)); const schemaConfJson = args[CONFIG_PARAMS.DATABASES.toUpperCase()]; if (schemaConfJson) { diff --git a/dataLayer/schemaDescribe.ts b/dataLayer/schemaDescribe.ts index e9c61e4603..76cf747c6c 100644 --- a/dataLayer/schemaDescribe.ts +++ b/dataLayer/schemaDescribe.ts @@ -204,6 +204,7 @@ async function descTable(describeTableObject: any, attrPerms?: any) { if (tableObj.replicate !== undefined) tableResult.replicate = tableObj.replicate; if (tableObj.expirationMS !== undefined) tableResult.expiration = tableObj.expirationMS / 1000 + 's'; if (tableObj.sealed !== undefined) tableResult.sealed = tableObj.sealed; + if (tableObj.cacheControl != null) tableResult.cacheControl = tableObj.cacheControl; if ((tableObj as any).sources?.length > 0) tableResult.sources = (tableObj as any).sources .map((source: any) => source.name) diff --git a/integrationTests/server/fixtures/http-cache-headers/config.yaml b/integrationTests/server/fixtures/http-cache-headers/config.yaml new file mode 100644 index 0000000000..efffc0833f --- /dev/null +++ b/integrationTests/server/fixtures/http-cache-headers/config.yaml @@ -0,0 +1,5 @@ +graphqlSchema: + files: '*.graphql' +jsResource: + files: resources.js +rest: true diff --git a/integrationTests/server/fixtures/http-cache-headers/resources.js b/integrationTests/server/fixtures/http-cache-headers/resources.js new file mode 100644 index 0000000000..1d42c34ec2 --- /dev/null +++ b/integrationTests/server/fixtures/http-cache-headers/resources.js @@ -0,0 +1,45 @@ +const { LabeledDoc, CachedDoc } = tables; + +export class CachedSource extends Resource { + async get() { + const id = this.getId(); + return { id, value: `sourced ${id}` }; + } +} +CachedDoc.sourcedFrom(CachedSource); + +// anonymous-readable exports of the tables above +export class PublicLabeled extends LabeledDoc { + allowRead() { + return true; + } +} +export class PublicCached extends CachedDoc { + allowRead() { + return true; + } +} + +// resource that sets its own Cache-Control (RFC 9111 shared-cache opt-in) +export class SelfCaching extends Resource { + allowRead() { + return true; + } + get() { + this.getContext().responseHeaders.set('Cache-Control', 'public, max-age=10'); + return { hello: 'world' }; + } +} + +// sets a shared-cache opt-in but then rejects the credentials — the 401 must NOT be shared-cacheable +export class PublicButDenied extends Resource { + allowRead() { + return true; + } + get() { + this.getContext().responseHeaders.set('Cache-Control', 'public, max-age=30'); + const error = new Error('Unauthorized'); + error.statusCode = 401; + throw error; + } +} diff --git a/integrationTests/server/fixtures/http-cache-headers/schema.graphql b/integrationTests/server/fixtures/http-cache-headers/schema.graphql new file mode 100644 index 0000000000..da4795cb2b --- /dev/null +++ b/integrationTests/server/fixtures/http-cache-headers/schema.graphql @@ -0,0 +1,14 @@ +type SecretDoc @table @export { + id: ID @primaryKey + value: String +} + +type LabeledDoc @table(cacheControl: "public, max-age=45") @export { + id: ID @primaryKey + value: String +} + +type CachedDoc @table(expiration: 120) @export { + id: ID @primaryKey + value: String +} diff --git a/integrationTests/server/http-cache-headers.test.ts b/integrationTests/server/http-cache-headers.test.ts new file mode 100644 index 0000000000..1e30074b15 --- /dev/null +++ b/integrationTests/server/http-cache-headers.test.ts @@ -0,0 +1,165 @@ +/** + * HTTP cache-header hardening and defaults (#1518, #1565). + * + * Covers the three tiers of Harper's Cache-Control/Vary policy: + * + * 1. `Vary: Origin` on CORS-enabled responses (reflected ACAO and preflight) so a shared + * cache/CDN cannot serve one origin's CORS headers to another (#1518). + * 2. The identity floor: authenticated (and 401) responses get `Cache-Control: private, + * no-cache` + `Vary: Authorization` (+ `Cookie` with sessions) unless the app explicitly + * opted into shared caching with `public`/`s-maxage` (#1565). + * 3. Default shared-cache headers for anonymous reads: `@table(cacheControl: "...")` and the + * TTL-derived `public, s-maxage=` fallback for caching tables, so CDNs like + * Akamai can cache public responses. + * + * The fixture app is installed before boot (so every thread, including main, processes the + * schema), and `AUTHENTICATION_AUTHORIZELOCAL=false` keeps unauthenticated loopback requests + * genuinely anonymous — overriding the harness's authorize-local default. + */ +import { suite, test, before, after } from 'node:test'; +import { ok, strictEqual } from 'node:assert'; +import { resolve } from 'node:path'; +import { setupHarperWithFixture, teardownHarper } from '@harperfast/integration-testing'; +// @ts-expect-error utils/client.mjs has no type declarations; runtime resolves fine +import { createApiClient } from '../apiTests/utils/client.mjs'; +import request from 'supertest'; + +const FIXTURE_PATH = resolve(import.meta.dirname, 'fixtures/http-cache-headers'); + +function varyTokens(res: any): string[] { + const vary = res.headers['vary'] ?? ''; + return vary + .toLowerCase() + .split(',') + .map((s: string) => s.trim()) + .filter(Boolean); +} + +suite('HTTP cache headers (#1518, #1565)', (ctx: any) => { + let client: any; + let restURL: string; + + before(async () => { + await setupHarperWithFixture(ctx, FIXTURE_PATH, { + config: { + http: { cors: true, corsAccessList: ['*'] }, + }, + env: { AUTHENTICATION_AUTHORIZELOCAL: 'false' }, + }); + client = createApiClient(ctx.harper); + restURL = ctx.harper.httpURL; + + // seed one record per table + for (const [path, id] of [ + ['/SecretDoc/', 'sec1'], + ['/LabeledDoc/', 'lab1'], + ]) { + await request(restURL) + .put(`${path}${id}`) + .set(client.headers) + .send({ id, value: 'seed' }) + .expect((r: any) => ok([200, 201, 204].includes(r.status), `seed PUT ${path}${id} → ${r.status}: ${r.text}`)); + } + }); + + after(async () => { + await teardownHarper(ctx); + }); + + // ── #1518: Vary: Origin ──────────────────────────────────────────────── + + test('CORS GET with Origin: ACAO reflected and Vary includes Origin', async () => { + const res = await request(restURL) + .get('/SecretDoc/sec1') + .set(client.headers) + .set('Origin', 'https://a.example.com') + .expect(200); + strictEqual(res.headers['access-control-allow-origin'], 'https://a.example.com'); + ok(varyTokens(res).includes('origin'), `Vary should include Origin, got: ${res.headers['vary']}`); + }); + + test('OPTIONS preflight carries Vary: Origin', async () => { + const res = await request(restURL) + .options('/SecretDoc/sec1') + .set('Origin', 'https://a.example.com') + .set('Access-Control-Request-Method', 'GET'); + strictEqual(res.headers['access-control-allow-origin'], 'https://a.example.com'); + ok(varyTokens(res).includes('origin'), `preflight Vary should include Origin, got: ${res.headers['vary']}`); + }); + + test('CORS-enabled response without an Origin header still varies on Origin', async () => { + // the no-Origin variant (no ACAO) is itself origin-dependent — a cache serving it to a + // CORS request would break that request + const res = await request(restURL).get('/SecretDoc/sec1').set(client.headers).expect(200); + ok(varyTokens(res).includes('origin'), `Vary should include Origin, got: ${res.headers['vary']}`); + }); + + test('Vary appends to serializer tokens instead of clobbering them', async () => { + const res = await request(restURL).get('/SecretDoc/sec1').set(client.headers).expect(200); + const tokens = varyTokens(res); + for (const expected of ['accept', 'accept-encoding', 'origin', 'authorization']) { + ok(tokens.includes(expected), `Vary should include ${expected}, got: ${res.headers['vary']}`); + } + }); + + // ── #1565: identity floor ────────────────────────────────────────────── + + test('authenticated read gets private, no-cache + Vary: Authorization, Cookie', async () => { + const res = await request(restURL).get('/SecretDoc/sec1').set(client.headers).expect(200); + strictEqual(res.headers['cache-control'], 'private, no-cache'); + const tokens = varyTokens(res); + ok(tokens.includes('authorization'), `Vary should include Authorization, got: ${res.headers['vary']}`); + ok(tokens.includes('cookie'), `Vary should include Cookie (sessions enabled), got: ${res.headers['vary']}`); + }); + + test('401 for anonymous access to a protected table carries the private floor', async () => { + const res = await request(restURL).get('/SecretDoc/sec1').expect(401); + strictEqual(res.headers['cache-control'], 'private, no-cache'); + ok(varyTokens(res).includes('authorization'), `Vary should include Authorization, got: ${res.headers['vary']}`); + }); + + test('401 with an app-set public Cache-Control is still forced private (no opt-in on rejection)', async () => { + const res = await request(restURL).get('/PublicButDenied/x').set(client.headers).expect(401); + strictEqual(res.headers['cache-control'], 'private, no-cache'); + ok(varyTokens(res).includes('authorization'), `Vary should include Authorization, got: ${res.headers['vary']}`); + }); + + test('authenticated read of a cacheControl-labeled table still gets the private floor', async () => { + // the @table(cacheControl:) default applies to anonymous reads only; an authenticated + // response must not inherit the shared-cache policy + const res = await request(restURL).get('/PublicLabeled/lab1').set(client.headers).expect(200); + strictEqual(res.headers['cache-control'], 'private, no-cache'); + }); + + test('app-set public Cache-Control on an authenticated response is trusted (RFC 9111 opt-in)', async () => { + const res = await request(restURL).get('/SelfCaching/x').set(client.headers).expect(200); + strictEqual(res.headers['cache-control'], 'public, max-age=10'); + ok(!varyTokens(res).includes('authorization'), `opted-in response should not vary on Authorization`); + }); + + // ── #1565 converse: anonymous shared-cache defaults ──────────────────── + + test('anonymous read emits @table(cacheControl:) declaration', async () => { + const res = await request(restURL).get('/PublicLabeled/lab1').expect(200); + strictEqual(res.headers['cache-control'], 'public, max-age=45'); + ok(!varyTokens(res).includes('authorization'), `anonymous response should not vary on Authorization`); + }); + + test('anonymous read of a caching table falls back to TTL-derived s-maxage', async () => { + const res = await request(restURL).get('/PublicCached/c1').expect(200); + strictEqual(res.headers['cache-control'], 'public, s-maxage=120'); + }); + + test('anonymous read with app-set Cache-Control keeps the app value', async () => { + const res = await request(restURL).get('/SelfCaching/x').expect(200); + strictEqual(res.headers['cache-control'], 'public, max-age=10'); + }); + + test('describe_table surfaces cacheControl', async () => { + const res = await client + .req() + .send({ operation: 'describe_table', database: 'data', table: 'LabeledDoc' }) + .expect(200); + strictEqual(res.body.cacheControl, 'public, max-age=45'); + }); +}); diff --git a/resources/Table.ts b/resources/Table.ts index 41d476b4a2..9fcbcf7e2c 100644 --- a/resources/Table.ts +++ b/resources/Table.ts @@ -203,6 +203,7 @@ export function makeTable(options) { replicate, description, hidden, + cacheControl, } = options; let { expirationMS: expirationMs, evictionMS: evictionMs, audit, trackDeletes } = options; evictionMs ??= 0; @@ -306,6 +307,8 @@ export function makeTable(options) { static description = description; static properties = properties; static hidden = hidden; + // default `Cache-Control` for anonymous REST reads (from `@table(cacheControl: "...")`), see REST.ts + static cacheControl = cacheControl; static outputSchemas: { [verb: string]: JsonSchemaFragment } | undefined; static mcp: { annotations?: { [verb: string]: any } } | undefined; static replicate = replicate; @@ -3414,7 +3417,7 @@ export function makeTable(options) { logger.error?.('Error getting history entry', auditRecord.localTime, error); } } - for (let i = history.length; i > 0;) { + for (let i = history.length; i > 0; ) { send(history[--i]); } // Use the latest record cursor saw (history[0] = most recent due to reverse @@ -3523,7 +3526,7 @@ export function makeTable(options) { } else break; if (count) count--; } while (nextTime > startTime && count !== 0); - for (let i = history.length; i > 0;) { + for (let i = history.length; i > 0; ) { send(history[--i]); } } @@ -3821,10 +3824,12 @@ export function makeTable(options) { ); break; case 'ID': - if (!( - typeof value === 'string' || - (value?.length > 0 && value.every?.((value) => typeof value === 'string')) - )) + if ( + !( + typeof value === 'string' || + (value?.length > 0 && value.every?.((value) => typeof value === 'string')) + ) + ) (validationErrors || (validationErrors = [])).push( `Value ${stringify(value)} in property ${name} must be a string, or an array of strings` ); diff --git a/resources/databases.ts b/resources/databases.ts index 7acb7d7752..f9fc08f24f 100644 --- a/resources/databases.ts +++ b/resources/databases.ts @@ -582,6 +582,7 @@ function initStores( const expiration = primaryAttribute.expiration; const eviction = primaryAttribute.eviction; const sealed = primaryAttribute.sealed; + const cacheControl = primaryAttribute.cacheControl; const splitSegments = primaryAttribute.splitSegments; const replicate = primaryAttribute.replicate; if (table && !recreateForEngineChange) { @@ -720,6 +721,7 @@ function initStores( replicate, expirationMS: expiration && expiration * 1000, evictionMS: eviction && eviction * 1000, + cacheControl, trackDeletes, tableName, tableId, @@ -773,6 +775,9 @@ interface TableDefinition { description?: string; properties?: Record; hidden?: boolean; + // default Cache-Control for anonymous REST reads; null = schema explicitly has none (clears a + // prior value on reload), undefined = caller is not schema-defining (leave the current value) + cacheControl?: string | null; } /** * Ensure that we have this database object (that holds a set of tables) set up @@ -1003,6 +1008,7 @@ export function table(tableDefinition: TableDefinition): Tabl description, properties, hidden, + cacheControl, } = tableDefinition; if (!databaseName) databaseName = DEFAULT_DATABASE_NAME; const rootStore = database({ database: databaseName, table: tableName }); @@ -1049,6 +1055,8 @@ export function table(tableDefinition: TableDefinition): Tabl Table.description = description; Table.properties = properties; Table.hidden = hidden; + // undefined means a non-schema caller (add_attribute, cluster schema events) — don't clobber + if (cacheControl !== undefined) Table.cacheControl = cacheControl; } else { const auditStore = rootStore.auditStore; primaryKeyAttribute = attributes.find((attribute) => attribute.isPrimaryKey) || {}; @@ -1062,6 +1070,10 @@ export function table(tableDefinition: TableDefinition): Tabl audit = primaryKeyAttribute.audit = typeof audit === 'boolean' ? audit : envGet(CONFIG_PARAMS.LOGGING_AUDITLOG); if (expiration) primaryKeyAttribute.expiration = expiration; if (eviction) primaryKeyAttribute.eviction = eviction; + // persist cacheControl so all threads (and future boots) see it; undefined callers inherit + // a descriptor value carried by cluster schema events; '' persists the explicit opt-out + if (cacheControl === undefined) cacheControl = primaryKeyAttribute.cacheControl; + else if (cacheControl !== null) primaryKeyAttribute.cacheControl = cacheControl; splitSegments ??= false; primaryKeyAttribute.splitSegments = splitSegments; // always default to not splitting segments going forward if (typeof sealed === 'boolean') primaryKeyAttribute.sealed = sealed; @@ -1153,6 +1165,7 @@ export function table(tableDefinition: TableDefinition): Tabl description, properties, hidden, + cacheControl, }) ); Table.schemaVersion = 1; diff --git a/resources/graphql.ts b/resources/graphql.ts index 66489fa156..9cd9a573b6 100644 --- a/resources/graphql.ts +++ b/resources/graphql.ts @@ -118,6 +118,14 @@ async function processGraphQLSchema(gqlContent, urlPath, filePath, resources) { // Boolean directive args arrive as actual booleans; tolerate string forms too. if (typeDef.randomAccessFields !== undefined) typeDef.randomAccessFields = typeDef.randomAccessFields === true || typeDef.randomAccessFields === 'true'; + // schema is authoritative for cacheControl: null (vs undefined) clears a removed directive on reload; + // coerce to a header-safe string so a non-string arg or CRLF can't reach the HTTP response + typeDef.cacheControl = + typeDef.cacheControl == null + ? null + : String(typeDef.cacheControl) + .replace(/[\r\n]+/g, ' ') + .trim(); tables.push(typeDef); } if (directive.name.value === 'sealed') typeDef.sealed = true; diff --git a/security/auth.ts b/security/auth.ts index 2789a58437..cc4e463b63 100644 --- a/security/auth.ts +++ b/security/auth.ts @@ -10,7 +10,7 @@ import harperLogger from '../utility/logging/harper_logger.ts'; const { forComponent, AuthAuditLog } = harperLogger; import serverHandlers from '../server/itc/serverHandlers.js'; const { user } = serverHandlers; -import { Headers } from '../server/serverHelpers/Headers.ts'; +import { Headers, addVaryHeader } from '../server/serverHelpers/Headers.ts'; import { convertToMS } from '../utility/common_utils.ts'; import { verifyCertificate } from './certificateVerification/index.ts'; import { serializeMessage } from '../server/serverHelpers/contentTypes.ts'; @@ -33,16 +33,27 @@ function getSessionTable() { return _sessionTable; } const ENABLE_SESSIONS = env.get(CONFIG_PARAMS.AUTHENTICATION_ENABLESESSIONS) ?? true; +// env-var strings need boolean parsing: a raw 'false'/'0' string is truthy, which would turn +// AUTHENTICATION_AUTHORIZELOCAL=false into an *enabled* auth bypass +function envFlag(value: string | undefined): boolean | undefined { + if (value === undefined) return undefined; + const normalized = value.trim().toLowerCase(); + return normalized !== 'false' && normalized !== '0' && normalized !== ''; +} // check the environment for a flag to bypass authentication (for testing) since it doesn't necessarily get set on child threads let AUTHORIZE_LOCAL = - process.env.AUTHENTICATION_AUTHORIZELOCAL ?? + envFlag(process.env.AUTHENTICATION_AUTHORIZELOCAL) ?? env.get(CONFIG_PARAMS.AUTHENTICATION_AUTHORIZELOCAL) ?? - process.env.DEV_MODE; + envFlag(process.env.DEV_MODE); const LOG_AUTH_SUCCESSFUL = env.get(CONFIG_PARAMS.LOGGING_AUDITAUTHEVENTS_LOGSUCCESSFUL) ?? false; const LOG_AUTH_FAILED = env.get(CONFIG_PARAMS.LOGGING_AUDITAUTHEVENTS_LOGFAILED) ?? false; const DEFAULT_COOKIE_EXPIRES = 'Tue, 01 Oct 8307 19:33:20 GMT'; +// RFC 9111 cache-scope directives; boundaries on both sides so a token like `public-foo` doesn't match +const SHARED_CACHE_OPTIN = /(^|[,\s])(public|s-maxage)($|[\s,;=])/i; +const PRIVATE_SCOPE = /(^|[,\s])(private|no-store)($|[\s,;=])/i; + let authorizationCache = new Map(); server.onInvalidatedUser(() => { // TODO: Eventually we probably want to be able to invalidate individual users @@ -61,6 +72,9 @@ export async function authentication(request, nextHandler) { const cookie = headers.cookie; let origin = headers.origin; let responseHeaders = []; + // a 401 that gets rewritten to a login-redirect 302 below must still be treated as + // identity-dependent by applyResponseHeaders + let wasUnauthorized = false; try { if (origin) { const accessList = request.isOperationsServer @@ -80,6 +94,8 @@ export async function authentication(request, nextHandler) { ['Access-Control-Allow-Methods', 'POST, GET, PUT, DELETE, PATCH, OPTIONS'], ['Access-Control-Allow-Headers', accessControlAllowHeaders], ['Access-Control-Allow-Origin', origin], + // the preflight response is keyed on the request Origin — a shared cache must partition on it + ['Vary', 'Origin'], ]); if (ENABLE_SESSIONS) headers.set('Access-Control-Allow-Credentials', 'true'); return { @@ -329,6 +345,7 @@ export async function authentication(request, nextHandler) { const response = await nextHandler(request); if (!response) return response; if (response.status === 401) { + wasUnauthorized = true; if ( headers['user-agent']?.startsWith('Mozilla') && headers.accept?.startsWith('text/html') && @@ -346,13 +363,48 @@ export async function authentication(request, nextHandler) { } function applyResponseHeaders(response) { const l = responseHeaders.length; - if (l > 0) { + // a response is identity-dependent if a principal was resolved (Authorization/session/mTLS) or + // if we are rejecting the credentials (401, possibly rewritten to a login redirect); such a + // response must never be stored by a shared cache and served to a different principal (#1565) + const rejectedAuth = response?.status === 401 || wasUnauthorized; + const identityDependent = !!request.user || rejectedAuth; + // with CORS enabled the response is origin-dependent — ACAO reflects the request Origin, and + // its absence when no Origin was sent is origin-dependent too — so a shared cache must + // partition on Origin either way (#1518) + const corsEnabled = request.isOperationsServer ? operationsCors : appsCors; + if ((l > 0 || corsEnabled || identityDependent) && typeof response === 'object') { let headers = response.headers; - if (!headers) response.headers = headers = new Headers(); - for (let i = 0; i < l;) { + // normalize plain-object headers so get/set below work uniformly + if (!headers || typeof headers.set !== 'function') response.headers = headers = new Headers(headers); + for (let i = 0; i < l; ) { const name = responseHeaders[i++]; headers.set(name, responseHeaders[i++]); } + if (corsEnabled) addVaryHeader(headers, 'Origin'); + // #1565: keep identity-dependent responses out of shared caches. An explicit + // `public`/`s-maxage` set by the app is RFC 9111's opt-in for shared-caching an + // authenticated response, so that is trusted as-is — but only for successfully + // authenticated responses; a credential rejection must never opt into shared caching. + // Otherwise partition on the credential headers and ensure a `private` scope + // (`private, no-cache` when none was set, so browsers still revalidate against the + // ETag/Last-Modified we emit). + if (identityDependent) { + const existingCacheControl = headers.get('Cache-Control'); + const cacheControlString = existingCacheControl + ? Array.isArray(existingCacheControl) + ? existingCacheControl.join(', ') + : existingCacheControl + : ''; + const optedIn = !rejectedAuth && SHARED_CACHE_OPTIN.test(cacheControlString); + if (!optedIn) { + addVaryHeader(headers, 'Authorization'); + if (ENABLE_SESSIONS) addVaryHeader(headers, 'Cookie'); + if (!cacheControlString || (rejectedAuth && SHARED_CACHE_OPTIN.test(cacheControlString))) + headers.set('Cache-Control', 'private, no-cache'); + else if (!PRIVATE_SCOPE.test(cacheControlString)) + headers.set('Cache-Control', cacheControlString + ', private'); + } + } } responseHeaders = null; return response; diff --git a/server/DESIGN.md b/server/DESIGN.md index 9449fd2fd0..4c9f35e26a 100644 --- a/server/DESIGN.md +++ b/server/DESIGN.md @@ -120,6 +120,16 @@ The default WebSocket upgrade handler is registered automatically inside `onWebS `REST.ts → http(request, nextHandler)` is the chief integration point: it takes a `Request`, asks the `Resources` registry for a match, builds a `RequestTarget`, and dispatches into the Resource class's static method. Cache headers are translated to `request.expiresAt` / `onlyIfCached` / `noCache` flags within the same function. +### Response Cache-Control / Vary policy (#1518, #1565) + +Three tiers, applied in two places: + +1. **App/resource explicit** — a `Cache-Control` set by the resource (or `@table(cacheControl: "...")` for anonymous reads, emitted in `REST.ts → http()`) always wins. Caching tables without a declaration fall back to `public, s-maxage=` for anonymous reads; an explicit empty string opts out of that fallback. +2. **Identity floor** — `security/auth.ts → applyResponseHeaders` stamps `Cache-Control: private, no-cache` + `Vary: Authorization` (+ `Cookie` when sessions are on) on any response where a principal was resolved or credentials were rejected (401), *unless* the app opted into shared caching with `public`/`s-maxage` (the RFC 9111 opt-in). +3. **CORS partitioning** — when CORS is enabled, every response gets `Vary: Origin` (the ACAO header is reflected per-origin, and its absence on no-Origin requests is origin-dependent too). + +The `@table(cacheControl:)` value is persisted on the primary-key attribute (like `expiration`), so all threads and future boots see it; `resources/databases.ts → table()` treats `null` as "schema explicitly has none" (clears on reload) and `undefined` as "caller is not schema-defining" (no clobber from `add_attribute`/cluster schema events). + --- ## "Where is X" cheat sheet diff --git a/server/REST.ts b/server/REST.ts index c53dcb0861..56cdc1125a 100644 --- a/server/REST.ts +++ b/server/REST.ts @@ -254,6 +254,23 @@ async function http(request: Request, nextHandler) { headers, body: undefined, }; + // #1565 converse: default Cache-Control for anonymous reads so a shared cache/CDN can store + // public responses. An explicit `@table(cacheControl: "...")`/static cacheControl wins; caching + // tables fall back to their source-cache TTL. Authenticated responses are excluded here — the + // auth layer applies a `private` floor to them instead. + if (!request.user && (method === 'GET' || method === 'HEAD')) { + const responseStatus = responseObject.status; + if (responseStatus === 200 || responseStatus === 304) { + let cacheControl = (resource as any)?.cacheControl; + // == null covers both no declaration and a schema without the directive; an explicit + // empty string opts a caching table out of the TTL-derived fallback + if (cacheControl == null && (resource as any)?.isCaching) { + const expirationMs = (resource as any).expirationMS; + if (expirationMs > 0) cacheControl = `public, s-maxage=${Math.floor(expirationMs / 1000)}`; + } + if (cacheControl) headers.setIfNone('Cache-Control', cacheControl); + } + } const loadedFromSource = target.loadedFromSource; if (loadedFromSource !== undefined) { // this appears to be a caching table with a source diff --git a/server/serverHelpers/Headers.ts b/server/serverHelpers/Headers.ts index bf34d9c2c6..919fba5a97 100644 --- a/server/serverHelpers/Headers.ts +++ b/server/serverHelpers/Headers.ts @@ -60,6 +60,27 @@ export class Headers extends Map { } } +/** + * Add a field-name token to the `Vary` response header, appending to (and de-duplicating against) any + * existing value rather than overwriting it. Used to declare cache-partitioning dimensions (Origin, + * Authorization, Cookie) so a shared cache/CDN keys the response correctly (#1518, #1565). + */ +export function addVaryHeader(headers, token: string) { + const existing = headers.get('Vary'); + if (!existing) { + headers.set('Vary', token); + return; + } + const existingString = Array.isArray(existing) ? existing.join(', ') : existing; + const lowerToken = token.toLowerCase(); + for (const part of existingString.split(',')) { + const trimmed = part.trim().toLowerCase(); + // a `Vary: *` already covers every dimension, and an exact token match is a no-op + if (trimmed === lowerToken || trimmed === '*') return; + } + headers.set('Vary', existingString + ', ' + token); +} + export function appendHeader(headers, name, value, commaDelimited) { if (headers.append) { headers.append(name, value, commaDelimited); diff --git a/unitTests/security/auth.test.js b/unitTests/security/auth.test.js index dd4db4ed84..684ab05ce1 100644 --- a/unitTests/security/auth.test.js +++ b/unitTests/security/auth.test.js @@ -1,5 +1,6 @@ const assert = require('node:assert'); const sinon = require('sinon'); +const { Headers } = require('#src/server/serverHelpers/Headers'); // First set up test environment const testUtils = require('../testUtils.js'); @@ -87,9 +88,7 @@ describe('auth.ts - certificate verification integration', function () { response = { status: 200, - headers: { - set: sandbox.stub(), - }, + headers: new Headers(), body: {}, }; @@ -346,9 +345,7 @@ describe('auth.ts - certificate verification integration', function () { nextHandler = sandbox.stub().resolves({ status: 200, - headers: { - set: sandbox.stub(), - }, + headers: new Headers(), }); }); diff --git a/unitTests/server/serverHelpers/Headers.test.js b/unitTests/server/serverHelpers/Headers.test.js index 53ad819ca5..72f4929e95 100644 --- a/unitTests/server/serverHelpers/Headers.test.js +++ b/unitTests/server/serverHelpers/Headers.test.js @@ -1,7 +1,13 @@ 'use strict'; const assert = require('assert'); -const { Headers, appendHeader, mergeHeaders, toWriteHeadHeaders } = require('#src/server/serverHelpers/Headers'); +const { + Headers, + appendHeader, + mergeHeaders, + toWriteHeadHeaders, + addVaryHeader, +} = require('#src/server/serverHelpers/Headers'); describe('Test Headers', () => { describe(`Create and modify headers`, function () { it('should handle headers', async function () { @@ -440,3 +446,36 @@ describe('toWriteHeadHeaders', () => { } }); }); + +describe('addVaryHeader', () => { + it('sets Vary when absent', () => { + const headers = new Headers(); + addVaryHeader(headers, 'Origin'); + assert.equal(headers.get('Vary'), 'Origin'); + }); + it('appends to an existing Vary value', () => { + const headers = new Headers(); + headers.set('Vary', 'Accept, Accept-Encoding'); + addVaryHeader(headers, 'Origin'); + assert.equal(headers.get('Vary'), 'Accept, Accept-Encoding, Origin'); + }); + it('deduplicates case-insensitively', () => { + const headers = new Headers(); + headers.set('Vary', 'Accept, origin'); + addVaryHeader(headers, 'Origin'); + assert.equal(headers.get('Vary'), 'Accept, origin'); + }); + it('is a no-op when Vary is *', () => { + const headers = new Headers(); + headers.set('Vary', '*'); + addVaryHeader(headers, 'Authorization'); + assert.equal(headers.get('Vary'), '*'); + }); + it('handles an array-valued Vary', () => { + const headers = new Headers(); + headers.set('Vary', 'Accept'); + headers.append('Vary', 'Accept-Encoding'); + addVaryHeader(headers, 'Cookie'); + assert.equal(headers.get('Vary'), 'Accept, Accept-Encoding, Cookie'); + }); +}); From d22095f0f3dc56c3e39fcf7905e76c4db5f30090 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Thu, 9 Jul 2026 16:30:42 -0600 Subject: [PATCH 2/5] Fix DESIGN.md formatting Co-Authored-By: Claude Opus 4.8 (1M context) --- server/DESIGN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/DESIGN.md b/server/DESIGN.md index 4c9f35e26a..25b4c03253 100644 --- a/server/DESIGN.md +++ b/server/DESIGN.md @@ -125,7 +125,7 @@ The default WebSocket upgrade handler is registered automatically inside `onWebS Three tiers, applied in two places: 1. **App/resource explicit** — a `Cache-Control` set by the resource (or `@table(cacheControl: "...")` for anonymous reads, emitted in `REST.ts → http()`) always wins. Caching tables without a declaration fall back to `public, s-maxage=` for anonymous reads; an explicit empty string opts out of that fallback. -2. **Identity floor** — `security/auth.ts → applyResponseHeaders` stamps `Cache-Control: private, no-cache` + `Vary: Authorization` (+ `Cookie` when sessions are on) on any response where a principal was resolved or credentials were rejected (401), *unless* the app opted into shared caching with `public`/`s-maxage` (the RFC 9111 opt-in). +2. **Identity floor** — `security/auth.ts → applyResponseHeaders` stamps `Cache-Control: private, no-cache` + `Vary: Authorization` (+ `Cookie` when sessions are on) on any response where a principal was resolved or credentials were rejected (401), _unless_ the app opted into shared caching with `public`/`s-maxage` (the RFC 9111 opt-in). 3. **CORS partitioning** — when CORS is enabled, every response gets `Vary: Origin` (the ACAO header is reflected per-origin, and its absence on no-Origin requests is origin-dependent too). The `@table(cacheControl:)` value is persisted on the primary-key attribute (like `expiration`), so all threads and future boots see it; `resources/databases.ts → table()` treats `null` as "schema explicitly has none" (clears on reload) and `undefined` as "caller is not schema-defining" (no clobber from `add_attribute`/cluster schema events). From f9bff66bc91d0a86ccf1a3580608c9c21ab1c4cf Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Thu, 9 Jul 2026 16:32:32 -0600 Subject: [PATCH 3/5] Address bot review: clear stale cacheControl on null, simplify 401 floor, type addVaryHeader Co-Authored-By: Claude Opus 4.8 (1M context) --- resources/databases.ts | 6 ++++-- security/auth.ts | 4 +++- server/REST.ts | 1 + server/serverHelpers/Headers.ts | 5 ++++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/resources/databases.ts b/resources/databases.ts index f9fc08f24f..c4c0eaa966 100644 --- a/resources/databases.ts +++ b/resources/databases.ts @@ -1071,9 +1071,11 @@ export function table(tableDefinition: TableDefinition): Tabl if (expiration) primaryKeyAttribute.expiration = expiration; if (eviction) primaryKeyAttribute.eviction = eviction; // persist cacheControl so all threads (and future boots) see it; undefined callers inherit - // a descriptor value carried by cluster schema events; '' persists the explicit opt-out + // a descriptor value carried by cluster schema events; '' persists the explicit opt-out; + // null (schema has no directive) clears a stale value the carried descriptor may hold if (cacheControl === undefined) cacheControl = primaryKeyAttribute.cacheControl; - else if (cacheControl !== null) primaryKeyAttribute.cacheControl = cacheControl; + else if (cacheControl === null) delete primaryKeyAttribute.cacheControl; + else primaryKeyAttribute.cacheControl = cacheControl; splitSegments ??= false; primaryKeyAttribute.splitSegments = splitSegments; // always default to not splitting segments going forward if (typeof sealed === 'boolean') primaryKeyAttribute.sealed = sealed; diff --git a/security/auth.ts b/security/auth.ts index cc4e463b63..0d1ae80dc1 100644 --- a/security/auth.ts +++ b/security/auth.ts @@ -399,7 +399,9 @@ export async function authentication(request, nextHandler) { if (!optedIn) { addVaryHeader(headers, 'Authorization'); if (ENABLE_SESSIONS) addVaryHeader(headers, 'Cookie'); - if (!cacheControlString || (rejectedAuth && SHARED_CACHE_OPTIN.test(cacheControlString))) + // a 401 never opts into shared caching: any non-private Cache-Control it carries is + // replaced outright (an existing private/no-store is already at least as strict) + if (!cacheControlString || (rejectedAuth && !PRIVATE_SCOPE.test(cacheControlString))) headers.set('Cache-Control', 'private, no-cache'); else if (!PRIVATE_SCOPE.test(cacheControlString)) headers.set('Cache-Control', cacheControlString + ', private'); diff --git a/server/REST.ts b/server/REST.ts index 56cdc1125a..c14eeaeae3 100644 --- a/server/REST.ts +++ b/server/REST.ts @@ -268,6 +268,7 @@ async function http(request: Request, nextHandler) { const expirationMs = (resource as any).expirationMS; if (expirationMs > 0) cacheControl = `public, s-maxage=${Math.floor(expirationMs / 1000)}`; } + // setIfNone: a resource-set Cache-Control (including a full no-store opt-out) always wins if (cacheControl) headers.setIfNone('Cache-Control', cacheControl); } } diff --git a/server/serverHelpers/Headers.ts b/server/serverHelpers/Headers.ts index 919fba5a97..a7ee3a20d5 100644 --- a/server/serverHelpers/Headers.ts +++ b/server/serverHelpers/Headers.ts @@ -65,7 +65,10 @@ export class Headers extends Map { * existing value rather than overwriting it. Used to declare cache-partitioning dimensions (Origin, * Authorization, Cookie) so a shared cache/CDN keys the response correctly (#1518, #1565). */ -export function addVaryHeader(headers, token: string) { +export function addVaryHeader( + headers: { get(name: string): any; set(name: string, value: string): any }, + token: string +) { const existing = headers.get('Vary'); if (!existing) { headers.set('Vary', token); From 94fcbc66b5a74c0bc20c266755686b287b54cd8e Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Thu, 9 Jul 2026 17:01:36 -0600 Subject: [PATCH 4/5] Format with prettier 3.9 (CI version) Co-Authored-By: Claude Opus 4.8 (1M context) --- resources/Table.ts | 14 ++++++-------- security/auth.ts | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/resources/Table.ts b/resources/Table.ts index 9fcbcf7e2c..1e449dfc1d 100644 --- a/resources/Table.ts +++ b/resources/Table.ts @@ -3417,7 +3417,7 @@ export function makeTable(options) { logger.error?.('Error getting history entry', auditRecord.localTime, error); } } - for (let i = history.length; i > 0; ) { + for (let i = history.length; i > 0;) { send(history[--i]); } // Use the latest record cursor saw (history[0] = most recent due to reverse @@ -3526,7 +3526,7 @@ export function makeTable(options) { } else break; if (count) count--; } while (nextTime > startTime && count !== 0); - for (let i = history.length; i > 0; ) { + for (let i = history.length; i > 0;) { send(history[--i]); } } @@ -3824,12 +3824,10 @@ export function makeTable(options) { ); break; case 'ID': - if ( - !( - typeof value === 'string' || - (value?.length > 0 && value.every?.((value) => typeof value === 'string')) - ) - ) + if (!( + typeof value === 'string' || + (value?.length > 0 && value.every?.((value) => typeof value === 'string')) + )) (validationErrors || (validationErrors = [])).push( `Value ${stringify(value)} in property ${name} must be a string, or an array of strings` ); diff --git a/security/auth.ts b/security/auth.ts index 0d1ae80dc1..a6649eabba 100644 --- a/security/auth.ts +++ b/security/auth.ts @@ -376,7 +376,7 @@ export async function authentication(request, nextHandler) { let headers = response.headers; // normalize plain-object headers so get/set below work uniformly if (!headers || typeof headers.set !== 'function') response.headers = headers = new Headers(headers); - for (let i = 0; i < l; ) { + for (let i = 0; i < l;) { const name = responseHeaders[i++]; headers.set(name, responseHeaders[i++]); } From 6e8d3164855906c6ccde53fe4d13d7d38dd4db86 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Thu, 9 Jul 2026 17:44:00 -0600 Subject: [PATCH 5/5] =?UTF-8?q?Require=20explicit=20cacheControl=20declara?= =?UTF-8?q?tion=20=E2=80=94=20drop=20implicit=20TTL-derived=20s-maxage=20f?= =?UTF-8?q?allback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anonymous readability alone doesn't establish that content is uniformly public (an allowRead gated on IP or headers would leak across a URL-keyed shared cache), so shared-cache headers now require the explicit @table(cacheControl:) / static cacheControl declaration. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fixtures/http-cache-headers/resources.js | 8 +++++++- .../fixtures/http-cache-headers/schema.graphql | 7 ++++++- .../server/http-cache-headers.test.ts | 14 ++++++++++---- resources/databases.ts | 4 ++-- server/DESIGN.md | 2 +- server/REST.ts | 18 +++++++----------- 6 files changed, 33 insertions(+), 20 deletions(-) diff --git a/integrationTests/server/fixtures/http-cache-headers/resources.js b/integrationTests/server/fixtures/http-cache-headers/resources.js index 1d42c34ec2..fc69c785ce 100644 --- a/integrationTests/server/fixtures/http-cache-headers/resources.js +++ b/integrationTests/server/fixtures/http-cache-headers/resources.js @@ -1,4 +1,4 @@ -const { LabeledDoc, CachedDoc } = tables; +const { LabeledDoc, CachedDoc, PlainCachedDoc } = tables; export class CachedSource extends Resource { async get() { @@ -7,6 +7,7 @@ export class CachedSource extends Resource { } } CachedDoc.sourcedFrom(CachedSource); +PlainCachedDoc.sourcedFrom(CachedSource); // anonymous-readable exports of the tables above export class PublicLabeled extends LabeledDoc { @@ -19,6 +20,11 @@ export class PublicCached extends CachedDoc { return true; } } +export class PublicPlainCached extends PlainCachedDoc { + allowRead() { + return true; + } +} // resource that sets its own Cache-Control (RFC 9111 shared-cache opt-in) export class SelfCaching extends Resource { diff --git a/integrationTests/server/fixtures/http-cache-headers/schema.graphql b/integrationTests/server/fixtures/http-cache-headers/schema.graphql index da4795cb2b..7ba79af80f 100644 --- a/integrationTests/server/fixtures/http-cache-headers/schema.graphql +++ b/integrationTests/server/fixtures/http-cache-headers/schema.graphql @@ -8,7 +8,12 @@ type LabeledDoc @table(cacheControl: "public, max-age=45") @export { value: String } -type CachedDoc @table(expiration: 120) @export { +type CachedDoc @table(expiration: 120, cacheControl: "public, s-maxage=120") @export { + id: ID @primaryKey + value: String +} + +type PlainCachedDoc @table(expiration: 120) @export { id: ID @primaryKey value: String } diff --git a/integrationTests/server/http-cache-headers.test.ts b/integrationTests/server/http-cache-headers.test.ts index 1e30074b15..582055cc90 100644 --- a/integrationTests/server/http-cache-headers.test.ts +++ b/integrationTests/server/http-cache-headers.test.ts @@ -8,9 +8,9 @@ * 2. The identity floor: authenticated (and 401) responses get `Cache-Control: private, * no-cache` + `Vary: Authorization` (+ `Cookie` with sessions) unless the app explicitly * opted into shared caching with `public`/`s-maxage` (#1565). - * 3. Default shared-cache headers for anonymous reads: `@table(cacheControl: "...")` and the - * TTL-derived `public, s-maxage=` fallback for caching tables, so CDNs like - * Akamai can cache public responses. + * 3. Declared shared-cache headers for anonymous reads: `@table(cacheControl: "...")` (or a + * resource `static cacheControl`), so CDNs like Akamai can cache public responses. The + * declaration is required — anonymous readability alone never emits shared-cache headers. * * The fixture app is installed before boot (so every thread, including main, processes the * schema), and `AUTHENTICATION_AUTHORIZELOCAL=false` keeps unauthenticated loopback requests @@ -145,11 +145,17 @@ suite('HTTP cache headers (#1518, #1565)', (ctx: any) => { ok(!varyTokens(res).includes('authorization'), `anonymous response should not vary on Authorization`); }); - test('anonymous read of a caching table falls back to TTL-derived s-maxage', async () => { + test('anonymous read of a caching table with a declared cacheControl emits it', async () => { const res = await request(restURL).get('/PublicCached/c1').expect(200); strictEqual(res.headers['cache-control'], 'public, s-maxage=120'); }); + test('anonymous read of a caching table WITHOUT a declaration emits no Cache-Control', async () => { + // shared-cache headers require the explicit declaration — a TTL alone is not an opt-in + const res = await request(restURL).get('/PublicPlainCached/c1').expect(200); + strictEqual(res.headers['cache-control'], undefined); + }); + test('anonymous read with app-set Cache-Control keeps the app value', async () => { const res = await request(restURL).get('/SelfCaching/x').expect(200); strictEqual(res.headers['cache-control'], 'public, max-age=10'); diff --git a/resources/databases.ts b/resources/databases.ts index c4c0eaa966..f337e631d3 100644 --- a/resources/databases.ts +++ b/resources/databases.ts @@ -1071,8 +1071,8 @@ export function table(tableDefinition: TableDefinition): Tabl if (expiration) primaryKeyAttribute.expiration = expiration; if (eviction) primaryKeyAttribute.eviction = eviction; // persist cacheControl so all threads (and future boots) see it; undefined callers inherit - // a descriptor value carried by cluster schema events; '' persists the explicit opt-out; - // null (schema has no directive) clears a stale value the carried descriptor may hold + // a descriptor value carried by cluster schema events; null (schema has no directive) + // clears a stale value the carried descriptor may hold if (cacheControl === undefined) cacheControl = primaryKeyAttribute.cacheControl; else if (cacheControl === null) delete primaryKeyAttribute.cacheControl; else primaryKeyAttribute.cacheControl = cacheControl; diff --git a/server/DESIGN.md b/server/DESIGN.md index 25b4c03253..1bc70b1fa5 100644 --- a/server/DESIGN.md +++ b/server/DESIGN.md @@ -124,7 +124,7 @@ The default WebSocket upgrade handler is registered automatically inside `onWebS Three tiers, applied in two places: -1. **App/resource explicit** — a `Cache-Control` set by the resource (or `@table(cacheControl: "...")` for anonymous reads, emitted in `REST.ts → http()`) always wins. Caching tables without a declaration fall back to `public, s-maxage=` for anonymous reads; an explicit empty string opts out of that fallback. +1. **App/resource explicit** — a `Cache-Control` set by the resource (or `@table(cacheControl: "...")` for anonymous reads, emitted in `REST.ts → http()`) always wins. The declaration is required: anonymous readability alone never emits shared-cache headers, because a request-attribute-gated `allowRead` (IP, headers) would make inferred `public` unsound. 2. **Identity floor** — `security/auth.ts → applyResponseHeaders` stamps `Cache-Control: private, no-cache` + `Vary: Authorization` (+ `Cookie` when sessions are on) on any response where a principal was resolved or credentials were rejected (401), _unless_ the app opted into shared caching with `public`/`s-maxage` (the RFC 9111 opt-in). 3. **CORS partitioning** — when CORS is enabled, every response gets `Vary: Origin` (the ACAO header is reflected per-origin, and its absence on no-Origin requests is origin-dependent too). diff --git a/server/REST.ts b/server/REST.ts index c14eeaeae3..0913b897c7 100644 --- a/server/REST.ts +++ b/server/REST.ts @@ -254,20 +254,16 @@ async function http(request: Request, nextHandler) { headers, body: undefined, }; - // #1565 converse: default Cache-Control for anonymous reads so a shared cache/CDN can store - // public responses. An explicit `@table(cacheControl: "...")`/static cacheControl wins; caching - // tables fall back to their source-cache TTL. Authenticated responses are excluded here — the - // auth layer applies a `private` floor to them instead. + // #1565 converse: a declared `@table(cacheControl: "...")`/static cacheControl is emitted on + // anonymous reads so a shared cache/CDN can store public responses. Emission requires the + // explicit declaration — "the anonymous request succeeded" alone doesn't establish that the + // content is uniformly public (an allowRead gated on IP or headers would leak across a + // URL-keyed cache). Authenticated responses are excluded here — the auth layer applies a + // `private` floor to them instead. if (!request.user && (method === 'GET' || method === 'HEAD')) { const responseStatus = responseObject.status; if (responseStatus === 200 || responseStatus === 304) { - let cacheControl = (resource as any)?.cacheControl; - // == null covers both no declaration and a schema without the directive; an explicit - // empty string opts a caching table out of the TTL-derived fallback - if (cacheControl == null && (resource as any)?.isCaching) { - const expirationMs = (resource as any).expirationMS; - if (expirationMs > 0) cacheControl = `public, s-maxage=${Math.floor(expirationMs / 1000)}`; - } + const cacheControl = (resource as any)?.cacheControl; // setIfNone: a resource-set Cache-Control (including a full no-store opt-out) always wins if (cacheControl) headers.setIfNone('Cache-Control', cacheControl); }