Skip to content
19 changes: 19 additions & 0 deletions .changeset/24097-metadata-protocol-by-name-disabled-package.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (no-migration-prescription) the narrowing is the served set of a runtime read door, getMetaItem and getMetaItemLayered for an object whose owning package is disabled: no stored metadata changes shape, no spec key, export or error code is added or removed, and nothing must be rewritten; re-enabling the package restores the read -->

**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.
19 changes: 19 additions & 0 deletions .changeset/24097-rest-exposure-gate-disabled-package.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (no-migration-prescription) the change is which refusal a runtime REST door answers for an object whose owning package is disabled: no stored metadata changes shape, no spec key, export or error code is added or removed, and nothing must be rewritten; re-enabling the package restores the object -->

**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.
18 changes: 18 additions & 0 deletions .changeset/24097-runtime-meta-object-disabled-package.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (no-migration-prescription) the narrowing is the served set of a runtime read door on the dispatcher transport: no stored metadata changes shape, no spec key, export or error code is added or removed, and nothing must be rewritten; re-enabling the package restores the read -->

**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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -70,13 +71,13 @@ type Disposition = 'definition' | 'served-package envelope' | 'no';
const SITES: ReadonlyArray<Site & { readonly noPackage: boolean; readonly disposition: Disposition; readonly why: string }> = [
{ 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' },
Expand Down Expand Up @@ -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']);
});
});
89 changes: 74 additions & 15 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,35 @@ function stripServedSystemColumns<T>(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?
*
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 } : {}),
Expand Down
Loading
Loading