From 8ce9d48018108f8cf4726c7f29bc590ed570cb10 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:53:00 +0000 Subject: [PATCH 1/6] fix(metadata-protocol): the by-name /meta read refuses an object of a disabled package getMetaItem and getMetaItemLayered answer 404 OBJECT_PACKAGE_DISABLED, from the data plane's own constructor, for an object whose owning package is disabled. The save's destructive-change check reads the private shared body, so it still sees that object. Claude-Session: https://claude.ai/code/session_01KTccq4q6rvEht5nZtQbLii Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 89 ++++++++++++++++++---- 1 file changed, 74 insertions(+), 15 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 5975e10fca..819097cbce 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -501,6 +501,35 @@ function stripServedSystemColumns(type: string, item: T): T { */ let warnedNoRegistryForDataGate = false; +/** + * [#7557, #24097] The ONE refusal for an object whose owning package is + * disabled, raised by every API reader of that object: the data plane + * ({@link ObjectStackProtocolImplementation.assertObjectRegistered}) and the + * by-name `/meta` reads (`getMetaItem`, `getMetaItemLayered`). One constructor, + * so the two halves of the answer cannot drift apart in code, status or text. + */ +function objectPackageDisabledError(object: string): Error { + const disabled: any = new Error( + `Object '${object}' belongs to a disabled package and is not being served. ` + + 'Re-enable the package to restore access.', + ); + disabled.code = 'OBJECT_PACKAGE_DISABLED'; + disabled.status = 404; + disabled.object = object; + return disabled; +} + +/** + * [#7557, #24097] Whether `registry` says `name` is an object of a disabled + * package. Optional-called: registry doubles across the test suites implement + * `isPackageDisabled` but not this, and a host whose registry cannot answer + * must not have every read refused. + */ +function isObjectOfDisabledPackage(registry: any, name: string): boolean { + return typeof registry?.isObjectPackageDisabled === 'function' + && registry.isObjectPackageDisabled(name) === true; +} + /** * [#17501] Does a converted JSON Schema carry any authorable surface at all? * @@ -11003,10 +11032,40 @@ export class ObjectStackProtocolImplementation implements return request; } + /** + * The by-name `/meta` read (`GET /meta/:type/:name`): an API reader. + * + * [#7557, #24097] An object whose owning package is disabled is refused + * here, `404 OBJECT_PACKAGE_DISABLED`, in every state this read serves + * (active, `state: 'draft'`, `previewDrafts`), checked once before any + * layer is read: the listing omits that object and the data plane refuses + * it, and this read answers with them. Authoring callers inside this class + * 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. + */ async getMetaItem(request: { type: string, name: string, packageId?: string, state?: 'active' | 'draft', previewDrafts?: boolean, /** [#22877] The bundle member to read — {@link resolveBundleMemberSelector}; omitted, the canonical one. */ member?: string, + }) { + // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. + request = canonicalizeMetaRequestType(request); + if (request.type === 'object' && isObjectOfDisabledPackage(this.engine?.registry, request.name)) { + throw objectPackageDisabledError(request.name); + } + return this.readMetaItem(request); + } + + /** + * The body of {@link getMetaItem} without its disabled-package refusal: + * the object as the layers resolve it, whether or not its package is + * disabled. Private on purpose; only this class's authoring callers read + * it. + */ + private async readMetaItem(request: { + type: string, name: string, packageId?: string, state?: 'active' | 'draft', previewDrafts?: boolean, + member?: string, }) { // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. request = canonicalizeMetaRequestType(request); @@ -11640,6 +11699,12 @@ export class ObjectStackProtocolImplementation implements // three-layer diagnostic must answer for ONE namespace, or `code` and // `overlay` can be read from two. request = canonicalizeMetaRequestType(request); + // [#7557, #24097] A by-name API face of its own (`?layers=true`), which + // reads its layers without going through {@link getMetaItem}, so it + // refuses a disabled package's object itself: one answer per door. + if (request.type === 'object' && isObjectOfDisabledPackage(this.engine?.registry, request.name)) { + throw objectPackageDisabledError(request.name); + } // [ADR-0131 D6] Environment → code: the overlay layer is the // environment's stored row; no organization's row is read. @@ -12418,20 +12483,9 @@ export class ObjectStackProtocolImplementation implements // object that is merely switched off, while this one names the cause // and therefore the fix (re-enable the package). // - // Optional-called: registry doubles across the test suites implement - // `isPackageDisabled` but not this, and a host whose registry cannot - // answer must not have every read refused. - if (typeof registry.isObjectPackageDisabled === 'function' - && registry.isObjectPackageDisabled(object)) { - const disabled: any = new Error( - `Object '${object}' belongs to a disabled package and is not being served. ` - + 'Re-enable the package to restore access.', - ); - disabled.code = 'OBJECT_PACKAGE_DISABLED'; - disabled.status = 404; - disabled.object = object; - throw disabled; - } + // [#24097] The by-name `/meta` reads raise the same refusal, from + // the same constructor ({@link objectPackageDisabledError}). + if (isObjectOfDisabledPackage(registry, object)) throw objectPackageDisabledError(object); return; } // The one envelope, shared with the engine's in-process verbs, which @@ -22888,7 +22942,12 @@ export class ObjectStackProtocolImplementation implements // package's it judged a row no package-less save writes, and a // save naming a third package was judged against another // package's row. - const existing = await this.getMetaItem({ + // [#24097] Read through {@link readMetaItem}, not the API face: + // this check must still see an object of a disabled package. + // The API face refuses one, and the `catch` below would read + // that refusal as "skip the check", so a field drop would land + // without its 409. + const existing = await this.readMetaItem({ type: request.type, name: request.name, ...(request.packageId ? { packageId: request.packageId } : {}), From ae7c051db3426b96aa1077489f8fd2b285aa1105 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:59:21 +0000 Subject: [PATCH 2/6] fix(runtime,rest): the /meta object door and the exposure gate answer a disabled package's object as the protocol does Claude-Session: https://claude.ai/code/session_01KTccq4q6rvEht5nZtQbLii Co-authored-by: Claude --- .../src/package-disable-enforcement.test.ts | 140 ++++++++++++++++++ packages/rest/src/meta-item-read-gate.ts | 5 + packages/runtime/src/domains/meta.ts | 14 +- 3 files changed, 158 insertions(+), 1 deletion(-) diff --git a/packages/objectql/src/package-disable-enforcement.test.ts b/packages/objectql/src/package-disable-enforcement.test.ts index f26100cf36..c206a341dd 100644 --- a/packages/objectql/src/package-disable-enforcement.test.ts +++ b/packages/objectql/src/package-disable-enforcement.test.ts @@ -176,6 +176,15 @@ async function seed(protocol: any, registry: SchemaRegistry) { ] as const) { registry.installPackage({ id: pkg, name: pkg, version: '1.0.0' } as any, 'code'); await protocol.saveMetaItem({ type: 'object', name: obj, packageId: pkg, item: objectBody(obj) }); + // [#24097] A pending draft too, so the by-name read's `state: 'draft'` row + // has a draft to serve before the disable (else it answers NO_DRAFT there). + await protocol.saveMetaItem({ + type: 'object', + name: obj, + packageId: pkg, + mode: 'draft', + item: { ...objectBody(obj), label: 'Demo (draft)' }, + }); // `app` rather than `view` as the non-object specimen: a `view` is saved as // an ADR-0017 CONTAINER and only becomes independently listable once the // registrar expands it into ViewItems, which this metadata-only harness has @@ -201,6 +210,21 @@ const listItemsOf = async (protocol: any, type: string): Promise => { const listNames = async (protocol: any, type: string): Promise => (await listItemsOf(protocol, type)).map((i) => i?.name).filter(Boolean); +/** + * [#24097] Whether a by-name read serves `demo_widget`. Only the + * `OBJECT_PACKAGE_DISABLED` refusal counts as "refuses": any other throw + * (`NO_DRAFT`, a bare not-found) is rethrown, so a row cannot read an + * unrelated failure as enforcement. + */ +const servesByName = async (read: () => Promise, pick: (r: any) => unknown): Promise => { + try { + return (pick(await read()) ?? null) !== null; + } catch (e: any) { + if (e?.code === 'OBJECT_PACKAGE_DISABLED') return false; + throw e; + } +}; + /** * Items a listing serves for one package. Counted by `_packageId` rather than * by name because `view` is enumerated in its EXPANDED form (ADR-0017 splits a @@ -240,6 +264,50 @@ const READERS: Array<{ why: 'the pre-existing behaviour the objects half was made consistent with; regression guard', serves: async ({ protocol }) => (await servedFor(protocol, 'app', PKG)) > 0, }, + { + reader: 'getMetaItem({ type: "object", name }) — GET /meta/object/:name', + enforces: true, + why: 'the by-name half of the listing: the console record form, metadata provider and SDK getItem read it', + serves: async ({ protocol }) => + servesByName(() => protocol.getMetaItem({ type: 'object', name: 'demo_widget' }), (r) => r?.item), + }, + { + reader: 'getMetaItem({ type: "objects", name }) — the plural spelling of the same route', + enforces: true, + why: 'folded to the singular before the refusal; pinned so the fold cannot silently stop folding', + serves: async ({ protocol }) => + servesByName(() => protocol.getMetaItem({ type: 'objects', name: 'demo_widget' }), (r) => r?.item), + }, + { + reader: 'getMetaItem({ state: "draft" }) — GET /meta/object/:name?state=draft', + enforces: true, + why: 'the designer\'s draft read: the refusal is checked once, before any layer, in every state', + serves: async ({ protocol }) => + servesByName( + () => protocol.getMetaItem({ type: 'object', name: 'demo_widget', state: 'draft' }), + (r) => r?.item, + ), + }, + { + reader: 'getMetaItem({ previewDrafts: true }) — GET /meta/object/:name?preview=draft', + enforces: true, + why: 'the render path\'s draft-overlay preview, which returns before the active read does', + serves: async ({ protocol }) => + servesByName( + () => protocol.getMetaItem({ type: 'object', name: 'demo_widget', previewDrafts: true }), + (r) => r?.item, + ), + }, + { + reader: 'getMetaItemLayered — GET /meta/object/:name?layers=true', + enforces: true, + why: 'a by-name API face that reads its layers without getMetaItem, so it carries the refusal itself', + serves: async ({ protocol }) => + servesByName( + () => protocol.getMetaItemLayered({ type: 'object', name: 'demo_widget' }), + (r) => r?.effective, + ), + }, { reader: 'assertObjectRegistered — the data plane (findData / getData funnel here)', enforces: true, @@ -347,6 +415,78 @@ describe('#7557 (A) — a disabled package stops serving, on every API reader at expect(String(err.message)).toMatch(/re-enable/i); }); + it('[#24097] the by-name refusal is the data plane\'s: OBJECT_PACKAGE_DISABLED / 404, one envelope', async () => { + const h = makeHarness(); + await seed(h.protocol, h.registry); + h.registry.disablePackage(PKG); + + const caught = async (read: () => Promise): Promise => { + try { + await read(); + } catch (e) { + return e; + } + return undefined; + }; + let dataPlane: any; + try { + (h.protocol as any).assertObjectRegistered('demo_widget'); + } catch (e) { + dataPlane = e; + } + const byName = await caught(() => h.protocol.getMetaItem({ type: 'object', name: 'demo_widget' })); + const layered = await caught(() => h.protocol.getMetaItemLayered({ type: 'object', name: 'demo_widget' })); + + for (const err of [byName, layered]) { + expect(err?.code).toBe('OBJECT_PACKAGE_DISABLED'); + expect(err?.status).toBe(404); + expect(err?.object).toBe('demo_widget'); + // One constructor for both halves of the answer, so they cannot drift. + expect(err?.message).toBe(dataPlane?.message); + } + }); + + it('[#24097] the control: another package\'s object is still served by name', async () => { + const h = makeHarness(); + await seed(h.protocol, h.registry); + h.registry.disablePackage(PKG); + + const served: any = await h.protocol.getMetaItem({ type: 'object', name: 'healthy_widget' }); + expect(served?.item?.name).toBe('healthy_widget'); + expect(served?.item?._packageId).toBe(OTHER_PKG); + const layered: any = await h.protocol.getMetaItemLayered({ type: 'object', name: 'healthy_widget' }); + expect((layered?.effective as any)?.name).toBe('healthy_widget'); + }); + + 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 + // the API face, its `catch` took the refusal for "skip the check" and the + // drop landed with no confirmation. + const h = makeHarness(); + await seed(h.protocol, h.registry); + h.registry.disablePackage(PKG); + + let err: any; + try { + await h.protocol.saveMetaItem({ + type: 'object', + name: 'demo_widget', + packageId: PKG, + item: { ...objectBody('demo_widget'), fields: { other: { name: 'other', type: 'text', label: 'Other' } } }, + }); + } catch (e) { + err = e; + } + expect(err?.code).toBe('DESTRUCTIVE_CHANGE'); + expect(err?.status).toBe(409); + const stored = [...h.rows.values()].find( + (r: any) => r.type === 'object' && r.name === 'demo_widget' && (r.state ?? 'active') === 'active', + ); + const body = typeof stored?.metadata === 'string' ? JSON.parse(stored.metadata) : stored?.metadata; + expect(Object.keys(body?.fields ?? {})).toContain('name'); + }); + it('an object that is genuinely absent still answers OBJECT_NOT_FOUND', async () => { const h = makeHarness(); await seed(h.protocol, h.registry); diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index e9a6080441..2f3a66a710 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -460,6 +460,10 @@ export async function loadObjectItems(source: MetaReadGateListSource): Promise Date: Sat, 17 Oct 2026 17:01:18 +0000 Subject: [PATCH 3/6] test(runtime,rest): pin the disabled-package object door on the dispatcher and the exposure gate Claude-Session: https://claude.ai/code/session_01KTccq4q6rvEht5nZtQbLii Co-authored-by: Claude --- ...-read-gate.object-package-disabled.test.ts | 72 +++++++++++++ .../meta-object-disabled-package.test.ts | 101 ++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 packages/rest/src/meta-item-read-gate.object-package-disabled.test.ts create mode 100644 packages/runtime/src/domains/meta-object-disabled-package.test.ts diff --git a/packages/rest/src/meta-item-read-gate.object-package-disabled.test.ts b/packages/rest/src/meta-item-read-gate.object-package-disabled.test.ts new file mode 100644 index 0000000000..92bf14121e --- /dev/null +++ b/packages/rest/src/meta-item-read-gate.object-package-disabled.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7557, #24097] The API-exposure gate's by-name object read + * (`loadObjectItem`) and an object of a DISABLED package. + * + * The by-name `/meta` read refuses that object `404 OBJECT_PACKAGE_DISABLED`, + * as the listing omits it. For this gate that refusal means "no object here": + * there is no declared exposure policy to judge, and the data plane refuses the + * request with the same code right after. It is not a failed read, so the + * gate's fail-open warning ("object metadata read failed") is not logged for + * it. Before, it was logged on every `/data` request to such an object. + * + * Any OTHER thrown read still fails open and still warns: that half is pinned + * beside it, so the exemption cannot widen into silence. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { loadObjectItem } from './meta-item-read-gate.js'; + +const source = (getObjectItem: (name: string) => Promise) => + ({ getMetaItems: async () => ({ items: [] }), getObjectItem }) as any; + +describe('#24097 — loadObjectItem and a disabled package\'s object', () => { + let warn: ReturnType; + let priorLevel: string | undefined; + + beforeEach(() => { + // The warning must be observable for the "not logged" half to mean + // anything: the suite's harness may lower the REST log level. + priorLevel = process.env.OS_REST_LOG; + process.env.OS_REST_LOG = 'warn'; + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => { + warn.mockRestore(); + if (priorLevel === undefined) delete process.env.OS_REST_LOG; + else process.env.OS_REST_LOG = priorLevel; + }); + + it('OBJECT_PACKAGE_DISABLED reads as "no object", with no fail-open warning', async () => { + const refused = Object.assign(new Error('disabled package'), { + code: 'OBJECT_PACKAGE_DISABLED', status: 404, object: 'demo_widget', + }); + + const item = await loadObjectItem(source(async () => { throw refused; }), 'demo_widget'); + + expect(item).toBeUndefined(); + expect(warn).not.toHaveBeenCalled(); + }); + + it('any other thrown read still fails open AND warns', async () => { + const item = await loadObjectItem( + source(async () => { throw Object.assign(new Error('store down'), { status: 503 }); }), + 'demo_widget', + ); + + expect(item).toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain('failing open'); + }); + + it('the control: a served object is returned', async () => { + const item = await loadObjectItem( + source(async (name) => ({ type: 'object', name, item: { name, enable: { apiEnabled: true } } })), + 'healthy_widget', + ); + + expect(item?.name).toBe('healthy_widget'); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/runtime/src/domains/meta-object-disabled-package.test.ts b/packages/runtime/src/domains/meta-object-disabled-package.test.ts new file mode 100644 index 0000000000..56448be6d2 --- /dev/null +++ b/packages/runtime/src/domains/meta-object-disabled-package.test.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7557, #24097] `GET /meta/object/:name` on the dispatcher transport answers + * an object of a DISABLED package as the protocol does: `404 + * OBJECT_PACKAGE_DISABLED`, never the registry's copy of it. + * + * The object branch of `handleMetadataRequest` has two registry reads, and + * both served that object before: + * + * - an UNSCOPED kernel reads `registry.getObject(name)` FIRST and asks the + * protocol only when the registry misses; + * - a SCOPED kernel asks the protocol first, and when the protocol throws it + * fell through to `registry.getObject(name)`. + * + * The registry is a resolution reader: it keeps a disabled package's objects + * on purpose (migrations and cross-package references resolve through it, see + * `packages/objectql/src/package-disable-enforcement.test.ts`). So neither + * read may answer an API door for such an object. The protocol's refusal is + * the answer. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const DISABLED = 'demo_widget'; +const HEALTHY = 'healthy_widget'; + +const schema = (name: string) => ({ + name, + label: 'Demo', + fields: { name: { name: 'name', type: 'text', label: 'Name' } }, +}); + +/** 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 }, +); + +function boot(opts: { scoped: boolean }) { + const getMetaItem = vi.fn(async ({ name }: any) => { + if (name === DISABLED) throw refusal(name); + return { type: 'object', name, item: schema(name) }; + }); + const protocol: Record = { + getMetaItem, + ...(opts.scoped ? { environmentId: 'env_scoped' } : {}), + }; + // The real registry keeps serving both objects (a resolution reader) and + // answers the predicate for the disabled one. + const registry = { + getObject: vi.fn((name: string) => (name === DISABLED || name === HEALTHY ? schema(name) : undefined)), + isObjectPackageDisabled: (name: string) => name === DISABLED, + }; + const auth = { api: { getSession: async () => ({ user: { id: 'u_session' } }) } }; + const services: Record = { protocol, auth, objectql: { registry } }; + 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 }), + getMetaItem, + registry, + }; +} + +const SESSION = (): any => ({ request: { headers: {} }, environmentId: 'platform' }); + +describe('#24097 — the dispatcher\'s /meta object door and a disabled package', () => { + for (const scoped of [false, true]) { + const kernel = scoped ? 'scoped kernel (protocol first)' : 'unscoped kernel (registry first)'; + + it(`${kernel}: refuses 404 OBJECT_PACKAGE_DISABLED, never the registry's copy`, async () => { + const stack = boot({ scoped }); + + const res = await stack.dispatcher.dispatch('GET', `/meta/object/${DISABLED}`, {}, {}, SESSION()); + + expect(res.response?.status).toBe(404); + expect(res.response?.body?.success).toBe(false); + expect(res.response?.body?.error?.code).toBe('OBJECT_PACKAGE_DISABLED'); + // The protocol answered, and the registry's copy was not served. + expect(stack.getMetaItem).toHaveBeenCalled(); + expect(stack.registry.getObject).not.toHaveBeenCalledWith(DISABLED); + }); + + it(`${kernel}: the control, another package's object is served`, async () => { + const stack = boot({ scoped }); + + const res = await stack.dispatcher.dispatch('GET', `/meta/object/${HEALTHY}`, {}, {}, SESSION()); + + expect(res.response?.status).toBe(200); + expect(res.response?.body?.data?.item?.name).toBe(HEALTHY); + }); + } +}); From 6ef386162415ec904142ff6bb8e994f4b278b588 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:02:57 +0000 Subject: [PATCH 4/6] test(rest): a denied operation on a disabled package's object answers the data plane's refusal again Claude-Session: https://claude.ai/code/session_01KTccq4q6rvEht5nZtQbLii Co-authored-by: Claude --- ...rest-exposure-gate-one-object-read.test.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/rest/src/rest-exposure-gate-one-object-read.test.ts b/packages/rest/src/rest-exposure-gate-one-object-read.test.ts index 8c34c977b3..94dad2586a 100644 --- a/packages/rest/src/rest-exposure-gate-one-object-read.test.ts +++ b/packages/rest/src/rest-exposure-gate-one-object-read.test.ts @@ -27,10 +27,9 @@ // 3. FAULTS — a THROWING by-name read fails OPEN and logs, as #3545 requires; // "no object" logs nothing. // 4. FALLBACK — a protocol with no by-name read is asked the list, as before. -// 5. A DISABLED PACKAGE'S OBJECT — the one class whose answer moves: the list -// omits it, the by-name read serves it, so a DENIED operation now answers -// the exposure verdict instead of the data plane's 404 -// `OBJECT_PACKAGE_DISABLED`; an ALLOWED one still gets that 404. +// 5. A DISABLED PACKAGE'S OBJECT — [#24097] the by-name read refuses it as the +// list omits it, so the gate sees no object and every operation, denied or +// allowed, gets the data plane's 404 `OBJECT_PACKAGE_DISABLED`. // // ⛔ The count rows are the regression this file exists for: put the list read // back in `enforceApiAccess` and they go red while every answer row stays @@ -298,19 +297,20 @@ describe('[#24031] a protocol with no by-name read is asked the list, as before' }); }); -// [#24031] An object whose package is DISABLED: the list read omits it (#7557) -// while the by-name read still serves it, so the gate now judges it by its own -// `enable`. A DENIED operation answers the exposure verdict — it answered the -// data plane's 404 OBJECT_PACKAGE_DISABLED while the gate read the list — and an -// ALLOWED one still reaches the data plane, which refuses it as before. +// [#24031, #24097] An object whose package is DISABLED: the list read omits it +// (#7557) and the by-name read refuses it `OBJECT_PACKAGE_DISABLED` (#24097), so +// the gate sees no object and judges nothing. Every operation reaches the data +// plane, which refuses it with that code. Between #24031 and #24097 the by-name +// read still served the object, and a DENIED operation answered the exposure +// verdict (405) instead. describe('[#24031] an object of a disabled package', () => { - it('a DENIED operation answers the exposure verdict (405), and the data call is never reached', async () => { + it('[#24097] a DENIED operation answers the data plane\'s 404 OBJECT_PACKAGE_DISABLED, not the exposure verdict', async () => { const h = host(); h.registry.disablePackage(OFF_PACKAGE); const out = await call(h, 'create', OFF.name); - expect(out.status, JSON.stringify(out.body)).toBe(405); - expect(errorCode(out.body)).toBe('OBJECT_API_METHOD_NOT_ALLOWED'); - expect(h.protocol.createData).not.toHaveBeenCalled(); + expect(out.status, JSON.stringify(out.body)).toBe(404); + expect(errorCode(out.body)).toBe('OBJECT_PACKAGE_DISABLED'); + expect(h.protocol.createData).toHaveBeenCalledTimes(1); }); it('an ALLOWED operation still answers the data plane\'s 404 OBJECT_PACKAGE_DISABLED', async () => { From 916d2bbd8c2200c33d914bfeb2d2c63562f78846 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:12:52 +0000 Subject: [PATCH 5/6] chore: changesets for the disabled-package by-name read; the lookupArtifactItem ledger keys its by-name rows on readMetaItem Claude-Session: https://claude.ai/code/session_01KTccq4q6rvEht5nZtQbLii Co-authored-by: Claude --- ...adata-protocol-by-name-disabled-package.md | 19 +++++++++++++++++++ ...097-rest-exposure-gate-disabled-package.md | 19 +++++++++++++++++++ ...97-runtime-meta-object-disabled-package.md | 18 ++++++++++++++++++ ...ol.lookup-artifact-item-call-sites.test.ts | 11 ++++++----- 4 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 .changeset/24097-metadata-protocol-by-name-disabled-package.md create mode 100644 .changeset/24097-rest-exposure-gate-disabled-package.md create mode 100644 .changeset/24097-runtime-meta-object-disabled-package.md diff --git a/.changeset/24097-metadata-protocol-by-name-disabled-package.md b/.changeset/24097-metadata-protocol-by-name-disabled-package.md new file mode 100644 index 0000000000..ffd9944149 --- /dev/null +++ b/.changeset/24097-metadata-protocol-by-name-disabled-package.md @@ -0,0 +1,19 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +fix(metadata-protocol)!: the by-name `/meta` read refuses an object of a disabled package with `404 OBJECT_PACKAGE_DISABLED`, as the object list and the data plane already do + +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 objects out of `GET /meta/objects` and refused their data with `404 OBJECT_PACKAGE_DISABLED`. The by-name read still served them. It now gives the same answer. + +- **Refused now:** `getMetaItem({ type: 'object', name })` (`GET /api/v1/meta/object/:name`, the plural spelling included) for an object whose owning package is disabled. The refusal applies to every state the read serves: the active read, `state: 'draft'` and `previewDrafts`. `getMetaItemLayered` (`?layers=true`) refuses it too. The answer is `404` with `code: 'OBJECT_PACKAGE_DISABLED'` and `object`, built by the same constructor as the data plane's refusal, so the two cannot drift. +- **Unchanged:** objects of enabled packages, other metadata types, and the registry's resolution reads (`registry.getObject`, `registry.listItems('object')`), which keep serving a disabled package's objects for migrations and cross-package references. +- **Still checked:** saving an object of a disabled package still runs the destructive-change check. A save that drops a field still answers `409 DESTRUCTIVE_CHANGE` until it is confirmed with `force`. The check reads the object's prior schema through the protocol's internal read, not the refusing door. + +What changes for you: a client that read a disabled package's object by name now gets `404 OBJECT_PACKAGE_DISABLED`. Re-enable the package to read it again. diff --git a/.changeset/24097-rest-exposure-gate-disabled-package.md b/.changeset/24097-rest-exposure-gate-disabled-package.md new file mode 100644 index 0000000000..9fb6aed902 --- /dev/null +++ b/.changeset/24097-rest-exposure-gate-disabled-package.md @@ -0,0 +1,19 @@ +--- +'@objectstack/rest': minor +--- + +fix(rest)!: the `/data` API-exposure check treats an object of a disabled package as no object, so every operation on it answers `404 OBJECT_PACKAGE_DISABLED` again, with no fail-open warning + +Clause-②: no (narrowing) + + + +**BREAKING** (a refusal moves), shipped as `minor` under the launch-window convention for breaking changes. + +The by-name `/meta` read now refuses an object of a disabled package with `404 OBJECT_PACKAGE_DISABLED`. The API-exposure check reads that object by name. It now reads that refusal as "no object here": there is no exposure policy to judge, and nothing failed, so it logs nothing. The request then reaches the data plane, which refuses it with the same code. + +- **Changed back:** for an object whose package is disabled, a denied operation goes from `405 OBJECT_API_METHOD_NOT_ALLOWED` (an operation outside `apiMethods`) or `404 OBJECT_API_DISABLED` (an object with `apiEnabled: false`) back to `404 OBJECT_PACKAGE_DISABLED`. That is what it answered before the check read one object by name. An allowed operation still answers `404 OBJECT_PACKAGE_DISABLED`. +- **No warning now:** such a request no longer logs `api-exposure gate: object metadata read failed — failing open`. +- **Unchanged:** any other failed read still fails open and still logs that warning. Objects of enabled packages get the same exposure answers as before. + +What changes for you: nothing to rewrite. No accepted request is now refused, and no refused request is now accepted. diff --git a/.changeset/24097-runtime-meta-object-disabled-package.md b/.changeset/24097-runtime-meta-object-disabled-package.md new file mode 100644 index 0000000000..7819071a3c --- /dev/null +++ b/.changeset/24097-runtime-meta-object-disabled-package.md @@ -0,0 +1,18 @@ +--- +'@objectstack/runtime': minor +--- + +fix(runtime)!: the dispatcher's `GET /meta/object/:name` answers an object of a disabled package with the protocol's `404 OBJECT_PACKAGE_DISABLED`, never the registry's copy + +Clause-②: no (narrowing) + + + +**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +The dispatcher's object read had two registry reads. On an unscoped kernel it read `registry.getObject(name)` before the protocol. On a scoped kernel it fell back to `registry.getObject(name)` when the protocol read threw. The registry keeps a disabled package's objects on purpose, so both reads served such an object. + +- **Refused now:** `GET /meta/object/:name` on the dispatcher transport, for an object whose owning package is disabled. It answers the protocol's `404 OBJECT_PACKAGE_DISABLED`, on scoped and unscoped kernels alike. +- **Unchanged:** every other object read, including the registry fallback for an object the protocol misses for any other reason. + +What changes for you: re-enable the package to read the object again. diff --git a/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts b/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts index 06647a5fc0..f95958233f 100644 --- a/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts +++ b/packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts @@ -13,7 +13,8 @@ * below answers is the card's: does that artifact's `_packageId` (its * envelope) reach an answer whose body came from a different package? * - * One row did: the by-name read's envelope merge in `getMetaItem`, which + * One row did: the by-name read's envelope merge in `getMetaItem` (since + * #24097 its body `readMetaItem`, which the rows below key on), which * served one package's stored copy under the first-registered package's * `_packageId`. It now looks its envelope up at the served item's own package * (`envelopePackageId`, the list's rule); the behaviour is pinned in @@ -70,13 +71,13 @@ type Disposition = 'definition' | 'served-package envelope' | 'no'; const SITES: ReadonlyArray = [ { member: 'lookupArtifactItem', args: 'type: string, name: string, currentPackageId?: string, member?: string', noPackage: false, disposition: 'definition', why: 'the definition itself' }, - { member: 'getMetaItem', args: 'request.type, request.name, envelopePackageId(request.packageId, item), bundleMemberOf(request.type, item)', noPackage: true, disposition: 'served-package envelope', + { member: 'readMetaItem', args: 'request.type, request.name, envelopePackageId(request.packageId, item), bundleMemberOf(request.type, item)', noPackage: true, disposition: 'served-package envelope', why: 'the by-name read\'s envelope: naming no package, the package of the item it serves (the list\'s rule); [#23074] at the served item\'s own bundle member' }, { member: 'readFlattenedMetaItems', args: 'request.type, itemName, itemPackageId, bundleMemberOf(request.type, it)', noPackage: true, disposition: 'served-package envelope', why: 'the list\'s envelope: each item\'s own package, the rule the by-name read shares; [#23074] at each item\'s own bundle member' }, - { member: 'getMetaItem', args: 'request.type, request.name, request.packageId', noPackage: true, disposition: 'no', + { member: 'readMetaItem', args: 'request.type, request.name, request.packageId', noPackage: true, disposition: 'no', why: 'the shipped-flow arm: the served body IS this artifact, and the envelope is looked up at its own package' }, - { member: 'getMetaItem', args: 'request.type, request.name, request.packageId, wantedMember', noPackage: true, disposition: 'no', + { member: 'readMetaItem', args: 'request.type, request.name, request.packageId, wantedMember', noPackage: true, disposition: 'no', why: '[#22877] a bundled type\'s addressed member (the selector\'s, else the canonical one) when no stored row of it exists: the served body IS this artifact, and the envelope is looked up at its own package' }, { member: 'getMetaItemLayered', args: 'request.type, request.name, request.packageId', noPackage: true, disposition: 'no', why: 'the layered read\'s code LAYER: body and envelope are one artifact, and its provenance fields are read off that layer' }, @@ -151,6 +152,6 @@ describe('[#22024] every lookupArtifactItem( in protocol.ts has a recorded dispo expect(SOURCE).toMatch(/\nfunction envelopePackageId\(requestedPackageId: string \| undefined, served: unknown\): string \| undefined \{/); expect(SOURCE).toContain('const itemPackageId = envelopePackageId(packageId, it);'); expect(SITES.filter((site) => site.disposition === 'served-package envelope').map((site) => site.member).sort()) - .toEqual(['getMetaItem', 'readFlattenedMetaItems']); + .toEqual(['readFlattenedMetaItems', 'readMetaItem']); }); }); From 81e9289baa3466f31285059cc907e133b4789953 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 17:26:41 +0000 Subject: [PATCH 6/6] fix(runtime): read the registry's disabled-package predicate through a local type, not the engine view Claude-Session: https://claude.ai/code/session_01KTccq4q6rvEht5nZtQbLii Co-authored-by: Claude --- packages/runtime/src/domains/meta.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 75122a8546..c1479ff3ac 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -1782,9 +1782,13 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // [#7557, #24097] The registry answers a disabled package's // object too, so it does not answer for one here: the // protocol read below refuses it `404 OBJECT_PACKAGE_DISABLED`, - // the answer the listing and the data plane give. - const disabled = typeof registry?.isObjectPackageDisabled === 'function' - && registry.isObjectPackageDisabled(name) === true; + // the answer the listing and the data plane give. The + // predicate is `SchemaRegistry`'s and is not on the engine's + // registry view, so it is asked when present, as the + // protocol's data plane asks it. + const predicate = (registry as { isObjectPackageDisabled?: (n: string) => boolean } | undefined) + ?.isObjectPackageDisabled; + const disabled = typeof predicate === 'function' && predicate.call(registry, name) === true; const data = disabled ? undefined : registry?.getObject(name); // [#5563] The registry hands back the bare ObjectSchema, so this // fallback used to answer a different body shape than the