diff --git a/.changeset/29568-readonly-remedy-pair-route.md b/.changeset/29568-readonly-remedy-pair-route.md new file mode 100644 index 00000000000..cb1f28b847f --- /dev/null +++ b/.changeset/29568-readonly-remedy-pair-route.md @@ -0,0 +1,12 @@ +--- +'@objectstack/runtime': patch +--- + +Clause-②: no (message text only) +Metadata-protocol: none + +fix(runtime): the read-only package refusals name the paired `.custom` route, not the closed package-less save (#29568) + +- **Before.** Every `422 WRITABLE_PACKAGE_REQUIRED` from the `/packages` doors (delete, disable, publish, revert, adopt-orphans, the manifest edit, an install with `overwrite`) and the `422 DUPLICATE_SOURCE_NOT_A_BASE` from the duplicate door ended with "customise what this package provides beside it, as environment-level state (ADR-0131 D6): save an item under its own name". That save, made with no `?package`, onto an item the package ships, is refused `403 NOT_OVERRIDABLE` since ADR-0146 D11, so the refusal sent the caller into a second refusal. +- **Now.** The sentence names the ADR-0146 route: `POST /api/v1/packages//ensure-customization`, then save the item into `.custom` (`?package=.custom`), declaring `overrides: { package: '', basedOn }` to replace an item the package ships. It also says that a type that takes no override, or a locked item, is refused naming its route or the lock, and that a package that cannot have a pair (no namespace, for example) is refused at the ensure door with the reason. +- **Unchanged.** The status, the `code`, the `details`, which doors refuse and in what order. Only the message text changed. A client that reads `code` sees no difference. diff --git a/content/docs/deployment/install-modes.mdx b/content/docs/deployment/install-modes.mdx index 5963085ba9c..11b2c982f03 100644 --- a/content/docs/deployment/install-modes.mdx +++ b/content/docs/deployment/install-modes.mdx @@ -106,15 +106,29 @@ The package is registered as code and stays the authority. Its install is recorded on the runtime host, so it is registered again at every boot, and a later version is installed over it. -**Nothing it ships is edited in place.** What a customer changes lives beside -the package, as environment-level state: - -- a `view`, `dashboard`, `report`, `translation` or `email_template` is - overlaid by saving one under the same name, and removing the overlay brings - the package's own back; -- a flow is switched off, or cloned under a new name and changed there; -- a save onto any other item the package ships is refused, and the refusal - names the route its type has. +**Nothing it ships is edited in place.** What a customer changes is an item of +the package's paired `.custom` package +([how](/docs/concepts/metadata-lifecycle#customizing-an-installed-package-the-paired-custom-override-adr-0146)). +`POST /api/v1/packages//ensure-customization` creates the pair, or +answers it when it already exists, and a save naming it +(`?package=.custom`) lands there: + +- an item the package ships, of a type that takes an override (a `view`, + `dashboard`, `flow` and the other types ADR-0146 lists), is replaced by + saving one of the same name into the pair that declares + `overrides: { package: '', basedOn }`, and removing it from the + pair brings the package's own back; +- a new item is saved into the pair under its own name; +- a flow can also be switched off; +- a type that takes no override (an `action` for now, a permission set, a + position, a capability, or a code-only type such as a hook) is refused, and + the refusal names the route its type has, such as a new item under a new + name. + +A save naming no package onto an item the package ships is refused with +`403 NOT_OVERRIDABLE`, naming the pair. A package the ensure door cannot pair, +for example one whose manifest declares no `namespace`, is refused there with +the reason. **The vendor's protection locks hold.** A package locks an item in either [ADR-0010](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0010-metadata-protection-model.md) diff --git a/packages/runtime/src/domains/packages-readonly-gate.test.ts b/packages/runtime/src/domains/packages-readonly-gate.test.ts index ca0b04dd6b8..25a1feaab31 100644 --- a/packages/runtime/src/domains/packages-readonly-gate.test.ts +++ b/packages/runtime/src/domains/packages-readonly-gate.test.ts @@ -393,12 +393,17 @@ describe('/packages duplicate — a source that is not a base is refused (#14451 expect(err?.httpStatus).toBe(422); expect(err?.details?.packageId).toBe(CODE_LOADED); expect(err?.details?.docs).toBe('docs/adr/0070-package-first-authoring.md'); - // The remedies are the ones that EXIST for a code package (ADR-0131 D6: - // customise beside it, or take an unmanaged copy), and the message says - // why the copy would be empty rather than only that it is refused. The - // retired ADR-0005 organization overlay (a write naming an organization - // answers 403 NOT_OVERRIDABLE) is no longer prescribed. - expect(err?.message).toContain('ADR-0131 D6'); + // The remedies are the ones that EXIST for a code package (ADR-0146: + // customize in its paired `.custom`, or take an unmanaged copy), and + // the message says why the copy would be empty rather than only that + // it is refused. The retired ADR-0005 organization overlay (a write + // naming an organization answers 403 NOT_OVERRIDABLE) is no longer + // prescribed, nor is the ADR-0131 D6 package-less save under the + // item's own name, which ADR-0146 D11 closed (#29568). The full + // sentence is pinned at every site in + // `packages-readonly-remedy-pair-route.test.ts`. + expect(err?.message).toContain(`POST /api/v1/packages/${CODE_LOADED}/ensure-customization`); + expect(err?.message).not.toContain('ADR-0131 D6'); expect(err?.message).toContain("installMode 'unmanaged'"); expect(err?.message).not.toContain('ADR-0005'); expect(err?.message).toContain('read-only'); @@ -485,7 +490,7 @@ describe('/packages lifecycle — the read-only refusal no longer sends callers { label: 'DELETE', path: (id: string) => `/${id}`, method: 'DELETE' }, { label: 'disable', path: (id: string) => `/${id}/disable`, method: 'PATCH' }, ]) { - it(`${verb.label} prescribes customising beside the package (ADR-0131 D6), not POST /:id/duplicate`, async () => { + it(`${verb.label} prescribes the package's paired .custom (ADR-0146), not POST /:id/duplicate`, async () => { const { dispatcher } = make(); const r = await dispatcher.handlePackages(verb.path(CODE_LOADED), verb.method, {}, {}, admin()); const message = String(r.response?.body?.error?.message ?? ''); @@ -497,10 +502,13 @@ describe('/packages lifecycle — the read-only refusal no longer sends callers // and before that answered an empty 200. A refusal that prescribes a // dead end is worse than one that prescribes nothing. Its next // remedy, the ADR-0005 organization overlay, is retired too (a write - // naming an organization answers 403 NOT_OVERRIDABLE). + // naming an organization answers 403 NOT_OVERRIDABLE), and so is the + // one after it, the ADR-0131 D6 package-less save under the item's + // own name, which ADR-0146 D11 closed (#29568). expect(message).not.toContain('/duplicate'); expect(message).not.toContain('ADR-0005'); - expect(message).toContain('ADR-0131 D6'); + expect(message).not.toContain('ADR-0131 D6'); + expect(message).toContain(`POST /api/v1/packages/${CODE_LOADED}/ensure-customization`); }); } }); diff --git a/packages/runtime/src/domains/packages-readonly-remedy-pair-route.test.ts b/packages/runtime/src/domains/packages-readonly-remedy-pair-route.test.ts new file mode 100644 index 00000000000..f81ff2d1126 --- /dev/null +++ b/packages/runtime/src/domains/packages-readonly-remedy-pair-route.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29568, ADR-0146 D2/D3/D11] The remedy every read-only refusal on the + * `/packages` doors names: customize in the package's paired `.custom`. + * + * ## The defect + * + * `422 WRITABLE_PACKAGE_REQUIRED` (seven doors) and `422 + * DUPLICATE_SOURCE_NOT_A_BASE` (the duplicate door) appended the ADR-0131 D6 + * remedy: "customise what this package provides beside it, as + * environment-level state (ADR-0131 D6): save an item under its own name …". + * Since ADR-0146 D11 that package-less save onto an item the package ships is + * refused `403 NOT_OVERRIDABLE`, naming the pair, so the refusal prescribed a + * dead end. Measured on `main` before this change, on the showcase over its + * real routes: a package-less `PUT` of `view/showcase_task.in_progress` + * answered `403 NOT_OVERRIDABLE`; the ensure door then answered `201`, and the + * same view saved into `com.example.showcase.custom` declaring `overrides` + * answered `200` and was served at the base's name. + * + * ## What is pinned + * + * At each of the eight sites, the code and status are unchanged and the + * message ends with (or, at the duplicate door, carries) the new sentence, + * spelled out WHOLE below — not a fragment, so a reworded sentence is a + * deliberate edit here. Each site also asserts the three phrases only the old + * sentence carried (`under its own name`, `environment-level state`, `ADR-0131 + * D6`) are absent: the new sentence shares none of them, so the old one cannot + * pass these pins under any truncation or concatenation. The read-only verdict + * itself, its ordering and the listing it protects are pinned in + * `packages-readonly-gate.test.ts`; the route the sentence names is followed + * end to end, over a booted stack, in + * `../package-readonly-remedy-follow.integration.test.ts`. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +/** A code-booted package (registered into `engine.manifests`), as the showcase is. */ +const CODE_LOADED = 'com.acme.helpdesk'; +/** A platform-delivered package (manifest `scope: 'system'`). */ +const PLATFORM_SCOPED = 'com.objectstack.platform'; +/** A code-booted package whose id has no pair: the pairing rule refuses an id already shaped like one. */ +const UNPAIRABLE = 'com.vendor.toolkit.custom'; + +/** The sentence, whole, for a package whose pair the pairing rule derives. */ +const remedy = (id: string): string => + `customize what it ships in its paired package '${id}.custom' (ADR-0146 D3): ensure it with ` + + `POST /api/v1/packages/${id}/ensure-customization, then save the item there (?package=${id}.custom), ` + + `declaring overrides: { package: '${id}', basedOn } to replace an item '${id}' ships. A type that takes ` + + 'no override, or a locked item, is refused naming its route or the lock; a package that cannot have a ' + + 'pair is refused at that door, naming why.'; + +/** The phrases only the retired ADR-0131 D6 sentence carried. */ +const RETIRED = ['under its own name', 'environment-level state', 'ADR-0131 D6']; + +function manifest(id: string, scope?: 'system' | 'project') { + return { id, name: id, version: '1.0.0', namespace: 'helpdesk', ...(scope ? { scope } : {}) } as any; +} + +function make() { + const registry = new SchemaRegistry({ logLevel: 'silent' } as any); + registry.installPackage(manifest(CODE_LOADED, 'project'), 'code'); + registry.installPackage({ ...manifest(PLATFORM_SCOPED, 'system'), namespace: 'platform' }, 'code'); + registry.installPackage({ ...manifest(UNPAIRABLE, 'project'), namespace: 'toolkit' }, 'code'); + const manifests = new Map([ + [CODE_LOADED, manifest(CODE_LOADED)], + [UNPAIRABLE, manifest(UNPAIRABLE)], + ]); + const objectql = { registry, manifests }; + // The duplicate door needs a protocol to reach its gate; the refusal runs + // before it, so this double is never called (asserted below). + const protocol = { duplicatePackage: vi.fn(async () => ({ success: true, copiedCount: 0 })) }; + const kernel: any = { + context: { + getService: (name: string) => (name === 'objectql' ? objectql : name === 'protocol' ? protocol : null), + }, + }; + return Object.assign(new HttpDispatcher(kernel), { protocol }); +} + +const admin = (): any => ({ + request: {}, + environmentId: 'pkg-readonly-remedy-test', + executionContext: { userId: 'u_admin', isSystem: false, systemPermissions: ['manage_metadata'], tenantId: 'org_acme' }, +}); + +/** The seven doors that answer `WRITABLE_PACKAGE_REQUIRED`, and the verb each message names. */ +const WRITABLE_DOORS: Array<{ verb: string; call: (d: HttpDispatcher, id: string) => Promise }> = [ + { verb: 'delete', call: (d, id) => d.handlePackages(`/${id}`, 'DELETE', {}, {}, admin()) }, + { verb: 'disable', call: (d, id) => d.handlePackages(`/${id}/disable`, 'PATCH', {}, {}, admin()) }, + { verb: 'publish', call: (d, id) => d.handlePackages(`/${id}/publish`, 'POST', {}, {}, admin()) }, + { verb: 'revert', call: (d, id) => d.handlePackages(`/${id}/revert`, 'POST', {}, {}, admin()) }, + { verb: 'adopt items into', call: (d, id) => d.handlePackages(`/${id}/adopt-orphans`, 'POST', {}, {}, admin()) }, + { verb: 'edit', call: (d, id) => d.handlePackages(`/${id}`, 'PATCH', { name: 'Renamed' }, {}, admin()) }, + { + verb: 'overwrite', + call: (d, id) => d.handlePackages( + '', 'POST', { manifest: { id, name: id, version: '1.0.0', type: 'app' }, overwrite: true }, {}, admin(), + ), + }, +]; + +function expectNotRetired(message: string): void { + for (const phrase of RETIRED) expect(message, `the retired remedy's "${phrase}"`).not.toContain(phrase); +} + +let home: string; +let priorHome: string | undefined; +beforeAll(() => { + priorHome = process.env.OS_HOME; + home = mkdtempSync(join(tmpdir(), 'os-pkg-readonly-remedy-')); + process.env.OS_HOME = home; +}); +afterAll(() => { + if (priorHome === undefined) delete process.env.OS_HOME; + else process.env.OS_HOME = priorHome; + rmSync(home, { recursive: true, force: true }); +}); + +describe('422 WRITABLE_PACKAGE_REQUIRED names the paired .custom route (ADR-0146), at every door', () => { + for (const { verb, call } of WRITABLE_DOORS) { + for (const id of [CODE_LOADED, PLATFORM_SCOPED]) { + it(`${verb} '${id}': same code and status, and the remedy is the pair route`, async () => { + const r = await call(make(), id); + const error = r.response?.body?.error; + expect(r.response?.status, JSON.stringify(r.response?.body)).toBe(422); + expect(error?.code).toBe('WRITABLE_PACKAGE_REQUIRED'); + const message = String(error?.message); + expect(message.startsWith(`[writable_package_required] Cannot ${verb} package '${id}': it is read-only`)) + .toBe(true); + expect(message.endsWith(`— ${verb} a package you own, or ${remedy(id)}`), message).toBe(true); + expectNotRetired(message); + }); + } + } + + it('an id the pairing rule derives no pair for is sent to the ensure door, which says why, with no pair id', async () => { + const r = await make().handlePackages(`/${UNPAIRABLE}`, 'DELETE', {}, {}, admin()); + expect(r.response?.status).toBe(422); + expect(r.response?.body?.error?.code).toBe('WRITABLE_PACKAGE_REQUIRED'); + const message = String(r.response?.body?.error?.message); + expect(message.endsWith( + `— delete a package you own, or customize what it ships in its paired customization package ` + + `(ADR-0146 D3): POST /api/v1/packages/${UNPAIRABLE}/ensure-customization answers that package, or ` + + 'why this one has none.', + ), message).toBe(true); + expect(message).not.toContain(`${UNPAIRABLE}.custom`); + expectNotRetired(message); + }); +}); + +describe('422 DUPLICATE_SOURCE_NOT_A_BASE carries the same route between its two other remedies', () => { + it('duplicate of a code package: same code and status, the pair route, and the unmanaged copy', async () => { + const d = make(); + const r = await d.handlePackages( + `/${CODE_LOADED}/duplicate`, 'POST', { targetPackageId: 'com.acme.dupbase' }, {}, admin(), + ); + expect(d.protocol.duplicatePackage).not.toHaveBeenCalled(); + const error = r.response?.body?.error; + expect(r.response?.status, JSON.stringify(r.response?.body)).toBe(422); + expect(error?.code).toBe('DUPLICATE_SOURCE_NOT_A_BASE'); + const message = String(error?.message); + expect(message).toContain( + `Duplicate a base you own, or ${remedy(CODE_LOADED)} For an editable copy of the whole package, ` + + "install it unmanaged instead (installMode 'unmanaged')", + ); + expectNotRetired(message); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 26600d5b846..5a50e5475d5 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -511,9 +511,12 @@ function requireCommitTrailReadCapability(deps: DomainHandlerDeps, context: Http * [ADR-0131 D6] For the same reason it no longer names the ADR-0005 * organization overlay, its remedy after that: the per-organization overlay * axis is retired, and a write naming an organization answers `403 - * NOT_OVERRIDABLE`. What a managed package provides is customised BESIDE it, - * as environment-level state, and the two doors name that remedy in one - * clause ({@link CUSTOMISE_BESIDE_REMEDY}). + * NOT_OVERRIDABLE`. Its remedy after THAT — save an item under its own name, + * as environment-level state — is retired too: ADR-0146 D11 closed the + * package-less write onto a packaged item (`403 NOT_OVERRIDABLE`, naming the + * base's pair). What a read-only package ships is customized in its paired + * `.custom` package (ADR-0146 D2/D3), and the two doors name that route + * in one clause ({@link customizeInPairRemedy}). * * ⛔ Deliberately NOT caller-sensitive — there is no `isSystem` bypass, unlike * {@link requireManageMetadata}. Read-only is a property of the PACKAGE. Engine @@ -603,22 +606,54 @@ function packageIsUnknown(registry: unknown, id: string): boolean { } /** - * [ADR-0131 D6] The customisation remedy both read-only refusals below name - * ({@link requireWritablePackage}, {@link requireDuplicableSource}), in one - * spelling. A managed package's items are sealed, and what a customer changes - * lives beside them as environment-level state: a save under an item's own - * name is an environment overlay on a type whose registry entry takes one - * (`allowEnvironmentOverlay`) unless the package's lock refuses it — that save - * is refused `ITEM_LOCKED`, naming the lock and its reason (ADR-0010 §3.3) — - * and on every other type the save door's own refusal names the route that - * type has: a flow's clone and switch, an action's switch, a permission set's - * clone, else the source. The sentence names that door rather than a list of - * types, so it cannot drift from the regime table the door reads. + * [ADR-0146 D2/D3/D11] The customization remedy both read-only refusals below + * name ({@link requireWritablePackage}, {@link requireDuplicableSource}), in + * one spelling. A managed package's items are sealed, and what a customer + * changes is an item of the package's paired `.custom`: the ensure door + * (`POST /api/v1/packages/:id/ensure-customization`, the pair's one creation + * path) answers the pair, and a save naming it (`?package=.custom`) lands + * there — a new item under its own name, or, declaring `overrides: { package, + * basedOn }`, the replacement of an item the package ships, served at that + * item's name. The sentence spells the pair, the route and the declaration + * exactly as the save door's own close does (`packagelessOverlayWriteRefusal`, + * `@objectstack/metadata-protocol`), so a caller sent here reads the fix that + * door would name. + * + * It replaces the ADR-0131 D6 remedy — "save an item under its own name", an + * environment-level overlay — which ADR-0146 D11 closed: that package-less + * save onto a packaged item answers `403 NOT_OVERRIDABLE`, naming the pair. A + * refusal that prescribes a dead end is worse than one that prescribes + * nothing (the #14451 lesson above). + * + * What the route does NOT promise, measured on the real routes before this + * sentence was written, and named so the sentence promises no more: + * - a type that takes no declared override (`action` until ADR-0146 D9's + * conditions land, the security catalog's `permission` / `position` / + * `capability`, the code-only types) is refused at the save door naming + * the route that type has ("new similar", the switch), and a locked item + * `ITEM_LOCKED` naming the lock (ADR-0010 §3.3); + * - a package the ensure door cannot pair — no namespace, a pair namespace + * another package owns, a version that is not SemVer — is refused there, + * naming why (ADR-0146 D3). + * Both refusals name their own reason, so the sentence names the door rather + * than a list of types or package shapes that could drift from them. + * + * An id the pairing rule derives no pair for (`customizationPackageId`, + * `@objectstack/spec/kernel`) gets the sentence without a pair id: the ensure + * door answers that id with the reason it has none. */ -const CUSTOMISE_BESIDE_REMEDY = - 'customise what this package provides beside it, as environment-level state (ADR-0131 D6): save an item ' - + 'under its own name — that overlays it where the item\'s type takes an environment overlay and the package\'s ' - + 'lock does not refuse it, and is otherwise refused naming the route that type has, or the lock.'; +function customizeInPairRemedy(id: string): string { + const ensure = `POST /api/v1/packages/${id}/ensure-customization`; + const pair = customizationPackageId(id); + if (!pair.ok) { + return `customize what it ships in its paired customization package (ADR-0146 D3): ${ensure} answers ` + + 'that package, or why this one has none.'; + } + return `customize what it ships in its paired package '${pair.value}' (ADR-0146 D3): ensure it with ${ensure}, ` + + `then save the item there (?package=${pair.value}), declaring overrides: { package: '${id}', basedOn } ` + + `to replace an item '${id}' ships. A type that takes no override, or a locked item, is refused naming its ` + + 'route or the lock; a package that cannot have a pair is refused at that door, naming why.'; +} function requireWritablePackage( deps: DomainHandlerDeps, @@ -634,7 +669,7 @@ function requireWritablePackage( `[writable_package_required] Cannot ${action} package '${id}': it is read-only ` + `(provided by code or an installed app). Packages the deployment ships are managed by ` + `the deployment, not over this API — ${action} a package you own, or ` - + CUSTOMISE_BESIDE_REMEDY, + + customizeInPairRemedy(id), 422, { code: 'WRITABLE_PACKAGE_REQUIRED', @@ -765,8 +800,8 @@ function isDisableablePackage(engine: unknown, id: string | undefined): boolean * packages — a NEW decision, and one the ADR itself still lists as open * ("should customising a code item also fork it into a writable base? * *Leaning: keep overlay for surgical tweaks*"). Two built answers exist - * instead (ADR-0131 D6): customising BESIDE the package, as environment-level - * state ({@link CUSTOMISE_BESIDE_REMEDY}), and, for an editable copy of the + * instead: customizing in the package's paired `.custom` (ADR-0146 D2/D3, + * {@link customizeInPairRemedy}), and, for an editable copy of the * whole package, its `unmanaged` install, which the install door judges (the * package must permit it, and the copy cannot sit beside a managed install of * the same package). So the honest behaviour of an unbuilt gesture is to say @@ -780,7 +815,7 @@ function isDisableablePackage(engine: unknown, id: string | undefined): boolean * a lie by implicature here: nothing is being written to the source, and its * remedy ("use a writable package") reads as *make the source writable*, which * is neither possible nor the point. `DUPLICATE_SOURCE_NOT_A_BASE` names the - * one thing the caller can act on — pick a base, customise beside the package, + * one thing the caller can act on — pick a base, customize in its pair, * or install it unmanaged where it permits that. * * ⛔ Deliberately NOT a check on emptiness. A WRITABLE base holding no active @@ -815,7 +850,7 @@ function requireDuplicableSource( + `(provided by code or an installed app), and duplicate clones a writable base's ` + `stored metadata rows. A code package's metadata is delivered as code, so there are ` + `no rows to clone and the copy would be empty (ADR-0070 D4 duplicates a BASE). ` - + `Duplicate a base you own, or ${CUSTOMISE_BESIDE_REMEDY} ` + + `Duplicate a base you own, or ${customizeInPairRemedy(id)} ` + `For an editable copy of the whole package, install it unmanaged instead ` + `(installMode 'unmanaged'), where its manifest's installModes permits that.`, 422, diff --git a/packages/runtime/src/package-readonly-remedy-follow.integration.test.ts b/packages/runtime/src/package-readonly-remedy-follow.integration.test.ts new file mode 100644 index 00000000000..72745f1db19 --- /dev/null +++ b/packages/runtime/src/package-readonly-remedy-follow.integration.test.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#29568, ADR-0146 D2/D3/D11] The read-only refusal's remedy, FOLLOWED: a +// caller refused `422 WRITABLE_PACKAGE_REQUIRED` on a code package reads the +// route out of the message and takes it, and it works. +// +// The composition is the one `os dev` serves (`createStandaloneStack` over an +// artifact the boot registers as code), driven through the HTTP dispatcher's +// own doors: `DELETE /packages/:id` for the refusal, the ensure door the +// message names, and the metadata save door with the `?package=` the message +// names, carrying the `overrides` declaration it names. The served read of the +// item's name then answers the saved body. +// +// The control is the retired remedy: the same body saved with no package (the +// ADR-0131 D6 "save an item under its own name") is refused `403 +// NOT_OVERRIDABLE` and names the pair — which is why the refusal stopped +// prescribing it. Per-door pins of the sentence itself live in +// `domains/packages-readonly-remedy-pair-route.test.ts`. + +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Runtime } from './runtime.js'; +import { createStandaloneStack } from './standalone-stack.js'; +import { HttpDispatcher } from './http-dispatcher.js'; + +// [#10126] Pay the first transform of the dist-resolved workspace deps the +// boot reaches through a dynamic `import()` at MODULE LOAD, never inside a +// clocked `it` (`scripts/check-test-source-alias.mjs`). +import '@objectstack/metadata'; +import '@objectstack/objectql'; +import '@objectstack/service-datasource'; + +const BOOT_TIMEOUT = 90_000; +const BASE = 'com.test.readonly-remedy'; +const VERSION = '2.3.0'; +const DASHBOARD = 'rrm_overview'; + +const artifact = { + // `namespace`: the base's `.custom` pair is formed from it (ADR-0146 D3). + manifest: { id: BASE, name: 'readonly_remedy', type: 'app', version: VERSION, namespace: 'rrm' }, + objects: [{ name: 'rrm_note', label: 'Note', fields: { title: { type: 'text', label: 'Title' } } }], + dashboards: [{ name: DASHBOARD, label: 'Overview (shipped)', widgets: [] }], +}; + +/** A `manage_metadata` holder under `single`, the caller every door below admits. */ +const admin = (): any => ({ + request: {}, + environmentId: undefined, + executionContext: { + userId: 'u_remedy_admin', + isSystem: false, + systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + }, +}); + +describe('the read-only refusal\'s remedy, followed through the real doors, works (#29568)', () => { + const dirs: string[] = []; + const kernels: any[] = []; + let priorHome: string | undefined; + + afterEach(async () => { + for (const k of kernels.splice(0)) { + try { await k.shutdown(); } catch { /* noop */ } + } + if (priorHome === undefined) delete process.env.OS_HOME; + else process.env.OS_HOME = priorHome; + for (const d of dirs.splice(0)) { + try { rmSync(d, { recursive: true, force: true }); } catch { /* noop */ } + } + }); + + async function boot() { + const dir = mkdtempSync(join(tmpdir(), 'os-readonly-remedy-')); + dirs.push(dir); + const artifactPath = join(dir, 'objectstack.json'); + writeFileSync(artifactPath, JSON.stringify(artifact), 'utf-8'); + // The ensure door really installs the pair; keep its writes in the temp dir. + priorHome = process.env.OS_HOME; + process.env.OS_HOME = dir; + const stack = await createStandaloneStack({ + artifactPath, + projectRoot: dir, + databaseUrl: ':memory:', + skipSeedData: true, + runPlatformMigrations: false, + }); + const runtime = new Runtime({ cluster: false }); + const kernel = runtime.getKernel(); + kernels.push(kernel); + for (const p of stack.plugins) await kernel.use(p as any); + await kernel.bootstrap(); + return { dispatcher: new HttpDispatcher(kernel as any), protocol: kernel.getService('protocol') as any }; + } + + it('DELETE is refused naming the pair route; ensure, then the declared save into the pair, is served', async () => { + const { dispatcher, protocol } = await boot(); + + // 1. The refusal, and the route it names, read out of the message. + const refused: any = await dispatcher.handlePackages(`/${BASE}`, 'DELETE', {}, {}, admin()); + expect(refused.response?.status, JSON.stringify(refused.response?.body)).toBe(422); + expect(refused.response?.body?.error?.code).toBe('WRITABLE_PACKAGE_REQUIRED'); + const message = String(refused.response?.body?.error?.message); + const ensureRoute = /ensure it with POST \/api\/v1\/packages\/(\S+)\/ensure-customization,/.exec(message)?.[1]; + const pair = /\(\?package=(\S+)\)/.exec(message)?.[1]; + const declared = /declaring overrides: \{ package: '([^']+)', basedOn \}/.exec(message)?.[1]; + expect({ ensureRoute, pair, declared }).toEqual({ ensureRoute: BASE, pair: `${BASE}.custom`, declared: BASE }); + + const shipped = { name: DASHBOARD, label: 'Overview (shipped)', widgets: [] }; + const ours = { ...shipped, label: 'Overview (ours)' }; + const served = async () => (await protocol.getMetaItem({ type: 'dashboard', name: DASHBOARD }))?.item?.label; + expect(await served()).toBe('Overview (shipped)'); + + // Control: the retired remedy — the same item saved under its own name, + // naming no package — is the dead end ADR-0146 D11 closed. + const packageless: any = await dispatcher.handleMetadata(`/dashboard/${DASHBOARD}`, admin(), 'PUT', ours, {}); + expect(packageless.response?.status, JSON.stringify(packageless.response?.body)).toBe(403); + expect(JSON.stringify(packageless.response?.body)).toContain('NOT_OVERRIDABLE'); + expect(JSON.stringify(packageless.response?.body)).toContain(`${BASE}.custom`); + expect(await served()).toBe('Overview (shipped)'); + + // 2. The ensure door the message names creates the pair. + const ensured: any = await dispatcher.handlePackages(`/${ensureRoute}/ensure-customization`, 'POST', undefined, {}, admin()); + expect(ensured.response?.status, JSON.stringify(ensured.response?.body)).toBe(201); + expect(ensured.response?.body?.data).toMatchObject({ basePackageId: BASE, packageId: pair, created: true }); + + // 3. The save the message names: into the pair, declaring the override. + const saved: any = await dispatcher.handleMetadata( + `/dashboard/${DASHBOARD}`, admin(), 'PUT', + { ...ours, overrides: { package: declared, basedOn: VERSION } }, + { package: pair }, + ); + expect(saved.response?.status, JSON.stringify(saved.response?.body)).toBe(200); + + // 4. The item's name now serves the customization; the base is untouched. + expect(await served()).toBe('Overview (ours)'); + const base: any = await dispatcher.handlePackages(`/${BASE}`, 'GET', undefined, {}, admin()); + expect(base.response?.status).toBe(200); + }, BOOT_TIMEOUT); +});