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
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions dataLayer/schemaDescribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
graphqlSchema:
files: '*.graphql'
jsResource:
files: resources.js
rest: true
51 changes: 51 additions & 0 deletions integrationTests/server/fixtures/http-cache-headers/resources.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
const { LabeledDoc, CachedDoc, PlainCachedDoc } = tables;

export class CachedSource extends Resource {
async get() {
const id = this.getId();
return { id, value: `sourced ${id}` };
}
}
CachedDoc.sourcedFrom(CachedSource);
PlainCachedDoc.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;
}
}
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 {
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;
}
}
19 changes: 19 additions & 0 deletions integrationTests/server/fixtures/http-cache-headers/schema.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
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, cacheControl: "public, s-maxage=120") @export {
id: ID @primaryKey
value: String
}

type PlainCachedDoc @table(expiration: 120) @export {
id: ID @primaryKey
value: String
}
171 changes: 171 additions & 0 deletions integrationTests/server/http-cache-headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* 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. 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
* 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 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');
});

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');
});
});
3 changes: 3 additions & 0 deletions resources/Table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ export function makeTable(options) {
replicate,
description,
hidden,
cacheControl,
} = options;
let { expirationMS: expirationMs, evictionMS: evictionMs, audit, trackDeletes } = options;
evictionMs ??= 0;
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions resources/databases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -720,6 +721,7 @@ function initStores(
replicate,
expirationMS: expiration && expiration * 1000,
evictionMS: eviction && eviction * 1000,
cacheControl,
trackDeletes,
tableName,
tableId,
Expand Down Expand Up @@ -773,6 +775,9 @@ interface TableDefinition {
description?: string;
properties?: Record<string, any>;
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
Expand Down Expand Up @@ -1003,6 +1008,7 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
description,
properties,
hidden,
cacheControl,
} = tableDefinition;
if (!databaseName) databaseName = DEFAULT_DATABASE_NAME;
const rootStore = database({ database: databaseName, table: tableName });
Expand Down Expand Up @@ -1049,6 +1055,8 @@ export function table<TableResourceType>(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) || {};
Expand All @@ -1062,6 +1070,12 @@ export function table<TableResourceType>(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; 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;
splitSegments ??= false;
primaryKeyAttribute.splitSegments = splitSegments; // always default to not splitting segments going forward
if (typeof sealed === 'boolean') primaryKeyAttribute.sealed = sealed;
Expand Down Expand Up @@ -1153,6 +1167,7 @@ export function table<TableResourceType>(tableDefinition: TableDefinition): Tabl
description,
properties,
hidden,
cacheControl,
})
);
Table.schemaVersion = 1;
Expand Down
8 changes: 8 additions & 0 deletions resources/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading