Skip to content
19 changes: 19 additions & 0 deletions .changeset/24210-metadata-protocol-by-name-disabled-non-object.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 answers a disabled package's item of every type but `object` and `package` as absent, as the list already omits it

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) the narrowing is the served set of a runtime read door, getMetaItem for an item 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 apps, views, pages, dashboards, flows and every other item out of `GET /meta/:type`. The by-name read `GET /meta/:type/:name` still served them. It now answers such an item the way it answers a name with nothing behind it.

- **Absent now:** `getMetaItem({ type, name })` for an item whose `_packageId` names a disabled package, for every type except `object` and `package`. The active read and `previewDrafts` serve no `item`, which both transports answer `404 RESOURCE_NOT_FOUND`, byte for byte the answer for a missing name. The strict draft read (`state: 'draft'`) throws the missing draft's `404 NO_DRAFT`. The list read and the by-name read ask one predicate, so they hide the same items.
- **Kept:** the protection envelope (`lock`, `editable`, `deletable`, `version` and the rest) still reports the write doors' verdicts about the address, because a disable does not change how the lock gate judges a save there. Only `item` and a flow's `registration` are withheld.
- **Unchanged:** objects, which keep their `404 OBJECT_PACKAGE_DISABLED`; packages, which are never hidden, so a disabled package can still be read and re-enabled; items of enabled packages; and `getMetaItemLayered` (`?layers=true`, `/layers`) and `/published`, which still serve a disabled package's non-object item.

What changes for you: a client that read a disabled package's app, view, page or other item by name now gets `404 RESOURCE_NOT_FOUND`, as for a missing name. Re-enable the package to read it again.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@objectstack/plugin-security': patch
---

fix(plugin-security): the security catalog echo keeps naming a disabled package's permission set or position as that package's

The echo reads which package owns a stored overlay from the protocol's by-name read. That read now withholds a disabled package's item and keeps its protection envelope, so the echo reads the envelope's `packageId` instead of `item._packageId`. Before, a disabled package's set was echoed unowned, lost its ADR-0090 D5 audience-binding suggestion, and stayed unowned after the package was re-enabled until the next save, publish or boot. Enforcement is unchanged.
18 changes: 18 additions & 0 deletions .changeset/24210-rest-event-diff-doors-disabled-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/rest': minor
---

fix(rest)!: `/history`, `/audit` and `/diff` answer a disabled package's item with the plain read's absence, never past the per-caller gate

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) the narrowing is the served set of three runtime read doors for an item 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.

The by-name read now answers a disabled package's item as absent. The event and diff doors judge the item the plain read would judge, and they read "no item" as "nothing behind the name": events served whole, and a view's or dashboard's diff served whole, to a caller the item's own gate refuses.

- **Absent now, `404 RESOURCE_NOT_FOUND`:** `GET /meta/:type/:name/history`, `/audit` and `/diff` for an `app`, `view`, `dashboard`, `doc` or `book` the plain read withholds while its layers still resolve, which is a disabled package's item. No events and no diff are read.
- **Unchanged:** an item that is really gone (every layer null, such as a deleted item) keeps its answer: its events, and its diff as before. Items of enabled packages keep the per-caller gate as before.

What changes for you: re-enable the package to read its items' history, audit trail and diff again.
20 changes: 20 additions & 0 deletions .changeset/24210-runtime-meta-disabled-package-doors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/runtime': minor
---

fix(runtime)!: the dispatcher's `/meta` doors that read past the protocol answer a disabled package's object or item as the protocol's by-name read does

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) the narrowing is the served set of runtime read doors 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 registry keeps a disabled package's objects on purpose, and the metadata service knows nothing of a disable. Four dispatcher doors read one of them for a disabled package's object or item and served it.

- **Refused now, `404 OBJECT_PACKAGE_DISABLED`:** `GET /meta/object/:name/state/:field`, and the legacy one-segment `GET /meta/:name` exit, for an object whose owning package is disabled. Both read `registry.getObject`; they now answer the protocol's refusal, the answer `GET /meta/object/:name` gives.
- **Refused now, `404 OBJECT_PACKAGE_DISABLED`:** `GET /meta/object/:name/published`. When the layered read refused the object, this door fell through to the published snapshot and served it. The refusal is now the answer, as on `RestServer`'s `/published`.
- **Absent now, `404 RESOURCE_NOT_FOUND`:** `GET /meta/:type/:name` for a non-object item of a disabled package. The protocol answers it as absent, and the door no longer serves the metadata service's copy in its place. The body is byte for byte the answer for a missing name.
- **Unchanged:** objects and items of enabled packages, and every other fall-through.

What changes for you: re-enable the package to read the object or item again.
67 changes: 55 additions & 12 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,35 @@ function isObjectOfDisabledPackage(registry: any, name: string): boolean {
&& registry.isObjectPackageDisabled(name) === true;
}

