diff --git a/.changeset/20378-diff-history-authoring-doors.md b/.changeset/20378-diff-history-authoring-doors.md new file mode 100644 index 00000000000..f78652a5ddc --- /dev/null +++ b/.changeset/20378-diff-history-authoring-doors.md @@ -0,0 +1,13 @@ +--- +"@objectstack/rest": patch +--- + +**`GET /api/v1/meta/:type/:name/diff` and `GET /api/v1/meta/:type/:name/history` are now authoring doors: a caller without an authoring capability is refused, as `GET /api/v1/meta/_drafts` refuses.** Before this release, any signed-in caller who could open an item could read its version diff and its change history. Both doors read the metadata version log, which records a draft save exactly as it records a published save. So a member could read an item's unpublished draft through `/diff`, either by naming the draft save's version in `from`/`to` or through the default range once a draft was pending. Through `/history`, the same member could read the draft-save events. This follows the maintainer's ruling on #20378 (letter B, comment 5865708652), which pulls both doors back into the declared contract: draft and preview reads are admin-gated upstream (ADR-0106 D4). It narrows the earlier ruling that let every caller who may open an app read `/diff` pruned, for these two doors only. + +Clause-②: no + +- **Who may read them:** a system context, or a caller holding `studio.access`, `setup.access` or `manage_metadata`. This is the predicate `/meta/_drafts` and every draft switch already ask, not a second rule. +- **Everyone else:** `403` with code `FORBIDDEN`, in the same nested `error` envelope `/meta/_drafts` answers. The refusal is decided on the caller before the query is parsed and before any item or version is read. So it is the same answer for an item that exists, one that does not, and one that exists only as a draft, and it carries no item name, version or event. The message names the door, not drafts. +- **Unchanged:** callers with an authoring capability read both doors exactly as before, per-caller pruning included: on `/diff`, whoever may save an app reads both sides whole, and any other admitted caller reads them pruned. `/layers` and the deprecated `?layers=true` read the active row, so they keep answering every caller who may open the app with the pruned plain-read answer. `/audit` is unchanged. + +A client that read `/diff` or `/history` as a member now receives `403 FORBIDDEN`. To read them, call as a caller holding one of the three capabilities above. diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index 9ce59d1e6b4..f1d20e7cab8 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -227,6 +227,7 @@ const view = await client.meta.getItem('view', 'crm_lead.pipeline'); // Per-item draft lifecycle (ADR-0033) await client.meta.publishItem('object', 'account', { message: 'go live' }); await client.meta.rollbackItem('object', 'account', 3); +// Authoring-only, like getHistory and listDrafts: without studio.access, setup.access or manage_metadata → 403 FORBIDDEN const diff = await client.meta.diffItem('object', 'account', { from: 2, to: 5 }); // Introspection & governance diff --git a/content/docs/ui/apps.mdx b/content/docs/ui/apps.mdx index 03982a74c63..57028fcce1b 100644 --- a/content/docs/ui/apps.mdx +++ b/content/docs/ui/apps.mdx @@ -379,7 +379,12 @@ an app an author may not open is refused on these doors too. Reading a needs an authoring capability (`studio.access`, `setup.access` or `manage_metadata`: the check `GET /api/v1/meta/_drafts` makes). A caller without one is answered as if the parameter were absent: the published app, -or `404` for an app that has never been published. +or `404` for an app that has never been published. `/diff`, and the change log +`/history` beside it, need that capability outright: both read the version log, +which records a draft save like any other, so they have no published-only +answer to fall back to. A caller without one is refused with `403`, as +`GET /api/v1/meta/_drafts` refuses, before anything is read — the same answer +whether or not the app exists. `visible` did **not** move server-side with them, and that asymmetry is deliberate: CEL is evaluated in the browser because server-side evaluation needs diff --git a/packages/rest/src/meta-alternate-door-read-gates.test.ts b/packages/rest/src/meta-alternate-door-read-gates.test.ts index 6868da1154f..bf786b6723b 100644 --- a/packages/rest/src/meta-alternate-door-read-gates.test.ts +++ b/packages/rest/src/meta-alternate-door-read-gates.test.ts @@ -51,6 +51,15 @@ * answers them what the plain read answers them, byte for byte. * - **`/history` and `/audit` serve events, never a body**: they refuse where * the plain read refuses the item whole, and otherwise serve the events. + * + * [#20378] **`/diff` and `/history` are AUTHORING doors** (ruling + * 5865708652, letter B, which narrows ruling 5856774816 item 2 for these + * two doors only). Both read `sys_metadata_history`, where a draft save is + * recorded exactly as an active save, so a caller who may not read drafts + * (`readsDrafts` below) is refused them exactly as `GET /meta/_drafts` + * refuses — 403 `FORBIDDEN`, before any read. Everything this census says + * about them holds for the callers they admit. `/layers` and + * `?layers=true` keep the pruned plain-read answer for everyone. * - **`/references`** is declared exempt: it serves the identities of OTHER * items that point at this one, never a member of this item's document. * @@ -348,7 +357,12 @@ type DoorKind = 'document' | 'stored' | 'events' | 'exempt'; * side, a `diff` of two versions, or the pending `draft` in the plain read's * envelope (its `item`). */ -interface Door { kind: DoorKind; suffix: string; query?: Record; reason?: string; serves?: 'layers' | 'diff' | 'draft' } +/** + * `authoring` — [#20378] ruling 5865708652: the door refuses a caller who may + * not read drafts (`readsDrafts`) with the `GET /meta/_drafts` 403, before any + * read; the rest of its row holds for the callers it admits. + */ +interface Door { kind: DoorKind; suffix: string; query?: Record; reason?: string; serves?: 'layers' | 'diff' | 'draft'; authoring?: true } const DOORS: Record = { '?layers=true': { kind: 'stored', suffix: '', query: { layers: 'true' }, serves: 'layers' }, @@ -357,8 +371,8 @@ const DOORS: Record = { // version — not the rendered world, which is `?preview=draft`. '?state=draft': { kind: 'stored', suffix: '', query: { state: 'draft' }, serves: 'draft' }, '/published': { kind: 'document', suffix: '/published' }, - '/diff': { kind: 'stored', suffix: '/diff', serves: 'diff' }, - '/history': { kind: 'events', suffix: '/history' }, + '/diff': { kind: 'stored', suffix: '/diff', serves: 'diff', authoring: true }, + '/history': { kind: 'events', suffix: '/history', authoring: true }, '/audit': { kind: 'events', suffix: '/audit' }, '/references': { kind: 'exempt', @@ -643,6 +657,20 @@ describe(`[#20156] every alternate door answers what the plain read answers, or protocol.getMetaItem.mockClear(); const res = await drive(rest, door.suffix, subject.type, subject.name, door.query); const stored = find(subject.type, subject.name); + if (door.authoring && CALLERS[callerName].ctx && !CALLERS[callerName].readsDrafts) { + // [#20378] ruling 5865708652: an authoring door + // refuses a caller who may not read drafts exactly + // as `GET /meta/_drafts` does, whatever the plain + // read answers them — and before any read. (An + // anonymous caller is refused by the `/meta` auth + // gate first, as on every door.) + expect(envelope(res)).toEqual({ status: 403, code: 'FORBIDDEN' }); + for (const s of subject.secrets) expect(text(res)).not.toContain(s); + expect(protocol.getMetaItem).not.toHaveBeenCalled(); + expect(protocol.diffMetaItem).not.toHaveBeenCalled(); + expect(protocol.historyMetaItem).not.toHaveBeenCalled(); + return; + } if (door.serves === 'draft' && CALLERS[callerName].ctx) { const asked = protocol.getMetaItem.mock.calls.map(([r]: any[]) => r?.state); if (!CALLERS[callerName].readsDrafts) { @@ -827,13 +855,20 @@ describe('[#20156] edges', () => { const app = await save(rest, 'app', 'crm', clone(CRM_APP)); expect(envelope(app)).toEqual({ status: 403, code: 'FORBIDDEN' }); expect(protocol.saveMetaItem).toHaveBeenCalledTimes(1); - // ...so every stored-version door serves them the plain read's pruned app. + // ...so every stored-version door serves them the plain read's pruned app + // — save `/diff`, an authoring door that refuses them outright + // ([#20378] ruling 5865708652: they may not read drafts either). const plain = await drive(rest, '', 'app', 'crm'); const expected = navIds(plainItem(plain)); expect(expected).not.toEqual(navIds(CRM_APP)); for (const doorName of AUTHOR_EXEMPTION.doors) { const door = DOORS[doorName]; const res = await drive(rest, door.suffix, 'app', 'crm', door.query); + if (door.authoring) { + expect(envelope(res), doorName).toEqual({ status: 403, code: 'FORBIDDEN' }); + for (const s of ['nav_finance_ledger', 'nav_admin_runbook']) expect(text(res), doorName).not.toContain(s); + continue; + } expect(res.statusCode, doorName).toBe(200); for (const s of ['nav_finance_ledger', 'nav_admin_runbook']) expect(text(res), doorName).not.toContain(s); if (door.serves === 'diff') { @@ -848,8 +883,12 @@ describe('[#20156] edges', () => { } }); + // [#20378] Both edges below drive an ADMITTED caller: a caller who may not + // read drafts is refused `/diff` and `/history` before any read (the + // census rows above), so the question these edges ask is only open for one + // who may. it('a gated type with nothing behind the name: /diff answers the plain read\'s absence, /history its events', async () => { - const { rest, protocol } = setup('non-reader'); + const { rest, protocol } = setup('reader'); protocol.getMetaItem.mockImplementation(async ({ type, name }: any) => ({ type: singular(type), name, item: undefined })); const diff = await drive(rest, '/diff', 'doc', 'crm_admin_runbook'); const history = await drive(rest, '/history', 'doc', 'crm_admin_runbook'); @@ -861,7 +900,7 @@ describe('[#20156] edges', () => { }); it('a type no per-caller gate judges costs its event and diff doors no extra read', async () => { - const { rest, protocol } = setup('non-reader'); + const { rest, protocol } = setup('reader'); for (const suffix of ['/history', '/audit', '/diff']) { protocol.getMetaItem.mockClear(); const res = await drive(rest, suffix, 'view', 'all_leads'); diff --git a/packages/rest/src/meta-history-diff-authoring-door.test.ts b/packages/rest/src/meta-history-diff-authoring-door.test.ts new file mode 100644 index 00000000000..f4e579ffc26 --- /dev/null +++ b/packages/rest/src/meta-history-diff-authoring-door.test.ts @@ -0,0 +1,378 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20378] `GET /meta/:type/:name/diff` and `GET /meta/:type/:name/history` are + * AUTHORING doors — ruling 5865708652, letter B. + * + * ## The defect + * + * Both doors read `sys_metadata_history`, the authoring commit log (ADR-0067), + * and a DRAFT save appends a row to it exactly as an active save does — nothing + * on the row says which it was. So a member with no authoring capability who + * could open an item read its pending draft through `/diff` (a `from`/`to` + * naming the draft save, or the default range once a draft is pending), and its + * draft-save events through `/history` — past the gate every draft switch + * asks since #20338, and against ADR-0106 D4 (「draft/preview reads are + * admin-gated upstream」). + * + * ## What the ruling decided + * + * The version store cannot tell a draft version from a published one, so these + * doors have no exact published-only answer to fall back to. A caller + * `mayReadPendingDrafts` does not admit is refused exactly as `GET /meta/_drafts` + * refuses — 403 `FORBIDDEN`, the same nested envelope — decided on the caller + * before any item or version is read, so the answer is one and the same for an + * item that exists, one that does not and a draft-only one: the door is no + * existence oracle. A builder reads what they read before, per-caller pruning + * included. Ruling 5856774816 (#20156) item 2 is narrowed for these two doors + * only: `/layers` and `?layers=true` read the active row and keep the pruned + * plain-read answer — the lit control below. + * + * ## Why this file boots the real stack + * + * The history rows a draft save appends, the versions `/diff` reads and the + * layered read are the protocol's, so the answers here are the real ones: a + * better-sqlite3 `:memory:` engine, the real `sys_metadata*` objects, a real + * `ObjectStackProtocolImplementation` and the real routes — booted exactly as + * `meta-draft-read-builder-gate.test.ts` boots it. The stubs are the auth + * boundary (`resolveExecCtx`) and the service probe that says `tenancy` is off. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { + SysMetadata, + SysMetadataHistoryObject, + SysMetadataAuditObject, +} from '@objectstack/platform-objects/metadata'; +import { RestServer } from './rest-server.js'; + +/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */ +const TEST_PACKAGE_ID = 'objectstack-test'; + +const CALLERS = { + /** Seeds every item: no principal, the machine-write shape. */ + system: { isSystem: true }, + /** May open the app; holds no authoring capability. */ + member: { userId: 'u_member', systemPermissions: [] as string[] }, + /** The three authoring capabilities `/meta/_drafts` admits, one each. */ + studioBuilder: { userId: 'u_studio', systemPermissions: ['studio.access'] }, + setupAdmin: { userId: 'u_setup', systemPermissions: ['setup.access'] }, + author: { userId: 'u_author', systemPermissions: ['manage_metadata'] }, +} as const; +type CallerName = keyof typeof CALLERS; +const BUILDERS = ['studioBuilder', 'setupAdmin', 'author'] as const satisfies readonly CallerName[]; +/** Of the builders, the one the app's save door admits: ruling 5856774816's author exemption. */ +const SAVES_APPS: readonly CallerName[] = ['author']; + +/** Published, with a pending draft that relabels it and adds an entry that exists ONLY in the draft. */ +const ATLAS = { + name: 'atlas', + label: 'Atlas', + navigation: [ + { id: 'nav_leads', type: 'page', label: 'Leads', pageName: 'leads_home' }, + // Withheld by the plain read from every caller here: none holds `finance.access`. + { id: 'nav_finance_ledger', type: 'page', label: 'Ledger', pageName: 'ledger', requiredPermissions: ['finance.access'] }, + ], +}; +const ATLAS_DRAFT = { + ...ATLAS, + label: 'Atlas (draft)', + navigation: [...ATLAS.navigation, { id: 'nav_atlas_launch_plan', type: 'page', label: 'Launch plan', pageName: 'launch_plan' }], +}; +/** Never published: a draft row and nothing else. */ +const BEACON_DRAFT = { + name: 'beacon', + label: 'Beacon', + navigation: [{ id: 'nav_beacon_home', type: 'page', label: 'Home', pageName: 'beacon_home' }], +}; + +/** A type no per-caller gate judges: `/diff` reads no current document for it. */ +const PIPELINE = { + name: 'opportunity.pipeline', + object: 'opportunity', + viewKind: 'list', + label: 'Pipeline', + type: 'grid', + columns: ['region'], +}; +const PIPELINE_DRAFT = { ...PIPELINE, label: 'Pipeline (draft)', columns: ['region', 'amount'] }; +const FORECAST_DRAFT = { ...PIPELINE, name: 'opportunity.forecast', label: 'Forecast' }; + +/** Per type: a published item with a pending draft, a draft-only item, and a name with nothing behind it. */ +const SUBJECTS = { + app: { published: 'atlas', draftOnly: 'beacon', missing: 'nowhere' }, + view: { published: 'opportunity.pipeline', draftOnly: 'opportunity.forecast', missing: 'opportunity.nowhere' }, +} as const; +type SubjectType = keyof typeof SUBJECTS; + +/** The strings that exist ONLY in pending drafts: a caller who may not read drafts must never receive one. */ +const DRAFT_ONLY_TEXT = ['Atlas (draft)', 'nav_atlas_launch_plan', 'Beacon', 'nav_beacon_home', 'Pipeline (draft)', 'Forecast']; + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +function createMockServer() { + const noop = () => {}; + return { + get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, + listen: async () => {}, close: async () => {}, + }; +} + +function makeRes() { + const res: any = { statusCode: 200, body: undefined, headers: {} as Record }; + res.status = (code: number) => { res.statusCode = code; return res; }; + res.json = (body: any) => { res.body = body; return res; }; + res.send = () => res; + res.end = () => res; + res.header = (k: string, v: string) => { res.headers[k] = v; return res; }; + res.setHeader = () => {}; res.write = () => true; + return res; +} + +const META = '/api/v1/meta'; + +/** The protocol members a refused caller must never reach. */ +const READS = ['getMetaItem', 'getMetaItemLayered', 'historyMetaItem', 'diffMetaItem'] as const; + +async function boot() { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), true); + await engine.init(); + engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID); + // The views' base object. + engine.registry.registerObject({ + name: 'opportunity', + label: 'Opportunity', + fields: { + region: { type: 'text', label: 'Region' }, + amount: { type: 'number', label: 'Amount' }, + }, + } as any, TEST_PACKAGE_ID); + await engine.syncSchemas(); + + const protocol: any = new ObjectStackProtocolImplementation(engine as any); + const rest: any = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + let caller: CallerName = 'system'; + rest.resolveExecCtx = async () => ({ ...CALLERS[caller] }); + // ADR-0057 D10 — `tenancy` is an optional service this deployment lacks. + rest.serviceExistsProvider = (name: string) => name !== 'tenancy'; + rest.registerRoutes(); + + const route = (method: string, path: string) => { + const found = rest.getRoutes().find((r: any) => r.method === method && r.path === path); + if (!found) throw new Error(`${method} ${path} is not registered`); + return found; + }; + const as = async (who: CallerName, method: string, routePath: string, req: Record) => { + caller = who; + const res = makeRes(); + await route(method, routePath).handler({ method, headers: {}, query: {}, params: {}, ...req }, res); + return res; + }; + /** `GET /meta/:type/:name` — the plain read (`''`) and its sub-resource doors. */ + const door = (who: CallerName, suffix: string, type: string, name: string, query: Record = {}) => + as(who, 'GET', `${META}/:type/:name${suffix}`, { path: `${META}/${type}/${name}${suffix}`, params: { type, name }, query }); + /** `GET /meta/_drafts` — the door whose refusal these two now give. */ + const drafts = (who: CallerName) => as(who, 'GET', `${META}/_drafts`, { path: `${META}/_drafts` }); + const save = async (type: string, item: { name: string }, query: Record) => { + const res = await as('system', 'PUT', `${META}/:type/:name`, { + path: `${META}/${type}/${item.name}`, params: { type, name: item.name }, query, body: item, + }); + if (res.statusCode !== 200) throw new Error(`seeding ${type}/${item.name} failed: ${JSON.stringify(res.body)}`); + }; + + await save('app', ATLAS, {}); + await save('app', ATLAS_DRAFT, { mode: 'draft' }); + await save('app', BEACON_DRAFT, { mode: 'draft' }); + await save('view', PIPELINE, {}); + await save('view', PIPELINE_DRAFT, { mode: 'draft' }); + await save('view', FORECAST_DRAFT, { mode: 'draft' }); + + /** Spies on every protocol read a refused caller must never reach, armed AFTER seeding. */ + const spies = Object.fromEntries(READS.map((m) => [m, vi.spyOn(protocol, m)])) as Record<(typeof READS)[number], ReturnType>; + const resetSpies = () => { for (const s of Object.values(spies)) s.mockClear(); }; + + /** The newest history version of an item — here, its draft save. */ + const draftVersion = async (type: string, name: string): Promise => { + const rows = await (engine as any).find('sys_metadata_history', { where: { type, name }, context: { isSystem: true } }); + return Math.max(...rows.map((r: any) => Number(r.version))); + }; + + return { door, drafts, spies, resetSpies, draftVersion }; +} + +const envelope = (res: any) => ({ status: res.statusCode, code: res.body?.error?.code ?? res.body?.code }); +const text = (res: any): string => JSON.stringify(res.body ?? null); +const navIds = (doc: any): string[] => (doc?.navigation ?? []).map((e: any) => e.id); +/** The keys of a body and of its nested `error` — the envelope's SHAPE, never its prose. */ +const shape = (res: any) => ({ top: Object.keys(res.body ?? {}).sort(), error: Object.keys(res.body?.error ?? {}).sort() }); + +const AUTHORING_DOORS = ['/diff', '/history'] as const; + +describe('[#20378] a member without an authoring capability is refused /diff and /history — the /meta/_drafts refusal, before any read', () => { + for (const suffix of AUTHORING_DOORS) { + for (const type of Object.keys(SUBJECTS) as SubjectType[]) { + it(`${suffix} ${type}: 403 FORBIDDEN in the /meta/_drafts envelope — one answer for a published item, a draft-only one and a missing name, and nothing read`, async () => { + const { door, drafts, spies, resetSpies } = await boot(); + const listing = await drafts('member'); + expect(envelope(listing)).toEqual({ status: 403, code: 'FORBIDDEN' }); + + const names = SUBJECTS[type]; + resetSpies(); + const answers = [ + await door('member', suffix, type, names.published), + await door('member', suffix, type, names.draftOnly), + await door('member', suffix, type, names.missing), + ]; + for (const res of answers) { + expect(envelope(res)).toEqual({ status: 403, code: 'FORBIDDEN' }); + // The same envelope `/meta/_drafts` answers: a nested + // `error` with a code and a message, and nothing beside it. + expect(shape(res)).toEqual(shape(listing)); + // No item or version detail: not the name, not a draft + // string, not a version, not an event. + for (const name of Object.values(names)) expect(text(res)).not.toContain(name); + for (const s of DRAFT_ONLY_TEXT) expect(text(res)).not.toContain(s); + for (const k of ['fromVersion', 'toVersion', 'events', 'added', 'changed']) expect(text(res)).not.toContain(k); + } + // No existence oracle: the three answers are byte-identical. + expect(answers[1].body).toEqual(answers[0].body); + expect(answers[2].body).toEqual(answers[0].body); + // Decided on the caller before ANY item or version is read. + for (const [member, spy] of Object.entries(spies)) expect(spy, member).not.toHaveBeenCalled(); + // The control: the spies are live — a builder's call on the + // same door reaches the protocol, so "not called" above is a + // reading, not a harness that never sees a call. + const builder = await door('author', suffix, type, names.published); + expect(builder.statusCode).toBe(200); + expect(spies[suffix === '/diff' ? 'diffMetaItem' : 'historyMetaItem']).toHaveBeenCalled(); + }, 60_000); + } + } + + it('/diff: a range naming the draft save, the default range and an unparseable bound all answer the member the same refusal', async () => { + const { door, draftVersion } = await boot(); + const v = await draftVersion('app', 'atlas'); + const plain = await door('member', '/diff', 'app', 'atlas'); + const ranges: Record[] = [{ from: '0', to: String(v) }, { from: String(v - 1), to: String(v) }, { from: 'abc' }]; + for (const query of ranges) { + const res = await door('member', '/diff', 'app', 'atlas', query); + expect(envelope(res), JSON.stringify(query)).toEqual({ status: 403, code: 'FORBIDDEN' }); + expect(res.body, JSON.stringify(query)).toEqual(plain.body); + } + // The control: the unparseable bound IS refused to a builder — as a + // 400, so the member's 403 above was decided before the query parse. + const builder = await door('studioBuilder', '/diff', 'app', 'atlas', { from: 'abc' }); + expect(builder.statusCode).toBe(400); + }, 60_000); + + it('/history: an unparseable `limit` answers the member the same refusal — decided before the query parse', async () => { + const { door } = await boot(); + const plain = await door('member', '/history', 'app', 'atlas'); + const res = await door('member', '/history', 'app', 'atlas', { limit: 'abc' }); + expect(envelope(res)).toEqual({ status: 403, code: 'FORBIDDEN' }); + expect(res.body).toEqual(plain.body); + const builder = await door('studioBuilder', '/history', 'app', 'atlas', { limit: 'abc' }); + expect(builder.statusCode).toBe(400); + }, 60_000); +}); + +describe('[#20378] builders read /diff and /history as before — the control', () => { + it('/diff app: every builder reads the draft version; the author reads it whole, every other builder pruned as the plain read prunes', async () => { + const { door, draftVersion } = await boot(); + const v = await draftVersion('app', 'atlas'); + for (const who of BUILDERS) { + const res = await door(who, '/diff', 'app', 'atlas', { from: '0', to: String(v) }); + expect(res.statusCode, who).toBe(200); + expect(res.body?.toVersion, who).toBe(v); + expect(text(res), who).toContain('Atlas (draft)'); + const navigation = res.body?.added?.find((e: any) => e.path === 'navigation')?.value; + expect(navIds({ navigation }), who).toContain('nav_atlas_launch_plan'); + // Ruling 5856774816: whole for whoever may save the app, pruned + // for any other caller — unchanged by this card. + if (SAVES_APPS.includes(who)) expect(navIds({ navigation }), who).toContain('nav_finance_ledger'); + else expect(text(res), who).not.toContain('nav_finance_ledger'); + } + }, 60_000); + + it('/diff view: every builder reads the draft version of a type no per-caller gate judges, and a draft-only view', async () => { + const { door, draftVersion } = await boot(); + const v = await draftVersion('view', 'opportunity.pipeline'); + for (const who of BUILDERS) { + const res = await door(who, '/diff', 'view', 'opportunity.pipeline', { from: String(v - 1), to: String(v) }); + expect(res.statusCode, who).toBe(200); + expect(text(res), who).toContain('Pipeline (draft)'); + const draftOnly = await door(who, '/diff', 'view', 'opportunity.forecast', { from: '0', to: '1' }); + expect(draftOnly.statusCode, who).toBe(200); + expect(text(draftOnly), who).toContain('Forecast'); + } + }, 60_000); + + it('/history: every builder reads the change log of an app and a view, draft saves included', async () => { + const { door } = await boot(); + for (const who of BUILDERS) { + for (const [type, name] of [['app', 'atlas'], ['view', 'opportunity.pipeline']] as const) { + const res = await door(who, '/history', type, name); + expect(res.statusCode, `${who} ${type}`).toBe(200); + // The published save and the draft save. + expect(res.body?.events?.length, `${who} ${type}`).toBe(2); + } + } + }, 60_000); +}); + +describe('[#20378] /layers and ?layers=true are unchanged for the member — the lit control', () => { + it('the member reads both layered doors of a published app: 200, the active row pruned as the plain read prunes it, no draft', async () => { + const { door } = await boot(); + const plain = await door('member', '', 'app', 'atlas'); + expect(plain.statusCode).toBe(200); + const expected = navIds(plain.body?.item); + expect(expected).toEqual(['nav_leads']); + for (const [label, suffix, query] of [['/layers', '/layers', {}], ['?layers=true', '', { layers: 'true' }]] as const) { + const res = await door('member', suffix, 'app', 'atlas', query); + expect(res.statusCode, label).toBe(200); + expect(navIds(res.body?.effective), label).toEqual(expected); + expect(text(res), label).not.toContain('nav_finance_ledger'); + for (const s of DRAFT_ONLY_TEXT) expect(text(res), label).not.toContain(s); + } + }, 60_000); + + it('the member reads both layered doors of a published view: 200, the active row', async () => { + const { door } = await boot(); + for (const [label, suffix, query] of [['/layers', '/layers', {}], ['?layers=true', '', { layers: 'true' }]] as const) { + const res = await door('member', suffix, 'view', 'opportunity.pipeline', query); + expect(res.statusCode, label).toBe(200); + expect(res.body?.effective?.label, label).toBe('Pipeline'); + for (const s of DRAFT_ONLY_TEXT) expect(text(res), label).not.toContain(s); + } + }, 60_000); +}); + +describe('[#20378] one predicate: /diff and /history refuse exactly the callers /meta/_drafts refuses', () => { + it('for each caller, the three doors agree', async () => { + const { door, drafts } = await boot(); + for (const who of ['member', ...BUILDERS] as const) { + const refused = (await drafts(who)).statusCode === 403; + for (const suffix of AUTHORING_DOORS) { + const res = await door(who, suffix, 'view', 'opportunity.pipeline'); + expect(res.statusCode === 403, `${who} ${suffix}`).toBe(refused); + } + } + expect((await drafts('member')).statusCode).toBe(403); + }, 60_000); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 61b0953957a..e0f7a215dbe 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -7349,6 +7349,44 @@ export class RestServer { handler: async (req: any, res: any) => { try { const environmentId = isScoped ? req.params?.environmentId : undefined; + // [#20378] AN AUTHORING DOOR — ruling 5865708652 (letter B). + // `sys_metadata_history` is the authoring commit log + // (ADR-0067), and a DRAFT save appends a row to it exactly + // as an active save does, with nothing on the row to tell + // the two apart — so this log, served to a caller who may + // not read pending drafts, lists unpublished authoring work + // (ADR-0106 D4: 「draft/preview reads are admin-gated + // upstream」). The caller is asked + // {@link mayReadPendingDrafts} FIRST, and one it does not + // admit is refused exactly as `GET /meta/_drafts` refuses + // (403 `FORBIDDEN`, the same nested envelope): before the + // protocol is resolved (no 501-vs-200 probe), before the + // query is parsed, before any item or event is read. The + // answer is therefore one and the same for an item that + // exists, one that does not and a draft-only one — the door + // is no existence oracle — and it carries no item or + // version detail. The message names THIS door, never + // drafts: a refusal worded about drafts would read as + // "this item has one". Whoever it admits reads exactly what + // they read before, the per-caller refusal below included. + // + // Ruling 5856774816 (#20156) item 2 is narrowed for this + // door and `/diff` only: `/layers` and `?layers=true` read + // the active row and keep the pruned plain-read answer. + // + // `historyCtx` is this door's one caller resolution; the org + // partition below reads the same value. + const historyCtx = await this.resolveExecCtx(environmentId, req) + .catch(rethrowAuthzStoreUnavailable); + if (!mayReadPendingDrafts(historyCtx)) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: 'Reading a metadata item\'s version history requires an authoring capability (studio.access, setup.access or manage_metadata).', + }, + }); + return; + } const p = await this.resolveProtocol(environmentId, req); // The cast came off when `MetadataProtocol` declared // `historyMetaItem` (#12005 — the #11006 pattern, exactly @@ -7429,11 +7467,11 @@ export class RestServer { // uses is what makes the two sides incapable of drifting — // the reasoning `organizationIdForMetaRead` was written for. // - // ⚠️ NOT a new org-resolution seam: `resolveExecCtx` is - // memoised per request (WeakMap keyed by `req`), the same - // result the audit twin and 40+ handlers here already share. - const historyCtx = await this.resolveExecCtx(environmentId, req) - .catch(rethrowAuthzStoreUnavailable); + // ⚠️ NOT a new org-resolution seam: `historyCtx` is the + // caller resolved at the head of this door (#20378), and + // `resolveExecCtx` is memoised per request (WeakMap keyed by + // `req`), the same result the audit twin and 40+ handlers + // here already share. const historyOrganizationId = organizationIdForMetaRead( // [#10340] FOLDED, not raw — see the PUT door's // org-scope comment for the measurement. @@ -7976,6 +8014,46 @@ export class RestServer { handler: async (req: any, res: any) => { try { const environmentId = isScoped ? req.params?.environmentId : undefined; + // [#20378] AN AUTHORING DOOR — ruling 5865708652 (letter B), + // the `/history` twin's gate on the same log. A diff reads + // stored versions out of `sys_metadata_history`, where a + // DRAFT save is recorded exactly as an active save, so + // `?from=`/`?to=` naming a draft save — or the default + // range, once a draft is pending — served unpublished + // content to a caller who may not read drafts. The version + // store cannot tell a draft version from a published one, + // so there is no exact published-only answer to fall back + // to: the `/meta/_drafts` shape, not the draft switches' + // "answer as if absent". The caller is asked + // {@link mayReadPendingDrafts} FIRST, and one it does not + // admit is refused exactly as `GET /meta/_drafts` refuses + // (403 `FORBIDDEN`, the same nested envelope): before the + // protocol is resolved, before the query is parsed, before + // the mask posture, the current document or any version is + // read — one answer for an item that exists, one that does + // not and a draft-only one, with no item or version detail, + // so the door is no existence oracle. The message names THIS + // door, never drafts. Whoever it admits reads exactly what + // they read before: every per-caller gate below, and ruling + // 5856774816's author exemption, still apply to them. + // + // Ruling 5856774816 (#20156) item 2 is narrowed for this + // door and `/history` only: `/layers` and `?layers=true` + // read the active row and keep the pruned plain-read answer. + // + // `diffCtx` is this door's one caller resolution; the org + // partition below reads the same value. + const diffCtx = await this.resolveExecCtx(environmentId, req) + .catch(rethrowAuthzStoreUnavailable); + if (!mayReadPendingDrafts(diffCtx)) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: 'Comparing a metadata item\'s stored versions requires an authoring capability (studio.access, setup.access or manage_metadata).', + }, + }); + return; + } const p = await this.resolveProtocol(environmentId, req); if (!(p as any).diffMetaItem) { res.status(501).json({ @@ -8060,8 +8138,9 @@ export class RestServer { // `request.organizationId ?? null`, so an `?? null` copied // from the audit door would type-check here and still be a // silent no-op — the exact fix-shaped-non-fix this card is. - const diffCtx = await this.resolveExecCtx(environmentId, req) - .catch(rethrowAuthzStoreUnavailable); + // + // `diffCtx` is the caller resolved at the head of this door + // (#20378), not a second resolution. const diffOrganizationId = organizationIdForMetaRead( // [#10340] FOLDED, not raw — see the PUT door's // org-scope comment for the measurement.