From d78cc5fdab100408c6156760d760797a2ee6294c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 00:55:06 +0000 Subject: [PATCH 1/6] fix(rest,runtime,metadata-protocol)!: /published, REST /state, _drafts and the event-door absence body answer a disabled package's item as the plain read does Claude-Session: https://claude.ai/code/session_01Y9zBTp9ePwgEEJXeqyLG4N Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 12 +++- packages/rest/src/rest-server.ts | 79 ++++++++++++++++++++-- packages/runtime/src/domains/meta.ts | 24 +++++++ 3 files changed, 107 insertions(+), 8 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index b3ab40e82d4..da8e036ca0c 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -25531,6 +25531,14 @@ export class ObjectStackProtocolImplementation implements * promote and discard by, with the canonical member spelled out, as the * by-name doors' `member` selector takes it back * ({@link bundleMemberDiscriminator}); absent for a type with no members. + * + * [#7557, #24210] A disabled package's drafts are omitted, as the list + * read omits its items and the by-name draft read answers them `404 + * NO_DRAFT`: each row's `packageId`, asked of the same predicate ({@link + * isItemOfDisabledPackage}); `package` is never hidden. This method serves + * the `_drafts` doors only. The package batch doors (publish, discard, the + * batch closure) read the repository's `listDrafts` directly, so a + * disabled package's drafts still publish and discard. */ async listDrafts(request?: { packageId?: string; @@ -25558,8 +25566,10 @@ export class ObjectStackProtocolImplementation implements // [#23073] The declared header (`ListDraftsResponseSchema`), with the // row's stored member on every draft of a bundled type: absent there // means the canonical member, which is spelled out here. + const served = drafts.filter((draft) => draft.type === 'package' + || !isItemOfDisabledPackage(this.engine?.registry, { _packageId: draft.packageId })); return { - drafts: drafts.map(({ member: stored, ...header }) => { + drafts: served.map(({ member: stored, ...header }) => { const member = bundleMemberDiscriminator(header.type, stored); return member === undefined ? header : { ...header, member }; }), diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 5b2a228499d..674c67fa10b 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3723,6 +3723,12 @@ export class RestServer { * Only the types {@link metaItemReadGate} judges per caller are fetched at * all, so every other type's door is untouched: no extra read, no new * failure mode. Answers the refusal to send, or `undefined` to go on. + * + * [#7557, #24210] A withheld item's absence (a disabled package's item, + * which {@link fetchCurrentMetaDocument} throws) is answered in the plain + * read's own body, {@link sendMetaItemAbsent}, recognised as the plain + * read's catch recognises it ({@link thrownAnswerIsBareNotFound}), not in + * the classification door's flat one. */ private async eventDoorRefusal( environmentId: string | undefined, @@ -3732,7 +3738,13 @@ export class RestServer { const metaType = RestServer.metaTypeSingular(req.params.type); const policy: MetaReadGatePolicy = { arms: 'per-caller', app: 'gate' }; if (!RestServer.gatesPerCaller(metaType)) return undefined; - const current = await this.fetchCurrentMetaDocument(req, p); + let current: any; + try { + current = await this.fetchCurrentMetaDocument(req, p); + } catch (error: any) { + if (thrownAnswerIsBareNotFound(error)) return sendMetaItemAbsent; + throw error; + } if (current == null) return undefined; const verdict = await this.metaItemReadGate( environmentId, req, p, metaType, req.params.name, [current], policy, @@ -7356,9 +7368,21 @@ export class RestServer { throw maskError; } const diffGated = RestServer.gatesPerCaller(diffMetaType); - const diffCurrent = diffGated - ? await this.fetchCurrentMetaDocument(req, p) - : undefined; + let diffCurrent: any; + try { + diffCurrent = diffGated + ? await this.fetchCurrentMetaDocument(req, p) + : undefined; + } catch (currentError: any) { + // [#7557, #24210] A withheld item (a disabled package's) + // is answered in the plain read's body, as + // {@link eventDoorRefusal} answers it. + if (thrownAnswerIsBareNotFound(currentError)) { + sendMetaItemAbsent(res); + return; + } + throw currentError; + } // [#22639] A list view's and a dashboard's per-caller gate // is the item's OWN `requiredPermissions`. With no // published row there is no current document to judge, so @@ -7539,7 +7563,23 @@ export class RestServer { Boolean(this.objectQLProvider), () => this.objectQLProvider!(environmentId), ); - const schema = (ql as any)?.registry?.getObject?.(name); + // [#7557, #24210] The registry is a resolution reader and + // keeps serving a disabled package's object, so this API + // door asks first and answers what `GET /meta/object/:name` + // answers: the protocol's own refusal, `404 + // OBJECT_PACKAGE_DISABLED` (the dispatcher twin's + // `disabledObjectAnswer`); any other throw of that read is + // answered as itself, never as an absence. A protocol that + // does not refuse it gets this door's own answer for no + // object, below. The schema is never read for such a name. + const registry = (ql as any)?.registry; + const disabledObject = typeof registry?.isObjectPackageDisabled === 'function' + && registry.isObjectPackageDisabled(name) === true; + if (disabledObject) { + const p = await this.resolveProtocol(environmentId, req); + await p.getMetaItem({ type: 'object', name }); + } + const schema = disabledObject ? undefined : registry?.getObject?.(name); if (!schema) { // `{ error: { code, message } }`, the envelope // `BaseResponseSchema` declares — not the bare @@ -7740,8 +7780,31 @@ export class RestServer { // READ, and a gate fault raised while serving must reach // `handleRouteError` as itself — never fall through to // the snapshot as though no overlay existed. + // [#7557, #24210] A disabled package's item is answered + // as a name with nothing behind it, as the plain read + // and the list answer it. Neither store below knows a + // disable (the layered read keeps serving its resolution + // reader, and the snapshot carries no owner), so the + // owner is the one the plain read's kept protection + // envelope names, `packageId`, asked of the registry's + // `isPackageDisabled`: the predicate the protocol asks of + // that same field. An object is the layered read's own + // refusal below; `package` is never hidden. + let withheld = false; + if (publishedMetaType !== 'object' && publishedMetaType !== 'package' + && typeof publishedProtocol?.getMetaItem === 'function') { + const ql = await wiredEngineOrLoud( + Boolean(this.objectQLProvider), + () => this.objectQLProvider!(environmentId), + ); + const registry = (ql as any)?.registry; + if (typeof registry?.isPackageDisabled === 'function') { + const envelope = await publishedProtocol.getMetaItem({ type, name }); + withheld = registry.isPackageDisabled(envelope?.packageId) === true; + } + } let publishedOverlay: unknown; - if (typeof publishedProtocol?.getMetaItemLayered === 'function') { + if (!withheld && typeof publishedProtocol?.getMetaItemLayered === 'function') { try { const layered = await publishedProtocol.getMetaItemLayered({ type, @@ -7829,7 +7892,9 @@ export class RestServer { // CANONICAL types; handed the raw segment it answered // 404 for a recognised plural and 200 for the singular // twin of the same code-published item. - const data = await (svc as any).getPublished(canonicalMetaUrlType(type), name); + const data = withheld + ? undefined + : await (svc as any).getPublished(canonicalMetaUrlType(type), name); // The 404 this route could never produce before. An // item that exists but was never published still // answers 200 with its current definition — that is diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index beac40753a7..3d333a7ec34 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -1261,6 +1261,30 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin return successWithHeaders(deps, served, { 'Cache-Control': cacheControl }); }; + // [#7557, #24210] A disabled package's item is answered as a name with + // nothing behind it (every exit below ends in this same `404`), as + // `RestServer`'s `/published` answers it: the owner the plain read's + // kept protection envelope names, `packageId`, asked of the engine + // registry ({@link isServiceItemOfDisabledPackage}). Neither store below + // knows a disable. A throw of that read is the item read's answer for + // it. An object is the layered read's own refusal below; `package` is + // never hidden. + if (publishedType !== 'object' && publishedType !== 'package' + && protocol && typeof protocol.getMetaItem === 'function') { + const registry = (await deps.getObjectQL(_context))?.registry; + if (typeof (registry as { isPackageDisabled?: unknown } | undefined)?.isPackageDisabled === 'function') { + let owner: unknown; + try { + owner = (await protocol.getMetaItem({ type, name }))?.packageId; + } catch (e) { + return { handled: true, response: deps.errorFromThrown(e, 404) }; + } + if (isServiceItemOfDisabledPackage(registry, { _packageId: owner })) { + return { handled: true, response: deps.error('Not found', 404) }; + } + } + } + let publishedOverlay: unknown; if (protocol && typeof protocol.getMetaItemLayered === 'function') { try { From b345fd8c744e5401972816d29a2385437c9a5245 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 01:00:15 +0000 Subject: [PATCH 2/6] test(rest,runtime,objectql): pin the second half's doors for a disabled package's item Claude-Session: https://claude.ai/code/session_01Y9zBTp9ePwgEEJXeqyLG4N Co-authored-by: Claude --- .../src/package-disable-enforcement.test.ts | 28 +++ .../meta-alternate-door-read-gates.test.ts | 11 + .../src/meta-disabled-package-doors.test.ts | 208 ++++++++++++++++++ .../meta-disabled-package-doors.test.ts | 75 ++++++- 4 files changed, 317 insertions(+), 5 deletions(-) create mode 100644 packages/rest/src/meta-disabled-package-doors.test.ts diff --git a/packages/objectql/src/package-disable-enforcement.test.ts b/packages/objectql/src/package-disable-enforcement.test.ts index ab049e92c84..4f6a46c442d 100644 --- a/packages/objectql/src/package-disable-enforcement.test.ts +++ b/packages/objectql/src/package-disable-enforcement.test.ts @@ -396,6 +396,29 @@ const READERS: Array<{ servesByName(() => protocol.getMetaItem({ type, name: shippedName(type, PKG) }), (r) => r?.item), }, ]), + { + reader: 'listDrafts() — GET /meta/_drafts', + enforces: true, + why: '[#24210] the pending-changes list omits a disabled package\'s drafts, as its by-name draft read answers NO_DRAFT', + serves: async ({ protocol }) => + ((await protocol.listDrafts())?.drafts ?? []).some((d: any) => d?.packageId === PKG), + }, + { + reader: 'listDrafts({ packageId }) — GET /meta/_drafts?packageId=', + enforces: true, + why: '[#24210] naming the package does not bring its drafts back', + serves: async ({ protocol }) => + ((await protocol.listDrafts({ packageId: PKG }))?.drafts ?? []).length > 0, + }, + { + reader: 'the repository\'s listDrafts — the package batch doors (publish-drafts, discard-drafts)', + enforces: false, + why: '[#24210] a disabled package\'s drafts still publish and discard: those doors define their batch here', + serves: async ({ protocol }) => { + await (protocol as any).ensureOverlayIndex(); + return ((await (protocol as any).getOverlayRepo().listDrafts({ packageId: PKG })) ?? []).length > 0; + }, + }, { reader: 'assertObjectRegistered — the data plane (findData / getData funnel here)', enforces: true, @@ -587,6 +610,11 @@ describe('#7557 (A) — a disabled package stops serving, on every API reader at expect(served).toEqual(allTrue(served)); const app: any = await h.protocol.getMetaItem({ type: 'app', name: 'healthy_app' }); expect(app?.item?._packageId).toBe(OTHER_PKG); + // [#24210] …and its pending drafts are still listed, both of them. + const drafts = (await h.protocol.listDrafts()).drafts; + expect(drafts.filter((d) => d.packageId === OTHER_PKG).map((d) => `${d.type}/${d.name}`).sort()) + .toEqual(['app/healthy_app', 'object/healthy_widget']); + expect(drafts.filter((d) => d.packageId === PKG)).toEqual([]); }); it('[#24210] the protection envelope stays: the write doors\' verdict on the address is not the item', async () => { 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 d3bdc238a2d..53f12719827 100644 --- a/packages/rest/src/meta-alternate-door-read-gates.test.ts +++ b/packages/rest/src/meta-alternate-door-read-gates.test.ts @@ -1016,6 +1016,8 @@ describe('[#20156] edges', () => { const history = await drive(rest, '/history', 'doc', 'crm_admin_runbook'); expect(envelope(diff)).toEqual({ status: 404, code: 'RESOURCE_NOT_FOUND' }); + // [#24210] Unchanged by the withheld-item answer: already the plain read's nested body. + expect(diff.body).toEqual({ error: { code: 'RESOURCE_NOT_FOUND', message: 'Metadata item not found or access denied.' } }); expect(text(diff)).not.toContain(DOC_SECRET); expect(history.statusCode).toBe(200); expect(Array.isArray(history.body?.events)).toBe(true); @@ -1040,9 +1042,18 @@ describe('[#20156] edges', () => { protocol.historyMetaItem.mockClear(); protocol.auditMetaItem.mockClear(); protocol.diffMetaItem.mockClear(); + // [#24210] The plain read's own answer for this withheld item, the + // missing name's nested body (`sendMetaItemAbsent`), is the one + // these doors give, byte for byte: not the flat `{ error, code }`. + const plain = await drive(rest, '', type, name); + expect({ status: plain.statusCode, body: plain.body }).toEqual({ + status: 404, + body: { error: { code: 'RESOURCE_NOT_FOUND', message: 'Metadata item not found or access denied.' } }, + }); 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} withheld body`).toBe(text(plain)); expect(text(res), `${type} ${suffix}`).not.toContain('w_open_matters'); expect(text(res), `${type} ${suffix}`).not.toContain('nav_payroll_runs'); } diff --git a/packages/rest/src/meta-disabled-package-doors.test.ts b/packages/rest/src/meta-disabled-package-doors.test.ts new file mode 100644 index 00000000000..8ef7924815d --- /dev/null +++ b/packages/rest/src/meta-disabled-package-doors.test.ts @@ -0,0 +1,208 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7557, #24210] Two `RestServer` doors that read past the by-name read for a + * DISABLED package's item, each pinned before, during and after the disable, + * beside a control (another package's item, still served): + * + * - `GET /meta/:type/:name/published`, a non-object item: answered as a name + * with nothing behind it, status and body, on both of its exits (the stored + * overlay row and the code/package snapshot). The owner is the plain read's + * kept protection envelope's `packageId`, asked of the registry. + * - `GET /meta/object/:name/state/:field`: the protocol's own refusal, `404 + * OBJECT_PACKAGE_DISABLED`, byte for byte what `GET /meta/object/:name` + * answers, and the registry is never asked for the schema. + * + * The protocol and the registry are the REAL ones (the predicate lives on the + * registry, so a double would measure the double); only `sys_metadata` is + * held in memory. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { RestServer } from './rest-server.js'; + +const META = '/api/v1/meta'; +const PKG = 'app.disabled_demo'; +const OTHER_PKG = 'app.healthy_demo'; + +const objectBody = (name: string) => ({ + name, + label: 'Gadget', + sharingModel: 'private', + fields: { name: { name: 'name', type: 'text', label: 'Name' } }, +}); + +const rowKey = (w: Record) => + [w.type, w.name, w.organization_id ?? '', w.package_id ?? '', w.state ?? 'active'].join('|'); + +const matchesWhere = (row: Record, where: Record) => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + if (v === null) return row[k] === null || row[k] === undefined; + return row[k] === v; + }); + +/** In-memory `sys_metadata` carrying a REAL `SchemaRegistry`. */ +function makeHarness() { + const registry = new SchemaRegistry({ multiTenant: false }); + (registry as any).logLevel = 'silent'; + const rows = new Map(); + let nextId = 0; + const findRow = (w: Record) => { + for (const [k, r] of rows) if (matchesWhere(r, w)) return { key: k, row: r }; + return null; + }; + const engine: any = { + registry, + async findOne(table: string, o: { where: Record }) { + assertEngineFindOnePredicate(table, o); + if (table !== 'sys_metadata') return null; + return findRow(o.where)?.row ?? null; + }, + async find(table: string, o: { where: Record }) { + if (table !== 'sys_metadata') return []; + return Array.from(rows.values()).filter((r) => matchesWhere(r, o.where)); + }, + async insert(table: string, data: Record) { + if (table !== 'sys_metadata') return { id: `rec_${++nextId}` }; + const row = { id: `r_${++nextId}`, ...(data as any) }; + rows.set(rowKey(data), row); + return { id: row.id }; + }, + async update(table: string, data: Record, o: { where: Record }) { + assertEngineUpdateDispatch(data, o); + if (table !== 'sys_metadata') return { id: null }; + const found = findRow(o.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as any) }; + rows.delete(found.key); + rows.set(rowKey(merged), merged); + return { id: merged.id }; + }, + async delete(table: string, o?: Record) { + assertEngineDeleteDispatch(o); + if (table !== 'sys_metadata') return { deleted: 0 }; + const found = findRow(((o as any)?.where ?? {}) as Record); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + async syncObjectSchema() { /* no physical storage in this double */ }, + }; + const protocol = new ObjectStackProtocolImplementation(engine, undefined, 'env_test'); + return { protocol, registry }; +} + +/** + * Each package: a runtime-saved dashboard (the `/published` overlay exit), a + * code-registered page (the snapshot exit, served by the metadata service's + * `getPublished`) and a runtime-saved object (the `/state` door). + */ +async function seed(protocol: any, registry: SchemaRegistry) { + for (const [pkg, prefix] of [[PKG, 'demo'], [OTHER_PKG, 'healthy']] as const) { + registry.installPackage({ id: pkg, name: pkg, version: '1.0.0' } as any, 'code'); + await protocol.saveMetaItem({ + type: 'dashboard', name: `${prefix}_dash`, packageId: pkg, + item: { name: `${prefix}_dash`, label: `${prefix} board`, widgets: [] }, + }); + registry.registerItem('page', { name: `${prefix}_page`, label: `${prefix} page` } as any, 'name', pkg); + await protocol.saveMetaItem({ type: 'object', name: `${prefix}_gadget`, packageId: pkg, item: objectBody(`${prefix}_gadget`) }); + } +} + +function makeRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn(() => res); + res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); res.send = vi.fn(); + return res; +} + +function setup() { + const h = makeHarness(); + const snapshot = { + getPublished: vi.fn(async (type: string, name: string) => + (type === 'page' && name.endsWith('_page') && !name.startsWith('no_such') + ? { name, label: `${name} (published)` } + : undefined)), + }; + const server = { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; + const rest = new RestServer(server as any, h.protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'u1' }); + (rest as any).metadataServiceProvider = async () => snapshot; + (rest as any).objectQLProvider = async () => ({ registry: h.registry }); + rest.registerRoutes(); + const get = async (pattern: string, path: string, params: Record, query: Record = {}) => { + const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === `${META}${pattern}`); + if (!route) throw new Error(`GET ${pattern} is not registered`); + const res = makeRes(); + await route.handler({ method: 'GET', path: `${META}${path}`, params, query, body: {}, headers: {} }, res); + return { status: res.statusCode, body: res.body }; + }; + return { + ...h, + snapshot, + published: (type: string, name: string) => get('/:type/:name/published', `/${type}/${name}/published`, { type, name }), + state: (name: string) => get('/object/:name/state/:field', `/object/${name}/state/status`, { name, field: 'status' }, { from: 'open' }), + plain: (type: string, name: string) => get('/:type/:name', `/${type}/${name}`, { type, name }), + }; +} + +describe('[#24210] RestServer: /published and /state of a disabled package\'s item', () => { + it('/published, a non-object item: served, then the missing name\'s answer on both exits, then served again', async () => { + const host = setup(); + await seed(host.protocol, host.registry); + + // Enabled: the overlay exit (a runtime-saved dashboard) and the + // snapshot exit (a code-registered page) both serve. + expect(await host.published('dashboard', 'demo_dash')).toMatchObject({ status: 200, body: { label: 'demo board' } }); + expect(await host.published('page', 'demo_page')).toEqual({ status: 200, body: { name: 'demo_page', label: 'demo_page (published)' } }); + + host.registry.disablePackage(PKG); + const missing = await host.published('dashboard', 'no_such_dash'); + expect(missing).toEqual({ status: 404, body: { error: { code: 'NOT_FOUND', message: 'Not found' } } }); + expect(await host.published('page', 'no_such_page')).toEqual(missing); + host.snapshot.getPublished.mockClear(); + expect(await host.published('dashboard', 'demo_dash')).toEqual(missing); + expect(await host.published('page', 'demo_page')).toEqual(missing); + // The snapshot was never asked for a withheld item. + expect(host.snapshot.getPublished).not.toHaveBeenCalled(); + // Control: another package's item, both exits, still served. + expect(await host.published('dashboard', 'healthy_dash')).toMatchObject({ status: 200, body: { label: 'healthy board' } }); + expect(await host.published('page', 'healthy_page')).toEqual({ status: 200, body: { name: 'healthy_page', label: 'healthy_page (published)' } }); + + host.registry.enablePackage(PKG); + expect(await host.published('dashboard', 'demo_dash')).toMatchObject({ status: 200, body: { label: 'demo board' } }); + expect(await host.published('page', 'demo_page')).toEqual({ status: 200, body: { name: 'demo_page', label: 'demo_page (published)' } }); + }, 60_000); + + it('/state/:field: served, then GET /meta/object/:name\'s own 404 OBJECT_PACKAGE_DISABLED, then served again', async () => { + const host = setup(); + await seed(host.protocol, host.registry); + const served = { status: 200, body: { object: 'demo_gadget', field: 'status', from: 'open', next: null } }; + expect(await host.state('demo_gadget')).toEqual(served); + + host.registry.disablePackage(PKG); + const getObject = vi.spyOn(host.registry, 'getObject'); + const refused = await host.state('demo_gadget'); + expect({ status: refused.status, code: refused.body?.code }).toEqual({ status: 404, code: 'OBJECT_PACKAGE_DISABLED' }); + // Byte for byte the by-name read's answer for the same object. + expect(refused).toEqual(await host.plain('object', 'demo_gadget')); + // The schema is never read for that name. + expect(getObject.mock.calls.filter(([name]) => name === 'demo_gadget')).toEqual([]); + getObject.mockRestore(); + // Control: another package's object, and a name with no object behind it. + expect(await host.state('healthy_gadget')).toEqual({ ...served, body: { ...served.body, object: 'healthy_gadget' } }); + expect(await host.state('no_such_gadget')).toEqual({ status: 404, body: { error: { code: 'NOT_FOUND', message: 'Object not found' } } }); + + host.registry.enablePackage(PKG); + expect(await host.state('demo_gadget')).toEqual(served); + }, 60_000); +}); diff --git a/packages/runtime/src/domains/meta-disabled-package-doors.test.ts b/packages/runtime/src/domains/meta-disabled-package-doors.test.ts index 87dfec21923..b2513b08090 100644 --- a/packages/runtime/src/domains/meta-disabled-package-doors.test.ts +++ b/packages/runtime/src/domains/meta-disabled-package-doors.test.ts @@ -15,7 +15,11 @@ * 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. + * absent, byte for byte the answer for a name with nothing behind it; + * - `/meta/:type/:name/published` of a non-object item reads the stored + * overlay row or the snapshot, neither of which knows a disable: a disabled + * package's item is absent there too, byte for byte the missing name's + * answer (the owner is the plain read's kept envelope `packageId`). */ import { describe, it, expect, vi } from 'vitest'; @@ -39,6 +43,20 @@ const refusal = (object: string) => Object.assign( { code: 'OBJECT_PACKAGE_DISABLED', status: 404, object }, ); +/** + * [#24210] The owner each page's plain read names in its kept protection + * envelope (`packageId`), withheld item or not: `*_page` is published as a + * stored overlay row, `*_snap` only in the code/package snapshot. + */ +const OWNERS: Record = { + demo_page: DISABLED_PKG, demo_snap: DISABLED_PKG, + healthy_page: HEALTHY_PKG, healthy_snap: HEALTHY_PKG, +}; +const PUBLISHED_ROWS: Record = { + demo_page: { name: 'demo_page', label: 'Demo (published row)' }, + healthy_page: { name: 'healthy_page', label: 'Healthy (published row)' }, +}; + /** The metadata service's copies: it keeps both packages' pages. */ const SERVICE_ITEMS: Record = { demo_page: { name: 'demo_page', label: 'Demo', _packageId: DISABLED_PKG }, @@ -46,16 +64,21 @@ const SERVICE_ITEMS: Record = { }; function boot(opts: { getMetaTypes?: boolean } = {}) { + const disabled = { on: true }; 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 }; + return { + type, name, item: undefined, lock: 'none', editable: true, + ...(OWNERS[name] ? { packageId: OWNERS[name] } : {}), + }; }); 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 row = PUBLISHED_ROWS[name] ?? null; + return { type, name, code: null, overlay: row, effective: row }; }); const protocol: Record = { getMetaItem, @@ -71,11 +94,14 @@ function boot(opts: { getMetaTypes?: boolean } = {}) { 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, + isPackageDisabled: (id: unknown) => disabled.on && 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)), + getPublished: vi.fn(async (_type: string, name: string) => { + if (name === DISABLED) return schema(name); + return name.endsWith('_snap') ? { name, label: `${name} (snapshot)` } : undefined; + }), list: vi.fn(async () => []), }; const auth = { api: { getSession: async () => ({ user: { id: 'u_session' } }) } }; @@ -91,6 +117,8 @@ function boot(opts: { getMetaTypes?: boolean } = {}) { dispatcher: new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false }), registry, metadata, + disabled, + getMetaItemLayered, }; } @@ -166,3 +194,40 @@ describe('#24210 — the item read\'s metadata-service fallback and a disabled p expect(res.response?.body?.data?.item?.name).toBe('healthy_page'); }); }); + +describe('#24210 — /published of a disabled package\'s non-object item', () => { + const published = (stack: ReturnType, name: string) => + stack.dispatcher.dispatch('GET', `/meta/page/${name}/published`, {}, {}, SESSION()); + + it('served while enabled, then the missing name\'s answer on both exits, then served again', async () => { + const stack = boot(); + stack.disabled.on = false; + expect((await published(stack, 'demo_page')).response?.body?.data).toEqual(PUBLISHED_ROWS.demo_page); + expect((await published(stack, 'demo_snap')).response?.body?.data).toEqual({ name: 'demo_snap', label: 'demo_snap (snapshot)' }); + + stack.disabled.on = true; + const missing = await published(stack, 'no_such_page'); + expect(missing.response?.status).toBe(404); + expect(missing.response?.body?.error?.code).toBe('RESOURCE_NOT_FOUND'); + stack.metadata.getPublished.mockClear(); + stack.getMetaItemLayered.mockClear(); + for (const name of ['demo_page', 'demo_snap']) { + const hidden = await published(stack, name); + expect(hidden.response?.status, name).toBe(missing.response?.status); + expect(JSON.stringify(hidden.response?.body), name).toBe(JSON.stringify(missing.response?.body)); + } + // Neither store was asked for a withheld item. + expect(stack.metadata.getPublished).not.toHaveBeenCalled(); + expect(stack.getMetaItemLayered).not.toHaveBeenCalled(); + + stack.disabled.on = false; + expect((await published(stack, 'demo_page')).response?.body?.data).toEqual(PUBLISHED_ROWS.demo_page); + expect((await published(stack, 'demo_snap')).response?.body?.data).toEqual({ name: 'demo_snap', label: 'demo_snap (snapshot)' }); + }); + + it('the control: another package\'s item is served on both exits while the package is disabled', async () => { + const stack = boot(); + expect((await published(stack, 'healthy_page')).response?.body?.data).toEqual(PUBLISHED_ROWS.healthy_page); + expect((await published(stack, 'healthy_snap')).response?.body?.data).toEqual({ name: 'healthy_snap', label: 'healthy_snap (snapshot)' }); + }); +}); From ecf20b6f9a12aa6f92fc3a2d86b25b44cc34d54c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 01:00:45 +0000 Subject: [PATCH 3/6] chore(changeset): the second half's three narrowings Claude-Session: https://claude.ai/code/session_01Y9zBTp9ePwgEEJXeqyLG4N Co-authored-by: Claude --- ...etadata-protocol-drafts-disabled-package.md | 18 ++++++++++++++++++ ...lished-state-event-body-disabled-package.md | 18 ++++++++++++++++++ ...24210-runtime-published-disabled-package.md | 16 ++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 .changeset/24210-metadata-protocol-drafts-disabled-package.md create mode 100644 .changeset/24210-rest-published-state-event-body-disabled-package.md create mode 100644 .changeset/24210-runtime-published-disabled-package.md diff --git a/.changeset/24210-metadata-protocol-drafts-disabled-package.md b/.changeset/24210-metadata-protocol-drafts-disabled-package.md new file mode 100644 index 00000000000..7c4bf49d015 --- /dev/null +++ b/.changeset/24210-metadata-protocol-drafts-disabled-package.md @@ -0,0 +1,18 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +fix(metadata-protocol)!: `listDrafts` omits a disabled package's drafts, as the list read omits its items + +Clause-②: no (narrowing) + + + +**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +`GET /meta/_drafts` (both transports) listed a disabled package's pending drafts, while the by-name draft read answers them `404 NO_DRAFT` and the list read omits the package's items. + +- **Omitted now:** every draft whose `packageId` names a disabled package, with or without `?packageId=`. The predicate is the one the list and by-name reads ask. Drafts of the `package` type are never hidden. +- **Unchanged:** the package batch doors (`POST /packages/:id/publish-drafts`, `/discard-drafts`) still publish and discard a disabled package's drafts; they read the store directly. Other packages' drafts are listed as before. + +What changes for you: re-enable the package to see its pending drafts in the list again. diff --git a/.changeset/24210-rest-published-state-event-body-disabled-package.md b/.changeset/24210-rest-published-state-event-body-disabled-package.md new file mode 100644 index 00000000000..30c4a88cc28 --- /dev/null +++ b/.changeset/24210-rest-published-state-event-body-disabled-package.md @@ -0,0 +1,18 @@ +--- +'@objectstack/rest': minor +--- + +fix(rest)!: `/published` and `/meta/object/:name/state/:field` answer a disabled package's item as the by-name read does, and the event and diff doors' absence is the plain read's body + +Clause-②: no (narrowing) + + + +**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +- **`GET /meta/:type/:name/published`, every type but `object` and `package`:** a disabled package's item is answered as a name with nothing behind it (`404 NOT_FOUND`, byte for byte), on both exits: the stored published row and the code/package snapshot. The owner is the one the by-name read's protection envelope names (`packageId`). Objects already answered `404 OBJECT_PACKAGE_DISABLED` here. +- **`GET /meta/object/:name/state/:field`:** a disabled package's object is refused `404 OBJECT_PACKAGE_DISABLED`, what `GET /meta/object/:name` answers, as the runtime dispatcher's twin already does. The schema is not read. +- **`/history`, `/audit` and `/diff`:** the `404 RESOURCE_NOT_FOUND` a disabled package's gated item gets is now the plain read's nested body, `{ "error": { "code": "RESOURCE_NOT_FOUND", "message": "Metadata item not found or access denied." } }`, instead of the flat `{ "error": "…", "code": "RESOURCE_NOT_FOUND" }`. Status and code are unchanged; read `body.error.code`. +- **Unchanged:** items of enabled packages, a deleted item, and a name with nothing behind it. + +What changes for you: re-enable the package to read its items' published versions and its objects' state machines again. diff --git a/.changeset/24210-runtime-published-disabled-package.md b/.changeset/24210-runtime-published-disabled-package.md new file mode 100644 index 00000000000..e2d0f5720cc --- /dev/null +++ b/.changeset/24210-runtime-published-disabled-package.md @@ -0,0 +1,16 @@ +--- +'@objectstack/runtime': minor +--- + +fix(runtime)!: the dispatcher's `/meta/:type/:name/published` answers a disabled package's non-object item as a missing name + +Clause-②: no (narrowing) + + + +**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +- **Absent now, `404 RESOURCE_NOT_FOUND` "Not found", byte for byte the missing name's answer:** `GET /meta/:type/:name/published` for an item of every type but `object` and `package` whose owning package is disabled, on the stored published row and the code/package snapshot alike. The owner is the one the by-name read's protection envelope names (`packageId`), as `RestServer`'s `/published` asks it. Objects already answered `404 OBJECT_PACKAGE_DISABLED` here. +- **Unchanged:** items of enabled packages, and a name with nothing behind it. + +What changes for you: re-enable the package to read its items' published versions again. From 0268f810c0bb157409c2ed9fdb13a25f4e032b4b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 01:16:41 +0000 Subject: [PATCH 4/6] test(rest): the objectQLProvider census counts /published's disabled-owner check, with its outage pin Claude-Session: https://claude.ai/code/session_01Y9zBTp9ePwgEEJXeqyLG4N Co-authored-by: Claude --- .../src/meta-disabled-package-doors.test.ts | 22 +++++++++++++++-- .../src/objectql-slot-consumer-census.test.ts | 24 +++++++++++++------ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/packages/rest/src/meta-disabled-package-doors.test.ts b/packages/rest/src/meta-disabled-package-doors.test.ts index 8ef7924815d..ac65a56b1bc 100644 --- a/packages/rest/src/meta-disabled-package-doors.test.ts +++ b/packages/rest/src/meta-disabled-package-doors.test.ts @@ -122,7 +122,7 @@ function makeRes() { return res; } -function setup() { +function setup(objectQLProvider?: () => Promise) { const h = makeHarness(); const snapshot = { getPublished: vi.fn(async (type: string, name: string) => @@ -137,7 +137,7 @@ function setup() { const rest = new RestServer(server as any, h.protocol as any, { api: { requireAuth: false } } as any); (rest as any).resolveExecCtx = async () => ({ userId: 'u1' }); (rest as any).metadataServiceProvider = async () => snapshot; - (rest as any).objectQLProvider = async () => ({ registry: h.registry }); + (rest as any).objectQLProvider = objectQLProvider ?? (async () => ({ registry: h.registry })); rest.registerRoutes(); const get = async (pattern: string, path: string, params: Record, query: Record = {}) => { const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === `${META}${pattern}`); @@ -205,4 +205,22 @@ describe('[#24210] RestServer: /published and /state of a disabled package\'s it host.registry.enablePackage(PKG); expect(await host.state('demo_gadget')).toEqual(served); }, 60_000); + + // The objectQLProvider slot's consumer census (`objectql-slot-consumer-census.test.ts`) + // cites this case: the `/published` consumer keeps a failed engine and an + // absent one apart, as every consumer of the slot must. + it('/published reads the objectQLProvider slot through wiredEngineOrLoud: a rejecting engine is loud, an absent one serves as before', async () => { + const rejecting = setup(async () => { throw new Error('driver handshake failed'); }); + await seed(rejecting.protocol, rejecting.registry); + const failed = await rejecting.published('dashboard', 'demo_dash'); + expect({ status: failed.status, code: failed.body?.code ?? failed.body?.error?.code }) + .toEqual({ status: 503, code: 'SERVICE_UNAVAILABLE' }); + expect(JSON.stringify(failed.body)).not.toContain('demo board'); + + const absent = setup(async () => undefined); + await seed(absent.protocol, absent.registry); + absent.registry.disablePackage(PKG); + // No engine to ask, so nothing is withheld: the door's answer before #24210. + expect(await absent.published('dashboard', 'demo_dash')).toMatchObject({ status: 200, body: { label: 'demo board' } }); + }, 60_000); }); diff --git a/packages/rest/src/objectql-slot-consumer-census.test.ts b/packages/rest/src/objectql-slot-consumer-census.test.ts index 572115f8a50..8b24ec6d330 100644 --- a/packages/rest/src/objectql-slot-consumer-census.test.ts +++ b/packages/rest/src/objectql-slot-consumer-census.test.ts @@ -29,6 +29,14 @@ * from the source text and pins the count, so a FOURTH consumer arriving * unexamined turns this red instead of being absorbed into a sentence. * + * [#24210] ⭐ And a FOURTH, examined on arrival rather than absorbed: `GET + * /meta/:type/:name/published` asks the engine registry whether a non-object + * item's owning package is disabled. It reaches the slot through + * `wiredEngineOrLoud`, so it passes the decidable test: a rejecting provider is + * a loud `503`, never the served answer an absent engine gets (no registry to + * ask, so nothing is withheld). Its behavioural pin is + * `meta-disabled-package-doors.test.ts`, cited in §3. + * * ## What is asserted about the answers * * §2 drives the third consumer on the wiring where it decides anything, and @@ -182,17 +190,18 @@ describe('[#18546] §1 the `objectQLProvider` consumer census is mechanical', () expect(invocationSites(SOURCE, 'objectQLProvider').some((s) => s.text.startsWith('//'))).toBe(false); }); - it('⭐ THREE consumers, not two — the count the two prose censuses got wrong', () => { + it('⭐ FOUR consumers: three, not two, was the count the two prose censuses got wrong, and #24210 adds /published', () => { // [#23050 slice 2] 3 UNCHANGED, over two files: 2 in `rest-server.ts` - // and the batch door's 1 in `batch-routes.ts`. + // and the batch door's 1 in `batch-routes.ts`. [#24210] 4: `/published`'s + // disabled-owner check joins `rest-server.ts`. const sites = censusInvocationSites('objectQLProvider'); // The message carries the sites, so a failure here hands the next // reader the new census instead of a bare number. expect(sites.map((s) => `${s.file}:${s.line}: ${s.text}`).join('\n')).toBeTruthy(); - expect(sites).toHaveLength(3); + expect(sites).toHaveLength(4); }); - it('⭐ [#18559] ALL THREE consumers now reach the slot through `wiredEngineOrLoud` — none reads it directly', () => { + it('⭐ [#18559] ALL FOUR consumers reach the slot through `wiredEngineOrLoud` — none reads it directly', () => { const sites = censusInvocationSites('objectQLProvider'); const viaSeam = sites.filter((s) => { // The helper is named on the line that OPENS the call, two lines up @@ -205,7 +214,7 @@ describe('[#18546] §1 the `objectQLProvider` consumer census is mechanical', () // the site rather than a bare number. expect(direct.map((s) => `${s.file}:${s.line}: ${s.text}`).join('\n')).toBe(''); expect(direct).toHaveLength(0); - expect(viaSeam).toHaveLength(3); + expect(viaSeam).toHaveLength(4); // ⭐ CONTROL for the window predicate itself, on BOTH axes. It reads a // WINDOW of source text, so "3 of 3" is only a reading if the same @@ -516,13 +525,14 @@ describe('[#18559] §2b the batch door answers ONE thing for one fact, on both w }); // --------------------------------------------------------------------------- -// §3 — the other two consumers are covered elsewhere, by name +// §3 — the other consumers are covered elsewhere, by name // --------------------------------------------------------------------------- -describe('[#18546] §3 the census names where the other two consumers are pinned', () => { +describe('[#18546] §3 the census names where the other consumers are pinned', () => { it.each([ ['execctx-authz-input-seam-reachability.test.ts', 'the computeExecCtx authorization-input seam'], ['meta-state-route-engine-outage.test.ts', 'GET /meta/object/:name/state/:field'], + ['meta-disabled-package-doors.test.ts', '[#24210] GET /meta/:type/:name/published'], ])('%s still exists and still names this slot (%s)', (file) => { const path = resolve(HERE, file); expect(existsSync(path)).toBe(true); From 98dd4f8141227fb5b371ca15886f9c16674dc2b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 01:54:38 +0000 Subject: [PATCH 5/6] test(rest): the new sys_metadata double holds the caller's bound, and the engine-double ledger records it Claude-Session: https://claude.ai/code/session_01Y9zBTp9ePwgEEJXeqyLG4N Co-authored-by: Claude --- .../rest/src/meta-disabled-package-doors.test.ts | 5 +++-- scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/rest/src/meta-disabled-package-doors.test.ts b/packages/rest/src/meta-disabled-package-doors.test.ts index ac65a56b1bc..d2519cf0cd6 100644 --- a/packages/rest/src/meta-disabled-package-doors.test.ts +++ b/packages/rest/src/meta-disabled-package-doors.test.ts @@ -62,9 +62,10 @@ function makeHarness() { if (table !== 'sys_metadata') return null; return findRow(o.where)?.row ?? null; }, - async find(table: string, o: { where: Record }) { + async find(table: string, o: { where: Record; limit?: number }) { if (table !== 'sys_metadata') return []; - return Array.from(rows.values()).filter((r) => matchesWhere(r, o.where)); + const matched = Array.from(rows.values()).filter((r) => matchesWhere(r, o.where)); + return typeof o?.limit === 'number' ? matched.slice(0, o.limit) : matched; }, async insert(table: string, data: Record) { if (table !== 'sys_metadata') return { id: `rec_${++nextId}` }; diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index b73c81d9945..268768f1314 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3791,6 +3791,21 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/rest/src/meta-disabled-package-doors.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/rest/src/meta-disabled-package-doors.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/rest/src/meta-disabled-package-doors.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/rest/src/meta-object-extension-agreement.test.ts", "verb": "findOne", From ff82c7a1bffa1edba88137d7a1056749abaac079 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:31:27 +0000 Subject: [PATCH 6/6] fix(rest): re-import thrownAnswerIsBareNotFound for the event and diff doors after slice 13b moved the item read's catch Claude-Session: https://claude.ai/code/session_01Y9zBTp9ePwgEEJXeqyLG4N Co-authored-by: Claude --- packages/rest/src/rest-server.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 6bfbf601180..d249a23bf33 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -369,8 +369,9 @@ import { sendDeclaredFault, sendFieldVisibilityFault, handleRouteError, - // `thrownAnswerIsBareNotFound`, read only by the item read's catch, moved with that body - // to `meta-item-read-routes.ts` (#23050 slice 13b). + // `thrownAnswerIsBareNotFound`: the item read's catch moved to `meta-item-read-routes.ts` + // (#23050 slice 13b); the event and diff doors here still read it (#24210). + thrownAnswerIsBareNotFound, // `logUnexpectedRouteError` and `applyDroppedFieldsHeader`, read only by the CRUD doors' catches // and their two write answers, moved with `registerCrudEndpoints`' body to `crud-routes.ts` // (#23050 slice 12).