/**
* [#7557, #24210] Whether `item` belongs to a disabled package: its
* `_packageId`, asked of `registry.isPackageDisabled`. The ONE predicate for a
* non-object item, asked by the list read ({@link
* ObjectStackProtocolImplementation.getMetaItems}, which omits the item) and
* the by-name read ({@link ObjectStackProtocolImplementation.getMetaItem},
* which answers as for a name with nothing behind it), so the two cannot
* disagree about which items a disable hides. Optional-called, as {@link
* isObjectOfDisabledPackage} is: a host whose registry cannot answer has no
* item hidden.
*/
function isItemOfDisabledPackage(registry: any, item: unknown): boolean {
return typeof registry?.isPackageDisabled === 'function'
&& registry.isPackageDisabled((item as { _packageId?: unknown } | null | undefined)?._packageId) === true;
}

/**
* The strict draft read's `404 NO_DRAFT`: no pending draft at this address
* (ADR-0005 §draft-lifecycle). [#24210] One constructor for the read that
* finds no draft row and the read of a disabled package's draft, so the two
* answers are byte-identical.
*/
function noPendingDraftError(type: string, name: string): Error & { code: 'NO_DRAFT'; status: 404 } {
const err = new Error(`No pending draft exists for ${type}/${name}.`) as Error & { code: 'NO_DRAFT'; status: 404 };
err.code = 'NO_DRAFT';
err.status = 404;
return err;
}

/**
* [#17501] Does a converted JSON Schema carry any authorable surface at all?
*
Expand Down Expand Up @@ -10375,10 +10404,11 @@ export class ObjectStackProtocolImplementation implements
// `assertObjectRegistered` (`OBJECT_PACKAGE_DISABLED`) — absence here
// and refusal there are the two halves of one answer, pinned together
// by `protocol.package-disable-enforcement.test.ts`.
//
// [#24210] The predicate is {@link isItemOfDisabledPackage}, the one the
// by-name read asks, so the list and the by-name read hide the same items.
if (request.type !== 'package') {
items = (items as any[]).filter(
(it) => !this.engine.registry.isPackageDisabled((it as any)?._packageId),
);
items = (items as any[]).filter((it) => !isItemOfDisabledPackage(this.engine.registry, it));
}

// Canonical-shape exposure (ADR-0017, "Object has-many View"): a
Expand Down Expand Up @@ -11046,6 +11076,19 @@ export class ObjectStackProtocolImplementation implements
* that must still see the object (the save's destructive-change check)
* read {@link readMetaItem}, the shared body, instead: the reader-class
* split `package-disable-enforcement.test.ts` pins.
*
* [#7557, #24210] Every other type but `package` has no data plane, so a
* disabled package's item is answered as ABSENT, the answer a name with
* nothing behind it gets: the list read omits that item, by the same
* predicate ({@link isItemOfDisabledPackage}) asked of the item this read
* resolved, in every state. The strict draft read throws the missing
* draft's `404 NO_DRAFT`. The other reads serve no `item`, which both
* transports answer `404 RESOURCE_NOT_FOUND`, byte for byte the missing
* name's answer, and drop `registration`, the engine's verdict on a served
* flow. The protection envelope stays: it reports the write doors'
* verdicts about this address (#21670), and a disable does not change how
* the lock gate judges a save there. `package` is never hidden, so a
* disabled package can still be read and re-enabled.
*/
async getMetaItem(request: {
type: string, name: string, packageId?: string, state?: 'active' | 'draft', previewDrafts?: boolean,
Expand All @@ -11057,7 +11100,14 @@ export class ObjectStackProtocolImplementation implements
if (request.type === 'object' && isObjectOfDisabledPackage(this.engine?.registry, request.name)) {
throw objectPackageDisabledError(request.name);
}
return this.readMetaItem(request);
const answer = await this.readMetaItem(request);
if (request.type === 'object' || request.type === 'package'
|| !isItemOfDisabledPackage(this.engine?.registry, answer?.item)) {
return answer;
}
if (request.state === 'draft') throw noPendingDraftError(request.type, request.name);
const { item: _withheld, registration: _servedOnly, ...envelope } = answer as typeof answer & { registration?: unknown };
return { ...envelope, item: undefined };
}

/**
Expand Down Expand Up @@ -11292,14 +11342,7 @@ export class ObjectStackProtocolImplementation implements
// status code to decide "no pending edit" without sniffing
// envelope shape. See ADR-0005 §draft-lifecycle.
if (readState === 'draft') {
if (item === undefined) {
const err: any = new Error(
`No pending draft exists for ${request.type}/${request.name}.`,
);
err.code = 'NO_DRAFT';
err.status = 404;
throw err;
}
if (item === undefined) throw noPendingDraftError(request.type, request.name);
return {
type: request.type,
name: request.name,
Expand Down
Loading
Loading