diff --git a/.changeset/24210-rest-layered-doors-disabled-package.md b/.changeset/24210-rest-layered-doors-disabled-package.md new file mode 100644 index 00000000000..dc5e6c5b5e8 --- /dev/null +++ b/.changeset/24210-rest-layered-doors-disabled-package.md @@ -0,0 +1,17 @@ +--- +'@objectstack/rest': minor +--- + +fix(rest)!: `/meta/:type/:name/layers` and the deprecated `?layers=true` answer a disabled package's non-object item as a missing name, on both transports + +Clause-②: no (narrowing) + + + +**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +- **Absent now, byte for byte the missing name's answer (`404 RESOURCE_NOT_FOUND`):** the layered view of an item of every type but `object` and `package` whose owning package is disabled, on `GET /meta/:type/:name/layers` and on `GET /meta/:type/:name?layers=true`, to every caller, an author who may write the item included. One step in the shared layered chain (`createMetaLayeredAnswer`) answers it, so the runtime dispatcher's two spellings answer the same. The owner is the one the by-name read's protection envelope names (`packageId`), read under the request's `?package=` scope, asked of the engine registry's `isPackageDisabled`. Objects already answered `404 OBJECT_PACKAGE_DISABLED` here. +- **Unchanged:** items of enabled packages, a name with nothing behind it, and the protocol's `getMetaItemLayered`, which keeps serving every layer to the readers that resolve with it (the security catalog echo, the packaged-permission-set lock gate). +- **Added, optional:** `MetaItemReadGateSources.isItemOfDisabledPackage` and `MetaLayeredRequest.packageId`. A host that supplies neither answers as before. + +What changes for you: re-enable the package to read its items' layered views again. diff --git a/.changeset/24210-runtime-layered-doors-disabled-package.md b/.changeset/24210-runtime-layered-doors-disabled-package.md new file mode 100644 index 00000000000..0ba74a27507 --- /dev/null +++ b/.changeset/24210-runtime-layered-doors-disabled-package.md @@ -0,0 +1,16 @@ +--- +'@objectstack/runtime': minor +--- + +fix(runtime)!: the dispatcher's `/meta/:type/:name/layers` and `?layers=true` answer 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/layers` and `GET /meta/:type/:name?layers=true` (with its `Deprecation` header) for an item of every type but `object` and `package` whose owning package is disabled, to every caller. The owner is the one the by-name read's protection envelope names (`packageId`) under the request's `?package=` scope, as `RestServer` 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' layered views again. diff --git a/packages/objectql/src/package-disable-enforcement.test.ts b/packages/objectql/src/package-disable-enforcement.test.ts index 4f6a46c442d..f3f1679b840 100644 --- a/packages/objectql/src/package-disable-enforcement.test.ts +++ b/packages/objectql/src/package-disable-enforcement.test.ts @@ -419,6 +419,27 @@ const READERS: Array<{ return ((await (protocol as any).getOverlayRepo().listDrafts({ packageId: PKG })) ?? []).length > 0; }, }, + { + reader: 'getMetaItemLayered({ type: "app", name }) — the producer under GET /meta/app/:name/layers and ?layers=true', + enforces: false, + why: '[#24210] the doors answer the absence (the shared layered chain\'s owner step, pinned per transport in @objectstack/rest and @objectstack/runtime); the producer keeps serving its resolution readers', + serves: async ({ protocol }) => + (await protocol.getMetaItemLayered({ type: 'app', name: 'demo_app' }))?.effective != null, + }, + { + reader: 'getMetaItemLayered({ type: "permission", name }) — the security catalog echo and the packaged-permission-set lock gate', + enforces: false, + why: '[#24210] plugin-security reads the layers directly, not through a door: a disabled package\'s permission set still echoes and stays locked', + serves: async ({ protocol }) => + (await protocol.getMetaItemLayered({ type: 'permission', name: shippedName('permission', PKG) }))?.effective != null, + }, + { + reader: 'getMetaItem(…).packageId — the owner the layered doors\' step and readServedPackageBinding read', + enforces: false, + why: '[#24210] the by-name read withholds the item but its kept envelope still names the owner, which is how the layered doors and the echo learn it', + serves: async ({ protocol }) => + (await protocol.getMetaItem({ type: 'app', name: 'demo_app' }))?.packageId === PKG, + }, { reader: 'assertObjectRegistered — the data plane (findData / getData funnel here)', enforces: true, diff --git a/packages/rest/src/meta-disabled-package-doors.test.ts b/packages/rest/src/meta-disabled-package-doors.test.ts index d2519cf0cd6..e3134a49e81 100644 --- a/packages/rest/src/meta-disabled-package-doors.test.ts +++ b/packages/rest/src/meta-disabled-package-doors.test.ts @@ -1,7 +1,7 @@ // 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 + * [#7557, #24210] The `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): * @@ -12,6 +12,13 @@ * - `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 layered view, `GET /meta/:type/:name/layers` and the deprecated + * `?layers=true`, a non-object item: answered as a name with nothing behind + * it, status, body and headers, for a code-shipped, an overlay-only and a + * combined item, to an author as to any other caller. The owner is the plain + * read's kept envelope `packageId` under the same `?package=` scope, asked + * of the registry by the shared chain's step 1b; the protocol's layered read + * keeps serving its layers. * * 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 @@ -114,16 +121,31 @@ async function seed(protocol: any, registry: SchemaRegistry) { } } +/** + * [#24210] The layered doors' extra specimens, beside {@link seed}'s: a + * dashboard the disabled package ships whose name also carries an unpackaged + * overlay row (the combined item: a code layer and an overlay), and one name + * stored in BOTH packages, which `?package=` tells apart. + */ +async function seedLayered(protocol: any, registry: SchemaRegistry) { + registry.registerItem('dashboard', { name: 'demo_both', label: 'shipped', widgets: [] } as any, 'name', PKG); + await protocol.saveMetaItem({ type: 'dashboard', name: 'demo_both', item: { name: 'demo_both', label: 'customised', widgets: [] } }); + for (const [pkg, label] of [[PKG, 'disabled copy'], [OTHER_PKG, 'healthy copy']] as const) { + await protocol.saveMetaItem({ type: 'dashboard', name: 'twin_dash', packageId: pkg, item: { name: 'twin_dash', label, widgets: [] } }); + } +} + 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.headers = {} as Record; + res.header = vi.fn((k: string, v: string) => { res.headers[k] = v; return res; }); res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); res.send = vi.fn(); return res; } -function setup(objectQLProvider?: () => Promise) { +function setup(objectQLProvider?: () => Promise, caller: Record = { userId: 'u1' }) { const h = makeHarness(); const snapshot = { getPublished: vi.fn(async (type: string, name: string) => @@ -136,16 +158,20 @@ function setup(objectQLProvider?: () => Promise) { 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).resolveExecCtx = async () => caller; (rest as any).metadataServiceProvider = async () => snapshot; (rest as any).objectQLProvider = objectQLProvider ?? (async () => ({ registry: h.registry })); rest.registerRoutes(); - const get = async (pattern: string, path: string, params: Record, query: Record = {}) => { + const call = 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 { status: res.statusCode, body: res.body, headers: res.headers as Record }; + }; + const get = async (pattern: string, path: string, params: Record, query: Record = {}) => { + const { status, body } = await call(pattern, path, params, query); + return { status, body }; }; return { ...h, @@ -153,6 +179,11 @@ function setup(objectQLProvider?: () => Promise) { 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 }), + // [#24210] The layered view's two spellings, with every header each answer carries. + layers: (type: string, name: string, query: Record = {}) => + call('/:type/:name/layers', `/${type}/${name}/layers`, { type, name }, query), + layersFlag: (type: string, name: string, query: Record = {}) => + call('/:type/:name', `/${type}/${name}`, { type, name }, { ...query, layers: 'true' }), }; } @@ -225,3 +256,163 @@ describe('[#24210] RestServer: /published and /state of a disabled package\'s it expect(await absent.published('dashboard', 'demo_dash')).toMatchObject({ status: 200, body: { label: 'demo board' } }); }, 60_000); }); + +/** + * [#24210] The flag's answer with its `Link` set aside: that header names the + * item's own path, so it differs from the missing name's by the name alone. + */ +const withoutLink = (r: { status: number; body: any; headers: Record }) => { + const { Link: _link, ...headers } = r.headers; + return { ...r, headers }; +}; + +describe('[#24210] RestServer: the layered view of a disabled package\'s item, both spellings', () => { + const SPECIMENS = [ + ['page', 'demo_page'], // code-shipped: a code layer only + ['dashboard', 'demo_dash'], // runtime-saved: an overlay only, whose layers name no owner + ['dashboard', 'demo_both'], // combined: the shipped layer and an overlay row + ] as const; + + it('served, then the missing name\'s answer on /layers and ?layers=true, then served again', async () => { + const host = setup(); + await seed(host.protocol, host.registry); + await seedLayered(host.protocol, host.registry); + + const before = new Map(); + for (const [type, name] of SPECIMENS) { + const layers = await host.layers(type, name); + expect(layers.status).toBe(200); + expect(layers.body?.effective).toMatchObject({ name }); + const flag = await host.layersFlag(type, name); + expect(flag.status).toBe(200); + expect(flag.headers.Deprecation).toBe('true'); + // One body behind both spellings. + expect(flag.body).toEqual(layers.body); + before.set(`${type}/${name}`, { layers, flag }); + } + + host.registry.disablePackage(PKG); + const missingPlain = await host.plain('dashboard', 'no_such_dash'); + expect(missingPlain.status).toBe(404); + const missingLayers = await host.layers('dashboard', 'no_such_dash'); + const missingFlag = await host.layersFlag('dashboard', 'no_such_dash'); + // The two spellings' missing name is the plain read's own absence. + expect({ status: missingLayers.status, body: missingLayers.body }).toEqual(missingPlain); + expect({ status: missingFlag.status, body: missingFlag.body }).toEqual(missingPlain); + for (const [type, name] of SPECIMENS) { + // Status, body and headers, byte for byte the missing name's. + expect(await host.layers(type, name)).toEqual(missingLayers); + const flag = await host.layersFlag(type, name); + expect(withoutLink(flag)).toEqual(withoutLink(missingFlag)); + expect(flag.headers.Link).toBe(`<${META}/${type}/${name}/layers>; rel="successor-version"`); + // …and the plain read's, which the list read agrees with. + expect(await host.plain(type, name)).toEqual(missingPlain); + // The producer keeps serving its layers to its resolution readers. + expect((await host.protocol.getMetaItemLayered({ type, name }))?.effective).toMatchObject({ name }); + } + // Control: another package's items, both spellings, still served. + for (const [type, name] of [['page', 'healthy_page'], ['dashboard', 'healthy_dash']] as const) { + expect(await host.layers(type, name)).toMatchObject({ status: 200, body: { effective: { name } } }); + expect(await host.layersFlag(type, name)).toMatchObject({ status: 200, body: { effective: { name } } }); + } + + host.registry.enablePackage(PKG); + for (const [type, name] of SPECIMENS) { + const { layers, flag } = before.get(`${type}/${name}`) as any; + expect(await host.layers(type, name)).toEqual(layers); + expect(await host.layersFlag(type, name)).toEqual(flag); + } + }, 60_000); + + it('an author is answered the same absence: the owner step precedes the per-caller gate', async () => { + const author = { userId: 'u_author', systemPermissions: ['manage_metadata', 'studio.access'] }; + expect((RestServer as any).metaSaveVerdict(author).allowed).toBe(true); + const authorHost = setup(undefined, author); + const memberHost = setup(undefined, { userId: 'u_member' }); + for (const host of [authorHost, memberHost]) { + await seed(host.protocol, host.registry); + await host.protocol.saveMetaItem({ + type: 'app', name: 'demo_gated_app', packageId: PKG, + item: { + name: 'demo_gated_app', label: 'Gated', + navigation: [ + { id: 'gated_entry', type: 'url', label: 'Gated', url: 'https://example.com/a', requiredPermissions: ['gate_24210_cap'] }, + { id: 'open_entry', type: 'url', label: 'Open', url: 'https://example.com/b' }, + ], + }, + }); + } + const navIds = (r: any) => (r.body?.effective?.navigation ?? []).map((e: any) => e?.id); + // Enabled: the per-caller gate decides. The author reads the stored + // version whole (ruling 5856774816); the member reads it pruned. + const authorServed = await authorHost.layers('app', 'demo_gated_app'); + expect(authorServed.status).toBe(200); + expect(navIds(authorServed)).toEqual(['gated_entry', 'open_entry']); + const memberServed = await memberHost.layers('app', 'demo_gated_app'); + expect(memberServed.status).toBe(200); + expect(navIds(memberServed)).toEqual(['open_entry']); + + for (const host of [authorHost, memberHost]) { + host.registry.disablePackage(PKG); + const missing = await host.layers('app', 'no_such_app'); + expect(missing.status).toBe(404); + // Both callers, both spellings: the missing name's answer, not the + // author's whole view, and not the member's pruned one. + expect(await host.layers('app', 'demo_gated_app')).toEqual(missing); + expect(withoutLink(await host.layersFlag('app', 'demo_gated_app'))) + .toEqual(withoutLink(await host.layersFlag('app', 'no_such_app'))); + host.registry.enablePackage(PKG); + } + expect(await authorHost.layers('app', 'demo_gated_app')).toEqual(authorServed); + expect(await memberHost.layers('app', 'demo_gated_app')).toEqual(memberServed); + }, 60_000); + + it('an object keeps the protocol\'s own 404 OBJECT_PACKAGE_DISABLED, the plain read\'s refusal', async () => { + const host = setup(); + await seed(host.protocol, host.registry); + host.registry.disablePackage(PKG); + const plain = await host.plain('object', 'demo_gadget'); + expect({ status: plain.status, code: plain.body?.code ?? plain.body?.error?.code }) + .toEqual({ status: 404, code: 'OBJECT_PACKAGE_DISABLED' }); + const layers = await host.layers('object', 'demo_gadget'); + expect({ status: layers.status, body: layers.body }).toEqual(plain); + const flag = await host.layersFlag('object', 'demo_gadget'); + expect({ status: flag.status, body: flag.body }).toEqual(plain); + expect(await host.layers('object', 'healthy_gadget')).toMatchObject({ status: 200, body: { effective: { name: 'healthy_gadget' } } }); + }, 60_000); + + it('?package= picks the owner: the healthy package\'s copy of a shared name is served, the disabled one\'s is absent', async () => { + const host = setup(); + await seed(host.protocol, host.registry); + await seedLayered(host.protocol, host.registry); + host.registry.disablePackage(PKG); + const missing = await host.layers('dashboard', 'no_such_dash', { package: OTHER_PKG }); + expect(missing.status).toBe(404); + expect(await host.layers('dashboard', 'twin_dash', { package: OTHER_PKG })) + .toMatchObject({ status: 200, body: { effective: { label: 'healthy copy' } } }); + expect(await host.layersFlag('dashboard', 'twin_dash', { package: OTHER_PKG })) + .toMatchObject({ status: 200, body: { effective: { label: 'healthy copy' } } }); + expect(await host.layers('dashboard', 'twin_dash', { package: PKG })).toEqual(missing); + }, 60_000); + + // The objectQLProvider slot's consumer census (`objectql-slot-consumer-census.test.ts`) + // cites this case: the layered owner step keeps a failed engine and an + // absent one apart, as every consumer of the slot must. + it('the owner step 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); + for (const read of [rejecting.layers, rejecting.layersFlag]) { + const failed = await read('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 doors' answer before #24210. + expect(await absent.layers('dashboard', 'demo_dash')).toMatchObject({ status: 200, body: { effective: { label: 'demo board' } } }); + expect(await absent.layersFlag('dashboard', 'demo_dash')).toMatchObject({ status: 200, body: { effective: { label: 'demo board' } } }); + }, 60_000); +}); diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 2f3a66a7107..c88089b5705 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -165,6 +165,23 @@ export interface MetaItemReadGateSources extends MetaReadGateAudienceSources, Me serviceProbe(caller: MetaReadGateCaller | undefined): ((name: string) => Promise) | null; /** Where the nav-servability prune log records what it has already said (one line per key). */ readonly navPruneLogged: Set; + /** + * [#7557, #24210] Does the by-name read withhold this item because its + * owning package is disabled? Withheld means the plain read, under the + * same `?package=` scope, serves no `item` while its kept protection + * envelope names an owner (`packageId`, the served item's `_packageId`) + * the engine registry's `isPackageDisabled` answers `true` for: the field + * and predicate the protocol's list and by-name reads ask, and the + * protocol's own decision of which types it hides (`package` never is). + * The layered answer cannot say it itself: a runtime-saved item's layers + * carry no owner. + * + * Read by the layered chain ({@link createMetaLayeredAnswer}) only. + * Absent, or a registry that cannot answer, is "not withheld": the answer + * before #24210. A REJECTION is a fault and propagates, ⛔ never "not + * withheld". + */ + isItemOfDisabledPackage?(item: { type: string; name: string; packageId?: string }): Promise; } // ── The caller's organization ───────────────────────────────────────────────── @@ -3670,6 +3687,12 @@ export interface MetaLayeredRequest { /** The SINGULAR type (the caller folds `/meta/apps/:name` once, at its boundary). */ readonly metaType: string; readonly name: string; + /** + * [ADR-0048] The `?package=` scope the transport read the layers under, + * absent for none. The same name can be stored in two packages, so the + * owner step 1b asks about is the one this scope resolves. + */ + readonly packageId?: string; /** * [ADR-0106 D2/D3] The caller's field-visibility posture for this item, * resolved by the transport BEFORE the read, the not-applicable passthrough @@ -3738,6 +3761,18 @@ export type MetaLayeredAnswer = * that difference told a member which unpublished apps exist. The * protocol's `effective` is never null while another layer is present, so * this is the name resolving to nothing at all, for every caller. + * 1b. [#7557, #24210] A disabled package's item — every type the by-name + * read withholds for it — is that same `absent`: the list read omits it + * and the by-name read answers it as a name with nothing behind it, so + * both spellings on both transports take the missing name's exit. Asked + * of {@link MetaItemReadGateSources.isItemOfDisabledPackage}, BEFORE the + * gate, so an author who may write the item is not served it whole + * either. An object is the protocol's own `OBJECT_PACKAGE_DISABLED` + * refusal, thrown by the read before this chain; which other types are + * hidden is the plain read's decision, never restated here. The + * protocol's layered read keeps serving every layer, because + * its resolution readers (the security catalog echo, the + * packaged-permission-set lock gate) need them: the door answers here. * 2. [#20156] THE per-caller gate on EVERY present layer, `effective` first * (it is what the plain read serves, so its refusal is the plain read's * own), under {@link STORED_VERSION_DOOR_POLICY}: per-caller arms only @@ -3763,7 +3798,7 @@ export function createMetaLayeredAnswer( sources: MetaItemReadGateSources, request: MetaLayeredRequest, ): (layered: unknown) => Promise { - const { metaType, name, maskPosture } = request; + const { metaType, name, packageId, maskPosture } = request; return async (raw) => { const layered = raw as Record | null | undefined; const present = META_ITEM_LAYERS.filter((layer) => layered?.[layer] != null); @@ -3772,6 +3807,13 @@ export function createMetaLayeredAnswer( // the plain read's absence, answered before the gate. if (present.length === 0) return { kind: 'refuse', refusal: { reason: 'absent' } }; + // 1b. [#7557, #24210] A disabled package's item is that same absence, + // for every caller: before the gate, so no author is served it. + if (metaType !== 'object' + && await sources.isItemOfDisabledPackage?.({ type: metaType, name, ...(packageId ? { packageId } : {}) }) === true) { + return { kind: 'refuse', refusal: { reason: 'absent' } }; + } + // 2. [#20156] THE per-caller gate, on every present layer. const served = new Map(); { diff --git a/packages/rest/src/objectql-slot-consumer-census.test.ts b/packages/rest/src/objectql-slot-consumer-census.test.ts index 8b24ec6d330..ae86aa84a13 100644 --- a/packages/rest/src/objectql-slot-consumer-census.test.ts +++ b/packages/rest/src/objectql-slot-consumer-census.test.ts @@ -37,6 +37,13 @@ * ask, so nothing is withheld). Its behavioural pin is * `meta-disabled-package-doors.test.ts`, cited in §3. * + * [#24210] ⭐ And a FIFTH, examined on arrival the same way: the layered view + * (`GET /meta/:type/:name/layers` and the deprecated `?layers=true`) asks the + * same registry question through the shared layered chain's owner step, wired + * in `metaItemReadGateSources`. It too reaches the slot through + * `wiredEngineOrLoud` (a rejecting provider is a loud `503`, an absent engine + * withholds nothing), pinned in the same file, cited in §3. + * * ## What is asserted about the answers * * §2 drives the third consumer on the wiring where it decides anything, and @@ -190,18 +197,19 @@ describe('[#18546] §1 the `objectQLProvider` consumer census is mechanical', () expect(invocationSites(SOURCE, 'objectQLProvider').some((s) => s.text.startsWith('//'))).toBe(false); }); - it('⭐ FOUR consumers: three, not two, was the count the two prose censuses got wrong, and #24210 adds /published', () => { + it('⭐ FIVE consumers: three, not two, was the count the two prose censuses got wrong, and #24210 adds /published and the layered view', () => { // [#23050 slice 2] 3 UNCHANGED, over two files: 2 in `rest-server.ts` // and the batch door's 1 in `batch-routes.ts`. [#24210] 4: `/published`'s - // disabled-owner check joins `rest-server.ts`. + // disabled-owner check joins `rest-server.ts`; 5: the layered chain's + // owner step (`metaItemReadGateSources`) joins it too. 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(4); + expect(sites).toHaveLength(5); }); - it('⭐ [#18559] ALL FOUR consumers reach the slot through `wiredEngineOrLoud` — none reads it directly', () => { + it('⭐ [#18559] ALL FIVE 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 @@ -214,7 +222,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(4); + expect(viaSeam).toHaveLength(5); // ⭐ 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 @@ -532,7 +540,7 @@ 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'], + ['meta-disabled-package-doors.test.ts', '[#24210] GET /meta/:type/:name/published, /layers and ?layers=true'], ])('%s still exists and still names this slot (%s)', (file) => { const path = resolve(HERE, file); expect(existsSync(path)).toBe(true); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index fb6bda712fe..d908f0c4a0f 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3403,6 +3403,14 @@ export class RestServer { * `withItemWriteVerdict` — [#20156] the door's policy honours the author * exemption, so the caller carries its `mayWriteItem` (see * {@link metaReadAudienceSources}). + * + * [#7557, #24210] `isItemOfDisabledPackage`, the layered chain's owner + * step: the plain read serves no `item` and its kept envelope `packageId` + * is one the engine registry's `isPackageDisabled` answers for, the owner + * `/published` asks about. The engine is + * reached through {@link wiredEngineOrLoud}: none wired withholds nothing, + * as before; one that cannot be resolved is the loud outage, never "not + * withheld". */ private metaItemReadGateSources( environmentId: string | undefined, @@ -3415,6 +3423,23 @@ export class RestServer { ...this.metaListSource(p, environmentId), serviceProbe: (caller) => this.serviceProbeFor((caller as any)?.__kernel), navPruneLogged: this.navPruneLogged, + isItemOfDisabledPackage: async ({ type, name, packageId }) => { + if (typeof p.getMetaItem !== 'function') return false; + const ql = await wiredEngineOrLoud( + Boolean(this.objectQLProvider), + () => this.objectQLProvider!(environmentId), + ); + const registry = (ql as any)?.registry; + if (typeof registry?.isPackageDisabled !== 'function') return false; + const itemRequest: TransportScopedMetaRequest = { + type, + name, + ...(packageId ? { packageId } : {}), + ...(environmentId ? { environmentId } : {}), + }; + const envelope = await p.getMetaItem(itemRequest) as { item?: unknown; packageId?: unknown } | undefined; + return envelope?.item == null && registry.isPackageDisabled(envelope?.packageId) === true; + }, }; } @@ -4063,7 +4088,12 @@ export class RestServer { // admission (`metaItemReadGateSources(…, true)`). const answer = await metaReadGate.createMetaLayeredAnswer( this.metaItemReadGateSources(environmentId, req, p, true), - { metaType: RestServer.metaTypeSingular(req.params.type), name: req.params.name, maskPosture }, + { + metaType: RestServer.metaTypeSingular(req.params.type), + name: req.params.name, + ...(layeredPackageId ? { packageId: layeredPackageId } : {}), + maskPosture, + }, )(layered); switch (answer.kind) { case 'refuse': 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 b2513b08090..8a9a70b0cca 100644 --- a/packages/runtime/src/domains/meta-disabled-package-doors.test.ts +++ b/packages/runtime/src/domains/meta-disabled-package-doors.test.ts @@ -19,7 +19,12 @@ * - `/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`). + * answer (the owner is the plain read's kept envelope `packageId`); + * - the layered view, `/meta/:type/:name/layers` and the deprecated + * `?layers=true`, of a non-object item: the shared layered chain's owner + * step answers a disabled package's item as the missing name, byte for + * byte, asking the plain read's envelope under the request's `?package=` + * scope; the object row stays the protocol's own refusal. */ import { describe, it, expect, vi } from 'vitest'; @@ -49,8 +54,17 @@ const refusal = (object: string) => Object.assign( * 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, + demo_page: DISABLED_PKG, demo_snap: DISABLED_PKG, demo_code: DISABLED_PKG, + healthy_page: HEALTHY_PKG, healthy_snap: HEALTHY_PKG, healthy_code: HEALTHY_PKG, +}; +/** + * [#24210] Code-shipped pages: the layered read answers a code layer and no + * overlay. `twin_page` is stored in BOTH packages, so its owner is the one the + * request's `?package=` names (unscoped, the disabled package's). + */ +const CODE_ROWS: Record = { + demo_code: { name: 'demo_code', label: 'Demo (shipped)' }, + healthy_code: { name: 'healthy_code', label: 'Healthy (shipped)' }, }; const PUBLISHED_ROWS: Record = { demo_page: { name: 'demo_page', label: 'Demo (published row)' }, @@ -65,19 +79,21 @@ const SERVICE_ITEMS: Record = { function boot(opts: { getMetaTypes?: boolean } = {}) { const disabled = { on: true }; - const getMetaItem = vi.fn(async ({ type, name }: any) => { + const getMetaItem = vi.fn(async ({ type, name, packageId }: any): Promise => { 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. + const owner = name === 'twin_page' ? (packageId ?? DISABLED_PKG) : OWNERS[name]; return { type, name, item: undefined, lock: 'none', editable: true, - ...(OWNERS[name] ? { packageId: OWNERS[name] } : {}), + ...(owner ? { packageId: owner } : {}), }; }); const getMetaItemLayered = vi.fn(async ({ type, name }: any) => { if (type === 'object' && name === DISABLED) throw refusal(name); - const row = PUBLISHED_ROWS[name] ?? null; + if (CODE_ROWS[name]) return { type, name, code: CODE_ROWS[name], overlay: null, effective: CODE_ROWS[name] }; + const row = name === 'twin_page' ? { name, label: 'Twin' } : PUBLISHED_ROWS[name] ?? null; return { type, name, code: null, overlay: row, effective: row }; }); const protocol: Record = { @@ -118,6 +134,7 @@ function boot(opts: { getMetaTypes?: boolean } = {}) { registry, metadata, disabled, + getMetaItem, getMetaItemLayered, }; } @@ -231,3 +248,90 @@ describe('#24210 — /published of a disabled package\'s non-object item', () => expect((await published(stack, 'healthy_snap')).response?.body?.data).toEqual({ name: 'healthy_snap', label: 'healthy_snap (snapshot)' }); }); }); + +describe('#24210 — the layered view of a disabled package\'s non-object item, both spellings', () => { + const layers = (stack: ReturnType, name: string, query: Record = {}) => + stack.dispatcher.dispatch('GET', `/meta/page/${name}/layers`, {}, query, SESSION()); + const flag = (stack: ReturnType, name: string, query: Record = {}) => + stack.dispatcher.dispatch('GET', `/meta/page/${name}`, {}, { ...query, layers: 'true' }, SESSION()); + const SPECIMENS = ['demo_page', 'demo_code']; // an overlay-only and a code-shipped page + + it('served while enabled, then the missing name\'s answer, then served again', async () => { + const stack = boot(); + stack.disabled.on = false; + const before = new Map(); + for (const name of SPECIMENS) { + const viaLayers = await layers(stack, name); + const viaFlag = await flag(stack, name); + expect(viaLayers.response?.status, name).toBe(200); + expect(viaLayers.response?.body?.data?.effective?.name, name).toBe(name); + expect(viaFlag.response?.body?.data, name).toEqual(viaLayers.response?.body?.data); + expect(viaFlag.response?.headers?.Deprecation, name).toBe('true'); + before.set(name, JSON.stringify([viaLayers.response, viaFlag.response])); + } + + stack.disabled.on = true; + const missingLayers = await layers(stack, 'no_such_page'); + const missingFlag = await flag(stack, 'no_such_page'); + expect(missingLayers.response?.status).toBe(404); + // The missing name's answer is the plain read's own absence. + const missingPlain = await stack.dispatcher.dispatch('GET', '/meta/page/no_such_page', {}, {}, SESSION()); + expect(JSON.stringify(missingLayers.response?.body)).toBe(JSON.stringify(missingPlain.response?.body)); + for (const name of SPECIMENS) { + // Status, body and headers, byte for byte the missing name's. + expect(JSON.stringify((await layers(stack, name)).response), name).toBe(JSON.stringify(missingLayers.response)); + expect(JSON.stringify((await flag(stack, name)).response), name).toBe(JSON.stringify(missingFlag.response)); + } + // Control: another package's pages, both spellings, still served. + for (const name of ['healthy_page', 'healthy_code']) { + expect((await layers(stack, name)).response?.body?.data?.effective?.name, name).toBe(name); + expect((await flag(stack, name)).response?.body?.data?.effective?.name, name).toBe(name); + } + + stack.disabled.on = false; + for (const name of SPECIMENS) { + expect(JSON.stringify([(await layers(stack, name)).response, (await flag(stack, name)).response]), name) + .toBe(before.get(name)); + } + }); + + it('?package= is the scope the owner is read under: the healthy package\'s copy is served', async () => { + const stack = boot(); + const missing = await layers(stack, 'no_such_page'); + expect((await layers(stack, 'twin_page', { package: HEALTHY_PKG })).response?.body?.data?.effective?.label).toBe('Twin'); + expect((await flag(stack, 'twin_page', { package: HEALTHY_PKG })).response?.body?.data?.effective?.label).toBe('Twin'); + expect(stack.getMetaItem).toHaveBeenCalledWith({ type: 'page', name: 'twin_page', packageId: HEALTHY_PKG }); + expect(JSON.stringify((await layers(stack, 'twin_page', { package: DISABLED_PKG })).response)) + .toBe(JSON.stringify(missing.response)); + expect(JSON.stringify((await layers(stack, 'twin_page')).response)).toBe(JSON.stringify(missing.response)); + }); + + it('the plain read decides which types are hidden: an item it still serves keeps its layered view', async () => { + // As the protocol serves a `package` item whatever its owner: the + // envelope names the disabled package, and the item is there. + const stack = boot(); + stack.getMetaItem.mockImplementation(async ({ type, name }: any) => + ({ type, name, item: { name, label: 'still served' }, lock: 'none', editable: true, packageId: DISABLED_PKG })); + expect((await layers(stack, 'demo_page')).response?.body?.data?.effective?.name).toBe('demo_page'); + expect((await flag(stack, 'demo_page')).response?.body?.data?.effective?.name).toBe('demo_page'); + }); + + it('an object keeps the layered read\'s own 404 OBJECT_PACKAGE_DISABLED, on both spellings', async () => { + const stack = boot(); + expectObjectRefusal(await stack.dispatcher.dispatch('GET', `/meta/object/${DISABLED}/layers`, {}, {}, SESSION())); + expectObjectRefusal(await stack.dispatcher.dispatch('GET', `/meta/object/${DISABLED}`, {}, { layers: 'true' }, SESSION())); + }); + + it('a plain read that rejects is that fault, never the layered view; a registry that cannot answer withholds nothing', async () => { + const stack = boot(); + stack.getMetaItem.mockImplementationOnce(async () => { + throw Object.assign(new Error('metadata store unavailable'), { code: 'SERVICE_UNAVAILABLE', status: 503 }); + }); + const failed = await layers(stack, 'demo_page'); + expect(failed.response?.status).toBe(503); + expect(JSON.stringify(failed.response?.body)).not.toContain('published row'); + + (stack.registry as { isPackageDisabled?: unknown }).isPackageDisabled = undefined; + expect((await layers(stack, 'demo_page')).response?.body?.data?.effective?.name).toBe('demo_page'); + }); +}); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 3d333a7ec34..7406fd2c61d 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -498,6 +498,13 @@ const NAV_PRUNE_LOGGED = new Set(); * author exemption (the `?state=draft` read): this transport's own save-door * answer (`saveVerdict` in {@link handleMetadataRequest}), carried on a COPY of * the context — the same object serves every other consumer of this request. + * + * [#7557, #24210] `isItemOfDisabledPackage`, the layered chain's owner step: + * the plain read serves no `item` and its kept envelope `packageId` is one the + * engine registry disables ({@link isServiceItemOfDisabledPackage}), the owner + * `/published` asks about. A host + * with no registry that can answer withholds nothing, as before; a read that + * rejects is that fault, never "not withheld". */ function metaItemReadGateSources( deps: DomainHandlerDeps, @@ -520,6 +527,13 @@ function metaItemReadGateSources( } }, navPruneLogged: NAV_PRUNE_LOGGED, + isItemOfDisabledPackage: async ({ type, name, packageId }) => { + if (typeof protocol?.getMetaItem !== 'function') return false; + const registry = (await deps.getObjectQL(context))?.registry; + if (typeof (registry as { isPackageDisabled?: unknown } | undefined)?.isPackageDisabled !== 'function') return false; + const envelope = await protocol.getMetaItem({ type, name, ...(packageId ? { packageId } : {}) }); + return envelope?.item == null && isServiceItemOfDisabledPackage(registry, { _packageId: envelope?.packageId }); + }, }; } @@ -869,7 +883,7 @@ async function answerMetaLayered( }); answer = await createMetaLayeredAnswer( metaItemReadGateSources(deps, context, protocol, mayWriteItem), - { metaType: pluralToSingular(type), name, maskPosture }, + { metaType: pluralToSingular(type), name, ...(packageId ? { packageId } : {}), maskPosture }, )(layered); } catch (e: any) { return withHeaders(deps.errorFromThrown(e, 500), headers);