diff --git a/packages/metadata-protocol/src/get-meta-item-cached-etag-scope.test.ts b/packages/metadata-protocol/src/get-meta-item-cached-etag-scope.test.ts new file mode 100644 index 0000000000..bcfed9ce94 --- /dev/null +++ b/packages/metadata-protocol/src/get-meta-item-cached-etag-scope.test.ts @@ -0,0 +1,337 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16525] WHICH organization value `getMetaItemCached` folds into the ETag — + * the SUPPLIED request member, or the EFFECTIVE scope the registry read gate + * returns — and why the answer is not the defect the card feared. + * + * ── The question, and the measurement ───────────────────────────────────── + * + * The card put two facts side by side. `organizationIdForMetaRead(type, org)` + * answers `undefined` for a supplied organization whenever the registry + * declares `allowOrgOverride: false`, and `getMetaItem` then resolves + * `(orgId ? findOverlay(orgId) : undefined) ?? findOverlay(null)` — so a + * gated-away organization is served the env-wide record. `getMetaItemCached` + * delegates to that. ⇒ If the ETag names the SUPPLIED organization while the + * body is the env-wide representation, the validator states a scope that was + * never served. + * + * MEASURED, and the answer is the SUPPLIED member: + * + * const scope = [ + * request.organizationId ? `org:${request.organizationId}` : undefined, + * request.locale || undefined, + * ].filter(...); + * + * §1 reproduces the divergence end to end — `object` is `allowOrgOverride: + * false`, a supplied organization is gated away, the env-wide record is + * served, and the validator still names the organization. + * + * ── ⭐ Why that is a COST and not a correctness fault ────────────────────── + * + * Because `content` — the serialized document actually being sent — is inside + * the same hash. The validator is therefore a function of the bytes, not only + * of the declared scope, so the harm the card names ("a validator claiming to + * describe an org-scoped representation that was never served") cannot reach + * a caller: a `304` is answered only on an exact match of a hash that covers + * those bytes, so a caller is only ever pinned to the representation IT + * previously received. §3 pins that, and it is the half a future change is + * most likely to break silently — hashing a cheap version marker instead of + * the document would leave §1 and §2 green and destroy the whole argument. + * + * What is left is validator FRAGMENTATION: N organizations reading one + * env-wide document through a non-overridable type hold N validators for + * byte-identical content. Wasteful, not wrong. + * + * ── ⚠️ §1 is a CHARACTERIZATION pin, not a prohibition ──────────────────── + * + * Folding the EFFECTIVE value instead would merge those validators and make + * the declared scope true. It is deliberately NOT done here: it changes every + * published ETag that carries an organization (one forced miss per caller per + * deploy) and buys nothing at the only production door, which already supplies + * the effective value (pinned in `@objectstack/rest`, + * `rest-server-meta-cached-etag-door-scope.test.ts`). ⇒ If §1 reddens, read + * #16525 before making it green: it means the fold moved, which is a decision, + * not a regression. + * + * ── Why the observation channel is a BODY-vs-VALIDATOR pair ─────────────── + * + * Asserting an ETag literal would pin the hash function, which is not what + * this card is about. Every assertion below compares two reads that differ in + * exactly one input, and asserts the served BODIES alongside the validators — + * so an ETag difference can never be attributed to a body difference nobody + * checked, and a body difference can never hide behind a validator nobody + * read. + */ + +import { describe, expect, it } from 'vitest'; +import { organizationIdForMetaRead } from '@objectstack/metadata-core'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const ORG = 'org_acme'; +const OTHER_ORG = 'org_globex'; + +/** `allowOrgOverride: false` — a supplied organization is gated away. */ +const NON_OVERRIDABLE = 'object'; + +/** `allowOrgOverride: true` — a supplied organization survives the gate. */ +const OVERRIDABLE = 'view'; + +interface StoredRow { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; +} + +/** + * A row whose `label` names the partition it came from, so a served document + * says which row answered without the assertion having to guess. + */ +const storedRow = ( + type: string, + name: string, + extra: Partial = {}, +): StoredRow => ({ + id: `r_${type}_${name}_${extra.organization_id ?? 'env'}`, + type, + name, + organization_id: null, + package_id: null, + state: 'active', + metadata: JSON.stringify({ name, label: `${extra.organization_id ?? 'env'} ${name}` }), + ...extra, +}); + +/** + * The engine double: `findOne` over a row table, plus the registry surface the + * single-item read path touches on its way past the overlay. + * + * ⛔ No `find` / `insert` / `update` / `delete`, deliberately — the read path + * under test issues exactly one verb, and a double declaring verbs no case + * exercises would owe `check:engine-double-contract` a dispatch contract that + * protects nothing. Same shape the sibling org-read-gate pin drives. + */ +function makeHarness(rows: StoredRow[]) { + const engine: any = { + async findOne(table: string, opts?: { where?: Record }) { + if (table !== 'sys_metadata') return undefined; + const where = opts?.where ?? {}; + // `check:where-matcher` — a hand-written matcher with no combinator + // branch reads `$and` as a field name and answers the wrong + // question rather than failing. Refuse the shape this double does + // not implement, matching the sibling doubles' convention. + for (const k of Object.keys(where)) { + if (k.startsWith('$')) { + throw new Error(`[test double] unsupported WHERE combinator '${k}'`); + } + } + return rows.find((r) => + Object.entries(where).every(([k, v]) => { + if (v === undefined) return true; + return (r as unknown as Record)[k] === v; + }), + ); + }, + registry: { + registerItem: () => undefined, + registerObject: () => undefined, + listItems: () => [], + getItem: () => undefined, + getObject: () => undefined, + getPackage: () => undefined, + getArtifactItem: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }, + }; + return new ObjectStackProtocolImplementation(engine, () => new Map()) as any; +} + +/** The label the served document carries — `'env …'` or `'org_acme …'`. */ +const servedLabel = (res: any): string => res?.data?.label; + +// ═══════════════════════════════════════════════════════════════════════════ +// §0 — the registry facts every section below rests on, read not assumed +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§0 the fixture types really are on opposite sides of the read gate', () => { + it(`${NON_OVERRIDABLE} is declared allowOrgOverride: false`, () => { + const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === NON_OVERRIDABLE); + expect(entry, `no registry entry for '${NON_OVERRIDABLE}'`).toBeDefined(); + expect(entry!.allowOrgOverride).toBe(false); + expect(organizationIdForMetaRead(NON_OVERRIDABLE, ORG)).toBeUndefined(); + }); + + it(`${OVERRIDABLE} is declared allowOrgOverride: true`, () => { + const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === OVERRIDABLE); + expect(entry, `no registry entry for '${OVERRIDABLE}'`).toBeDefined(); + expect(entry!.allowOrgOverride).toBe(true); + expect(organizationIdForMetaRead(OVERRIDABLE, ORG)).toBe(ORG); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// §1 — (a) WHICH VALUE. The ETag folds the SUPPLIED member. +// ⚠️ Characterization. If this reddens, read #16525 before greening it. +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§1 the ETag names the SUPPLIED organization, not the effective scope', () => { + it('a gated-away organization is absent from the BODY and present in the VALIDATOR', async () => { + // A pre-#6190 phantom org row sits beside the env-wide one. The gate is + // what keeps it out of the response; without it this case would prove + // nothing about the ETag, because the two reads would differ in body. + const protocol = makeHarness([ + storedRow(NON_OVERRIDABLE, 'customer'), + storedRow(NON_OVERRIDABLE, 'customer', { organization_id: ORG, id: 'phantom' }), + ]); + + const supplied = await protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, + }); + const orgless = await protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: 'customer', + }); + + // ⭐ The control that makes the validator assertion mean anything: the + // gate held, so BOTH reads were answered by the env-wide row and the + // phantom was not served. + expect(servedLabel(supplied), 'the phantom org row was served').toBe('env customer'); + expect(servedLabel(orgless)).toBe('env customer'); + expect(JSON.stringify(supplied.data)).toBe(JSON.stringify(orgless.data)); + + // ⇒ Byte-identical representations, different validators. The scope + // component is the request member, unreduced by the read gate. + expect( + supplied.etag.value, + 'the ETag no longer distinguishes a gated-away organization — see #16525', + ).not.toBe(orgless.etag.value); + }); + + it('two organizations gated away from the same document hold two validators', async () => { + const protocol = makeHarness([storedRow(NON_OVERRIDABLE, 'customer')]); + + const a = await protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, + }); + const b = await protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: 'customer', organizationId: OTHER_ORG, + }); + + expect(JSON.stringify(a.data)).toBe(JSON.stringify(b.data)); + // The fragmentation the card names. Wasteful, and harmless for the + // reason §3 pins. + expect(a.etag.value).not.toBe(b.etag.value); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// §2 — (b) REACHABILITY of the fallback the fold was written against +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§2 the org-resolved-then-env-wide fallback is present, not future', () => { + /** + * The fold's own comment argued from "any FUTURE path that resolves an org + * row but falls back to the env-wide body". That path is the `??` in + * `getMetaItem` and it runs today on an OVERRIDABLE type whose + * organization simply has no row — reachable through the production + * cached door, which forwards the organization for exactly these types. + */ + it('an overridable type with no org row serves env-wide under an org-named validator', async () => { + const protocol = makeHarness([storedRow(OVERRIDABLE, 'account_list')]); + + const supplied = await protocol.getMetaItemCached({ + type: OVERRIDABLE, name: 'account_list', organizationId: ORG, + }); + const orgless = await protocol.getMetaItemCached({ + type: OVERRIDABLE, name: 'account_list', + }); + + // The organization survived the gate here — and still resolved nothing, + // so the env-wide row answered. + expect(organizationIdForMetaRead(OVERRIDABLE, ORG)).toBe(ORG); + expect(servedLabel(supplied)).toBe('env account_list'); + expect(JSON.stringify(supplied.data)).toBe(JSON.stringify(orgless.data)); + expect(supplied.etag.value).not.toBe(orgless.etag.value); + }); + + it('the same type WITH an org row serves it, so the fallback above was a real fallback', async () => { + // ⭐ Without this control the case above is indistinguishable from a + // harness that can never resolve an org row at all. + const protocol = makeHarness([ + storedRow(OVERRIDABLE, 'account_list'), + storedRow(OVERRIDABLE, 'account_list', { organization_id: ORG, id: 'org_row' }), + ]); + + const supplied = await protocol.getMetaItemCached({ + type: OVERRIDABLE, name: 'account_list', organizationId: ORG, + }); + expect(servedLabel(supplied)).toBe(`${ORG} account_list`); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// §3 — ⭐ WHY §1 IS A COST AND NOT A FAULT: the served bytes are in the hash +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§3 the validator is a function of the served bytes, not of the scope alone', () => { + it('an unchanged document answers 304 to the validator it issued', async () => { + // ⭐ The control that must FIRE. Every assertion below reads "no 304"; + // if the 304 arm were unreachable in this harness they would all pass + // for the wrong reason. + const rows = [storedRow(NON_OVERRIDABLE, 'customer')]; + const protocol = makeHarness(rows); + + const first = await protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, + }); + const again = await protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, + cacheRequest: { ifNoneMatch: `"${first.etag.value}"` }, + }); + + expect(again.notModified, 'the 304 arm is unreachable in this harness').toBe(true); + }); + + it('a changed document never answers 304, though the scope is unchanged', async () => { + // This is the invariant the whole "harmless" argument rests on. A hash + // over the scope and a cheap version marker — a plausible optimization — + // leaves §1 and §2 green and turns this red. + const rows = [storedRow(NON_OVERRIDABLE, 'customer')]; + const protocol = makeHarness(rows); + + const first = await protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, + }); + + rows[0].metadata = JSON.stringify({ name: 'customer', label: 'env customer REVISED' }); + + const after = await protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, + cacheRequest: { ifNoneMatch: `"${first.etag.value}"` }, + }); + + expect(after.notModified, 'a stale validator was honoured across a body change').toBe(false); + expect(servedLabel(after)).toBe('env customer REVISED'); + expect(after.etag.value).not.toBe(first.etag.value); + }); + + it("one organization's validator is never honoured for another's request", async () => { + const protocol = makeHarness([storedRow(NON_OVERRIDABLE, 'customer')]); + + const mine = await protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, + }); + const theirs = await protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: 'customer', organizationId: OTHER_ORG, + cacheRequest: { ifNoneMatch: `"${mine.etag.value}"` }, + }); + + expect(theirs.notModified).toBe(false); + expect(servedLabel(theirs)).toBe('env customer'); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 3d7ca6399e..b8dae77930 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -11916,12 +11916,43 @@ export class ObjectStackProtocolImplementation implements // It is folded in anyway because that makes the scope a DECLARED // property of the validator instead of an emergent property of the // body. Two orgs whose documents are byte-identical today share a - // validator by coincidence, not by statement; and any future path - // that resolves an org row but falls back to the env-wide body - // would answer a 304 pinning the caller to a wrong-scope document - // with nothing in the validator to show it. Prepended, and ONLY + // validator by coincidence, not by statement. Prepended, and ONLY // when present, so an org-less caller's validator stays byte-for- // byte the one it is issued today. + // + // [#16525] ⚠️ The paragraph above used to argue from "any FUTURE + // path that resolves an org row but falls back to the env-wide + // body". THAT PATH IS PRESENT, and reading it as future is how a + // later author concludes the risk has not arrived yet: + // `getMetaItem` resolves `(orgId ? findOverlay(orgId) : undefined) + // ?? findOverlay(null)`, so an organization with no row of its own + // is served the env-wide document under an org-named validator. + // + // ⭐ AND `request.organizationId` IS THE SUPPLIED MEMBER, not the + // effective scope: {@link organizationIdForMetaRead} reduces it to + // `undefined` for a type declaring `allowOrgOverride: false`, and + // that reduction happens BELOW this line, inside `getMetaItem`. So + // a caller that hands this verb a raw organization gets a validator + // naming a scope its body was never resolved under. + // + // ⛔ Neither is a correctness fault, and the reason is the ONE + // invariant this block depends on: `content` — the bytes actually + // being sent — is inside the hash below. A 304 is therefore + // answered only on an exact match over those bytes, so a caller is + // only ever pinned to the representation IT received; the cost is + // validator FRAGMENTATION (N orgs, one env-wide document, N + // validators), which is waste, not error. ⇒ Hashing anything + // cheaper than the document — a version marker, the scope alone — + // destroys that argument silently. `get-meta-item-cached-etag- + // scope.test.ts` §3 is the pin; measured, removing `content` here + // reddens exactly one assertion and leaves the rest green. + // + // ⛔ Do NOT "repair" this by folding the effective value without + // reading #16525: it changes every published ETag that carries an + // organization, and buys nothing at the only production door — + // `@objectstack/rest` computes `organizationIdForMetaRead` BEFORE + // it calls (pinned by `rest-server-meta-cached-etag-door-scope. + // test.ts`), so supplied and effective already agree there. const content = JSON.stringify(item); const scope = [ request.organizationId ? `org:${request.organizationId}` : undefined, diff --git a/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts b/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts new file mode 100644 index 0000000000..d26a7ce9a7 --- /dev/null +++ b/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts @@ -0,0 +1,349 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16525] The REST cached `/meta` door hands `getMetaItemCached` the EFFECTIVE + * organization, already reduced by the registry read gate — so the validator's + * scope prefix and the representation it validates agree at the only door that + * reaches this verb in production. + * + * ── Why this file exists ────────────────────────────────────────────────── + * + * `getMetaItemCached` folds `request.organizationId` — the value AS SUPPLIED — + * into the ETag (pinned in `@objectstack/metadata-protocol`, + * `get-meta-item-cached-etag-scope.test.ts` §1). On its own that is a validator + * that can name a scope the body was never resolved under. What makes it + * agree in production is one line at THIS door: + * + * const readOrganizationId = organizationIdForMetaRead( + * canonicalMetaUrlType(req.params.type), readCtx?.tenantId, + * ); + * + * computed above the cached/uncached fork and spread into the cached request. + * The gate has already run by the time the protocol sees the member, and + * `organizationIdForMetaRead` is idempotent, so supplied === effective here. + * + * ⭐ NOTHING PINNED THAT. The sibling `rest-server-meta-read-org-scope.test.ts` + * says so in its own words — "it pins that the arm still FOLDS, never that the + * fold happens at the door" — and its measured ablation confirms it: swapping + * the door's predicate for a raw `ctx?.tenantId` leaves that file green, + * because the callee re-folds. It re-folds for the BODY. It does not re-fold + * for the ETag, which is computed one layer above from the member as handed in. + * ⇒ That ablation is exactly the change this file exists to redden. + * + * ── The observation channel, and the control ────────────────────────────── + * + * The `ETag` response header, compared across two reads of ONE document that + * differ only in the session's active organization. + * + * • a NON-overridable type: the two validators must be IDENTICAL, because + * the door reduced both organizations to `undefined`. + * • ⭐ THE CONTROL THAT MUST FIRE — an OVERRIDABLE type: the two validators + * must DIFFER, because the door forwards the organization there. Without + * it, a harness that emitted no ETag at all, or never took the cached arm, + * would pass every assertion above by answering `undefined === undefined`. + * + * §3 closes the loop by asking the protocol the counterfactual directly: given + * the RAW tenant the door started from, the validator it would issue differs + * from the one the door actually issued. That is the whole of what the door's + * pre-gate buys, stated as a difference rather than as an intention. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server.js'; + +const META = '/api/v1/meta'; +const ORG_A = 'org_alpha'; +const ORG_B = 'org_beta'; + +/** `allowOrgOverride: false` — the door must reduce the organization away. */ +const NON_OVERRIDABLE = 'object'; + +/** `allowOrgOverride: true`, and the type that takes the CACHED arm. */ +const OVERRIDABLE = 'view'; + +const NAME = 'shared_document'; + +interface Row { + id: string; type: string; name: string; + organization_id: string | null; package_id: string | null; + state: string; metadata: string; +} + +const row = (type: string, organization_id: string | null = null): Row => ({ + id: `r_${type}_${organization_id ?? 'env'}`, + type, + name: NAME, + organization_id, + package_id: null, + state: 'active', + metadata: JSON.stringify( + type === OVERRIDABLE + ? { + name: NAME, label: `${organization_id ?? 'env'} ${type}`, + object: 'task', viewKind: 'list', + columns: [{ field: 'name', label: 'Name' }], + } + : { + name: NAME, label: `${organization_id ?? 'env'} ${type}`, + sharingModel: 'private', + fields: { title: { type: 'text', label: 'Title' } }, + }, + ), +}); + +function matchesWhere(r: Record, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + // ⛔ REFUSE a combinator rather than reading it as a field name: a + // double that answered `$and` by looking for a column literally called + // `$and` would return a well-formed WRONG answer. + if (k.startsWith('$')) { + throw new Error(`stub engine: unsupported WHERE combinator \`${k}\``); + } + if (v === undefined) continue; + if (r[k] !== v) return false; + } + return true; +} + +/** + * ⛔ Read-only double, deliberately — every case below is a GET, so declaring + * `insert` / `update` / `delete` would owe `check:engine-double-contract` a + * dispatch contract that no case exercises. + */ +function makeStubEngine(rows: Row[]) { + return { + async findOne(table: string, opts: { where: Record }) { + assertEngineFindOnePredicate(table, opts); + if (table !== 'sys_metadata') return null; + return rows.find( + (r) => matchesWhere(r as unknown as Record, opts.where), + ) ?? null; + }, + async find(table: string, opts?: { where?: Record; limit?: number }) { + if (table !== 'sys_metadata') return []; + const matched = rows.filter( + (r) => matchesWhere(r as unknown as Record, opts?.where ?? {}), + ); + // Bound AFTER the filter and BY PRESENCE, so `limit: 0` returns + // nothing rather than everything (`check:objectql-double-limit`). + return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched; + }, + registry: { + registerItem: () => {}, registerObject: () => {}, + listItems: () => [], getItem: () => undefined, + getObject: () => undefined, getPackage: () => undefined, + getArtifactItem: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }, + } as any; +} + +function mockServer() { + const noop = () => {}; + return { + get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, + listen: async () => undefined, close: async () => undefined, + }; +} + +/** + * Records headers — the sibling harness discards them, and the header IS the + * observation channel here. + */ +function mockRes() { + const headers = new Map(); + const res: any = { + statusCode: 200, + _body: undefined, + _headers: headers, + json(body: any) { this._body = body; return this; }, + send() { return this; }, + setHeader(k: string, v: string) { headers.set(String(k).toLowerCase(), String(v)); return this; }, + status(code: number) { this.statusCode = code; return this; }, + header(k: string, v: string) { headers.set(String(k).toLowerCase(), String(v)); return this; }, + }; + return res; +} + +function boot(rows: Row[]) { + const protocol = new ObjectStackProtocolImplementation(makeStubEngine(rows), () => new Map()) as any; + protocol.getDiscovery = async () => ({ + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }); + + const rest = new RestServer( + mockServer() as any, + protocol as any, + { api: { requireAuth: false } } as any, + ); + let session: any = { userId: 'u1', systemPermissions: ['manage_metadata'], tenantId: ORG_A }; + (rest as any).resolveExecCtx = async () => session; + rest.registerRoutes(); + + const get = async (type: string) => { + const found = (rest as any).getRoutes().find( + (r: any) => r.method === 'GET' && r.path === `${META}/:type/:name`, + ); + if (!found) throw new Error('route not registered: GET /meta/:type/:name'); + const res = mockRes(); + let thrown: any; + try { + await found.handler( + { method: 'GET', path: '', params: { type, name: NAME }, query: {}, headers: {}, body: {} } as any, + res, + ); + } catch (err) { thrown = err; } + return { status: res.statusCode, body: res._body, etag: res._headers.get('etag'), thrown }; + }; + + return { + protocol, + as(tenantId: string | undefined) { + session = tenantId === undefined + ? { userId: 'u1', systemPermissions: ['manage_metadata'] } + : { userId: 'u1', systemPermissions: ['manage_metadata'], tenantId }; + }, + get, + }; +} + +/** The document a GET served, whichever envelope shape the arm answers in. */ +const servedLabel = (body: any): string | undefined => + (body?.item ?? body?.data ?? body)?.label; + +// ═══════════════════════════════════════════════════════════════════════════ +// §0 — the channel itself, proved before anything is concluded from it +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§0 the cached arm runs and emits a validator', () => { + it('a read of the cached-arm type answers 200 with an ETag header', async () => { + const b = boot([row(OVERRIDABLE)]); + const res = await b.get(OVERRIDABLE); + expect(res.thrown, `GET threw: ${res.thrown?.code ?? res.thrown?.message}`).toBeUndefined(); + expect(res.status).toBe(200); + expect(res.etag, 'no ETag header — the cached arm was not taken').toBeTruthy(); + expect(servedLabel(res.body)).toBe(`env ${OVERRIDABLE}`); + }); + + it('the non-overridable type reaches the same arm', async () => { + const b = boot([row(NON_OVERRIDABLE)]); + const res = await b.get(NON_OVERRIDABLE); + expect(res.thrown, `GET threw: ${res.thrown?.code ?? res.thrown?.message}`).toBeUndefined(); + expect(res.status).toBe(200); + expect(res.etag, 'no ETag header — the cached arm was not taken').toBeTruthy(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// §1 — the door reduces the organization before the validator is computed +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§1 two tenants reading ONE env-wide document', () => { + let rows: Row[]; + beforeEach(() => { rows = []; }); + + it(`${NON_OVERRIDABLE}: the validators are IDENTICAL — the door gated both organizations away`, async () => { + rows.push(row(NON_OVERRIDABLE)); + const b = boot(rows); + + b.as(ORG_A); + const a = await b.get(NON_OVERRIDABLE); + b.as(ORG_B); + const bb = await b.get(NON_OVERRIDABLE); + b.as(undefined); + const none = await b.get(NON_OVERRIDABLE); + + expect(servedLabel(a.body)).toBe(`env ${NON_OVERRIDABLE}`); + expect(servedLabel(bb.body)).toBe(`env ${NON_OVERRIDABLE}`); + expect(servedLabel(none.body)).toBe(`env ${NON_OVERRIDABLE}`); + + // The claim: the scope the validator states is the scope the body was + // resolved under — environment-wide, for all three callers. + expect( + a.etag, + 'the cached door stopped reducing the organization — see #16525', + ).toBe(none.etag); + expect(bb.etag).toBe(none.etag); + }); + + it(`⭐ CONTROL — ${OVERRIDABLE}: the validators DIFFER, because the door forwards the organization`, async () => { + rows.push(row(OVERRIDABLE)); + const b = boot(rows); + + b.as(ORG_A); + const a = await b.get(OVERRIDABLE); + b.as(ORG_B); + const bb = await b.get(OVERRIDABLE); + b.as(undefined); + const none = await b.get(OVERRIDABLE); + + // Same bytes for all three — the organization resolves no row here, so + // any validator difference is the SCOPE component and nothing else. + expect(servedLabel(a.body)).toBe(`env ${OVERRIDABLE}`); + expect(servedLabel(bb.body)).toBe(`env ${OVERRIDABLE}`); + expect(servedLabel(none.body)).toBe(`env ${OVERRIDABLE}`); + + expect(a.etag, 'the control did not fire — the ETag ignores the organization entirely').not.toBe(none.etag); + expect(bb.etag).not.toBe(none.etag); + expect(a.etag).not.toBe(bb.etag); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// §2 — the organization still reaches the BODY where it legitimately can +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§2 the reduction is the registry gate, not a dropped organization', () => { + it(`${OVERRIDABLE}: an org-scoped row is still served to its tenant`, async () => { + // ⭐ Without this, §1 is equally consistent with a door that forwards no + // organization at all — which would be #9454 reopened, not a fix. + const b = boot([row(OVERRIDABLE), row(OVERRIDABLE, ORG_A)]); + + b.as(ORG_A); + const mine = await b.get(OVERRIDABLE); + b.as(ORG_B); + const theirs = await b.get(OVERRIDABLE); + + expect(servedLabel(mine.body)).toBe(`${ORG_A} ${OVERRIDABLE}`); + expect(servedLabel(theirs.body)).toBe(`env ${OVERRIDABLE}`); + expect(mine.etag).not.toBe(theirs.etag); + }); + + it(`${NON_OVERRIDABLE}: a phantom org row is served to nobody`, async () => { + const b = boot([row(NON_OVERRIDABLE), row(NON_OVERRIDABLE, ORG_A)]); + + b.as(ORG_A); + const mine = await b.get(NON_OVERRIDABLE); + expect(servedLabel(mine.body)).toBe(`env ${NON_OVERRIDABLE}`); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// §3 — ⭐ the counterfactual: what the pre-gate is actually worth +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§3 handing the protocol the RAW tenant issues a different validator', () => { + it(`${NON_OVERRIDABLE}: the raw-tenant validator differs from the one the door issued`, async () => { + const rows = [row(NON_OVERRIDABLE)]; + const b = boot(rows); + + b.as(ORG_A); + const throughDoor = await b.get(NON_OVERRIDABLE); + + // The same read the door performs, minus the door's `organizationIdFor + // MetaRead` reduction. The protocol re-folds for the BODY — the label + // below proves it — and does NOT re-fold for the validator. + const raw = await b.protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: NAME, organizationId: ORG_A, + }); + + expect(raw.data?.label, 'the callee stopped re-folding for the body').toBe(`env ${NON_OVERRIDABLE}`); + expect( + throughDoor.etag, + 'the door\'s pre-gate no longer changes the validator — re-read #16525 §3', + ).not.toBe(`"${raw.etag.value}"`); + }); +}); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index d9f65548e6..9eae55ed2f 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3266,6 +3266,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts", "verb": "delete",