Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/packages-lifecycle-readonly-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
"@objectstack/runtime": patch
"@objectstack/metadata-protocol": patch
"@objectstack/spec": patch
---

fix(runtime): refuse to disable or delete a read-only package on the `/packages` lifecycle routes (#7560)

`PATCH /packages/<id>/disable` and `DELETE /packages/<id>` answered **200** on a
platform package, and the `DELETE` really removed it from the running process's
registry listing. One authorized API call took platform functionality out of a
live deployment. Reproduced on two platform packages in the QA run behind #7514.

**Blast radius, measured.** The card reported that the packages come back after a
restart — true for `DELETE` (they are code-loaded, so nothing is permanently
destroyed), but **not** for `disable`: `setPackageDisabled` persists the choice
to `<OS_HOME>/package-state/<env>.json`, which `SchemaRegistry` replays at boot.
A disabled platform package stayed disabled across restarts.

**Two axes, not one.** #7033 / PR #7083 gave the whole `/packages` domain caller
authorization (`manage_metadata` on writes, the ADR-0106 D4 set on reads, an
anonymous floor) — *who may call the route*. This is the second, missing check
on the same routes: *what the route may do once the caller is allowed*. An
authorized admin — and `isSystem` — is now refused, because read-only is a
property of the **package**, not of the caller. The caller gate is unchanged;
tightening it would not have fixed this and would have broken legitimate admins.

**No new vocabulary.** The refusal is ADR-0070's existing one, reused: `422` /
`WRITABLE_PACKAGE_REQUIRED`, the code `saveMetaItem` already throws when asked to
author *into* a read-only package. The predicate behind it moved out of
`ObjectStackProtocolImplementation`'s private method into
`@objectstack/metadata-protocol`'s exported `isWritablePackage(engine, id)` and
is now **referenced** by both callers — a second hand-kept copy of "which
packages are read-only" is exactly the drift that let `DELETE` remove a platform
package while `saveMetaItem` was refusing to add one field to it. Both read-only
signals are covered: a booted code package (`engine.manifests`) and a
platform-delivered manifest `scope` of `system` / `cloud`.

Packages an org owns (project-scoped bases, ADR-0048 authoring workspaces) still
disable, re-enable and delete exactly as before — pinned in both directions, on
the registry listing rather than on the status code, since the listing is where
the original defect's harm actually showed.
7 changes: 7 additions & 0 deletions packages/metadata-protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ export type { MetadataDiagnostics } from './metadata-diagnostics.js';

export type { MetadataHostEngine } from './host-engine.js';

// [#7560] ADR-0070's read-only-package rule. The authoring path (`saveMetaItem`
// → `WRITABLE_PACKAGE_REQUIRED`) and the `/packages` lifecycle gate in
// `@objectstack/runtime` (`PATCH /:id/disable`, `DELETE /:id`) both ask it, so
// "which packages are read-only" has ONE definition rather than two that drift.
export { isWritablePackage, READ_ONLY_PACKAGE_SCOPES } from './package-writability.js';
export type { PackageWritabilityEngine } from './package-writability.js';

// #4556 — the `sys_metadata_history.recorded_by` sentinel → NULL conversion,
// as an ADR-0119 D2 migration plan. Run by `os migrate recorded-by`.
export {
Expand Down
83 changes: 83 additions & 0 deletions packages/metadata-protocol/src/package-writability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ADR-0070 — the read-only-package predicate, in ONE place.
*
* A package is either a **writable base** (an org may author into it, and its
* lifecycle is the org's to manage) or **read-only** (it belongs to the
* deployment that ships it). Until #7560 this distinction was a private method
* on {@link ObjectStackProtocolImplementation}, reachable only by the metadata
* authoring path — so `saveMetaItem` refused to author INTO a platform package
* while `PATCH /packages/:id/disable` and `DELETE /packages/:id` happily took
* the whole package out of the running deployment.
*
* The two callers now share this function rather than each spelling the rule:
* a third read-only signal added here reaches the authoring gate and the
* lifecycle gate together, which is the only way the two can't drift apart.
*/

/**
* The engine surface this predicate reads. Structural on purpose — it is
* satisfied by the real `ObjectQLEngine`, by `MetadataHostEngine`, and by the
* partial doubles the gate tests build, and it keeps this module free of a
* dependency on `@objectstack/objectql`.
*/
export interface PackageWritabilityEngine {
/** Booted code packages, keyed by manifest id (`registerApp` populates it). */
manifests?: { has?(id: string): boolean };
registry?: {
getPackage?(id: string): { manifest?: { scope?: string } } | undefined;
};
}

/**
* Manifest scopes that mark a package as platform-delivered, hence read-only.
* `system` is the platform's own; `cloud` is marketplace / control-plane
* delivered. Anything else (`project`, or an absent scope) is an org's own.
*/
export const READ_ONLY_PACKAGE_SCOPES: readonly string[] = ['system', 'cloud'];

/**
* True when `packageId` is a **writable base** — a DB-backed package an org or
* the AI may author *new* metadata into, and whose lifecycle the org owns
* (ADR-0070 D2). The two read-only kinds return `false`:
*
* • **Booted code packages** — they register a manifest into the engine at
* startup (`registerApp` → `engine.manifests`); their items are code-shipped
* artifacts. Only `allowOrgOverride` overlays are allowed (ADR-0005), never
* fresh authored items.
* • **Installed / platform packages** — manifest `scope` is `system` or
* `cloud` (marketplace / platform-delivered).
*
* A project-scoped DB package, or a bare ADR-0048 *authoring-workspace* id with
* no registered manifest, is writable.
*
* NOTE: the code-package signal is the engine manifest map ONLY — we
* deliberately do NOT fall back to "owns ≥1 registered object" (the old
* `isLoadedPackage` heuristic). A writable base accrues registered objects once
* its drafts publish, and that must never flip the base to read-only — that is
* the exact #2252 read-only-after-publish trap ADR-0070 removes.
*
* NOTE: this is a property of the PACKAGE, not of the caller. There is
* deliberately no `isSystem` escape hatch: #7033 decided *who may call* the
* package routes, and #7560 is what those routes may do once the caller is
* allowed. An authorized admin — and the engine itself — still may not disable
* or delete a package the deployment ships. Internal code that legitimately
* tears a code package down calls `registry.uninstallPackage` directly and never
* passes through a gate.
*
* An absent/empty `packageId` is NOT writable: the authoring path treats "no
* base resolved" as a refusal (`WRITABLE_PACKAGE_REQUIRED`), and answering
* "writable" for an unknown would make this predicate fail open.
*/
export function isWritablePackage(engine: unknown, packageId: string | null | undefined): boolean {
if (!packageId) return false;
const e = engine as PackageWritabilityEngine | null | undefined;
// Booted code package → read-only artifact source.
if (e?.manifests?.has?.(packageId)) return false;
// Installed / platform package → read-only by manifest scope.
const scope = e?.registry?.getPackage?.(packageId)?.manifest?.scope;
if (typeof scope === 'string' && READ_ONLY_PACKAGE_SCOPES.includes(scope)) return false;
// Project-scoped base, or unregistered authoring-workspace id → writable.
return true;
}
42 changes: 15 additions & 27 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import { readEnvWithDeprecation, resolveTenancyPosture } from '@objectstack/type
import { postureEnforcesWall } from '@objectstack/spec/security';
import type { MetadataHostEngine } from './host-engine.js';
import { evaluateRuntimeAuthoringGate } from './runtime-authoring-gate.js';
// [#7560] ADR-0070's read-only-package rule, shared with the `/packages`
// lifecycle gate in `@objectstack/runtime` — see `./package-writability.js`.
import { isWritablePackage as isWritablePackageShared } from './package-writability.js';
import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js';
// [#6418] `sys_metadata`'s overlay-uniqueness indexes: probe-first DDL plus the
// ADR-0120 D4 reporting that replaced this file's empty `catch` blocks.
Expand Down Expand Up @@ -8283,35 +8286,20 @@ export class ObjectStackProtocolImplementation implements

/**
* True when `packageId` is a **writable base** — a DB-backed package an
* org or the AI may author *new* metadata into (ADR-0070 D2). The two
* read-only kinds return `false`:
*
* • **Booted code packages** — they register a manifest into the engine
* at startup (`registerApp` → `engine.manifests`); their items are
* code-shipped artifacts. Only `allowOrgOverride` overlays are allowed
* (ADR-0005), never fresh authored items.
* • **Installed / platform packages** — manifest `scope` is `system` or
* `cloud` (marketplace / platform-delivered).
*
* A project-scoped DB package, or a bare ADR-0048 *authoring-workspace* id
* with no registered manifest, is writable.
*
* NOTE: the code-package signal is the engine manifest map ONLY — we
* deliberately do NOT fall back to "owns ≥1 registered object" (the old
* `isLoadedPackage` heuristic). A writable base accrues registered objects
* once its drafts publish, and that must never flip the base to read-only
* — that is the exact #2252 read-only-after-publish trap this ADR removes.
* org or the AI may author *new* metadata into (ADR-0070 D2).
*
* [#7560] The rule itself moved to {@link isWritablePackage} in
* `./package-writability.js` because it gained a SECOND caller: the
* `/packages` lifecycle routes, which must refuse to disable or delete a
* read-only package the same way this path refuses to author into one. Two
* hand-kept copies of "which packages are read-only" is precisely the drift
* that let `DELETE /packages/:id` remove a platform package from a live
* deployment while `saveMetaItem` was refusing to add one field to it. This
* method stays as the in-class spelling; the shared function is the
* definition, and its doc comment carries the reasoning.
*/
private isWritablePackage(packageId: string | null | undefined): boolean {
if (!packageId) return false;
const engine = this.engine as any;
// Booted code package → read-only artifact source.
if (engine?.manifests?.has?.(packageId)) return false;
// Installed / platform package → read-only by manifest scope.
const scope = engine?.registry?.getPackage?.(packageId)?.manifest?.scope;
if (scope === 'system' || scope === 'cloud') return false;
// Project-scoped base, or unregistered authoring-workspace id → writable.
return true;
return isWritablePackageShared(this.engine, packageId);
}

/**
Expand Down
Loading
Loading