diff --git a/.changeset/27627-metadata-caching-headers.md b/.changeset/27627-metadata-caching-headers.md new file mode 100644 index 00000000000..73a3a8c2266 --- /dev/null +++ b/.changeset/27627-metadata-caching-headers.md @@ -0,0 +1,27 @@ +--- +'@objectstack/rest': patch +'@objectstack/plugin-hono-server': patch +'@objectstack/cloud-connection': patch +'@objectstack/types': patch +--- + +feat(rest,plugin-hono-server,cloud-connection): metadata lists, the uncached item reads, `runtime/config` and `auth/me/localization` answer an `ETag` and `304`; every timed response carries `db`, `meta` and `total` + +Clause-②: no +Metadata-protocol: none + +The Console's first reads on every load sent no validator, so each one was a full download every time. Measured against HotCRM on `main`, none of these sent an `ETag`, a `Cache-Control` or a `304`: `GET /runtime/config`, `GET /auth/me/localization`, the type list (`GET /meta`, `GET /meta/types`), every `GET /meta/:type` list, and the item reads the shared item cache never serves (`GET /meta/app/:name`, `/meta/dashboard/:name`, `/meta/doc/:name`, `/meta/book/:name`, and drafts, `?package=` and member-scoped reads). Each of them now answers: + +- **`ETag`**: a weak validator (`W/"…"`) that hashes the body actually served, after every per-caller step (read gates, the app filter, the field mask, the translation, the host's environment). Two callers whose permissions change what they see get different validators, and one caller's validator never answers `304` for another caller's body. +- **`Cache-Control: private, no-cache`**: the browser keeps the body and revalidates it on every use, so a publish, a permission change or a language switch shows on the next read. No shared cache stores it. A schema served under an undetermined field visibility keeps `private, no-store` and gets no validator. +- **`304 Not Modified`**, with no body, when `If-None-Match` names the current validator. + +`metadata.enableCache: false` still switches the `/meta` validators off, as its description says ("Enable HTTP cache headers"). + +`Server-Timing` (still opt-in: `OS_SERVER_TIMING=true`, or an admin's `X-OS-Debug-Timing: 1`): + +- Every timed response carries `db` and `meta` beside `total`. A request that ran no SQL or read no metadata says so with a zero member (`db;dur=0;desc="0 queries"`), so "nothing spent there" no longer reads like "not measured". +- The `/meta` routes count each metadata read into `meta` (`meta;dur=…;desc="N reads"`). +- `GET /runtime/config` now opens the timing gate for an admin who sends `X-OS-Debug-Timing`. It resolves the caller only when that header is sent; the payload never depends on who asked. + +`@objectstack/types` exports the rule the three packages share: `representationEtag(body)`, `ifNoneMatchHits(header, etag)` and `REVALIDATE_PRIVATE_CACHE_CONTROL`. diff --git a/content/docs/api/metadata-api.mdx b/content/docs/api/metadata-api.mdx index 44b0035c9e9..f1c112b04d4 100644 --- a/content/docs/api/metadata-api.mdx +++ b/content/docs/api/metadata-api.mdx @@ -21,6 +21,9 @@ List all registered metadata types. **Response**: `{ types: ["object", "view", "plugin", ...] }` +The answer carries a weak `ETag` and `Cache-Control: private, no-cache`; send the +`ETag` back as `If-None-Match` and an unchanged list answers **304** with no body. + ### `GET /meta/:type` List all items of a metadata type. @@ -40,6 +43,13 @@ filtering to privileged apps, and the usual response shaping. **Response**: `{ type: "object", items: [{ name: "account", ... }, ...] }` +The list carries a weak `ETag` and `Cache-Control: private, no-cache`, and +`If-None-Match` with the current `ETag` answers **304** with no body. The `ETag` +hashes the list this caller was served, after the audience and RBAC filtering +above, so two callers who see different items hold different tags and one +caller's tag never revalidates another caller's list. Keep the response in the +client only: `private` means no shared cache may store it. + ### `GET /meta/:type/:name` Get a specific metadata item with optional ETag caching. diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md index 84ef560da4d..81d6dd7a40c 100644 --- a/docs/OBSERVABILITY.md +++ b/docs/OBSERVABILITY.md @@ -374,6 +374,12 @@ out of the box: | `auth` | Dispatcher | Identity / session resolution — the prime suspect for unexplained data-API overhead. | | `db` | SQL driver | Total SQL time across the request; `desc` is the **query count** (folded from knex's per-query events, attributed to the originating request via `AsyncLocalStorage` so it is correct under concurrency). The basic header carries no SQL text; individual **parametrized** statements appear only in the admin-only `X-OS-Debug-Timing: json` detail payload. | | `hooks` | ObjectQL engine | Total business-hook execution time; `desc` is the hook count. | +| `meta` | REST `/meta` routes | Total metadata-read time across the request; `desc` is the **read count** (`meta;dur=0.7;desc="1 reads"`). | + +`db` and `meta` are on every timed response, even one that ran no SQL or read no +metadata: such a response carries them as zero members (`db;dur=0;desc="0 queries"`, +`meta;dur=0;desc="0 reads"`). A missing member therefore means the response was +not timed at all, never that nothing was spent there. Each phase is recorded through a request-scoped collector that is a no-op when the mode is off, so every one of them costs nothing on the normal path. The diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json index ebca5862aed..a8abae995f5 100644 --- a/packages/cloud-connection/package.json +++ b/packages/cloud-connection/package.json @@ -29,6 +29,7 @@ "@objectstack/metadata-core": "workspace:*", "@objectstack/metadata-protocol": "workspace:*", "@objectstack/objectql": "workspace:*", + "@objectstack/observability": "workspace:*", "@objectstack/runtime": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*" diff --git a/packages/cloud-connection/src/runtime-config-etag.test.ts b/packages/cloud-connection/src/runtime-config-etag.test.ts new file mode 100644 index 00000000000..d0c92e7c666 --- /dev/null +++ b/packages/cloud-connection/src/runtime-config-etag.test.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27627] `GET /api/v1/runtime/config` — the first request every Console load + * makes, measured on the cloud at 0.5–1.4 s for 293 bytes with no + * `Cache-Control` and no `ETag`. It now answers the weak validator of the + * payload it serves (`representationEtag`, `@objectstack/types`), `private, + * no-cache`, and a bodiless `304` on a matching `If-None-Match`; the payload is + * per host and read per request, both inside the hashed bytes. + * + * And the per-request `Server-Timing` gate: this route serves everyone and + * resolved nobody, so an admin's `X-OS-Debug-Timing` never opened the gate + * here (the one route of 58 measured on `main` with no timing for an admin). + * The caller is resolved only when that header is sent. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { runWithPerfDisclosure, type PerfDisclosureGate } from '@objectstack/observability'; + +// A plain recorder rather than a `vi.fn`: a spy wraps a rejected promise in a +// derived one nobody awaits, which vitest reports as unhandled even though +// the route catches the original. +const resolver = { + calls: 0, + answer: (async () => undefined) as () => Promise, +}; +vi.mock('@objectstack/core', async (importOriginal) => ({ + ...(await importOriginal()), + resolveRequestExecutionContext: () => { resolver.calls += 1; return resolver.answer(); }, +})); + +const { RuntimeConfigPlugin } = await import('./runtime-config-plugin.js'); + +interface Answer { status: number; body: any; headers: Record } + +async function mountHandler(opts: { host?: string; resolveByHostname?: (h: string) => Promise } = {}) { + let handler: ((c: any) => Promise) | undefined; + const rawApp = { + routes: [] as Array<{ method: string; path: string }>, + get(path: string, h: (c: any) => Promise) { + this.routes.push({ method: 'GET', path }); + if (path === '/api/v1/runtime/config') handler = h; + }, + }; + const services: Record = { 'http-server': { getRawApp: () => rawApp }, auth: {}, objectql: {} }; + if (opts.resolveByHostname) services['env-registry'] = { resolveByHostname: opts.resolveByHostname }; + const ctx: any = { + logger: { info() {}, warn() {} }, + getService: (n: string) => { const s = services[n]; if (!s) throw new Error(`no ${n}`); return s; }, + hooks: [] as Array<() => Promise>, + hook(_e: string, cb: () => Promise) { this.hooks.push(cb); }, + }; + const plugin = new RuntimeConfigPlugin({ controlPlaneUrl: '' }); + await plugin.start(ctx); + for (const cb of ctx.hooks) await cb(); + if (!handler) throw new Error('handler not mounted'); + // A Hono-shaped context that keeps what the handler hands `json` / `body`. + return (requestHeaders: Record = {}): Promise => { + const all: Record = { host: opts.host ?? '', ...requestHeaders }; + return handler!({ + req: { header: (n: string) => all[n.toLowerCase()], raw: { headers: new Headers(all) } }, + json: (body: any, status = 200, headers: Record = {}) => ({ status, body, headers }), + body: (body: any, status = 200, headers: Record = {}) => ({ status, body, headers }), + }); + }; +} + +beforeEach(() => { resolver.calls = 0; resolver.answer = async () => undefined; }); + +describe('[#27627] runtime/config answers a validator of the payload it serves', () => { + it('a weak ETag and private, no-cache — never public', async () => { + const get = await mountHandler(); + const res = await get(); + expect(res.status).toBe(200); + expect(res.headers.ETag).toMatch(/^W\/"[0-9a-f]{16}"$/); + expect(res.headers['Cache-Control']).toBe('private, no-cache'); + }); + + it('an unchanged payload revalidates to a bodiless 304', async () => { + const get = await mountHandler(); + const etag = (await get()).headers.ETag; + const again = await get({ 'if-none-match': etag }); + expect(again.status).toBe(304); + expect(again.body).toBeNull(); + expect(again.headers.ETag).toBe(etag); + }); + + it('two hosts resolving different environments hold different tags; one never revalidates the other', async () => { + const resolveByHostname = async (h: string) => (h === 'a.example.com' ? { environmentId: 'env_a' } : { environmentId: 'env_b' }); + const a = await (await mountHandler({ host: 'a.example.com', resolveByHostname }))(); + const getB = await mountHandler({ host: 'b.example.com', resolveByHostname }); + const b = await getB(); + expect(a.headers.ETag).not.toBe(b.headers.ETag); + const crossed = await getB({ 'if-none-match': a.headers.ETag }); + expect(crossed.status).toBe(200); + expect(crossed.body.defaultEnvironmentId).toBe('env_b'); + }); + + it('the response never depends on who asked: no caller is resolved without the timing header', async () => { + const get = await mountHandler(); + await get(); + await get({ cookie: 'session=abc' }); + expect(resolver.calls).toBe(0); + }); +}); + +describe('[#27627] runtime/config opens the per-request Server-Timing gate for an admin only', () => { + const timed = async (posture: string | undefined, headers: Record) => { + resolver.answer = async () => (posture === undefined ? undefined : { userId: 'u1', posture }); + const get = await mountHandler(); + const gate: PerfDisclosureGate = { allowed: false }; + const res = await runWithPerfDisclosure(gate, () => get(headers)); + return { gate, res }; + }; + + it('an admin who sends X-OS-Debug-Timing opens it', async () => { + const { gate, res } = await timed('PLATFORM_ADMIN', { 'x-os-debug-timing': '1' }); + expect(gate.allowed).toBe(true); + expect(resolver.calls).toBe(1); + expect(res.status).toBe(200); + }); + + it('a member who sends it does not, and neither does a sessionless caller', async () => { + expect((await timed('MEMBER', { 'x-os-debug-timing': '1' })).gate.allowed).toBe(false); + expect((await timed(undefined, { 'x-os-debug-timing': '1' })).gate.allowed).toBe(false); + }); + + it('a resolution that fails leaves the gate closed and serves the payload unchanged', async () => { + resolver.answer = async () => { throw new Error('authz store unavailable'); }; + const get = await mountHandler(); + const gate: PerfDisclosureGate = { allowed: false }; + const res = await runWithPerfDisclosure(gate, () => get({ 'x-os-debug-timing': '1' })); + expect(gate.allowed).toBe(false); + expect(res.status).toBe(200); + expect(res.body.features).toBeDefined(); + }); +}); diff --git a/packages/cloud-connection/src/runtime-config-plugin.ts b/packages/cloud-connection/src/runtime-config-plugin.ts index 6cb18de3986..c24f296a609 100644 --- a/packages/cloud-connection/src/runtime-config-plugin.ts +++ b/packages/cloud-connection/src/runtime-config-plugin.ts @@ -270,7 +270,15 @@ * exactly as it does for `marketplace`. */ -import type { Plugin, PluginContext } from '@objectstack/core'; +import { + resolveRequestExecutionContext, + type IDataEngine, + type Plugin, + type PluginContext, + type RequestAuthService, +} from '@objectstack/core'; +import { allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; +import { ifNoneMatchHits, representationEtag, REVALIDATE_PRIVATE_CACHE_CONTROL } from '@objectstack/types'; import { resolveCloudUrl, isControlPlaneDeclined } from './cloud-url.js'; import { CLIENT_ERROR_REPORTING_DSN_ENV, @@ -287,6 +295,66 @@ import { import type { IHttpServer } from '@objectstack/spec/contracts'; import { readConsoleMapStyleUrl } from './console-map-style.js'; +/** + * [#27627] Answer `body` under the weak validator of exactly what is served + * (`representationEtag`, `@objectstack/types`), `private, no-cache`, and a + * bodiless `304` when the request's `If-None-Match` already names it. The + * payload is decided per HOST (each subdomain resolves its own environment) + * and read per request (a Branding save shows on the next load), both inside + * the hashed bytes, so a revalidation sees either change at once. `private`, + * not `public`: the host-to-environment resolution and the saved branding are + * this deployment's, and nothing here asks a shared cache to hold them. + */ +function sendRevalidated(c: any, body: Record): Response { + const etag = representationEtag(body); + const headers = { 'Cache-Control': REVALIDATE_PRIVATE_CACHE_CONTROL, ETag: etag }; + if (ifNoneMatchHits(c.req.header('if-none-match'), etag)) return c.body(null, 304, headers); + // Sent as a literal, not as `body`: `check:route-envelope` counts this + // route's one bare payload at the call site, and the payload is still built + // by this plugin (the parameter exists only so the validator hashes it). + return c.json({ ...body }, 200, headers); +} + +/** + * [#27627] Resolve this request's caller and, for an admin or service + * principal, open the per-request `Server-Timing` disclosure gate — the same + * predicate (`isPerfDisclosurePrincipal`, over the resolved `posture`) every + * other door hands its caller to. The composition is the current-user + * endpoints' own (`plugin-hono-server` `current-user-endpoints.ts`): a + * sessionless request resolves nobody, and no API-key door is opened. + * + * Best-effort by design, and that is the whole difference from those doors: + * the answer never depends on the caller, so a resolution that fails — an + * authorization store outage included — only leaves the gate closed. A no-op + * when no request opened a gate (perf-tuning off). + */ +async function openTimingGateForAdmin(ctx: PluginContext, headers: Headers): Promise { + const service = (name: string): T | undefined => { + try { return ctx.getService(name); } catch { return undefined; } + }; + try { + const context = await resolveRequestExecutionContext({ + headers, + ql: service('objectql'), + authService: service('auth'), + tenancy: undefined, + anonymous: 'fail-closed', + authGate: 'withhold', + credentials: 'session-only', + sessionApi: 'required', + session: 'request', + localization: 'withhold', + requestLocale: undefined, + accessToken: 'withhold', + oauth: undefined, + onAdmission: undefined, + }); + if (context && isPerfDisclosurePrincipal(context)) allowPerfDisclosure(); + } catch { + // The gate stays closed; the payload is served unchanged. + } +} + /** * The `env-registry` slot's hostname resolvers — see the call site for both * spellings. Async by contract: the consumer awaits the result, and declaring @@ -1046,6 +1114,15 @@ export class RuntimeConfigPlugin implements Plugin { }; const handler = async (c: any) => { + // [#27627] Open the per-request `Server-Timing` gate for an + // admin, as every other door does once it has resolved its + // caller. This route serves everyone anonymously and resolves + // nobody, so without this an admin's `X-OS-Debug-Timing` was + // the one request in the API that never got its timing. The + // caller is resolved ONLY when that header is sent: an + // ordinary load pays no session read, and the answer below + // never depends on who asked. + if (c.req.header('x-os-debug-timing')) await openTimingGateForAdmin(ctx, c.req.raw.headers); const rawHost = c.req.header('host') ?? ''; const host = rawHost.split(':')[0].toLowerCase().trim(); let defaultEnvironmentId: string | undefined; @@ -1087,7 +1164,7 @@ export class RuntimeConfigPlugin implements Plugin { // 500s. Worst case the SPA shows its env picker. } } - return c.json({ + return sendRevalidated(c, { cloudUrl: this.cloudUrl, // Declared by the host, never derived (#14514). Spread, // not `upgradeUrl: this.upgradeUrl`, for the same reason diff --git a/packages/cloud-connection/vitest.config.ts b/packages/cloud-connection/vitest.config.ts index a6ef0d5e896..ab6e28b8bcd 100644 --- a/packages/cloud-connection/vitest.config.ts +++ b/packages/cloud-connection/vitest.config.ts @@ -86,6 +86,14 @@ export default defineConfig({ find: /^@objectstack\/plugin-security$/, replacement: path.resolve(__dirname, '../plugins/plugin-security/src/index.ts'), }, + // [#27627] `runtime-config-etag.test.ts` drives the per-request + // Server-Timing gate the route opens for an admin (`allowPerfDisclosure`), + // so it judges THIS checkout's gate, not an `observability/dist` that may + // be behind it. Anchored like the entries above. + { + find: /^@objectstack\/observability$/, + replacement: path.resolve(__dirname, '../observability/src/index.ts'), + }, ], }, // Discovery is untouched — but this file is NOT "the alias and nothing else". diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts index 949d1bd26e7..138d9e38a27 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts @@ -188,7 +188,7 @@ function mount({ storedLocale, rules = [LOCALE_SHAPE_RULE], settingLocale, setti }); return { status: res.status, body: await res.json() as any }; }; - return { get, reads }; + return { get, reads, app }; } describe('/auth/me/localization — the signed-in user\'s language, three rungs (#14788)', () => { @@ -334,3 +334,56 @@ describe('/auth/me/localization — the regional defaults are RESOLVED, not alwa expect(reads.filter((r) => r.object === 'sys_setting')).toEqual([]); }); }); + +// [#27627] Every renderer reads this on every load, and the cloud reading +// measured it at 8.2 s under a burst, with no validator at all. The signed-in +// answer is per caller (their own `locale`, their tenant's defaults), so it is +// `private, no-cache` under the weak validator of THIS caller's body +// (`representationEtag`, `@objectstack/types`), and `304` when the caller +// already holds it. +describe('/auth/me/localization — a per-caller validator (#27627)', () => { + const request = (app: Hono, headers: Record = {}) => + app.request(`http://localhost${ME_LOCALIZATION}`, { headers }); + + it('the signed-in answer carries a weak ETag, private, no-cache and Vary: Accept-Language', async () => { + const { app } = mount({ storedLocale: 'zh-CN' }); + const res = await request(app); + expect(res.status).toBe(200); + expect(res.headers.get('etag')).toMatch(/^W\/"[0-9a-f]{16}"$/); + expect(res.headers.get('cache-control')).toBe('private, no-cache'); + expect(res.headers.get('vary')).toContain('Accept-Language'); + }); + + it('an unchanged answer revalidates to a bodiless 304', async () => { + const { app } = mount({ storedLocale: 'zh-CN' }); + const etag = (await request(app)).headers.get('etag')!; + const again = await request(app, { 'if-none-match': etag }); + expect(again.status).toBe(304); + expect(await again.text()).toBe(''); + }); + + it('two users whose answers differ hold different tags, and one tag never revalidates the other\'s body', async () => { + const zh = mount({ storedLocale: 'zh-CN' }); + const ja = mount({ storedLocale: 'ja-JP' }); + const zhTag = (await request(zh.app)).headers.get('etag')!; + const jaTag = (await request(ja.app)).headers.get('etag')!; + expect(zhTag).not.toBe(jaTag); + const crossed = await request(ja.app, { 'if-none-match': zhTag }); + expect(crossed.status).toBe(200); + expect(((await crossed.json()) as any).locale).toBe('ja-JP'); + }); + + it('a change moves the tag: the request language decides the answer when no locale is stored', async () => { + const { app } = mount({}); + const en = (await request(app, { 'accept-language': 'en-US' })).headers.get('etag')!; + const fr = await request(app, { 'accept-language': 'fr-FR', 'if-none-match': en }); + expect(fr.status).toBe(200); + expect(fr.headers.get('etag')).not.toBe(en); + }); + + it('the signed-out answer carries no validator', async () => { + const { app } = mount({ authenticated: false }); + const res = await request(app); + expect(res.headers.get('etag')).toBeNull(); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts index f0be04d68e3..b506c9b2d86 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -58,6 +58,7 @@ import type { Logger, } from '@objectstack/spec/contracts'; import { allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; +import { ifNoneMatchHits, representationEtag, REVALIDATE_PRIVATE_CACHE_CONTROL } from '@objectstack/types'; /** API prefix these endpoints mount under unless the host overrides it. */ export const DEFAULT_CURRENT_USER_PREFIX = '/api/v1'; @@ -1003,12 +1004,26 @@ export function registerCurrentUserEndpoints( tenantId: execCtx.tenantId ?? undefined, acceptLanguage: c.req.raw?.headers?.get?.('accept-language') ?? undefined, }); - return c.json({ + const body = { authenticated: true, currency: localization.currency ?? null, locale: localization.locale, timezone: localization.timezone, - }); + }; + // [#27627] Read by every renderer on every load, and per caller (the + // user's own `locale`, their tenant's defaults): `private, no-cache` + // under the weak validator of THIS caller's body, `304` when the + // caller already holds it. Only the signed-in answer is validated — + // the signed-out one above is a fixed body nothing needs to revalidate. + const etag = representationEtag(body); + c.header('Cache-Control', REVALIDATE_PRIVATE_CACHE_CONTROL); + c.header('Vary', 'Accept-Language'); + c.header('ETag', etag); + if (ifNoneMatchHits(c.req.header('if-none-match'), etag)) return c.body(null, 304); + // Sent as a literal, not as `body`: `check:route-envelope` counts the + // bare bodies this file builds at the call site, and this one is still + // built here (the `const` exists only so the validator hashes it). + return c.json({ ...body }); })); // GET /me/apps — list apps the current user is allowed to enter. diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index 26f623b2f5e..32c697e2f33 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -142,6 +142,17 @@ export interface HonoPluginOptions { }; } +/** + * [#27627] The aggregate `Server-Timing` marks every timed response carries, + * zero when the request spent nothing there — `db` (the SQL driver counts each + * query into it) and `meta` (the `/meta` routes count each metadata read). With + * `total`, the three a reading needs to split origin time. + */ +const SEEDED_TIMING_MARKS: ReadonlyArray = [ + ['db', 'queries'], + ['meta', 'reads'], +]; + /** * How much per-request timing the caller opted into via `X-OS-Debug-Timing`: * - `off` — no header sent (or an unrecognized value). @@ -344,6 +355,13 @@ export class HonoServerPlugin implements Plugin { const gate: PerfDisclosureGate = { allowed: globalTiming }; const endTotal = timing.start('total', 'Total server time'); await runWithPerfTiming(timing, () => runWithPerfDisclosure(gate, () => next())); + // [#27627] `db` and `meta` on EVERY timed response: a request + // that ran no SQL or read no metadata says so with a zero mark, + // so "no time spent there" never reads like "not instrumented" + // (the two looked identical before — an absent member). + for (const [name, unit] of SEEDED_TIMING_MARKS) { + if (!timing.marks().some((m) => m.name === name)) timing.record(name, 0, `0 ${unit}`); + } endTotal(); if (!gate.allowed) return; // per-request, unverified caller — withhold const header = timing.toHeader(); diff --git a/packages/plugins/plugin-hono-server/src/server-timing-seeded-marks.test.ts b/packages/plugins/plugin-hono-server/src/server-timing-seeded-marks.test.ts new file mode 100644 index 00000000000..7810a812f8d --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/server-timing-seeded-marks.test.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27627] Under the existing gate, every timed response carries the three + * marks a reading needs to split origin time: `total`, `db` and `meta`. A + * request that ran no SQL or read no metadata says so with a zero member, so + * "nothing spent there" no longer reads like "not instrumented" — measured on + * `main`, 0 of 58 routes carried `meta`, and `db` was absent on every request + * that ran no query. + * + * The gate itself is unchanged: no header without global mode or an admin's + * `X-OS-Debug-Timing`. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { HonoServerPlugin } from './hono-plugin'; +import { allowPerfDisclosure, countServerTiming } from '@objectstack/observability'; +import type { PluginContext } from '@objectstack/core'; + +function fakeCtx(): PluginContext { + const services = new Map(); + return { + logger: { debug() {}, info() {}, warn() {}, error() {} }, + registerService: (name: string, svc: unknown) => services.set(name, svc), + getService: (name: string) => services.get(name), + } as unknown as PluginContext; +} + +async function setup(opts: { serverTiming?: boolean } = {}) { + const plugin = new HonoServerPlugin({ ...opts, cors: false }); + await (plugin as any).init(fakeCtx()); + const server = (plugin as any).server; + server.get('/idle', (_req: any, res: any) => res.json({ ok: true })); + server.get('/admin-idle', (_req: any, res: any) => { + allowPerfDisclosure(); + return res.json({ ok: true }); + }); + server.get('/reads', (_req: any, res: any) => { + countServerTiming('db', 3, 'queries'); + countServerTiming('meta', 5, 'reads'); + countServerTiming('meta', 2, 'reads'); + return res.json({ ok: true }); + }); + return server.getRawApp(); +} + +/** `name -> { dur, desc }` for each member of a Server-Timing header. */ +function members(header: string | null): Record { + const out: Record = {}; + for (const part of (header ?? '').split(',').map((p) => p.trim()).filter(Boolean)) { + const [name, ...params] = part.split(';'); + const dur = Number(params.find((p) => p.startsWith('dur='))?.slice(4)); + const desc = params.find((p) => p.startsWith('desc='))?.slice(6, -1); + out[name] = { dur, desc }; + } + return out; +} + +describe('[#27627] db, meta and total on every timed response', () => { + const prev = process.env.OS_SERVER_TIMING; + beforeEach(() => { delete process.env.OS_SERVER_TIMING; }); + afterEach(() => { if (prev === undefined) delete process.env.OS_SERVER_TIMING; else process.env.OS_SERVER_TIMING = prev; }); + + it('global mode: a request that read nothing carries db and meta as zero members', async () => { + const app = await setup({ serverTiming: true }); + const m = members((await app.request('/idle')).headers.get('server-timing')); + expect(m.db).toEqual({ dur: 0, desc: '0 queries' }); + expect(m.meta).toEqual({ dur: 0, desc: '0 reads' }); + expect(m.total.dur).toBeGreaterThanOrEqual(0); + }); + + it('a request that did read keeps its measured members, never a zero beside them', async () => { + const app = await setup({ serverTiming: true }); + const header = (await app.request('/reads')).headers.get('server-timing')!; + const m = members(header); + expect(m.db).toEqual({ dur: 3, desc: '1 queries' }); + expect(m.meta).toEqual({ dur: 7, desc: '2 reads' }); + expect(header.match(/(^|, )meta;/g)).toHaveLength(1); + }); + + it('per-request mode: an admin gets all three; the same header from an unproven caller gets none', async () => { + const app = await setup(); + const admin = members((await app.request('/admin-idle', { headers: { 'X-OS-Debug-Timing': '1' } })).headers.get('server-timing')); + expect(Object.keys(admin).sort()).toEqual(expect.arrayContaining(['db', 'meta', 'total'])); + expect((await app.request('/idle', { headers: { 'X-OS-Debug-Timing': '1' } })).headers.get('server-timing')).toBeNull(); + }); + + it('without the gate there is no header at all', async () => { + const app = await setup(); + expect((await app.request('/idle')).headers.get('server-timing')).toBeNull(); + }); +}); diff --git a/packages/rest/src/meta-audience-plural.test.ts b/packages/rest/src/meta-audience-plural.test.ts index eeb62e4457a..3c76dc5c6df 100644 --- a/packages/rest/src/meta-audience-plural.test.ts +++ b/packages/rest/src/meta-audience-plural.test.ts @@ -282,11 +282,18 @@ describe('#6241 — the cached branch cannot be spelled around either', () => { expect(protocol2.getMetaItemCached).toHaveBeenCalledTimes(1); }); - it('the price of the bypass, pinned rather than hidden: gated reads carry no ETag', async () => { - const { rest } = setupCached(); + it('a gated read bypasses the shared validator, and carries the validator of its own served body instead', async () => { + // This pinned "gated reads carry no ETag" — the price of the bypass — + // until #27627 gave the uncached exit the body-hash validator + // (`representationEtag`): the SHARED cache's ETag still never fronts a + // gated body, which is what the bypass is for. + const { rest, protocol } = setupCached(); const res = await getItem(rest, 'books', 'manual'); expect(res.statusCode).toBe(200); - expect(res.header.mock.calls.map((c: any[]) => c[0])).not.toContain('ETag'); + expect(protocol.getMetaItemCached).not.toHaveBeenCalled(); + const etag = res.header.mock.calls.find((c: any[]) => c[0] === 'ETag')?.[1]; + expect(etag).toMatch(/^W\/"[0-9a-f]{16}"$/); + expect(res.header.mock.calls).toContainEqual(['Cache-Control', 'private, no-cache']); }); }); diff --git a/packages/rest/src/meta-collection-routes.ts b/packages/rest/src/meta-collection-routes.ts index e6771aaf447..dd604608df3 100644 --- a/packages/rest/src/meta-collection-routes.ts +++ b/packages/rest/src/meta-collection-routes.ts @@ -91,6 +91,8 @@ import type { GetMetaItemsRequest } from '@objectstack/spec/api'; import { truncateClientMessage } from '@objectstack/types'; import { handleRouteError, sendFieldVisibilityFault } from './error-response.js'; import * as metaReadGate from './meta-item-read-gate.js'; +import { measureMetaRead } from './meta-read-timing.js'; +import { metaRepresentationCaching, sendMetaRepresentation } from './meta-representation-validator.js'; import { unknownQueryParamRefusalMessage } from './query-allowlist.js'; import { refuseRepeatedQueryParams } from './query-multiplicity.js'; import { mayReadPendingDrafts, metaItemPackageBinding, type RestServer } from './rest-server.js'; @@ -248,10 +250,13 @@ export function registerMetaCollectionRoutes(this: MetaCollectionRouteHost, scop try { const environmentId = isScoped ? req.params?.environmentId : undefined; const p = await this.resolveProtocol(environmentId, req); - const types = await p.getMetaTypes(); + const types = await measureMetaRead(() => p.getMetaTypes()); const translated = await this.translateMetaTypesResponse(req, environmentId, types); res.header('Vary', 'Accept-Language'); - res.json(translated); + // [#27627] The type list varies by locale and by what this + // kernel registers, both inside the served bytes the + // validator hashes. + sendMetaRepresentation(req, res, translated, metaRepresentationCaching(translated), metadata.enableCache !== false); } catch (error: any) { handleRouteError(res, error); } @@ -678,7 +683,7 @@ export function registerMetaCollectionRoutes(this: MetaCollectionRouteHost, scop ...(previewDrafts ? { previewDrafts: true } : {}), ...(environmentId ? { environmentId } : {}), }; - const items = await p.getMetaItems(listRequest); + const items = await measureMetaRead(() => p.getMetaItems(listRequest)); // [#20320] Everything this route does to the list after // the read — the #5224 `api` served-set face, THE @@ -709,9 +714,11 @@ export function registerMetaCollectionRoutes(this: MetaCollectionRouteHost, scop // [ADR-0106 D6 tier 2] The chain's cache posture — an // undetermined field visibility serves the list // `private, no-store`, exactly as this route always has. - if (answer.cacheControl) res.header('Cache-Control', answer.cacheControl); + // [#27627] Otherwise `private, no-cache` under the chain's + // validator of the served list, and a `304` when the + // caller already holds it. res.header('Vary', 'Accept-Language'); - res.json(answer.data); + sendMetaRepresentation(req, res, answer.data, answer, metadata.enableCache !== false); } catch (error: any) { handleRouteError(res, error); } diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index a6688ea7c4d..30fe35f0812 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -85,6 +85,7 @@ import { deriveViewContainerObject } from '@objectstack/metadata/view-container' import type { ExpandedViewItem } from '@objectstack/spec/ui'; import { isEndpointMatchAuthority, selectServedEndpoints } from './served-endpoints.js'; import { logError, logWarn } from './log.js'; +import { metaRepresentationCaching } from './meta-representation-validator.js'; // [#23072] The recognition sentence and the arity rule's portable halves, for // the by-name doors' query decision ({@link metaItemQueryRefusal}). import { unknownQueryParamRefusalMessage } from './query-allowlist.js'; @@ -3367,13 +3368,17 @@ export interface MetaListRequest { * or the `{ type, items }` envelope), or the object whose mask could not be * evaluated, which the transport answers as its D6 tier-3 fault. * - * [#20408] `cacheControl` — the `Cache-Control` the list must be served under, - * when it owes one: {@link META_UNDETERMINED_CACHE_CONTROL} when any listed - * object schema was served under an undetermined field visibility (ADR-0106 - * D6 tier 2). Absent otherwise; the transport then sets none. + * [#20408] `cacheControl` — the `Cache-Control` the list must be served under: + * {@link META_UNDETERMINED_CACHE_CONTROL} when any listed object schema was + * served under an undetermined field visibility (ADR-0106 D6 tier 2), and no + * `etag`. [#27627] Otherwise `private, no-cache` and `etag`, the weak + * validator of `data` itself — the served list, after every step below + * (`metaRepresentationCaching` in `./meta-representation-validator.ts` says + * why the served bytes, and only they, are hashed). A transport that answers + * conditional requests compares `If-None-Match` against `etag`. */ export type MetaListAnswer = - | { ok: true; data: unknown; cacheControl?: typeof META_UNDETERMINED_CACHE_CONTROL } + | { ok: true; data: unknown; cacheControl: string; etag?: string } | { ok: false; object: string }; /** The items of a list shape this chain serves, or `null` for anything else (left alone, never replaced). */ @@ -3590,7 +3595,9 @@ export function createMetaListAnswer( // 9. The translation step ({@link translateMetaList}, through the transport's entry). const data = await sources.translateList(metaType, visible); - return cacheControl ? { ok: true, data, cacheControl } : { ok: true, data }; + // 10. [#27627] The validator of what is served — over `data`, the + // list after every step above, never an earlier stage of it. + return { ok: true, data, ...metaRepresentationCaching(data, cacheControl) }; }; } @@ -3643,16 +3650,18 @@ export interface MetaItemRequest { /** * What the item chain answers: * - * - `serve` — send `envelope`, under `cacheControl` when it owes one - * ({@link META_UNDETERMINED_CACHE_CONTROL}, ADR-0106 D6 tier 2), and under - * `Vary: Accept-Language` always (the body is translated per request); + * - `serve` — send `envelope` under `cacheControl` and `Vary: + * Accept-Language` (the body is translated per request): + * {@link META_UNDETERMINED_CACHE_CONTROL} with no `etag` on an ADR-0106 D6 + * tier-2 posture, otherwise [#27627] `private, no-cache` and `etag`, the + * weak validator of `envelope` itself, after every step below; * - `refuse` — the gate's {@link MetaItemReadRefusal} (`absent` included: * nothing behind the name is the transport's own absence answer); * - `mask-fault` — the projection left the schema with no field (D6), which * the transport answers as its field-visibility fault for `object`. */ export type MetaItemAnswer = - | { kind: 'serve'; envelope: unknown; cacheControl?: typeof META_UNDETERMINED_CACHE_CONTROL } + | { kind: 'serve'; envelope: unknown; cacheControl: string; etag?: string } | { kind: 'refuse'; refusal: MetaItemReadRefusal } | { kind: 'mask-fault'; object: string }; @@ -3726,9 +3735,8 @@ export function createMetaItemAnswer( // over [#22250] the caller's runtime view of what step 4 serves. const runtime = await resolveObjectSchemaRuntimeView(visible, maskPosture); const served = await sources.translateEnvelope(envelope ?? {}, visible, runtime.document); - return masked.cacheControl - ? { kind: 'serve', envelope: served, cacheControl: masked.cacheControl } - : { kind: 'serve', envelope: served }; + // 6. [#27627] The validator of what is served — over `served`, never an earlier stage of it. + return { kind: 'serve', envelope: served, ...metaRepresentationCaching(served, masked.cacheControl) }; }; } diff --git a/packages/rest/src/meta-item-read-routes.ts b/packages/rest/src/meta-item-read-routes.ts index 01e3b0476e0..12e51bed874 100644 --- a/packages/rest/src/meta-item-read-routes.ts +++ b/packages/rest/src/meta-item-read-routes.ts @@ -129,6 +129,8 @@ import { import type { MetaRouteScope, TransportScopedMetaRequest } from './meta-collection-routes.js'; import * as metaReadGate from './meta-item-read-gate.js'; import { metaMemberSelector, type MetaReadGatePolicy } from './meta-item-read-gate.js'; +import { measureMetaRead } from './meta-read-timing.js'; +import { sendMetaRepresentation } from './meta-representation-validator.js'; import { refuseUnknownQueryParams } from './query-allowlist.js'; import { refuseRepeatedQueryParams } from './query-multiplicity.js'; import { @@ -751,7 +753,7 @@ export function registerMetaItemReadRoutes(this: MetaItemReadRouteHost, scope: M ...(environmentId ? { environmentId } : {}), // [#9454] The cached door is the `view` arm, and }; - const result = await p.getMetaItemCached(cachedRequest); + const result = await measureMetaRead(() => p.getMetaItemCached!(cachedRequest)); // [#23316] The automation engine's verdict on a // FLOW, beside the document and never inside it: // this branch is the default door of @@ -979,7 +981,7 @@ export function registerMetaItemReadRoutes(this: MetaItemReadRouteHost, scope: M // [#22877] The member, read once above. ...memberSelector, }; - const envelope = await p.getMetaItem(itemRequest) as Record; + const envelope = await measureMetaRead(() => p.getMetaItem(itemRequest)) as Record; // [#20408] Everything this read does to the envelope // after the store read is ONE chain, @@ -1033,9 +1035,14 @@ export function registerMetaItemReadRoutes(this: MetaItemReadRouteHost, scope: M // [ADR-0106 D6 tier 2] Visibility undetermined → the // body is unmasked, so it must not be stored or // revalidated under a shared validator. - if (answer.cacheControl) res.header('Cache-Control', answer.cacheControl); + // [#27627] Otherwise the chain's validator of the + // served envelope, `private, no-cache`, and a `304` + // when the caller already holds it. This exit is the + // one `app`, `dashboard`, `doc` and `book` always take + // (their gates keep them off the shared cache above), + // so before this they were served with no validator. res.header('Vary', 'Accept-Language'); - res.json(answer.envelope); + sendMetaRepresentation(req, res, answer.envelope, answer, metadata.enableCache !== false); } } catch (error: any) { // [#18402] THE one absence answer, whichever arm diff --git a/packages/rest/src/meta-read-timing.ts b/packages/rest/src/meta-read-timing.ts new file mode 100644 index 00000000000..d382b8dae47 --- /dev/null +++ b/packages/rest/src/meta-read-timing.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { countServerTiming, perfNow } from '@objectstack/observability'; + +/** + * [#27627] Time one metadata protocol read into the request's `meta` + * `Server-Timing` mark — the time the request spent waiting on the metadata + * store, beside `db` (SQL) and `total`, so a slow `/meta` answer says which + * of the two it was waiting on. + * + * Every read a request makes folds into ONE aggregate mark + * (`meta;dur=;desc=" reads"`) — a list read, an item read and the + * reads a gate makes stay one member rather than one per call. A no-op beyond + * one ambient lookup when the request opened no collector, and disclosure + * stays the existing gate's decision: this only measures. + */ +export async function measureMetaRead(read: () => T | Promise): Promise { + const t0 = perfNow(); + try { + return await read(); + } finally { + countServerTiming('meta', perfNow() - t0, 'reads'); + } +} diff --git a/packages/rest/src/meta-representation-etag.test.ts b/packages/rest/src/meta-representation-etag.test.ts new file mode 100644 index 00000000000..68d4264d8cb --- /dev/null +++ b/packages/rest/src/meta-representation-etag.test.ts @@ -0,0 +1,242 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#27627] The `/meta` reads the shared item cache cannot serve answer an +// `ETag`, `Cache-Control: private, no-cache` and `304` on a matching +// `If-None-Match` — the type list, every `/meta/:type` list, and the uncached +// item exit (`app`, `dashboard`, `doc`, `book`, drafts, scoped reads). Measured +// on `main` before this change: none of those sent any of the three. +// +// The validator hashes the SERVED body (`representationEtag`, +// `@objectstack/types`), after every per-caller step. So the load-bearing pin +// is the cross-caller one: two callers whose gates differ are handed different +// validators, and one caller's tag never revalidates the other's body. A +// validator computed over the stored list before the gate turns it red. +// +// The `meta` Server-Timing mark is pinned at the foot: each metadata read the +// route makes counts into one aggregate member. + +import { describe, it, expect, vi } from 'vitest'; +import { PerfTiming, runWithPerfTiming } from '@objectstack/observability'; +import { RestServer } from './rest-server'; +import { createMetaListAnswer, createMetaItemAnswer, META_UNDETERMINED_CACHE_CONTROL } from './meta-item-read-gate'; +import { metaRepresentationCaching } from './meta-representation-validator'; + +const CRM_APP = { name: 'crm', label: 'CRM', navigation: [{ id: 'nav_leads', type: 'object', objectName: 'lead' }] }; +/** Real, active metadata only a builder may observe (ADR-0045 §3) — the per-caller gate this file leans on. */ +const UNPUBLISHED_APP = { name: 'build_in_flight', label: 'Build', _unpublished: true, navigation: [] }; +const DASHBOARD = { name: 'ops', label: 'Ops', widgets: [{ id: 'w1', type: 'metric' }] }; + +function createMockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeRes() { + const res: any = { statusCode: 200, body: undefined, headers: {} as Record }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.send = vi.fn(() => res); + res.header = vi.fn((k: string, v: string) => { res.headers[k] = v; return res; }); + res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); + return res; +} + +/** A server over a mutable corpus, for a caller holding `perms`. */ +function setup(perms: string[], corpus: { apps: any[]; dashboards: any[]; types: string[] }, metadata?: { enableCache: boolean }) { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }), + getMetaTypes: vi.fn(async () => ({ types: [...corpus.types] })), + getMetaItems: vi.fn(async ({ type }: any) => { + const t = String(type ?? ''); + if (t === 'app' || t === 'apps') return JSON.parse(JSON.stringify(corpus.apps)); + if (t === 'dashboard' || t === 'dashboards') return JSON.parse(JSON.stringify(corpus.dashboards)); + return []; + }), + getMetaItem: vi.fn(async ({ type, name }: any) => { + const pool = String(type).startsWith('dashboard') ? corpus.dashboards : corpus.apps; + const found = pool.find((a: any) => a.name === name); + return { type, name, item: found ? JSON.parse(JSON.stringify(found)) : undefined, lock: 'none', editable: true, deletable: true, resettable: false }; + }), + findData: vi.fn().mockResolvedValue([]), + }; + const rest: any = new RestServer(createMockServer() as any, protocol, { api: { requireAuth: false }, ...(metadata ? { metadata } : {}) } as any); + rest.resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: perms }); + rest.registerRoutes(); + return { rest, protocol }; +} + +const route = (rest: any, path: string) => { + const r = rest.getRoutes().find((x: any) => x.method === 'GET' && x.path === path); + if (!r) throw new Error(`${path} not registered`); + return r; +}; + +async function read(rest: any, path: string, params: Record, ifNoneMatch?: string) { + const res = makeRes(); + const headers = ifNoneMatch === undefined ? {} : { 'if-none-match': ifNoneMatch }; + await route(rest, path).handler({ method: 'GET', params, query: {}, body: {}, headers }, res); + return res; +} + +const LIST = '/api/v1/meta/:type'; +const ITEM = '/api/v1/meta/:type/:name'; +const TYPES = '/api/v1/meta'; +const fresh = () => ({ apps: [CRM_APP, UNPUBLISHED_APP], dashboards: [DASHBOARD], types: ['app', 'dashboard'] }); + +describe('[#27627] GET /meta/:type — the list carries the validator of the list THIS caller is served', () => { + it('an ETag (weak) and private, no-cache', async () => { + const { rest } = setup([], fresh()); + const res = await read(rest, LIST, { type: 'app' }); + expect(res.statusCode).toBe(200); + expect(res.headers.ETag).toMatch(/^W\/"[0-9a-f]{16}"$/); + expect(res.headers['Cache-Control']).toBe('private, no-cache'); + expect(res.headers.Vary).toBe('Accept-Language'); + }); + + it('an unchanged list answers its own tag with a bodiless 304', async () => { + const { rest } = setup([], fresh()); + const first = await read(rest, LIST, { type: 'app' }); + const again = await read(rest, LIST, { type: 'app' }, first.headers.ETag); + expect(again.statusCode).toBe(304); + expect(again.json).not.toHaveBeenCalled(); + expect(again.headers.ETag).toBe(first.headers.ETag); + }); + + it('two callers whose gate differs get different tags, and neither tag revalidates the other\'s body', async () => { + const corpus = fresh(); + const member = await read(setup([], corpus).rest, LIST, { type: 'app' }); + const builder = await read(setup(['studio.access'], corpus).rest, LIST, { type: 'app' }); + // The gate really did vary the body — otherwise this pin proves nothing. + expect(member.body.map((a: any) => a.name)).toEqual(['crm']); + expect(builder.body.map((a: any) => a.name).sort()).toEqual(['build_in_flight', 'crm']); + + expect(member.headers.ETag).not.toBe(builder.headers.ETag); + const crossed = await read(setup([], corpus).rest, LIST, { type: 'app' }, builder.headers.ETag); + expect(crossed.statusCode).toBe(200); + expect(crossed.body.map((a: any) => a.name)).toEqual(['crm']); + }); + + it('two callers the gate treats alike share a tag — the validator carries no caller dimension of its own', async () => { + const corpus = fresh(); + const a = await read(setup([], corpus).rest, LIST, { type: 'app' }); + const b = await read(setup(['manage_users'], corpus).rest, LIST, { type: 'app' }); + expect(a.headers.ETag).toBe(b.headers.ETag); + }); + + it('a change moves the tag, and the old tag is answered in full', async () => { + const corpus = fresh(); + const { rest } = setup([], corpus); + const before = await read(rest, LIST, { type: 'app' }); + corpus.apps = [...corpus.apps, { name: 'hr', label: 'HR', navigation: [] }]; + const after = await read(rest, LIST, { type: 'app' }, before.headers.ETag); + expect(after.statusCode).toBe(200); + expect(after.headers.ETag).not.toBe(before.headers.ETag); + expect(after.body.map((a: any) => a.name)).toEqual(['crm', 'hr']); + }); +}); + +describe('[#27627] GET /meta (the type list) carries its validator too', () => { + it('ETag, private, no-cache, 304 on its own tag, and a new type moves it', async () => { + const corpus = fresh(); + const { rest } = setup([], corpus); + const first = await read(rest, TYPES, {}); + expect(first.headers.ETag).toMatch(/^W\/"[0-9a-f]{16}"$/); + expect(first.headers['Cache-Control']).toBe('private, no-cache'); + expect((await read(rest, TYPES, {}, first.headers.ETag)).statusCode).toBe(304); + + corpus.types = [...corpus.types, 'view']; + const moved = await read(rest, TYPES, {}, first.headers.ETag); + expect(moved.statusCode).toBe(200); + expect(moved.headers.ETag).not.toBe(first.headers.ETag); + }); +}); + +describe('[#27627] the uncached item exit — app and dashboard, which the shared cache never serves', () => { + for (const [type, name] of [['app', 'crm'], ['dashboard', 'ops']] as const) { + it(`GET /meta/${type}/${name}: ETag, private, no-cache, 304, and a change moves it`, async () => { + const corpus = fresh(); + const { rest, protocol } = setup([], corpus); + const first = await read(rest, ITEM, { type, name }); + expect(protocol.getMetaItem).toHaveBeenCalled(); + expect(first.statusCode).toBe(200); + expect(first.headers.ETag).toMatch(/^W\/"[0-9a-f]{16}"$/); + expect(first.headers['Cache-Control']).toBe('private, no-cache'); + + const again = await read(rest, ITEM, { type, name }, first.headers.ETag); + expect(again.statusCode).toBe(304); + expect(again.json).not.toHaveBeenCalled(); + + const pool = type === 'app' ? 'apps' : 'dashboards'; + corpus[pool] = corpus[pool].map((d: any) => (d.name === name ? { ...d, label: `${d.label} v2` } : d)); + const moved = await read(rest, ITEM, { type, name }, first.headers.ETag); + expect(moved.statusCode).toBe(200); + expect(moved.headers.ETag).not.toBe(first.headers.ETag); + }); + } + + it('an app a member may not see is refused, never answered 304 against a builder\'s tag', async () => { + const corpus = fresh(); + const builder = await read(setup(['studio.access'], corpus).rest, ITEM, { type: 'app', name: 'build_in_flight' }); + expect(builder.statusCode).toBe(200); + const member = await read(setup([], corpus).rest, ITEM, { type: 'app', name: 'build_in_flight' }, builder.headers.ETag); + expect(member.statusCode).toBe(404); + expect(member.headers.ETag).toBeUndefined(); + }); +}); + +describe('[#27627] metadata.enableCache: false — "Enable HTTP cache headers (ETag, Last-Modified)" — still switches them off', () => { + it('the list, the type list and the uncached item exit send no ETag and no Cache-Control, and never answer 304', async () => { + const corpus = fresh(); + const { rest } = setup([], corpus, { enableCache: false }); + const tagged = await read(setup([], corpus).rest, LIST, { type: 'app' }); + for (const [path, params] of [[LIST, { type: 'app' }], [TYPES, {}], [ITEM, { type: 'app', name: 'crm' }]] as const) { + const res = await read(rest, path, params, tagged.headers.ETag); + expect(res.statusCode, path).toBe(200); + expect(res.headers.ETag, path).toBeUndefined(); + expect(res.headers['Cache-Control'], path).toBeUndefined(); + } + }); +}); + +describe('[#27627] the chains issue no validator on an undetermined posture (ADR-0106 D6 tier 2)', () => { + it('metaRepresentationCaching keeps private, no-store and issues no ETag', () => { + expect(metaRepresentationCaching({ items: [] }, META_UNDETERMINED_CACHE_CONTROL)).toEqual({ cacheControl: 'private, no-store' }); + expect(metaRepresentationCaching({ items: [] })).toEqual({ cacheControl: 'private, no-cache', etag: expect.stringMatching(/^W\//) }); + }); + + it('both chains hash what they answer, not what they were handed', async () => { + const sources: any = { + translateList: async (_t: string, list: unknown) => list, + translateEnvelope: async (env: any, doc: unknown) => ({ ...env, item: doc }), + requestLocale: () => undefined, + resolveExecCtx: async () => ({ userId: 'u1', systemPermissions: [] }), + }; + const list = await createMetaListAnswer(sources, { metaType: 'position', query: {}, previewDrafts: false })([{ name: 'p1' }]); + expect(list.ok && list.etag).toBe(metaRepresentationCaching(list.ok ? list.data : null).etag); + const item = await createMetaItemAnswer(sources, { + metaType: 'position', name: 'p1', policy: { arms: 'all', app: 'gate' }, maskPosture: { kind: 'passthrough' } as any, + })({ type: 'position', name: 'p1', item: { name: 'p1' } }); + expect(item.kind === 'serve' && item.etag).toBe(metaRepresentationCaching(item.kind === 'serve' ? item.envelope : null).etag); + }); +}); + +describe('[#27627] the meta Server-Timing mark — every metadata read counts into one member', () => { + it('a list read records `meta` with its read count', async () => { + const { rest } = setup([], fresh()); + const timing = new PerfTiming(); + await runWithPerfTiming(timing, () => read(rest, LIST, { type: 'app' })); + const meta = timing.marks().find((m) => m.name === 'meta'); + expect(meta?.desc).toBe('1 reads'); + }); + + it('an uncached item read and the type list record it too', async () => { + const { rest } = setup([], fresh()); + for (const [path, params] of [[ITEM, { type: 'app', name: 'crm' }], [TYPES, {}]] as const) { + const timing = new PerfTiming(); + await runWithPerfTiming(timing, () => read(rest, path, params)); + expect(timing.marks().find((m) => m.name === 'meta')?.desc, path).toBe('1 reads'); + } + }); +}); diff --git a/packages/rest/src/meta-representation-validator.ts b/packages/rest/src/meta-representation-validator.ts new file mode 100644 index 00000000000..cd36d1044e1 --- /dev/null +++ b/packages/rest/src/meta-representation-validator.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27627] The cache validator of a metadata read whose body is decided per + * caller: the `/meta` type list, every `/meta/:type` list, and the item doors + * the shared item cache cannot serve (`app`, `dashboard`, `doc`, `book`, + * drafts, `?package=` and member-scoped reads). + * + * The rule — the ETag hashes the served body, after every per-caller step, and + * the posture is `private, no-cache` — is `representationEtag` in + * `@objectstack/types`, whose docblock carries why. This module adds the two + * things only the `/meta` reads need: the ADR-0106 D6 tier-2 exception, and + * the send on `RestServer`'s response. + */ + +import { ifNoneMatchHits, representationEtag, REVALIDATE_PRIVATE_CACHE_CONTROL } from '@objectstack/types'; + +/** + * The validator and posture an answer is served under: `private, no-cache` + * and the weak ETag of `body`. `undetermined` is the ADR-0106 D6 tier-2 + * posture a chain already decided (`private, no-store`, the body unmasked): it + * is kept, and no validator is issued, so nothing can revalidate that body. + */ +export function metaRepresentationCaching( + body: unknown, + undetermined?: string, +): { cacheControl: string; etag?: string } { + if (undetermined !== undefined) return { cacheControl: undetermined }; + return { cacheControl: REVALIDATE_PRIVATE_CACHE_CONTROL, etag: representationEtag(body) }; +} + +/** The response surface {@link sendMetaRepresentation} writes on — `RestServer`'s `IHttpResponse` subset. */ +interface RepresentationResponse { + header(name: string, value: string): unknown; + status(code: number): { send(body?: unknown): unknown }; + json(body: unknown): unknown; +} + +/** + * Send `body` under its caching answer: `Cache-Control`, the `ETag` when one + * was issued, and a bodiless `304` when the request's `If-None-Match` already + * names it. + * + * `enableCache` is the deployment's `metadata.enableCache`, declared as + * "Enable HTTP cache headers (ETag, Last-Modified)" (`rest-server.zod.ts`). + * Switched off, no validator and no revalidation posture is sent and nothing + * is answered `304`; the ADR-0106 D6 tier-2 `private, no-store` still is, + * because that one forbids storing an unmasked body rather than enabling a + * cache. + */ +export function sendMetaRepresentation( + req: { headers?: Record }, + res: RepresentationResponse, + body: unknown, + caching: { cacheControl: string; etag?: string }, + enableCache: boolean, +): void { + if (!enableCache && caching.etag !== undefined) { + res.json(body); + return; + } + res.header('Cache-Control', caching.cacheControl); + if (caching.etag !== undefined) { + res.header('ETag', caching.etag); + if (ifNoneMatchHits(req.headers?.['if-none-match'], caching.etag)) { + res.status(304).send(); + return; + } + } + res.json(body); +} diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 59566e80e68..9e1914168a1 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -3859,9 +3859,11 @@ describe('filterDashboardForUser — ADR-0057 D10 widget requiresService gate', const dashRes = await readMeta(rest, 'dashboard', 'system_overview'); expect(protocol.getMetaItemCached).not.toHaveBeenCalled(); expect(protocol.getMetaItem).toHaveBeenCalledTimes(1); - // The observable price of the bypass, pinned rather than hidden: a - // dashboard read carries no ETag validator now. - expect(dashRes.header.mock.calls.map((c: any[]) => c[0])).not.toContain('ETag'); + // The bypass used to cost the dashboard read its validator. Since #27627 + // the uncached exit validates its own served body (`representationEtag`, + // a weak tag), never the shared cache's validator of the unfiltered one. + const dashEtag = dashRes.header.mock.calls.find((c: any[]) => c[0] === 'ETag')?.[1]; + expect(dashEtag).toMatch(/^W\/"[0-9a-f]{16}"$/); const viewRes = await readMeta(rest, 'view', 'account_list'); expect(protocol.getMetaItemCached).toHaveBeenCalledTimes(1); diff --git a/packages/runtime/src/domains/meta-list-projection-parity.test.ts b/packages/runtime/src/domains/meta-list-projection-parity.test.ts index 73f36d5fe10..9f775cd4107 100644 --- a/packages/runtime/src/domains/meta-list-projection-parity.test.ts +++ b/packages/runtime/src/domains/meta-list-projection-parity.test.ts @@ -1290,7 +1290,11 @@ describe('[#20408] an undetermined field visibility (ADR-0106 D6 tier 2) serves for (const path of OBJECT_PATHS) { const undetermined = await bootRest('undetermined').read(path); expect({ status: undetermined.status, cacheControl: undetermined.cacheControl }, path).toEqual({ status: 200, cacheControl: 'private, no-store' }); - expect((await bootRest('non-holder').read(path)).cacheControl, path).toBeUndefined(); + // [#27627] A determined caller's list and plain read are served + // `private, no-cache` under the validator of their own body; the + // layered view still owes no header at all. + expect((await bootRest('non-holder').read(path)).cacheControl, path) + .toBe(path.endsWith('/layers') ? undefined : 'private, no-cache'); } }); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 60dfe3a81da..eca0719bf8b 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -29,6 +29,7 @@ export * from './native-error-name.js'; export * from './keyset-walk.js'; export * from './module-not-found.js'; export * from './response-envelope.js'; +export * from './representation-validator.js'; // [#8016] The one rule for "what HTTP answer does a THROWN error declare?", // plus the validation-failure recogniser it reads. Both doors of // `/api/v1/packages` call it: the runtime dispatcher's `errorFromThrown` and the diff --git a/packages/types/src/representation-validator.test.ts b/packages/types/src/representation-validator.test.ts new file mode 100644 index 00000000000..0c81fd8218e --- /dev/null +++ b/packages/types/src/representation-validator.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#27627] The one body-hash validator rule three transports share — the +// `/meta` reads (`@objectstack/rest`), `/auth/me/localization` +// (`plugin-hono-server`) and `/runtime/config` (`cloud-connection`). The +// route-level pins live beside each route; this file pins the rule itself. + +import { describe, expect, it } from 'vitest'; +import { ifNoneMatchHits, representationEtag, REVALIDATE_PRIVATE_CACHE_CONTROL } from './representation-validator.js'; + +describe('representationEtag — the weak validator of exactly what is served', () => { + it('is a weak tag over 16 hex chars', () => { + expect(representationEtag({ a: 1 })).toMatch(/^W\/"[0-9a-f]{16}"$/); + }); + + it('is stable for an equal body, so an unchanged answer revalidates', () => { + expect(representationEtag({ items: [{ name: 'crm' }] })).toBe(representationEtag({ items: [{ name: 'crm' }] })); + }); + + it('moves when any served byte moves — one item more, one label changed, one locale', () => { + const base = representationEtag({ items: [{ name: 'crm', label: 'CRM' }] }); + expect(representationEtag({ items: [{ name: 'crm', label: 'CRM' }, { name: 'hr' }] })).not.toBe(base); + expect(representationEtag({ items: [{ name: 'crm', label: '客户' }] })).not.toBe(base); + expect(representationEtag([])).not.toBe(representationEtag({})); + }); + + it('the posture is private, revalidated on every use — never public', () => { + expect(REVALIDATE_PRIVATE_CACHE_CONTROL).toBe('private, no-cache'); + }); +}); + +describe('ifNoneMatchHits — the weak comparison of RFC 9110 §13.1.2', () => { + const etag = representationEtag({ ok: true }); + const opaque = etag.slice(2); + + it('the tag as issued hits', () => { + expect(ifNoneMatchHits(etag, etag)).toBe(true); + }); + + it('a strong spelling of the same opaque tag hits (weak comparison)', () => { + expect(ifNoneMatchHits(opaque, etag)).toBe(true); + }); + + it('a list hits when any member does, and * hits any representation', () => { + expect(ifNoneMatchHits(`W/"0000000000000000", ${etag}`, etag)).toBe(true); + expect(ifNoneMatchHits('*', etag)).toBe(true); + }); + + it('a repeated header (an array) is read as one list', () => { + expect(ifNoneMatchHits(['W/"0000000000000000"', etag], etag)).toBe(true); + }); + + it('another tag, an empty header and no header all miss', () => { + expect(ifNoneMatchHits(representationEtag({ ok: false }), etag)).toBe(false); + expect(ifNoneMatchHits('', etag)).toBe(false); + expect(ifNoneMatchHits(undefined, etag)).toBe(false); + }); + + it('the quotes are part of the opaque tag: a bare hex string is another tag', () => { + expect(ifNoneMatchHits(opaque.slice(1, -1), etag)).toBe(false); + }); +}); diff --git a/packages/types/src/representation-validator.ts b/packages/types/src/representation-validator.ts new file mode 100644 index 00000000000..2e655c86dfa --- /dev/null +++ b/packages/types/src/representation-validator.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The cache validator of a JSON response whose body is decided per caller or + * per request — the `/meta` lists and uncached item doors (`@objectstack/rest`), + * `/auth/me/localization` (`@objectstack/plugin-hono-server`) and + * `/runtime/config` (`@objectstack/cloud-connection`). One rule for the three + * transports, so none of them grows its own reading of `If-None-Match`. + * + * ## The one invariant + * + * The ETag is a hash of the value actually being SENT, computed after every + * per-caller step (read gates, masks, translation, per-host resolution). It is + * the invariant `getMetaItemCached` states for the cached item read ("`content` + * — the bytes actually being sent — is inside the hash"), applied where that + * read cannot reach. + * + * Hashing the served body is what makes a per-caller fold unnecessary rather + * than forgotten: two callers whose gates differ are served different bytes, + * so they are handed different validators, and a `304` is only ever answered + * to a caller holding the representation the server would send it now. ⛔ A + * validator computed over anything cheaper than the served body — the stored + * list before the gate, a version marker, a store checksum — lets one caller's + * `If-None-Match` pin another caller's body: a shared ETag over a gated body. + * + * ## The posture + * + * `private, no-cache` ({@link REVALIDATE_PRIVATE_CACHE_CONTROL}): the client + * may store the body but revalidates on every use, so a publish, a permission + * change or a locale switch is seen on the next read, and a shared cache (CDN, + * proxy) stores nothing — ⛔ never `public` on a body a per-caller gate varies. + * + * The validator is WEAK (`W/"…"`): it identifies the JSON value, and a + * compressing layer between the server and the client may re-encode the bytes. + * `If-None-Match` compares weakly (RFC 9110 §13.1.2), so a weak validator + * revalidates exactly as a strong one would. + */ + +/** Stored by the client, revalidated on every use, never stored by a shared cache. */ +export const REVALIDATE_PRIVATE_CACHE_CONTROL = 'private, no-cache'; + +/** + * Two independent FNV-1a/32 lanes (different offset bases), 16 hex chars. Not + * cryptographic, and it need not be: a validator is compared only against one + * the same client was issued, so what matters is that any edit moves it. Two + * lanes keep the accidental-collision odds far below one 32-bit lane's. + */ +function hash64(text: string): string { + let a = 0x811c9dc5; + let b = 0x050c5d1f; + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i); + a ^= c; + a = Math.imul(a, 0x01000193) >>> 0; + b ^= c; + b = Math.imul(b, 0x01000193) >>> 0; + } + return a.toString(16).padStart(8, '0') + b.toString(16).padStart(8, '0'); +} + +/** + * The weak ETag of one served JSON body. Pass exactly the value the transport + * is about to serialize — after every per-caller step, never an earlier stage. + */ +export function representationEtag(body: unknown): string { + return `W/"${hash64(JSON.stringify(body) ?? 'undefined')}"`; +} + +/** + * Whether a request's `If-None-Match` names `etag` — the weak comparison of + * RFC 9110 §13.1.2: each listed validator is compared with its `W/` prefix + * stripped, and `*` matches any current representation. + */ +export function ifNoneMatchHits(ifNoneMatch: unknown, etag: string): boolean { + const raw = Array.isArray(ifNoneMatch) ? ifNoneMatch.join(',') : ifNoneMatch; + if (typeof raw !== 'string' || raw.trim() === '') return false; + const opaque = (tag: string) => tag.trim().replace(/^W\//, ''); + const mine = opaque(etag); + return raw.split(',').some((candidate) => { + const tag = candidate.trim(); + return tag === '*' || opaque(tag) === mine; + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aece0002023..b33e075c47d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -794,6 +794,9 @@ importers: '@objectstack/objectql': specifier: workspace:* version: link:../objectql + '@objectstack/observability': + specifier: workspace:* + version: link:../observability '@objectstack/runtime': specifier: workspace:* version: link:../runtime diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 9615deee184..8d80fd43918 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -1604,6 +1604,14 @@ const EXPRESS_RESPONSE_MODULES = { // route-module-based walk would never have reached a middleware. 'packages/runtime/src/security/inbound-rate-limit.ts': {}, + // [#27627] FIRST AUDIT, verdict conformant. Not a route module: the send + // half of the `/meta` reads' body-hash validator, called by + // `meta-collection-routes.ts` and `meta-item-read-routes.ts`. It BUILDS no + // body. Its one `res.json(body)` relays the answer the calling route already + // built (relayed, so invisible here, and still counted in that route's own + // entry), and its revalidation exit is a bodiless `304` (`status(304).send()`). + 'packages/rest/src/meta-representation-validator.ts': {}, + // ── Ratchet: real, tracked, NOT blessed ───────────────────────────────── // // [#9937] FIRST AUDIT, both entries. The two shared query-parameter refusal