diff --git a/.changeset/24210-metadata-protocol-by-name-disabled-non-object.md b/.changeset/24210-metadata-protocol-by-name-disabled-non-object.md new file mode 100644 index 00000000000..fd08c23966e --- /dev/null +++ b/.changeset/24210-metadata-protocol-by-name-disabled-non-object.md @@ -0,0 +1,19 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +fix(metadata-protocol)!: the by-name `/meta` read answers a disabled package's item of every type but `object` and `package` as absent, as the list already omits it + +Clause-②: no (narrowing) + + + +**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +Disabling a package already took its apps, views, pages, dashboards, flows and every other item out of `GET /meta/:type`. The by-name read `GET /meta/:type/:name` still served them. It now answers such an item the way it answers a name with nothing behind it. + +- **Absent now:** `getMetaItem({ type, name })` for an item whose `_packageId` names a disabled package, for every type except `object` and `package`. The active read and `previewDrafts` serve no `item`, which both transports answer `404 RESOURCE_NOT_FOUND`, byte for byte the answer for a missing name. The strict draft read (`state: 'draft'`) throws the missing draft's `404 NO_DRAFT`. The list read and the by-name read ask one predicate, so they hide the same items. +- **Kept:** the protection envelope (`lock`, `editable`, `deletable`, `version` and the rest) still reports the write doors' verdicts about the address, because a disable does not change how the lock gate judges a save there. Only `item` and a flow's `registration` are withheld. +- **Unchanged:** objects, which keep their `404 OBJECT_PACKAGE_DISABLED`; packages, which are never hidden, so a disabled package can still be read and re-enabled; items of enabled packages; and `getMetaItemLayered` (`?layers=true`, `/layers`) and `/published`, which still serve a disabled package's non-object item. + +What changes for you: a client that read a disabled package's app, view, page or other item by name now gets `404 RESOURCE_NOT_FOUND`, as for a missing name. Re-enable the package to read it again. diff --git a/.changeset/24210-plugin-security-echo-binding-disabled-package.md b/.changeset/24210-plugin-security-echo-binding-disabled-package.md new file mode 100644 index 00000000000..74232079e74 --- /dev/null +++ b/.changeset/24210-plugin-security-echo-binding-disabled-package.md @@ -0,0 +1,7 @@ +--- +'@objectstack/plugin-security': patch +--- + +fix(plugin-security): the security catalog echo keeps naming a disabled package's permission set or position as that package's + +The echo reads which package owns a stored overlay from the protocol's by-name read. That read now withholds a disabled package's item and keeps its protection envelope, so the echo reads the envelope's `packageId` instead of `item._packageId`. Before, a disabled package's set was echoed unowned, lost its ADR-0090 D5 audience-binding suggestion, and stayed unowned after the package was re-enabled until the next save, publish or boot. Enforcement is unchanged. diff --git a/.changeset/24210-rest-event-diff-doors-disabled-package.md b/.changeset/24210-rest-event-diff-doors-disabled-package.md new file mode 100644 index 00000000000..0638bccc4de --- /dev/null +++ b/.changeset/24210-rest-event-diff-doors-disabled-package.md @@ -0,0 +1,18 @@ +--- +'@objectstack/rest': minor +--- + +fix(rest)!: `/history`, `/audit` and `/diff` answer a disabled package's item with the plain read's absence, never past the per-caller gate + +Clause-②: no (narrowing) + + + +**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +The by-name read now answers a disabled package's item as absent. The event and diff doors judge the item the plain read would judge, and they read "no item" as "nothing behind the name": events served whole, and a view's or dashboard's diff served whole, to a caller the item's own gate refuses. + +- **Absent now, `404 RESOURCE_NOT_FOUND`:** `GET /meta/:type/:name/history`, `/audit` and `/diff` for an `app`, `view`, `dashboard`, `doc` or `book` the plain read withholds while its layers still resolve, which is a disabled package's item. No events and no diff are read. +- **Unchanged:** an item that is really gone (every layer null, such as a deleted item) keeps its answer: its events, and its diff as before. Items of enabled packages keep the per-caller gate as before. + +What changes for you: re-enable the package to read its items' history, audit trail and diff again. diff --git a/.changeset/24210-runtime-meta-disabled-package-doors.md b/.changeset/24210-runtime-meta-disabled-package-doors.md new file mode 100644 index 00000000000..9003affa9bc --- /dev/null +++ b/.changeset/24210-runtime-meta-disabled-package-doors.md @@ -0,0 +1,20 @@ +--- +'@objectstack/runtime': minor +--- + +fix(runtime)!: the dispatcher's `/meta` doors that read past the protocol answer a disabled package's object or item as the protocol's by-name read does + +Clause-②: no (narrowing) + + + +**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +The registry keeps a disabled package's objects on purpose, and the metadata service knows nothing of a disable. Four dispatcher doors read one of them for a disabled package's object or item and served it. + +- **Refused now, `404 OBJECT_PACKAGE_DISABLED`:** `GET /meta/object/:name/state/:field`, and the legacy one-segment `GET /meta/:name` exit, for an object whose owning package is disabled. Both read `registry.getObject`; they now answer the protocol's refusal, the answer `GET /meta/object/:name` gives. +- **Refused now, `404 OBJECT_PACKAGE_DISABLED`:** `GET /meta/object/:name/published`. When the layered read refused the object, this door fell through to the published snapshot and served it. The refusal is now the answer, as on `RestServer`'s `/published`. +- **Absent now, `404 RESOURCE_NOT_FOUND`:** `GET /meta/:type/:name` for a non-object item of a disabled package. The protocol answers it as absent, and the door no longer serves the metadata service's copy in its place. The body is byte for byte the answer for a missing name. +- **Unchanged:** objects and items of enabled packages, and every other fall-through. + +What changes for you: re-enable the package to read the object or item again. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index faa0d7283b0..b3ab40e82d4 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -533,6 +533,35 @@ function isObjectOfDisabledPackage(registry: any, name: string): boolean { && registry.isObjectPackageDisabled(name) === true; } +/** + * [#7557, #24210] Whether `item` belongs to a disabled package: its + * `_packageId`, asked of `registry.isPackageDisabled`. The ONE predicate for a + * non-object item, asked by the list read ({@link + * ObjectStackProtocolImplementation.getMetaItems}, which omits the item) and + * the by-name read ({@link ObjectStackProtocolImplementation.getMetaItem}, + * which answers as for a name with nothing behind it), so the two cannot + * disagree about which items a disable hides. Optional-called, as {@link + * isObjectOfDisabledPackage} is: a host whose registry cannot answer has no + * item hidden. + */ +function isItemOfDisabledPackage(registry: any, item: unknown): boolean { + return typeof registry?.isPackageDisabled === 'function' + && registry.isPackageDisabled((item as { _packageId?: unknown } | null | undefined)?._packageId) === true; +} + +/** + * The strict draft read's `404 NO_DRAFT`: no pending draft at this address + * (ADR-0005 §draft-lifecycle). [#24210] One constructor for the read that + * finds no draft row and the read of a disabled package's draft, so the two + * answers are byte-identical. + */ +function noPendingDraftError(type: string, name: string): Error & { code: 'NO_DRAFT'; status: 404 } { + const err = new Error(`No pending draft exists for ${type}/${name}.`) as Error & { code: 'NO_DRAFT'; status: 404 }; + err.code = 'NO_DRAFT'; + err.status = 404; + return err; +} + /** * [#17501] Does a converted JSON Schema carry any authorable surface at all? * @@ -10375,10 +10404,11 @@ export class ObjectStackProtocolImplementation implements // `assertObjectRegistered` (`OBJECT_PACKAGE_DISABLED`) — absence here // and refusal there are the two halves of one answer, pinned together // by `protocol.package-disable-enforcement.test.ts`. + // + // [#24210] The predicate is {@link isItemOfDisabledPackage}, the one the + // by-name read asks, so the list and the by-name read hide the same items. if (request.type !== 'package') { - items = (items as any[]).filter( - (it) => !this.engine.registry.isPackageDisabled((it as any)?._packageId), - ); + items = (items as any[]).filter((it) => !isItemOfDisabledPackage(this.engine.registry, it)); } // Canonical-shape exposure (ADR-0017, "Object has-many View"): a @@ -11046,6 +11076,19 @@ export class ObjectStackProtocolImplementation implements * that must still see the object (the save's destructive-change check) * read {@link readMetaItem}, the shared body, instead: the reader-class * split `package-disable-enforcement.test.ts` pins. + * + * [#7557, #24210] Every other type but `package` has no data plane, so a + * disabled package's item is answered as ABSENT, the answer a name with + * nothing behind it gets: the list read omits that item, by the same + * predicate ({@link isItemOfDisabledPackage}) asked of the item this read + * resolved, in every state. The strict draft read throws the missing + * draft's `404 NO_DRAFT`. The other reads serve no `item`, which both + * transports answer `404 RESOURCE_NOT_FOUND`, byte for byte the missing + * name's answer, and drop `registration`, the engine's verdict on a served + * flow. The protection envelope stays: it reports the write doors' + * verdicts about this address (#21670), and a disable does not change how + * the lock gate judges a save there. `package` is never hidden, so a + * disabled package can still be read and re-enabled. */ async getMetaItem(request: { type: string, name: string, packageId?: string, state?: 'active' | 'draft', previewDrafts?: boolean, @@ -11057,7 +11100,14 @@ export class ObjectStackProtocolImplementation implements if (request.type === 'object' && isObjectOfDisabledPackage(this.engine?.registry, request.name)) { throw objectPackageDisabledError(request.name); } - return this.readMetaItem(request); + const answer = await this.readMetaItem(request); + if (request.type === 'object' || request.type === 'package' + || !isItemOfDisabledPackage(this.engine?.registry, answer?.item)) { + return answer; + } + if (request.state === 'draft') throw noPendingDraftError(request.type, request.name); + const { item: _withheld, registration: _servedOnly, ...envelope } = answer as typeof answer & { registration?: unknown }; + return { ...envelope, item: undefined }; } /** @@ -11292,14 +11342,7 @@ export class ObjectStackProtocolImplementation implements // status code to decide "no pending edit" without sniffing // envelope shape. See ADR-0005 §draft-lifecycle. if (readState === 'draft') { - if (item === undefined) { - const err: any = new Error( - `No pending draft exists for ${request.type}/${request.name}.`, - ); - err.code = 'NO_DRAFT'; - err.status = 404; - throw err; - } + if (item === undefined) throw noPendingDraftError(request.type, request.name); return { type: request.type, name: request.name, diff --git a/packages/objectql/src/package-disable-enforcement.test.ts b/packages/objectql/src/package-disable-enforcement.test.ts index c206a341dd7..ab049e92c84 100644 --- a/packages/objectql/src/package-disable-enforcement.test.ts +++ b/packages/objectql/src/package-disable-enforcement.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { MetadataTypeSchema } from '@objectstack/spec/kernel'; import { SchemaRegistry } from './registry.js'; // [#4550 / #5480] The producer's OWN write-verb dispatch decisions, so the // engine double cannot accept a call `ObjectQL.delete` / `ObjectQL.update` @@ -66,11 +67,28 @@ import { assertEngineFindOnePredicate } from './engine-findone-predicate.js'; * halves of the enforcement are independently pinned, and the resolution rows * are the control that keeps a future "fix" from over-reaching into the * registry primitives. + * + * --------------------------------------------------------------------------- + * [#24210] Every other type: absence, by name as in the listing + * --------------------------------------------------------------------------- + * A type with no data plane has no refusal to share, so the by-name read + * (`getMetaItem`) answers a disabled package's item as ABSENT, as the listing + * omits it: no `item` (the transports' `404 RESOURCE_NOT_FOUND`), or the + * missing draft's `404 NO_DRAFT` on the strict draft read. `READERS` carries a + * listing row and a by-name row for EVERY type `MetadataTypeSchema` declares + * but `object` (`BY_NAME_TYPES`; the schema does not list `package`), so a + * type added there gets its two rows here without anyone writing them. + * `package` is never hidden: the Packages page re-enables from it. */ const PKG = 'app.disabled_demo'; const OTHER_PKG = 'app.healthy_demo'; +/** [#24210] Every declared metadata type the by-name read answers as absent. */ +const BY_NAME_TYPES: readonly string[] = MetadataTypeSchema.options.filter((type) => type !== 'object'); +/** The item each package ships of `type`, registered as a code package registers it. */ +const shippedName = (type: string, pkg: string) => `${pkg === PKG ? 'demo' : 'healthy'}_${type}_item`; + const objectBody = (name: string) => ({ name, label: 'Demo', @@ -199,6 +217,20 @@ async function seed(protocol: any, registry: SchemaRegistry) { packageId: pkg, item: { name: app, label: 'Demo App' }, }); + // [#24210] A pending draft of the stored app, so the by-name read's + // `state: 'draft'` and `previewDrafts` rows have a draft to serve. + await protocol.saveMetaItem({ + type: 'app', + name: app, + packageId: pkg, + mode: 'draft', + item: { name: app, label: 'Demo App (draft)' }, + }); + // [#24210] One item of every declared type, as a code package ships it: + // the registry layer, stamped with the package that registered it. + for (const type of BY_NAME_TYPES) { + registry.registerItem(type, { name: shippedName(type, pkg), label: 'Shipped' } as any, 'name', pkg); + } } } @@ -238,7 +270,8 @@ const servedFor = async (protocol: any, type: string, pkg: string): Promise r?.effective, ), }, + { + reader: 'getMetaItem({ type: "app", name }) — GET /meta/app/:name, a stored row', + enforces: true, + why: '[#24210] the by-name half of the app listing: absent, as the listing omits it', + serves: async ({ protocol }) => + servesByName(() => protocol.getMetaItem({ type: 'app', name: 'demo_app' }), (r) => r?.item), + }, + { + reader: 'getMetaItem({ type: "apps", name }) — the plural spelling of the same route', + enforces: true, + why: '[#24210] folded to the singular before the predicate is asked', + serves: async ({ protocol }) => + servesByName(() => protocol.getMetaItem({ type: 'apps', name: 'demo_app' }), (r) => r?.item), + }, + { + reader: 'getMetaItem({ type: "app", state: "draft" }) — GET /meta/app/:name?state=draft', + enforces: true, + why: '[#24210] the strict draft read answers the missing draft\'s 404 NO_DRAFT', + serves: async ({ protocol }) => { + try { + return (await protocol.getMetaItem({ type: 'app', name: 'demo_app', state: 'draft' }))?.item != null; + } catch (e: any) { + if (e?.code === 'NO_DRAFT') return false; + throw e; + } + }, + }, + { + reader: 'getMetaItem({ type: "app", previewDrafts: true }) — GET /meta/app/:name?preview=draft', + enforces: true, + why: '[#24210] the draft-overlay preview returns before the active read, and is asked the same predicate', + serves: async ({ protocol }) => + servesByName( + () => protocol.getMetaItem({ type: 'app', name: 'demo_app', previewDrafts: true }), + (r) => r?.item, + ), + }, + // [#24210] Two rows per declared type, generated: a type `MetadataTypeSchema` + // adds is enumerated here with no row to write. + ...BY_NAME_TYPES.flatMap((type) => [ + { + reader: `getMetaItems({ type: "${type}" }) — GET /meta/${type}`, + enforces: true, + why: 'the listing omits a disabled package\'s item of every type but package', + serves: async ({ protocol }: { protocol: any }) => + (await listNames(protocol, type)).includes(shippedName(type, PKG)), + }, + { + reader: `getMetaItem({ type: "${type}", name }) — GET /meta/${type}/:name`, + enforces: true, + why: 'the by-name half of that listing: absent, the answer a name with nothing behind it gets', + serves: async ({ protocol }: { protocol: any }) => + servesByName(() => protocol.getMetaItem({ type, name: shippedName(type, PKG) }), (r) => r?.item), + }, + ]), { reader: 'assertObjectRegistered — the data plane (findData / getData funnel here)', enforces: true, @@ -458,6 +546,66 @@ describe('#7557 (A) — a disabled package stops serving, on every API reader at expect((layered?.effective as any)?.name).toBe('healthy_widget'); }); + it('[#24210] a disabled package\'s item is answered as a missing name is, on every type', async () => { + const h = makeHarness(); + await seed(h.protocol, h.registry); + h.registry.disablePackage(PKG); + + for (const type of BY_NAME_TYPES) { + const missing: any = await h.protocol.getMetaItem({ type, name: `no_such_${type}_item` }); + const hidden: any = await h.protocol.getMetaItem({ type, name: shippedName(type, PKG) }); + // No item, which is what the transports judge absence on (`item == null`). + expect({ type, item: missing?.item }).toEqual({ type, item: undefined }); + expect({ type, item: hidden?.item, hasItemKey: 'item' in hidden }).toEqual({ type, item: undefined, hasItemKey: true }); + expect(hidden?.registration).toBeUndefined(); + } + + // The strict draft read: the missing draft's own error, code, status and text. + let draftErr: any; + try { + await h.protocol.getMetaItem({ type: 'app', name: 'demo_app', state: 'draft' }); + } catch (e) { + draftErr = e; + } + expect(draftErr?.code).toBe('NO_DRAFT'); + expect(draftErr?.status).toBe(404); + expect(draftErr?.message).toBe('No pending draft exists for app/demo_app.'); + }); + + it('[#24210] the control: another package\'s item of every type is still listed and served by name', async () => { + const h = makeHarness(); + await seed(h.protocol, h.registry); + h.registry.disablePackage(PKG); + + const served: Record = {}; + for (const type of BY_NAME_TYPES) { + const name = shippedName(type, OTHER_PKG); + const read: any = await h.protocol.getMetaItem({ type, name }); + served[`${type}: by name`] = read?.item?.name === name; + served[`${type}: listed`] = (await listNames(h.protocol, type)).includes(name); + } + expect(served).toEqual(allTrue(served)); + const app: any = await h.protocol.getMetaItem({ type: 'app', name: 'healthy_app' }); + expect(app?.item?._packageId).toBe(OTHER_PKG); + }); + + it('[#24210] the protection envelope stays: the write doors\' verdict on the address is not the item', async () => { + // The ownership ceremony gates its own writes on `editable` / `lock` from + // this read (`packages/cli`, `lock.verdict`), and the save door judges a + // save to this address the same way whether or not the package is enabled. + const h = makeHarness(); + await seed(h.protocol, h.registry); + const before: any = await h.protocol.getMetaItem({ type: 'app', name: 'demo_app' }); + h.registry.disablePackage(PKG); + const after: any = await h.protocol.getMetaItem({ type: 'app', name: 'demo_app' }); + + expect(before?.item?.name).toBe('demo_app'); + expect(after?.item).toBeUndefined(); + const { item: _beforeItem, ...beforeEnvelope } = before; + const { item: _afterItem, ...afterEnvelope } = after; + expect(afterEnvelope).toEqual(beforeEnvelope); + }); + it('[#24097] a field drop on a disabled package\'s object still answers 409 DESTRUCTIVE_CHANGE', async () => { // The save's destructive-change check reads the prior schema through the // protocol's private shared body, not the refusing API face. Read through diff --git a/packages/plugins/plugin-security/src/security-catalog-echo.test.ts b/packages/plugins/plugin-security/src/security-catalog-echo.test.ts index 9483c1a40d3..f9d510d70db 100644 --- a/packages/plugins/plugin-security/src/security-catalog-echo.test.ts +++ b/packages/plugins/plugin-security/src/security-catalog-echo.test.ts @@ -286,6 +286,30 @@ describe('#23233 — controls: the owner is carried, never invented', () => { expect(suggestionRows(engine, 'org_member_default')).toEqual([]); }); + it('[#24210] a disabled package\'s set echoes owned, and stays owned after the package is re-enabled', async () => { + // The by-name read withholds a disabled package's item and keeps its + // envelope; the binding is a resolution read, so it reads the envelope. + const { engine, metadata, protocol } = boot(); + await publishThroughPackageDoor(protocol, RUNTIME_PKG, runtimeDefault()); + const disabled = new Set([RUNTIME_PKG]); + engine.registry.isPackageDisabled = ((id: unknown) => typeof id === 'string' && disabled.has(id)) as never; + // Anti-vacuity: the by-name read withholds the item now. + expect((await protocol.getMetaItem({ type: 'permission', name: RUNTIME_SET })).item).toBeUndefined(); + + metadata.store.delete(RUNTIME_SET); + const out = await echoSecurityCatalogMutation(protocol, { ql: engine, metadata }, { + type: 'permission', name: RUNTIME_SET, state: 'active', + }); + expect(out).toBe('registered'); + expect(metadata.store.get(RUNTIME_SET)).toMatchObject({ name: RUNTIME_SET, _packageId: RUNTIME_PKG, _envProjection: true }); + + disabled.delete(RUNTIME_PKG); + expect(metadata.store.get(RUNTIME_SET)).toMatchObject({ _packageId: RUNTIME_PKG }); + await echoSecurityCatalogMutation(protocol, { ql: engine, metadata }, { type: 'permission', name: RUNTIME_SET, state: 'active' }); + expect(metadata.store.get(RUNTIME_SET)).toMatchObject({ _packageId: RUNTIME_PKG }); + expect((await protocol.getMetaItem({ type: 'permission', name: RUNTIME_SET })).item?._packageId).toBe(RUNTIME_PKG); + }); + it('a binding read that fails leaves the echo registered and unowned, as before, and says so once', async () => { const { engine, metadata, warnings } = boot(); // The layered read answers an overlay; the by-name read is refused. @@ -320,7 +344,11 @@ function stubProtocol(overlays: Record { const stored = overlays[`${type}/${name}`]; - return { type, name, item: stored ? { ...stored.body, ...(stored.packageId ? { _packageId: stored.packageId } : {}) } : declared[`${type}/${name}`] }; + // The protection envelope states the served document's `_packageId` as + // `packageId`, as the protocol's `servedLockState` does. + const item = stored ? { ...stored.body, ...(stored.packageId ? { _packageId: stored.packageId } : {}) } : declared[`${type}/${name}`]; + const packageId = item?._packageId; + return { type, name, item, ...(typeof packageId === 'string' ? { packageId } : {}) }; }, }; } diff --git a/packages/plugins/plugin-security/src/security-catalog-echo.ts b/packages/plugins/plugin-security/src/security-catalog-echo.ts index ebf55247189..fe3451502e2 100644 --- a/packages/plugins/plugin-security/src/security-catalog-echo.ts +++ b/packages/plugins/plugin-security/src/security-catalog-echo.ts @@ -150,9 +150,15 @@ export const isProjectionEcho = (v: any): boolean => /** * The package the stored row a by-name read SERVES for `name` is bound to: - * the `_packageId` the protocol's single-item read states from that row's - * `package_id` column. `undefined` when the row is bound to no package, or the - * protocol has no single-item read. A read that fails is not caught here. + * the protection envelope's `packageId`, which the protocol's single-item read + * states from the served document's `_packageId` (that row's `package_id` + * column). `undefined` when the row is bound to no package, or the protocol + * has no single-item read. A read that fails is not caught here. + * + * [#7557, #24210] The envelope, not `item._packageId`: this is a resolution + * read, and the by-name read withholds the item of a disabled package while + * keeping its envelope. Read off the item, a disabled package's set echoed + * unowned, and stayed unowned after the package was re-enabled. */ export async function readServedPackageBinding( protocol: any, @@ -161,7 +167,7 @@ export async function readServedPackageBinding( ): Promise { if (typeof protocol?.getMetaItem !== 'function') return undefined; const served = await protocol.getMetaItem({ type, name }); - const packageId = served?.item?._packageId; + const packageId = served?.packageId; return typeof packageId === 'string' && packageId !== '' ? packageId : undefined; } 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 84366779211..d3bdc238a2d 100644 --- a/packages/rest/src/meta-alternate-door-read-gates.test.ts +++ b/packages/rest/src/meta-alternate-door-read-gates.test.ts @@ -1007,7 +1007,11 @@ describe('[#20156] edges', () => { // 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('reader'); + // Nothing behind the name: no item, and no layer either (a deleted item). protocol.getMetaItem.mockImplementation(async ({ type, name }: any) => ({ type: singular(type), name, item: undefined })); + protocol.getMetaItemLayered.mockImplementation(async ({ type, name }: any) => ({ + type: singular(type), name, code: null, overlay: null, effective: null, + })); const diff = await drive(rest, '/diff', 'doc', 'crm_admin_runbook'); const history = await drive(rest, '/history', 'doc', 'crm_admin_runbook'); @@ -1017,6 +1021,37 @@ describe('[#20156] edges', () => { expect(Array.isArray(history.body?.events)).toBe(true); }); + // [#7557, #24210] A disabled package's item: the by-name read withholds it + // (no item) while its layers still resolve. The event and diff doors give + // it the plain read's answer, absence, never the events or the diff past + // the gate. Driven with a caller the item's own gate refuses, who is + // admitted to the doors themselves (an author: `manage_metadata`). + it('[#24210] a gated item the plain read withholds: /history, /audit and /diff answer 404 RESOURCE_NOT_FOUND, never past the gate', async () => { + for (const [type, name] of [['dashboard', 'legal_board'], ['app', 'payroll']] as const) { + const { rest, protocol } = setup('author'); + // Control: enabled, the gate refuses this caller the item whole. + for (const suffix of ['/history', '/audit', '/diff']) { + const res = await drive(rest, suffix, type, name); + expect(envelope(res), `${type} ${suffix} enabled`).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + } + protocol.getMetaItem.mockImplementation(async ({ type: t, name: n }: any) => ({ + type: singular(t), name: n, item: undefined, lock: 'none', editable: true, deletable: true, resettable: false, + })); + protocol.historyMetaItem.mockClear(); + protocol.auditMetaItem.mockClear(); + protocol.diffMetaItem.mockClear(); + for (const suffix of ['/history', '/audit', '/diff']) { + const res = await drive(rest, suffix, type, name); + expect(envelope(res), `${type} ${suffix} withheld`).toEqual({ status: 404, code: 'RESOURCE_NOT_FOUND' }); + expect(text(res), `${type} ${suffix}`).not.toContain('w_open_matters'); + expect(text(res), `${type} ${suffix}`).not.toContain('nav_payroll_runs'); + } + expect(protocol.historyMetaItem).not.toHaveBeenCalled(); + expect(protocol.auditMetaItem).not.toHaveBeenCalled(); + expect(protocol.diffMetaItem).not.toHaveBeenCalled(); + } + }); + it('a type no per-caller gate judges costs its event and diff doors no extra read', async () => { // [#22639] Re-subjected from `view`, which the list view audience gate // now judges per caller, to `flow`, which no gate judges. diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index bc2c2161436..f6de39eac3b 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3671,6 +3671,15 @@ export class RestServer { * which is not the item these doors read. Such a key is answered as no * current document, exactly as it was before the arm existed: no app name * is in the package-id grammar, so nothing is stored under it. + * + * [#7557, #24210] …and THROWS the plain read's absence (`404 + * RESOURCE_NOT_FOUND`) when the plain read withholds an item that is + * there: a disabled package's item, which the by-name read answers as + * absent while its layers still resolve. Answered as no current document, + * it took these doors past their per-caller gate (events served ungated, a + * view's or dashboard's diff served ungated). The layered read, which keeps + * serving every layer, tells it apart from a deleted item, whose layers are + * all null: that one keeps its answer, `undefined`. */ private async fetchCurrentMetaDocument( req: any, @@ -3682,7 +3691,16 @@ export class RestServer { name: req.params.name, }; const envelope = await p.getMetaItem(currentRequest) as Record; - return envelope?.item ?? undefined; + const current = envelope?.item ?? undefined; + if (current !== undefined || typeof (p as any).getMetaItemLayered !== 'function') return current; + const layered = await (p as any).getMetaItemLayered(currentRequest); + if (layered?.effective != null) { + throw Object.assign(new Error('Metadata item not found or access denied.'), { + code: 'RESOURCE_NOT_FOUND', + status: 404, + }); + } + return undefined; } /** diff --git a/packages/runtime/src/domains/meta-disabled-package-doors.test.ts b/packages/runtime/src/domains/meta-disabled-package-doors.test.ts new file mode 100644 index 00000000000..87dfec21923 --- /dev/null +++ b/packages/runtime/src/domains/meta-disabled-package-doors.test.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7557, #24210] The dispatcher's `/meta` doors that read past the protocol — + * the engine registry (a resolution reader, which keeps a disabled package's + * objects) or the metadata service (which knows nothing of a disable) — answer + * a DISABLED package's object or item as `GET /meta/object/:name` and the + * protocol's by-name read do: + * + * - `/meta/object/:name/state/:field` and the legacy one-segment + * `/meta/:name` exit read `registry.getObject`: an object of a disabled + * package is answered the protocol's `404 OBJECT_PACKAGE_DISABLED`; + * - `/meta/:type/:name/published` falls through to the published snapshot + * when the layered read throws: the refusal is the answer, never the + * snapshot; + * - the item read of a non-object type falls back to the metadata service + * when the protocol serves no item: a disabled package's item there is + * absent, byte for byte the answer for a name with nothing behind it. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const DISABLED_PKG = 'app.disabled_demo'; +const HEALTHY_PKG = 'app.healthy_demo'; +const DISABLED = 'demo_widget'; +const HEALTHY = 'healthy_widget'; + +const schema = (name: string) => ({ + name, + label: 'Demo', + fields: { status: { name: 'status', type: 'select', label: 'Status' } }, +}); + +/** The protocol's own refusal, as `objectPackageDisabledError` builds it. */ +const refusal = (object: string) => Object.assign( + new Error(`Object '${object}' belongs to a disabled package and is not being served. ` + + 'Re-enable the package to restore access.'), + { code: 'OBJECT_PACKAGE_DISABLED', status: 404, object }, +); + +/** The metadata service's copies: it keeps both packages' pages. */ +const SERVICE_ITEMS: Record = { + demo_page: { name: 'demo_page', label: 'Demo', _packageId: DISABLED_PKG }, + healthy_page: { name: 'healthy_page', label: 'Healthy', _packageId: HEALTHY_PKG }, +}; + +function boot(opts: { getMetaTypes?: boolean } = {}) { + const getMetaItem = vi.fn(async ({ type, name }: any) => { + if (type === 'object' && name === DISABLED) throw refusal(name); + if (type === 'object' && name === HEALTHY) return { type, name, item: schema(name) }; + // The protocol answers the disabled package's page as absent (#24210), + // and holds no copy of the healthy one: the service fallback serves it. + return { type, name, item: undefined, lock: 'none', editable: true }; + }); + const getMetaItemLayered = vi.fn(async ({ type, name }: any) => { + if (type === 'object' && name === DISABLED) throw refusal(name); + return { type, name, code: null, overlay: null, effective: null }; + }); + const protocol: Record = { + getMetaItem, + getMetaItemLayered, + getMetaItems: vi.fn(async () => undefined), + ...(opts.getMetaTypes === false ? {} : { + getMetaTypes: vi.fn(async () => ({ types: ['object', 'page'] })), + }), + }; + // The real registry keeps serving both objects (a resolution reader) and + // answers both predicates. + const registry = { + getObject: vi.fn((name: string) => (name === DISABLED || name === HEALTHY ? schema(name) : undefined)), + listItems: vi.fn(() => []), + isObjectPackageDisabled: (name: string) => name === DISABLED, + isPackageDisabled: (id: unknown) => id === DISABLED_PKG, + }; + const metadata = { + getItem: vi.fn(async (_type: string, name: string) => SERVICE_ITEMS[name]), + getPublished: vi.fn(async (_type: string, name: string) => (name === DISABLED ? schema(name) : undefined)), + list: vi.fn(async () => []), + }; + const auth = { api: { getSession: async () => ({ user: { id: 'u_session' } }) } }; + const services: Record = { protocol, auth, objectql: { registry }, metadata }; + const get = (n: string) => services[n] ?? null; + const kernel = { + context: { getService: get }, + getService: get, + getServiceAsync: async (n: string) => get(n), + } as any; + return { + // The environment-membership gate is not this file's subject. + dispatcher: new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false }), + registry, + metadata, + }; +} + +const SESSION = (): any => ({ request: { headers: {} }, environmentId: 'platform' }); + +const expectObjectRefusal = (res: any) => { + expect(res.response?.status).toBe(404); + expect(res.response?.body?.success).toBe(false); + expect(res.response?.body?.error?.code).toBe('OBJECT_PACKAGE_DISABLED'); +}; + +describe('#24210 — the dispatcher\'s registry-reading object doors and a disabled package', () => { + it('/state/:field refuses 404 OBJECT_PACKAGE_DISABLED, never the registry\'s copy', async () => { + const stack = boot(); + const res = await stack.dispatcher.dispatch( + 'GET', `/meta/object/${DISABLED}/state/status`, {}, { from: 'open' }, SESSION(), + ); + expectObjectRefusal(res); + expect(stack.registry.getObject).not.toHaveBeenCalledWith(DISABLED); + }); + + it('/state/:field, the control: another package\'s object is answered', async () => { + const stack = boot(); + const res = await stack.dispatcher.dispatch( + 'GET', `/meta/object/${HEALTHY}/state/status`, {}, { from: 'open' }, SESSION(), + ); + expect(res.response?.status).toBe(200); + expect(res.response?.body?.data).toEqual({ object: HEALTHY, field: 'status', from: 'open', next: null }); + }); + + it('the legacy one-segment exit refuses 404 OBJECT_PACKAGE_DISABLED, never the registry\'s copy', async () => { + // Reached only by a host whose protocol declares no type set (the + // unknown-type refusal fails open), so the segment is read as a name. + const stack = boot({ getMetaTypes: false }); + const res = await stack.dispatcher.dispatch('GET', `/meta/${DISABLED}`, {}, {}, SESSION()); + expectObjectRefusal(res); + expect(stack.registry.getObject).not.toHaveBeenCalledWith(DISABLED); + }); + + it('the legacy one-segment exit, the control: another package\'s object is served', async () => { + const stack = boot({ getMetaTypes: false }); + const res = await stack.dispatcher.dispatch('GET', `/meta/${HEALTHY}`, {}, {}, SESSION()); + expect(res.response?.status).toBe(200); + expect(res.response?.body?.data?.name).toBe(HEALTHY); + }); + + it('/published answers the layered read\'s refusal, never the snapshot', async () => { + const stack = boot(); + const res = await stack.dispatcher.dispatch('GET', `/meta/object/${DISABLED}/published`, {}, {}, SESSION()); + expectObjectRefusal(res); + expect(stack.metadata.getPublished).not.toHaveBeenCalled(); + }); +}); + +describe('#24210 — the item read\'s metadata-service fallback and a disabled package', () => { + it('a disabled package\'s item is absent, byte for byte the answer for a missing name', async () => { + const stack = boot(); + const hidden = await stack.dispatcher.dispatch('GET', '/meta/page/demo_page', {}, {}, SESSION()); + const missing = await stack.dispatcher.dispatch('GET', '/meta/page/no_such_page', {}, {}, SESSION()); + + expect(hidden.response?.status).toBe(404); + expect(hidden.response?.body?.error?.code).toBe('RESOURCE_NOT_FOUND'); + expect(hidden.response?.status).toBe(missing.response?.status); + expect(JSON.stringify(hidden.response?.body)).toBe(JSON.stringify(missing.response?.body)); + // The service was asked, and its copy was not served. + expect(stack.metadata.getItem).toHaveBeenCalledWith('page', 'demo_page', undefined); + }); + + it('the control: another package\'s item the service holds is served', async () => { + const stack = boot(); + const res = await stack.dispatcher.dispatch('GET', '/meta/page/healthy_page', {}, {}, SESSION()); + expect(res.response?.status).toBe(200); + expect(res.response?.body?.data?.item?.name).toBe('healthy_page'); + }); +}); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index c1479ff3ac2..beac40753a7 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -210,6 +210,53 @@ async function resolveProtocol( return await deps.resolveService(context, 'protocol'); } +/** + * [#7557, #24210] The answer for an object of a DISABLED package on a door that + * reads the registry for it: the registry is a resolution reader and keeps + * serving that object (`packages/objectql/src/package-disable-enforcement.test.ts`), + * so such a door asks this first. `undefined` when `name` is no such object. + * Otherwise the protocol's own refusal, `404 OBJECT_PACKAGE_DISABLED`, the + * answer `GET /meta/object/:name` gives; a host whose protocol does not refuse + * it is answered `absent`, the door's own answer for no object. The predicate is + * `SchemaRegistry`'s and is not on the engine's registry view, so it is asked + * when present, as the object branch of the item read asks it. + */ +async function disabledObjectAnswer( + deps: DomainHandlerDeps, + context: HttpProtocolContext, + registry: unknown, + name: string, + absent: { status: number; body: any }, +): Promise { + const predicate = (registry as { isObjectPackageDisabled?: (n: string) => boolean } | undefined) + ?.isObjectPackageDisabled; + if (typeof predicate !== 'function' || predicate.call(registry, name) !== true) return undefined; + const protocol = await resolveProtocol(deps, context); + if (protocol && typeof protocol.getMetaItem === 'function') { + try { + await protocol.getMetaItem({ type: 'object', name }); + } catch (e) { + if ((e as { code?: unknown } | null)?.code === 'OBJECT_PACKAGE_DISABLED') { + return { handled: true, response: deps.errorFromThrown(e, 404) }; + } + } + } + return { handled: true, response: absent }; +} + +/** + * [#7557, #24210] Whether a document the metadata service holds belongs to a + * disabled package: its `_packageId`, asked of the engine registry's + * `isPackageDisabled`, the predicate the protocol's list and by-name reads ask + * of a non-object item. The service knows nothing of a disable, so a door that + * falls back to it asks this before serving what it found. + */ +function isServiceItemOfDisabledPackage(registry: unknown, item: unknown): boolean { + const predicate = (registry as { isPackageDisabled?: (id: unknown) => boolean } | undefined)?.isPackageDisabled; + return typeof predicate === 'function' + && predicate.call(registry, (item as { _packageId?: unknown } | null | undefined)?._packageId) === true; +} + /** * [#20338] May this caller read PENDING metadata (a `sys_metadata` row in * `state: 'draft'`)? The one question this domain's draft doors ask — @@ -1023,6 +1070,12 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin if (arity) return queryRefusalAnswer(deps, arity); const from = query?.from !== undefined ? String(query.from) : undefined; const qlService = await deps.getObjectQL(_context); + // [#7557, #24210] An API door reading the registry, a resolution + // reader: an object of a disabled package gets the item read's answer. + const disabled = await disabledObjectAnswer( + deps, _context, qlService?.registry, name, deps.error('Object not found', 404), + ); + if (disabled) return disabled; const schema = qlService?.registry?.getObject(name); if (!schema) return { handled: true, response: deps.error('Object not found', 404) }; // Dynamic import (matches the runtime convention for @objectstack/objectql) @@ -1235,7 +1288,16 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin ? layered.effective : layered.overlay; } - } catch { /* fall through to the code/package snapshot below */ } + } catch (e) { + // [#7557, #24210] The protocol's refusal of an object of a + // disabled package is the answer, as `RestServer`'s + // `/published` renders it: the snapshot below would serve the + // object the refusal withholds. Any other throw still falls + // through to the code/package snapshot below. + if ((e as { code?: unknown } | null)?.code === 'OBJECT_PACKAGE_DISABLED') { + return { handled: true, response: deps.errorFromThrown(e, 404) }; + } + } } if (publishedOverlay !== undefined) return servePublished(publishedOverlay); @@ -1875,6 +1937,13 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // package-scoped (prefer-local), matching list resolution. data = await (metaSvc as any).getItem(singularType, name, packageId); } catch { /* not found */ } + // [#7557, #24210] The protocol answers a disabled package's item + // as absent, and this fallback must not serve it in its place: + // the service keeps that item. `package` itself is never hidden. + if (data && singularType !== 'package' + && isServiceItemOfDisabledPackage((await deps.getObjectQL(_context))?.registry, data)) { + data = undefined; + } // [#5563] Same convergence as the object branch above: the // MetadataService hands back the bare document, so wrap it in // the declared envelope rather than letting which service @@ -2159,6 +2228,13 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // `book`) serves an object SCHEMA. An anonymous caller never reaches // a schema — the deny it skipped is answered here instead. if (anonymous) return anonymousDeny(); + // [#7557, #24210] The registry keeps serving a disabled package's + // object (a resolution reader), so this exit answers it as the item + // read does. + const disabled = await disabledObjectAnswer( + deps, _context, qlService.registry, typeOrName, deps.error('Not found', 404), + ); + if (disabled) return disabled; // Legacy: treat as object name. [ADR-0106 D5(4)] A schema-bearing // exit reached by a one-segment path — masked like every other, so // the legacy spelling is not a way around the projection.