diff --git a/.changeset/22591-sealed-remedy-kind.md b/.changeset/22591-sealed-remedy-kind.md new file mode 100644 index 00000000000..2ac3649db73 --- /dev/null +++ b/.changeset/22591-sealed-remedy-kind.md @@ -0,0 +1,25 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/plugin-security': minor +--- + +fix(metadata-protocol): a create under a name a managed package or a built-in holds is told to choose another name, not to edit the source artifact (#22591) + +Clause-②: yes + +The public input surface widens: `packagedBaseRefusal` accepts `operation: 'create'`, and `tenantAuthoredWriteRefusal` takes an optional operation; no verdict, code or status moves. + +A write onto an item a managed package ships, on a type with no environment overlay, is refused with `403 NOT_OVERRIDABLE`. The refusal's remedy used to assume the write was an edit of that item: "Edit the source artifact and redeploy", or, for a flow, an action or a permission set, its clone or on/off switch. That remedy is wrong for a create. An administrator who creates a position of their own under a name a package or a built-in already holds (`manager`, `everyone`, `guest`, …), or renames one into such a name, cannot edit a source artifact they did not write, and did not ask to customize the package's item. + +The remedy now follows what the write did: + +- **A create, or a rename into the name**, is told: the name is taken; to author your own item, choose a name no package or built-in holds, and `GET /api/v1/meta/:type` lists the names in use. This is one sentence for every metadata type. +- **An edit** keeps the sentence it had, byte for byte. + +Which writes are refused does not change, and neither do the code and the status. + +Where the remedy is a create's: + +- **Setup** (`@objectstack/plugin-security`): a create of a `sys_position` row, or a rename into a name a package or a built-in holds, under the `single` posture. The data door relays the metadata door's refusal for a create. +- **The metadata door** (`PUT /api/v1/meta/:type/:name`): a save sent with `If-None-Match: *`, the first-write precondition (`parentVersion: null` in `saveMetaItem`). Any other save is an edit, as before. +- **`ObjectStackProtocolImplementation.packagedBaseRefusal`** takes `operation: 'create'` beside `'save'` and `'delete'`, for a door that knows its write takes a name. The verdict is the `'save'` verdict. `tenantAuthoredWriteRefusal` takes an optional `operation: 'create'` the same way. diff --git a/packages/metadata-protocol/src/packaged-base-regime.ts b/packages/metadata-protocol/src/packaged-base-regime.ts index 6cd2bc78d54..a0fda00025d 100644 --- a/packages/metadata-protocol/src/packaged-base-regime.ts +++ b/packages/metadata-protocol/src/packaged-base-regime.ts @@ -109,6 +109,25 @@ * every package whose items the artifact loader registered is managed, and every * sentence here names it so: the install mode is what the refusal is about, not * the fact that the item happens to be code. + * + * ## The remedy follows what the caller DID: an edit, or a create + * + * [#22591] One sealed item is refused for two different acts, and each has its + * own remedy. A save that changes the item under the name (an EDIT) is told + * where that item can change: its row's sanctioned path, or its source. A save + * that brings an item of the caller's own into existence under a name a package + * or a built-in already holds (a CREATE, or a rename into that name) is told the + * one thing it can do instead: choose another name. "Edit the source artifact" + * sends that author to an artifact they did not write and cannot edit, and a + * Regime C path customizes the package's item, which is not what they asked for. + * + * The create sentence is ONE sentence for every type ({@link heldNameSentence}), + * never a row: the remedy does not depend on the type, so a type added later + * gets it without a row, and a row added later cannot take it away. Only the + * opener and the citation are the row's, as in the edit sentences. The act is + * the caller's fact, never inferred here: a door that knows it is taking a name + * says so with the `create` operation ({@link SealedItemOperation}), and every + * other save keeps the edit remedy, byte for byte. */ import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; @@ -221,9 +240,12 @@ function regimeCPrescription(routes: PackagedBaseRegimeCRoutes): string { const prescription = paths .map(([opening, following, rest], i) => (i === 0 ? opening : following) + rest) .join(', or '); - return `${prescription}. See docs/adr/0126-packaged-metadata-customization-model.md.`; + return `${prescription}. See ${REGIME_C_ADR}.`; } +/** [ADR-0126 §2] The decision record every Regime C sentence cites. */ +const REGIME_C_ADR = 'docs/adr/0126-packaged-metadata-customization-model.md'; + /** * The PRESCRIPTION half of a regime's refusal, built from the row alone: a * Regime C row's sanctioned paths ({@link regimeCPrescription}), or an @@ -307,6 +329,49 @@ export function packagedBaseRegimeSentence( /** [ADR-0131 D6] The decision record every sealed-item sentence without a regime row cites. */ const MANAGED_SEAL_ADR = 'docs/adr/0131-total-organization-ownership-no-null-organization-id.md'; +/** + * [#22591] What a refused write did to the item's name — the fact the remedy + * follows (module header, "The remedy follows what the caller DID"): + * + * - `save` — a change to the item already under the name: an edit. The + * default for every save whose caller declares nothing else, so a door + * that cannot tell keeps the remedy it always gave; + * - `create` — a save that brings an item of the caller's own into existence + * under the name: a create, or a rename into the name. Refused on exactly + * the ground, with exactly the code and status, a `save` is; + * - `delete` — a removal. + */ +export type SealedItemOperation = 'save' | 'create' | 'delete'; + +/** + * [#22591] The sentence for a CREATE under a name a managed package or a + * built-in holds, or a rename into it — ONE sentence for every type (module + * header): who holds the name, the one remedy a create has (choose a name no + * package or built-in holds), where the names in use are listed, and the type's + * own decision record. The opener and the citation are the row's, as in the edit + * sentences — an origin-gated type's item is code-defined, not shipped by a + * managed package; a Regime C type's record is the one whose §2 makes a new + * machine name mandatory for an item authored beside a packaged one. + * + * ⛔ It names neither the source artifact, nor a row's sanctioned path, nor the + * `OS_METADATA_WRITABLE` hatch: all three act on the item that holds the name, + * and the caller asked for an item of their own. The listing route is the + * type's own `/meta` list, which answers the names the type serves. + * + * Kept under the REST door's 500-character client-message bound: outside the + * item's name and its type's three mentions it is 255 characters at most + * (measured; pinned with an 88-character name on the longest type). + */ +function heldNameSentence(singular: string, name: string): string { + const row = packagedBaseRegimeRow(singular); + const holder = row?.regime === 'origin-gated' + ? `${row.noun} '${name}' is code-defined` + : `Metadata item '${singular}/${name}' is provided by a managed package`; + const docs = row === undefined ? MANAGED_SEAL_ADR : row.regime === 'C' ? REGIME_C_ADR : row.docs; + return `${holder}, so its name is taken. To author your own ${singular}, choose a name no package or ` + + `built-in holds (GET /api/v1/meta/${singular} lists the names in use). See ${docs}.`; +} + /** * [ADR-0131 D6] THE refusal sentence for a write onto, or a removal of, an item a * managed package ships, on a type with no environment overlay — every door that @@ -316,6 +381,10 @@ const MANAGED_SEAL_ADR = 'docs/adr/0131-total-organization-ownership-no-null-org * the same condition one layer down for the writes that reach it without passing * a package door (draft promotion, restore, revert). * + * [#22591] A `create` — a save that takes the name for an item of the caller's + * own — reads {@link heldNameSentence} whatever the type. An edit (`save`) and a + * removal read on: + * * A type with a regime row speaks for its regime ({@link packagedBaseRegimeSentence}: * the sanctioned path, never the hatch). Every other type reads the managed seal * itself: the item is sealed, its type takes no environment overlay, the @@ -328,8 +397,9 @@ const MANAGED_SEAL_ADR = 'docs/adr/0131-total-organization-ownership-no-null-org * Kept under the REST door's 500-character client-message bound: the sentence * outside the item's type and name is 321 / 275 characters, save / removal (measured). */ -export function managedItemSealedSentence(type: string, name: string, operation: 'save' | 'delete'): string { +export function managedItemSealedSentence(type: string, name: string, operation: SealedItemOperation): string { const singular = PLURAL_TO_SINGULAR[type] ?? type; + if (operation === 'create') return heldNameSentence(singular, name); const regime = packagedBaseRegimeSentence(singular, name, operation); if (regime !== undefined) return regime; return `Metadata item '${singular}/${name}' is provided by a managed package and is sealed ` diff --git a/packages/metadata-protocol/src/protocol.sealed-remedy-kind.test.ts b/packages/metadata-protocol/src/protocol.sealed-remedy-kind.test.ts new file mode 100644 index 00000000000..991ea88c385 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.sealed-remedy-kind.test.ts @@ -0,0 +1,195 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22591] The sealed-item refusal's REMEDY follows what the caller did — the + * family close-out after #20819 (flow) and #20910 (action, permission), which + * each fixed one type's sentence for an edit. + * + * One refusal answers two different acts on a name a managed package or a + * built-in holds, and each act has its own remedy KIND: + * + * - an EDIT of the item under the name (`save`) is told where that item can + * change — its regime row's sanctioned path or source, or, with no row, its + * source artifact; + * - a CREATE of an item of the caller's own under the name, or a rename into + * it (`create`), is told to choose a name no package or built-in holds. + * + * THE ENUMERATION PIN iterates every type the refusal can reach — read from the + * registry, plus a type no static entry declares (a plugin-registered type) — + * for both acts, and asserts the remedy KIND, never the wording. A type added to + * the registry later is iterated the day it lands, so its create cannot keep the + * edit remedy unnoticed. The kind is read off what the sentence POINTS AT: the + * row's own routes and source (read from the table, never transcribed), the + * source/redeploy remedy, or the type's listing route. + * + * The control half: the verdict does not move — the same code and status on + * every row, and a create is refused exactly where a save is, on every type. + */ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import { PACKAGED_BASE_REGIME } from './packaged-base-regime.js'; + +const PACKAGE_ID = 'com.example.pkg'; +const HELD = 'pkg_held'; +const FREE = 'my_own'; + +/** A type no static registry entry declares: a plugin-registered type, sealed like any other. */ +const PLUGIN_TYPE = 'p22591_widget'; + +const shipped = (name: string) => ({ name, label: name, _packageId: PACKAGE_ID, _provenance: 'package' }); + +/** + * A registry double that serves one packaged item, `HELD`, under EVERY type — + * what the real `SchemaRegistry.getArtifactItem` returns for an artifact a code + * package registered. `@objectstack/objectql` cannot be imported here: it + * depends on this package. + */ +function protocolOn(environmentId: string | undefined): ObjectStackProtocolImplementation { + const registry = { + getArtifactItem: (_type: string, name: string) => (name === HELD ? shipped(name) : undefined), + getRegisteredTypes: () => [...DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type), PLUGIN_TYPE], + }; + return new ObjectStackProtocolImplementation({ registry } as never, () => new Map(), environmentId); +} + +const shape = (e: any) => (e ? { code: e.code, status: e.status } : null); + +/** Every type the iteration covers: the whole registry, and a plugin-registered type. */ +const TYPES = [...DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type), PLUGIN_TYPE]; + +/** The type's listing route — where the names in use are read. A create's remedy points here. */ +const listingOf = (type: string) => `GET /api/v1/meta/${type}`; + +/** + * What an EDIT remedy points at for `type`: the routes or the source its regime + * row names (read from the row), and the source/redeploy remedy every row-less + * type reads. None of these acts on an item of the caller's own. + */ +function editSigns(type: string): Array { + const row = PACKAGED_BASE_REGIME[type]; + const fromRow: string[] = row === undefined + ? [] + : row.regime === 'C' + ? Object.values(row.routes).filter((route): route is string => typeof route === 'string') + : [row.source, ...Object.values(row.hostOwned ?? {})]; + return [...fromRow, /\bredeploy\b/, /\bsource\b/]; +} + +type RemedyKind = 'new-name' | 'edit' | 'both' | 'none'; + +/** The remedy KIND a sentence names for `type` — read off what it points at, not off its wording. */ +function remedyKind(message: string, type: string): RemedyKind { + const newName = message.includes(listingOf(type)); + const edit = editSigns(type).some((sign) => (typeof sign === 'string' ? message.includes(sign) : sign.test(message))); + if (newName && edit) return 'both'; + if (newName) return 'new-name'; + return edit ? 'edit' : 'none'; +} + +describe('[#22591] the sealed-item refusal names the remedy the caller\'s ACT has — every reachable type, edit and create', () => { + for (const environmentId of [undefined, 'env_1']) { + const kernel = environmentId ? 'environment' : 'host-config'; + + it(`the iteration reaches every type with no overlay channel, and a plugin-registered type (${kernel} kernel)`, () => { + const p = protocolOn(environmentId); + const reached = TYPES.filter((type) => p.packagedBaseRefusal({ type, name: HELD, operation: 'save' }) !== null); + // Measured against the registry's own flag, never a transcribed list: + // a type is sealed exactly when its entry opens no overlay channel. + const expected = [ + ...DEFAULT_METADATA_TYPE_REGISTRY.filter((e) => !e.allowOrgOverride).map((e) => e.type), + PLUGIN_TYPE, + ]; + expect(reached.sort()).toEqual(expected.sort()); + // Every regime row is on a type the refusal reaches — a row on an + // unreachable type would be a sentence no door can serve. + for (const type of Object.keys(PACKAGED_BASE_REGIME)) expect(reached, type).toContain(type); + // A floor, so an iteration that silently shrank cannot read green. + expect(reached.length).toBeGreaterThanOrEqual(20); + }); + + it(`an EDIT names the edit remedy, and a CREATE names a name no package or built-in holds — on every reachable type (${kernel} kernel)`, () => { + const p = protocolOn(environmentId); + const kinds: Record = {}; + for (const type of TYPES) { + const edit: any = p.packagedBaseRefusal({ type, name: HELD, operation: 'save' }); + if (edit === null) continue; + const create: any = p.packagedBaseRefusal({ type, name: HELD, operation: 'create' }); + kinds[type] = { edit: remedyKind(String(edit.message), type), create: remedyKind(String(create?.message), type) }; + } + const expected = Object.fromEntries(Object.keys(kinds).map((type) => [type, { edit: 'edit', create: 'new-name' }])); + expect(kinds).toEqual(expected); + }); + + it(`control: the verdict does not move — a create is refused exactly where a save is, with the same code and status (${kernel} kernel)`, () => { + const p = protocolOn(environmentId); + for (const type of TYPES) { + const save = shape(p.packagedBaseRefusal({ type, name: HELD, operation: 'save' })); + const create = shape(p.packagedBaseRefusal({ type, name: HELD, operation: 'create' })); + expect(create, type).toEqual(save); + if (save !== null) expect(save, type).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + // A name nothing holds is no question for this verdict, either act. + expect(p.packagedBaseRefusal({ type, name: FREE, operation: 'create' }), type).toBeNull(); + } + }); + } + + it('a create\'s sentence points at nothing that acts on the held item — no hatch, no clone, no switch — and arrives whole through the REST door', () => { + // `truncateClientMessage` (packages/rest/src/error-response.ts) keeps a + // message only while it is SHORTER than 500 characters; past that the + // tail is cut. An 88-character name on every type, the bound the + // edit sentences are held to. + const longName = `pkg_${'x'.repeat(84)}`; + expect(longName).toHaveLength(88); + const registry = { getArtifactItem: (_t: string, name: string) => (name === longName ? shipped(name) : undefined) }; + const p = new ObjectStackProtocolImplementation({ registry } as never, () => new Map(), 'env_1'); + for (const type of TYPES) { + const refusal: any = p.packagedBaseRefusal({ type, name: longName, operation: 'create' }); + if (refusal === null) continue; + const message = String(refusal.message); + expect(message.length, type).toBeLessThan(500); + expect(message, type).toContain(listingOf(type)); + expect(message, type).not.toContain('OS_METADATA_WRITABLE'); + expect(message.toLowerCase(), type).not.toMatch(/clone it|switch it off/); + expect(message.endsWith('.md.'), type).toBe(true); + } + }); + + it('a removal is no create: it keeps its own sentence, which names no new name', () => { + const p = protocolOn('env_1'); + for (const type of TYPES) { + const removal: any = p.packagedBaseRefusal({ type, name: HELD, operation: 'delete' }); + if (removal === null) continue; + expect(String(removal.message), type).not.toContain(listingOf(type)); + } + }); +}); + +/** + * The `/meta` save door says which act a save is from the one fact its caller + * sends about the name: a present `null` parent (`If-None-Match: *` on the REST + * door) is the first-write pin — the caller holds no row under the name and is + * writing one of its own, a create. Every other save is an edit, as before. + * Pinned on a row-less type (`position`, the card's own) and on a Regime C type + * (`flow`, which the flow authoring rule answers before the package door). + */ +describe('[#22591] the /meta save door reads its first-write pin as a create', () => { + const thrownBy = (run: Promise) => run.then(() => undefined, (e: any) => e); + + for (const type of ['position', 'flow'] as const) { + it(`\`${type}\`: a first-write save names a free name; an unpinned or pinned save keeps the edit remedy — same code and status`, async () => { + const p = protocolOn('env_1'); + const item = { name: HELD, label: 'Mine' }; + const first: any = await thrownBy(p.saveMetaItem({ type, name: HELD, item, parentVersion: null })); + const unpinned: any = await thrownBy(p.saveMetaItem({ type, name: HELD, item })); + const pinned: any = await thrownBy(p.saveMetaItem({ type, name: HELD, item, parentVersion: 'v1' })); + for (const refusal of [first, unpinned, pinned]) expect(shape(refusal)).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(remedyKind(String(first.message), type)).toBe('new-name'); + expect(remedyKind(String(unpinned.message), type)).toBe('edit'); + expect(remedyKind(String(pinned.message), type)).toBe('edit'); + // One emitter: the door's first write throws the verdict a create is handed. + expect(first.message).toBe((p.packagedBaseRefusal({ type, name: HELD, operation: 'create' }) as any).message); + expect(unpinned.message).toBe((p.packagedBaseRefusal({ type, name: HELD, operation: 'save' }) as any).message); + }); + } +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index ac55aabbb8d..239dd6270f9 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -16344,10 +16344,21 @@ export class ObjectStackProtocolImplementation implements * package door gives (`ITEM_LOCKED` on a read-only base), so asking * here never moves that door's vocabulary. * + * ## `create` — a save that TAKES the name + * + * [#22591] A door that knows its write brings an item of the caller's own + * into existence under `name` — a create, or a rename into the name — asks + * with `operation: 'create'` (Setup's position write-through does). The + * verdict is the `save` verdict: the same predicate, code and status, so + * no write moves. Only the sentence follows the act: it names the remedy a + * create has, a name no package or built-in holds, never the edit remedy + * (`managedItemSealedSentence` in `./packaged-base-regime.ts`). A door that + * cannot tell an edit from a create asks `save`, and keeps the edit remedy. + * * @returns the refusal to relay, or `null` when the `/meta` door would not * refuse this write or removal on the locked-base ground. */ - packagedBaseRefusal(request: { type: string; name: string; operation: 'save' | 'delete'; packageId?: string | null }): Error | null { + packagedBaseRefusal(request: { type: string; name: string; operation: 'save' | 'create' | 'delete'; packageId?: string | null }): Error | null { // [#9009] Folded HERE, at the producer of the verdict, so a caller that // arrives with a plural spelling cannot address around the lock. const folded = canonicalizeMetaRequestType(request); @@ -16358,7 +16369,7 @@ export class ObjectStackProtocolImplementation implements type: folded.type, name: folded.name, ...(folded.packageId ? { packageId: folded.packageId } : {}), - }); + }, folded.operation); } } catch (err) { if (ObjectStackProtocolImplementation.isPackagedBaseRefusal(err)) return err; @@ -16707,10 +16718,15 @@ export class ObjectStackProtocolImplementation implements * `/meta`'s handling of every OTHER type is unchanged, so for those this * returns `null` and the caller proceeds exactly as before. * + * [#22591] `operation: 'create'` says the write takes the name for a flow of + * the caller's own (the `/meta` door's first-write pin); rule 1's refusal + * then names the create remedy. The verdict is the same; absent, the write + * is an edit. + * * @returns the refusal to relay verbatim, or `null` when the write may * proceed on these grounds. */ - async tenantAuthoredWriteRefusal(request: { type: string; name: string; item: unknown; packageId?: string | null }): Promise { + async tenantAuthoredWriteRefusal(request: { type: string; name: string; item: unknown; packageId?: string | null; operation?: 'save' | 'create' }): Promise { const folded = canonicalizeMetaRequestType(request); const singular = PLURAL_TO_SINGULAR[folded.type] ?? folded.type; if (singular !== 'flow') return null; @@ -16719,7 +16735,7 @@ export class ObjectStackProtocolImplementation implements const locked = this.packagedBaseRefusal({ type: folded.type, name: folded.name, - operation: 'save', + operation: folded.operation ?? 'save', ...(folded.packageId ? { packageId: folded.packageId } : {}), }); if (locked) return locked; @@ -16837,9 +16853,15 @@ export class ObjectStackProtocolImplementation implements * sentence names the managed package ({@link managedItemSealedSentence}, the * one builder the repository's type door reads too); the code and the status * are unchanged. + * + * [#22591] `operation` is what the save does to the name — `create` when the + * caller takes it for an item of its own, `save` (an edit) otherwise — and + * only the sentence reads it: the remedy follows the act. The predicate, the + * limbs, the code and the status are the same for both. */ private refusePackagedBaseOverride( request: { type: string; name: string; packageId?: string | null }, + operation: 'save' | 'create' = 'save', ): void { if (this.isSealedManagedItem(request.type, request.name)) { // [#8184] THE PACKAGE DOOR — the SECOND refusal point for one @@ -16906,8 +16928,9 @@ export class ObjectStackProtocolImplementation implements } // [#20819, ADR-0131 D6] The SENTENCE is chosen per ADR-0126 regime, // and names the managed package for every other type; the code and - // the status are unchanged. - const err = new Error(managedItemSealedSentence(request.type, request.name, 'save')); + // the status are unchanged. [#22591] A create reads the held-name + // remedy instead, whatever the type. + const err = new Error(managedItemSealedSentence(request.type, request.name, operation)); (err as any).code = 'NOT_OVERRIDABLE'; (err as any).status = 403; throw err; @@ -19790,12 +19813,21 @@ export class ObjectStackProtocolImplementation implements // are no caller's claim about anything: a row stored before the // #16702 strip may still carry stale stamps, and rewriting it keeps // the silent strip it always had rather than failing the rewrite. + // + // [#22591] What this save does to the name, read for the sealed-item + // refusal's remedy and nothing else: a present `null` parent is the + // first-write pin (`If-None-Match: *` on the REST door) — the caller + // holds no row under the name and is writing one of its own, a create. + // Every other save is an edit. Read by the flow rule just below and by + // the package door further down. + const sealedWrite: 'save' | 'create' = request.parentVersion === null ? 'create' : 'save'; if (request.source === undefined && request.writeFace !== 'package-duplicate') { const authored = await this.tenantAuthoredWriteRefusal({ type: request.type, name: request.name, item: request.item, ...(request.packageId ? { packageId: request.packageId } : {}), + ...(sealedWrite === 'create' ? { operation: sealedWrite } : {}), }); if (authored) throw authored; } @@ -19956,7 +19988,10 @@ export class ObjectStackProtocolImplementation implements // enforcement" kept such deployments "any type writable", but the // repository has refused these writes on them all along; the door // only answers first. - this.refusePackagedBaseOverride(request); + // + // [#22591] A first write (`sealedWrite`, above) reads the create + // remedy; the verdict is the same. + this.refusePackagedBaseOverride(request, sealedWrite); // ADR-0010 L3 — per-item lock. Artifact `_lock` (or persisted // overlay `_lock`) blocks save independent of the L1 type-level diff --git a/packages/plugins/plugin-security/src/position-write-through.test.ts b/packages/plugins/plugin-security/src/position-write-through.test.ts index 92be1d290c6..daeac0a8ad4 100644 --- a/packages/plugins/plugin-security/src/position-write-through.test.ts +++ b/packages/plugins/plugin-security/src/position-write-through.test.ts @@ -410,7 +410,7 @@ describe('[ADR-0131 D3] Q2 = A — an edit that keeps, or a delete of, a name a saveMetaItem: vi.fn(async (_request: Record) => undefined), deleteMetaItem: vi.fn(async (_request: Record) => undefined), // The real door's verdict, so the S10 refusal is in force for this double too. - packagedBaseRefusal: vi.fn((request: { type: string; name: string; operation: 'save' | 'delete' }) => + packagedBaseRefusal: vi.fn((request: { type: string; name: string; operation: 'save' | 'create' | 'delete' }) => b.protocol.packagedBaseRefusal(request) as Error | null), }; const writeThrough = createPositionWriteThrough({ @@ -468,16 +468,54 @@ describe('[C2 stage S10, ADR-0048 addendum N.2/N.3] one namespace — a create o expect(await b.envRows(name)).toEqual([]); }); - it('the refusal is the door\'s own, relayed as the door built it — the same verdict a metadata save gets', async () => { + it('the refusal is the door\'s own, relayed as the door built it — the same verdict a metadata CREATE gets', async () => { + // [#22591] A Setup create is a create, so it is the door's answer to a + // create that is relayed: the metadata door's first-write save + // (`parentVersion: null`, `If-None-Match: *` on its REST route). const b = await boot(); const refusal: any = await refusalOf(create(b, { name: PKG_POSITION, label: 'Mine' })); - const door: any = await refusalOf( - b.protocol.saveMetaItem({ type: 'position', name: PKG_POSITION, item: { name: PKG_POSITION, label: 'Mine' } }), - ); + const door: any = await refusalOf(b.protocol.saveMetaItem({ + type: 'position', name: PKG_POSITION, item: { name: PKG_POSITION, label: 'Mine' }, parentVersion: null, + })); expect({ code: refusal?.code, status: refusal?.status, message: refusal?.message }) .toEqual({ code: door?.code, status: door?.status, message: door?.message }); }); + // [#22591] The remedy follows the act. The administrator is creating a + // position of their own (or renaming one into the name), so the refusal names + // the remedy a create has — a name no package or built-in holds, and where the + // held names are listed — never the edit remedy ("edit the source artifact and + // redeploy") of a position someone else declared. Pinned on the KIND: what the + // sentence points at, not its words. The door's edit refusal is the control. + const LISTING = 'GET /api/v1/meta/position'; + const remedyKind = (message: unknown) => { + const text = String(message); + const newName = text.includes(LISTING); + const edit = /\bredeploy\b|\bsource\b/.test(text); + return newName && edit ? 'both' : newName ? 'new-name' : edit ? 'edit' : 'none'; + }; + + it.each([ + ['a package declares', PKG_POSITION], + ['the platform declares (everyone)', 'everyone'], + ])('a create under a name %s names a free name, not the edit remedy; the door\'s edit refusal keeps its own', async (_holder, name) => { + const b = await boot(); + const refusal: any = await refusalOf(create(b, { name, label: 'Mine' })); + expect(envelope(refusal)).toEqual(DOOR_REFUSAL); + expect(remedyKind(refusal?.message)).toBe('new-name'); + const edit: any = await refusalOf(b.protocol.saveMetaItem({ type: 'position', name, item: { name, label: 'Mine' } })); + expect(envelope(edit)).toEqual(DOOR_REFUSAL); + expect(remedyKind(edit?.message)).toBe('edit'); + }); + + it('a rename into a held name names a free name too', async () => { + const b = await boot(); + const created: any = await create(b, { name: 'lane_lead', label: 'Lane lead' }); + const refusal: any = await refusalOf(patch(b, created.id, { name: 'guest' })); + expect(envelope(refusal)).toEqual(DOOR_REFUSAL); + expect(remedyKind(refusal?.message)).toBe('new-name'); + }); + it('a bulk create carrying one held name is refused whole: no row of the write is kept', async () => { const b = await boot(); const refusal = await refusalOf(b.engine.insert('sys_position', [ diff --git a/packages/plugins/plugin-security/src/position-write-through.ts b/packages/plugins/plugin-security/src/position-write-through.ts index 2d36e8cfbad..17ac65e2093 100644 --- a/packages/plugins/plugin-security/src/position-write-through.ts +++ b/packages/plugins/plugin-security/src/position-write-through.ts @@ -138,8 +138,10 @@ export interface PositionMetadataDoor { * would raise for `(type, name)` on that ground, or `null` * (`ObjectStackProtocolImplementation.packagedBaseRefusal`). Optional: a door * without it keeps the stand-down for a package-held name (module note). + * `create` is the save that takes a name for an item of the caller's own: the + * same verdict as `save`, with the remedy a create has. */ - packagedBaseRefusal?(request: { type: string; name: string; operation: 'save' | 'delete' }): Error | null; + packagedBaseRefusal?(request: { type: string; name: string; operation: 'save' | 'create' | 'delete' }): Error | null; } export interface PositionWriteThroughDeps { @@ -194,10 +196,17 @@ export function packageHoldsPosition(ql: any, name: string): boolean { * position taking `name` — its locked-base verdict, asked and never re-derived * (module note, "One namespace"). `null` when the door would not refuse that * save on the locked-base ground, or brings no such verdict. + * + * [#22591] Asked as a `create`, because that is what this write is: a create, + * or a rename into `name`, never an edit of the item that holds it (an edit + * that keeps a held name is never asked about: it stands down). The door's verdict + * is the `save` verdict; what the act changes is the remedy its sentence names + * — a name no package or built-in holds, not the source artifact of a position + * this administrator did not write. */ function heldPositionNameRefusal(door: PositionMetadataDoor, name: unknown): Error | null { if (typeof name !== 'string' || name === '' || typeof door.packagedBaseRefusal !== 'function') return null; - return door.packagedBaseRefusal({ type: POSITION_METADATA_TYPE, name, operation: 'save' }); + return door.packagedBaseRefusal({ type: POSITION_METADATA_TYPE, name, operation: 'create' }); } let cachedPositionNameSchema: { safeParse(v: unknown): { success: boolean } } | null = null;