|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * ADR-0070 — the read-only-package predicate, in ONE place. |
| 5 | + * |
| 6 | + * A package is either a **writable base** (an org may author into it, and its |
| 7 | + * lifecycle is the org's to manage) or **read-only** (it belongs to the |
| 8 | + * deployment that ships it). Until #7560 this distinction was a private method |
| 9 | + * on {@link ObjectStackProtocolImplementation}, reachable only by the metadata |
| 10 | + * authoring path — so `saveMetaItem` refused to author INTO a platform package |
| 11 | + * while `PATCH /packages/:id/disable` and `DELETE /packages/:id` happily took |
| 12 | + * the whole package out of the running deployment. |
| 13 | + * |
| 14 | + * The two callers now share this function rather than each spelling the rule: |
| 15 | + * a third read-only signal added here reaches the authoring gate and the |
| 16 | + * lifecycle gate together, which is the only way the two can't drift apart. |
| 17 | + */ |
| 18 | + |
| 19 | +/** |
| 20 | + * The engine surface this predicate reads. Structural on purpose — it is |
| 21 | + * satisfied by the real `ObjectQLEngine`, by `MetadataHostEngine`, and by the |
| 22 | + * partial doubles the gate tests build, and it keeps this module free of a |
| 23 | + * dependency on `@objectstack/objectql`. |
| 24 | + */ |
| 25 | +export interface PackageWritabilityEngine { |
| 26 | + /** Booted code packages, keyed by manifest id (`registerApp` populates it). */ |
| 27 | + manifests?: { has?(id: string): boolean }; |
| 28 | + registry?: { |
| 29 | + getPackage?(id: string): { manifest?: { scope?: string } } | undefined; |
| 30 | + }; |
| 31 | +} |
| 32 | + |
| 33 | +/** |
| 34 | + * Manifest scopes that mark a package as platform-delivered, hence read-only. |
| 35 | + * `system` is the platform's own; `cloud` is marketplace / control-plane |
| 36 | + * delivered. Anything else (`project`, or an absent scope) is an org's own. |
| 37 | + */ |
| 38 | +export const READ_ONLY_PACKAGE_SCOPES: readonly string[] = ['system', 'cloud']; |
| 39 | + |
| 40 | +/** |
| 41 | + * True when `packageId` is a **writable base** — a DB-backed package an org or |
| 42 | + * the AI may author *new* metadata into, and whose lifecycle the org owns |
| 43 | + * (ADR-0070 D2). The two read-only kinds return `false`: |
| 44 | + * |
| 45 | + * • **Booted code packages** — they register a manifest into the engine at |
| 46 | + * startup (`registerApp` → `engine.manifests`); their items are code-shipped |
| 47 | + * artifacts. Only `allowOrgOverride` overlays are allowed (ADR-0005), never |
| 48 | + * fresh authored items. |
| 49 | + * • **Installed / platform packages** — manifest `scope` is `system` or |
| 50 | + * `cloud` (marketplace / platform-delivered). |
| 51 | + * |
| 52 | + * A project-scoped DB package, or a bare ADR-0048 *authoring-workspace* id with |
| 53 | + * no registered manifest, is writable. |
| 54 | + * |
| 55 | + * NOTE: the code-package signal is the engine manifest map ONLY — we |
| 56 | + * deliberately do NOT fall back to "owns ≥1 registered object" (the old |
| 57 | + * `isLoadedPackage` heuristic). A writable base accrues registered objects once |
| 58 | + * its drafts publish, and that must never flip the base to read-only — that is |
| 59 | + * the exact #2252 read-only-after-publish trap ADR-0070 removes. |
| 60 | + * |
| 61 | + * NOTE: this is a property of the PACKAGE, not of the caller. There is |
| 62 | + * deliberately no `isSystem` escape hatch: #7033 decided *who may call* the |
| 63 | + * package routes, and #7560 is what those routes may do once the caller is |
| 64 | + * allowed. An authorized admin — and the engine itself — still may not disable |
| 65 | + * or delete a package the deployment ships. Internal code that legitimately |
| 66 | + * tears a code package down calls `registry.uninstallPackage` directly and never |
| 67 | + * passes through a gate. |
| 68 | + * |
| 69 | + * An absent/empty `packageId` is NOT writable: the authoring path treats "no |
| 70 | + * base resolved" as a refusal (`WRITABLE_PACKAGE_REQUIRED`), and answering |
| 71 | + * "writable" for an unknown would make this predicate fail open. |
| 72 | + */ |
| 73 | +export function isWritablePackage(engine: unknown, packageId: string | null | undefined): boolean { |
| 74 | + if (!packageId) return false; |
| 75 | + const e = engine as PackageWritabilityEngine | null | undefined; |
| 76 | + // Booted code package → read-only artifact source. |
| 77 | + if (e?.manifests?.has?.(packageId)) return false; |
| 78 | + // Installed / platform package → read-only by manifest scope. |
| 79 | + const scope = e?.registry?.getPackage?.(packageId)?.manifest?.scope; |
| 80 | + if (typeof scope === 'string' && READ_ONLY_PACKAGE_SCOPES.includes(scope)) return false; |
| 81 | + // Project-scoped base, or unregistered authoring-workspace id → writable. |
| 82 | + return true; |
| 83 | +} |
0 commit comments