From e2657fe1188f3d11d9d567f60e9faa3194458f52 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:40:03 +0000 Subject: [PATCH 1/6] feat(runtime): the permission-set clone door lands "new similar" in a writable target package A copy of a packaged set with no target lands in the source package's paired .custom, ensured through the ensure door; a given target must be installed, writable and reach the set's objects (the shared new-similar target check); an environment-authored source copies environment-wide. Claude-Session: https://claude.ai/code/session_01FEkQ5khiLzZmecWFfjSZLc Co-authored-by: Claude --- .../permission-set-clone-door.dogfood.test.ts | 112 +++++++++- packages/runtime/package.json | 1 + .../runtime/src/domains/new-similar-target.ts | 132 +++++++++++ .../src/domains/permission-set-clone.ts | 209 +++++++++++++++--- pnpm-lock.yaml | 3 + 5 files changed, 425 insertions(+), 32 deletions(-) create mode 100644 packages/runtime/src/domains/new-similar-target.ts diff --git a/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts index 9423126a5ce..50391c40e53 100644 --- a/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts @@ -16,7 +16,15 @@ // - the copy GRANTS: a holder of it resolves its object grants; // - the source is read from the catalog, not from a `sys_permission_set` row: // the source's rows are removed first, and the clone still lands; -// - a caller without `manage_metadata` is refused 403 and nothing is saved. +// - a caller without `manage_metadata` is refused 403 and nothing is saved; +// - [ADR-0146 D3/D6] "new similar": a copy of a PACKAGED set with no target +// lands in the source package's paired `.custom`, created by N-13's +// ensure door on the first copy and reused on the next — the stored row +// bound to the pair, the item itself naming no package — and is granted +// through a position AND a direct assignment by its bare name; a given +// target is judged (writable and in closure lands, read-only / unknown / +// out-of-closure are refused with no row); an environment-authored source +// still copies environment-wide. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { resolveUserAuthzGrants, securityCatalogReaderOf } from '@objectstack/core'; @@ -34,6 +42,12 @@ const DEFAULT_COPY = 'my_member_default'; const MEMBER_EMAIL = 'clone.member@example.com'; const HOLDER_EMAIL = 'clone.holder@example.com'; const COPY_POSITION = 'clone_door_position'; +/** The showcase's manifest id, and its paired customization package (ADR-0146 D3). */ +const SHOWCASE = 'com.example.showcase'; +const SHOWCASE_PAIR = `${SHOWCASE}.custom`; +/** A Studio-created writable package that declares the showcase, and one that declares nothing. */ +const IN_CLOSURE = 'com.acme.clone-in-closure'; +const OUT_OF_CLOSURE = 'com.acme.clone-out-of-closure'; interface Engine { find(object: string, options?: unknown): Promise>>; @@ -82,17 +96,35 @@ describe('the permission-set clone door, on a booted showcase', () => { // stage 8 retired the `sys_permission_set` row object. expect(ql.getObject?.('sys_permission_set')).toBeFalsy(); + // Anti-vacuity for the landing: the source is the showcase's, and no + // pair exists before the first copy. + expect(source?.packageId).toBe(SHOWCASE); + expect((await stack.apiAs(token, 'GET', `/packages/${SHOWCASE_PAIR}`)).status).toBe(404); + const res = await clone(token, DELEGATE_SET, { name: DELEGATE_COPY, label: 'My Field Ops Delegate' }); const text = await res.clone().text(); expect(res.status, text).toBe(200); - const data = JSON.parse(text).data as { permissionSet: Record; notice: string }; + const data = JSON.parse(text).data as { permissionSet: Record; package: string | null; notice: string }; expect(data.permissionSet.name).toBe(DELEGATE_COPY); expect(data.permissionSet).not.toHaveProperty('adminScope'); + expect(data.permissionSet).not.toHaveProperty('packageId'); expect(data.notice).toContain('adminScope'); + // [ADR-0146 D3/D6] The copy landed in the showcase's pair, which the + // ensure door created for it. + expect(data.package).toBe(SHOWCASE_PAIR); + expect(data.notice).toContain(`'${SHOWCASE_PAIR}'`); + const pair = await stack.apiAs(token, 'GET', `/packages/${SHOWCASE_PAIR}`); + expect(pair.status, await pair.clone().text()).toBe(200); + + // The stored row is bound to the pair (its `package_id`), and the item + // names no package of its own. + const rows = await ql.find('sys_metadata', { where: { type: 'permission', name: DELEGATE_COPY }, context: SYSTEM_CTX }); + expect(rows.map((r) => r.package_id)).toEqual([SHOWCASE_PAIR]); + expect(JSON.parse(String(rows[0].metadata))).not.toHaveProperty('packageId'); const copy = await reader!.resolve('permission', DELEGATE_COPY); expect(copy?.name, 'the copy does not resolve through the catalog').toBe(DELEGATE_COPY); - expect(copy?.packageId).toBeUndefined(); + expect(copy?.packageId).toBe(SHOWCASE_PAIR); expect(copy?.definition).not.toHaveProperty('adminScope'); expect(copy?.definition.label).toBe('My Field Ops Delegate'); // The facets came across whole. @@ -106,9 +138,16 @@ describe('the permission-set clone door, on a booted showcase', () => { const res = await clone(token, DEFAULT_SET, { name: DEFAULT_COPY, label: 'My Member Default' }); expect(res.status, await res.clone().text()).toBe(200); + // The second copy REUSES the pair the first created: one pair, the same id. + expect(((await res.json()) as any).data.package).toBe(SHOWCASE_PAIR); + const listed = ((await (await stack.apiAs(token, 'GET', '/packages')).json()) as any).data; + const pairs = (Array.isArray(listed) ? listed : listed?.packages ?? []) + .filter((p: any) => (p?.manifest?.id ?? p?.id) === SHOWCASE_PAIR); + expect(pairs).toHaveLength(1); const copy = await reader.resolve('permission', DEFAULT_COPY); expect(copy?.name).toBe(DEFAULT_COPY); + expect(copy?.packageId).toBe(SHOWCASE_PAIR); expect(copy?.definition.isDefault === undefined || copy?.definition.isDefault === false).toBe(true); expect(copy?.definition.objects).toEqual(source!.definition.objects); @@ -131,6 +170,73 @@ describe('the permission-set clone door, on a booted showcase', () => { expect(grants.permissions).toContain(DEFAULT_COPY); }); + it('a copy in the pair is granted through a direct assignment too, by its bare name', async () => { + const email = 'clone.direct@example.com'; + await stack.signUp(email); + const [user] = await ql.find('sys_user', { where: { email }, limit: 1, context: SYSTEM_CTX }); + expect(user?.id, `no sys_user row for ${email}`).toBeTruthy(); + const tenantId = await sessionOrgOf(stack); + // Anti-vacuity: the user holds no copy before the assignment. + const before = await resolveUserAuthzGrants(ql as never, String(user.id), { tenantId }); + expect(before.permissions).not.toContain(DELEGATE_COPY); + await ql.insert( + 'sys_user_permission_set', + { id: 'ups_clone_door_direct', user_id: String(user.id), permission_set: DELEGATE_COPY }, + { context: SYSTEM_CTX }, + ); + const after = await resolveUserAuthzGrants(ql as never, String(user.id), { tenantId }); + expect(after.permissions).toContain(DELEGATE_COPY); + }); + + it('a given writable target inside the closure lands there; read-only, unknown and out-of-closure targets are refused with no row', async () => { + const reader = securityCatalogReaderOf(ql)!; + for (const [id, namespace, dependencies] of [ + [IN_CLOSURE, 'clone_in', { [SHOWCASE]: '*' }], + [OUT_OF_CLOSURE, 'clone_out', {}], + ] as const) { + const created = await stack.apiAs(token, 'POST', '/packages', { + manifest: { id, name: id, version: '0.1.0', type: 'app', namespace, dependencies }, + }); + expect(created.status, await created.clone().text()).toBe(201); + } + + const landed = await clone(token, DEFAULT_SET, { name: 'in_closure_copy', label: 'In Closure', package: IN_CLOSURE }); + expect(landed.status, await landed.clone().text()).toBe(200); + expect(((await landed.json()) as any).data.package).toBe(IN_CLOSURE); + expect((await reader.resolve('permission', 'in_closure_copy'))?.packageId).toBe(IN_CLOSURE); + + const refused: Array<[string, string, number, string]> = [ + ['read_only_copy', SHOWCASE, 422, 'WRITABLE_PACKAGE_REQUIRED'], + ['unknown_copy', 'com.acme.no_such_package', 422, 'WRITABLE_PACKAGE_REQUIRED'], + ['out_closure_copy', OUT_OF_CLOSURE, 409, 'RESOURCE_CONFLICT'], + ]; + for (const [name, target, status, code] of refused) { + const res = await clone(token, DEFAULT_SET, { name, label: 'X', package: target }); + const text = await res.clone().text(); + expect(res.status, `${target}: ${text}`).toBe(status); + expect(((await res.json()) as any).error?.code, target).toBe(code); + expect(await reader.resolve('permission', name), target).toBeUndefined(); + const rows = await ql.find('sys_metadata', { where: { type: 'permission', name }, context: SYSTEM_CTX }); + expect(rows, target).toEqual([]); + } + }); + + it('CONTROL — an environment-authored source still copies environment-wide, with no package', async () => { + const saved = await stack.apiAs(token, 'PUT', '/meta/permission/env_authored_source', { + name: 'env_authored_source', label: 'Env Authored', objects: { showcase_task: { allowRead: true } }, + }); + expect(saved.status, await saved.clone().text()).toBe(200); + const source = await securityCatalogReaderOf(ql)!.resolve('permission', 'env_authored_source'); + expect(source?.packageId, 'the control source is packaged').toBeUndefined(); + + const res = await clone(token, 'env_authored_source', { name: 'env_authored_copy', label: 'Env Authored Copy' }); + expect(res.status, await res.clone().text()).toBe(200); + expect(((await res.json()) as any).data.package).toBeNull(); + expect((await securityCatalogReaderOf(ql)!.resolve('permission', 'env_authored_copy'))?.packageId).toBeUndefined(); + const rows = await ql.find('sys_metadata', { where: { type: 'permission', name: 'env_authored_copy' }, context: SYSTEM_CTX }); + expect(rows.map((r) => r.package_id ?? null)).toEqual([null]); + }); + it('a taken name is 409, an unknown source 404, a bad name 400', async () => { const taken = await clone(token, DEFAULT_SET, { name: DELEGATE_SET, label: 'X' }); expect(taken.status, await taken.clone().text()).toBe(409); diff --git a/packages/runtime/package.json b/packages/runtime/package.json index b2804be6e6d..b5f8f0369c4 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -44,6 +44,7 @@ "@objectstack/driver-sql": "workspace:*", "@objectstack/driver-sqlite-wasm": "workspace:*", "@objectstack/formula": "workspace:*", + "@objectstack/lint": "workspace:*", "@objectstack/metadata": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/metadata-protocol": "workspace:*", diff --git a/packages/runtime/src/domains/new-similar-target.ts b/packages/runtime/src/domains/new-similar-target.ts new file mode 100644 index 00000000000..eaa4ed7d191 --- /dev/null +++ b/packages/runtime/src/domains/new-similar-target.ts @@ -0,0 +1,132 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0146 D3/D6] THE ONE CHECK OF A "NEW SIMILAR" DOOR'S GIVEN TARGET + * PACKAGE — the permission-set clone door (`./permission-set-clone.ts`) and + * the flow clone door ask it; ⛔ no door spells its own. + * + * A "new similar" copy is a new item of a package the customer can author + * into. When the caller names that package (`package` in the door's body), + * the target must be: + * + * 1. **installed and writable** — an id the package registry holds, and + * writable by ADR-0070 D2's one predicate (`isWritablePackage`, + * `@objectstack/metadata-protocol`). Unknown and read-only are one + * refusal, `422 WRITABLE_PACKAGE_REQUIRED`, the save door's own code for + * both: `isWritablePackage` alone answers "writable" for a bare id no + * registry holds (an ADR-0048 authoring workspace), and a copy must land + * in a package that exists, so installation is asked first; + * 2. **able to reach what the copy references** — its dependency closure + * (`resolvePackageClosure`, `@objectstack/lint/runtime`: the one walk the + * save gate and Studio's pickers use) holds the source's package, and, + * when the door names the objects the copy grants on, every one of them + * survives the save gate's own closure predicate + * (`narrowObjectsToPackageClosure`: a platform/system object, an + * unpackaged one, or one owned inside the closure). Otherwise + * `409 RESOURCE_CONFLICT`, naming what is out of reach and the remedy (a + * declared dependency). + * + * Pure over the engine's registry: it writes nothing and resolves no default + * target — each door decides its own default (the paired `.custom` + * through N-13's ensure door) and asks this only of a target its caller gave. + */ + +import { narrowObjectsToPackageClosure, resolvePackageClosure } from '@objectstack/lint/runtime'; +import { isWritablePackage } from '@objectstack/metadata-protocol'; + +/** The ADR-0146 record the refusals point at. */ +const NEW_SIMILAR_TARGET_DOCS = 'docs/adr/0146-studio-designs-setup-administers-one-editor-per-item-paired-customize-package.md'; + +/** What a door asks about its caller's target. */ +export interface NewSimilarTargetRequest { + /** The package the caller named. */ + readonly target: string; + /** The package that ships the source, when it has one (an environment-authored source has none). */ + readonly sourcePackageId?: string; + /** The source, in words, for the sentences: `permission set 'crm_sales_rep'`, `flow 'x'`. */ + readonly subject: string; + /** + * The objects the copy grants on or reads, when the door can name them + * from the item's own declaration; each must be reachable from the target. + */ + readonly objects?: readonly string[]; +} + +/** The engine surface this check reads — the ObjectQL engine satisfies it as it stands. */ +interface TargetEngine { + manifests?: { has?(id: string): boolean }; + registry?: { + getPackage?(id: string): { manifest?: unknown } | undefined; + getObject?(name: string): unknown; + getObjectOwner?(name: string): { packageId?: string } | undefined; + }; +} + +function refusal(status: number, code: string, message: string, packageId: string): Error { + return Object.assign(new Error(message), { + status, + statusCode: status, + code, + details: { code, packageId, docs: NEW_SIMILAR_TARGET_DOCS }, + }); +} + +/** + * Why `request.target` cannot take the copy, as an ADR-0112 error (status + + * code + `packageId` + `docs`), or `undefined` when it can. + */ +export function newSimilarTargetRefusal(engine: unknown, request: NewSimilarTargetRequest): Error | undefined { + const { target, sourcePackageId, subject } = request; + const e = engine as TargetEngine | null | undefined; + const registry = e?.registry; + const getPackage = (id: string) => registry?.getPackage?.call(registry, id); + + if (!getPackage(target) || !isWritablePackage(engine, target)) { + const installed = getPackage(target) !== undefined; + return refusal( + 422, + 'WRITABLE_PACKAGE_REQUIRED', + `Cannot save the copy of ${subject} in package '${target}': ` + + (installed + ? 'it is read-only (provided by code or an installed app), and a copy is a new item of a package you can author into. ' + : 'no package with that id is installed. ') + + 'Name an installed, writable package, or leave `package` out to save the copy in the paired customization ' + + 'package of the package the original came from (ADR-0146 D3).', + target, + ); + } + + const closure = resolvePackageClosure(target, (id) => getPackage(id)); + const reachable = new Set(closure ? [closure.packageId, ...closure.dependencies] : [target]); + const outOfReach: string[] = []; + if (sourcePackageId !== undefined && !reachable.has(sourcePackageId)) { + outOfReach.push(`the package '${sourcePackageId}' the original comes from`); + } + if (request.objects && request.objects.length > 0) { + const entries: Array> = []; + for (const name of request.objects) { + const object = registry?.getObject?.call(registry, name) as Record | undefined; + // An object no registry holds is the save's to judge, not this check's. + if (!object) continue; + entries.push({ name, isSystem: object.isSystem, _packageId: registry?.getObjectOwner?.call(registry, name)?.packageId }); + } + const kept = new Set(narrowObjectsToPackageClosure(entries, closure)); + const dropped = entries.filter((entry) => !kept.has(entry)); + if (dropped.length > 0) { + outOfReach.push(dropped.length === 1 + ? `object '${String(dropped[0].name)}' (package '${String(dropped[0]._packageId)}')` + : `objects ${dropped.map((d) => `'${String(d.name)}' (package '${String(d._packageId)}')`).join(', ')}`); + } + } + if (outOfReach.length === 0) return undefined; + return refusal( + 409, + 'RESOURCE_CONFLICT', + `Cannot save the copy of ${subject} in package '${target}': it cannot reach ${outOfReach.join(', and ')}. ` + + `A package references only itself, the packages its manifest declares in \`dependencies\` (and theirs), and ` + + `platform objects. Declare the dependency on '${target}', name a package that already declares it, or leave ` + + '`package` out to save the copy in the paired customization package of the package the original came from ' + + '(ADR-0146 D3).', + target, + ); +} diff --git a/packages/runtime/src/domains/permission-set-clone.ts b/packages/runtime/src/domains/permission-set-clone.ts index 820646d40ba..c57e927a467 100644 --- a/packages/runtime/src/domains/permission-set-clone.ts +++ b/packages/runtime/src/domains/permission-set-clone.ts @@ -2,8 +2,8 @@ /** * [ADR-0126 §7.1, amendment ruling 2] THE PERMISSION-SET CLONE DOOR: - * `POST /security/permission-sets/:name/clone`, body `{ name, label }`, both - * keys required, the set closed. + * `POST /security/permission-sets/:name/clone`, body `{ name, label, package? }`, + * `name` and `label` required, the set closed. * * ## Why it exists * @@ -28,9 +28,9 @@ * — the facet drop commit 5cb62d88b measured on the param-list clone (three * of six facets silently absent) cannot happen here. A permission set has * no `status`, so the mutation set is two fields, not the flow's three. - * 2. **No linkage to the base.** The copy is an ordinary environment-authored - * set: no package, no provenance envelope, and no field or response key - * naming what it was copied from (ADR-0126 §9). + * 2. **No linkage to the base.** The copy carries no provenance envelope, and + * no field or response key naming what it was copied from (ADR-0126 §9). + * It is a NEW set beside its base, never an override of it (ADR-0146 D6). * 3. **A new name, mandatory.** A name the catalog already holds — the * source's own included — is refused 409, and the save itself is * create-only, so a row written between the check and the save is refused @@ -50,7 +50,11 @@ * out) and `_lock` would leave the copy as locked as its base; * - **the package-authoring keys** — `packageId`, `managedBy` (ADR-0086 D3 * record provenance) and `protection` (the package author's lock block): each - * states who ships the set, and the copy is shipped by nobody; + * states who ships the SOURCE. The package the copy lands in is the save's + * own context (`saveMetaItem`'s `packageId`, see "Where the copy lands"), + * never a body key: a body `packageId` naming the source's package would be + * refused by the owner-binding rule (`permissionSetOwnerBindingRefusal`) + * the moment it disagreed with the row the save writes; * - **the privilege decisions** — `adminScope` (an ADR-0090 D12 * delegated-administration grant) and `isDefault` (an ADR-0090 D5 suggestion * to bind the set to every signed-in member). Putting either on a brand-new @@ -60,6 +64,30 @@ * either deliberately on the copy if it needs one, and the response says so * ({@link PERMISSION_SET_CLONE_NOTICE}). * + * ## Where the copy lands — ADR-0146 D3/D6 ("new similar") + * + * A customer's own set is a NEW set in the package that holds their changes + * (ADR-0146 D6), so the door decides one package, {@link resolveCloneLanding}: + * + * - **`package` given** — it must be an installed, writable package whose + * dependency closure reaches the source's package and every object the set + * grants on; the one check both "new similar" doors ask + * (`newSimilarTargetRefusal`, `./new-similar-target.ts`), else refused with + * nothing written; + * - **omitted, the source shipped by a read-only package X** — X's paired + * customization package `.custom`, answered by N-13's ensure door + * (`ensureCustomizationPackage`, `@objectstack/metadata-protocol`), the + * ONLY creation path of the pair: created when absent, reused when present; + * - **omitted, the source in a writable package** (a base the customer owns, + * or already `.custom`) — that package: the ensure door refuses a + * writable base, whose changes go into it directly; + * - **omitted, the source authored in the environment** (no package) — the + * save stays environment-wide, as before: D6 charters new sets for a base, + * not a package for every set. + * + * The response names the package (`package`, `null` for environment-wide) and + * the notice says where the copy went ({@link permissionSetCloneNotice}). + * * ## Authority * * The metadata door's own: `manage_metadata` (or `isSystem`), asked through the @@ -81,7 +109,8 @@ import { SnakeCaseIdentifierSchema } from '@objectstack/spec/shared'; import { metaWriteCapabilityVerdict, walledCatalogWriteVerdict } from '@objectstack/metadata-core'; import { readEffectiveTenancyPosture } from './activation-gate.js'; import { securityCatalogReaderOf, type SecurityCatalogEntry } from '@objectstack/core'; -import type { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { isWritablePackage, type ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { newSimilarTargetRefusal } from './new-similar-target.js'; import { validationFailure } from '../validation-failure.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps } from '../domain-handler-registry.js'; @@ -120,6 +149,31 @@ export const PERMISSION_SET_CLONE_NOTICE = + 'everyone-baseline suggestion (`isDefault`) — grant either deliberately on the copy if it needs one. ' + 'The copy is held by nobody yet: add it to a position, or assign it to users.'; +/** + * Where a clone landed, as the door decided it ({@link resolveCloneLanding}): + * the package, or `null` for an environment-wide save, and whether the door + * created that package for this copy (only ever the paired `.custom`). + */ +export interface PermissionSetCloneLanding { + readonly packageId: string | null; + readonly created: boolean; +} + +/** + * The notice for one landing: where the copy went, in the server's words, + * ahead of {@link PERMISSION_SET_CLONE_NOTICE}. + */ +export function permissionSetCloneNotice(landing: PermissionSetCloneLanding): string { + if (landing.packageId === null) { + return 'The copy was saved environment-wide, in no package, as the set it was copied from is. ' + + PERMISSION_SET_CLONE_NOTICE; + } + return `The copy was saved in package '${landing.packageId}'` + + (landing.created ? ', created for it to hold your changes to the package the set came from' : '') + + ', beside the original rather than in place of it, and travels with that package. ' + + PERMISSION_SET_CLONE_NOTICE; +} + /** ADR-0112 status for a target name the catalog already holds. */ export const PERMISSION_SET_CLONE_NAME_TAKEN_STATUS = 409; @@ -155,6 +209,72 @@ export function clonePermissionSetDefinition( return copy; } +/** + * Every object a permission set grants on: the keys of its `objects` map, the + * object half of each `fields` key (`object.field`), and each row-level rule's + * `object` — the three places `PermissionSetSchema` names an object. These are + * what a given target's dependency closure must reach. + */ +export function permissionSetObjectNames(definition: Readonly>): string[] { + const names = new Set(); + const objects = definition.objects; + if (objects && typeof objects === 'object' && !Array.isArray(objects)) { + for (const name of Object.keys(objects)) names.add(name); + } + const fields = definition.fields; + if (fields && typeof fields === 'object' && !Array.isArray(fields)) { + for (const key of Object.keys(fields)) { + const dot = key.indexOf('.'); + if (dot > 0) names.add(key.slice(0, dot)); + } + } + const rules = definition.rowLevelSecurity; + if (Array.isArray(rules)) { + for (const rule of rules) { + const object = (rule as { object?: unknown } | null)?.object; + if (typeof object === 'string' && object !== '') names.add(object); + } + } + return [...names]; +} + +/** + * The package a clone lands in — the rules in the module doc's "Where the copy + * lands". Throws an ADR-0112 refusal (status + code) when a given target is + * refused or the pair cannot be ensured; nothing has been written by then. + */ +export async function resolveCloneLanding( + ql: unknown, + protocol: Partial>, + source: SecurityCatalogEntry, + requested: string | undefined, +): Promise { + if (requested !== undefined) { + const refusal = newSimilarTargetRefusal(ql, { + target: requested, + sourcePackageId: source.packageId, + subject: `permission set '${source.name}'`, + objects: permissionSetObjectNames(source.definition), + }); + if (refusal) throw refusal; + return { packageId: requested, created: false }; + } + const sourcePackageId = source.packageId; + if (sourcePackageId === undefined) return { packageId: null, created: false }; + if (isWritablePackage(ql, sourcePackageId)) return { packageId: sourcePackageId, created: false }; + if (typeof protocol.ensureCustomizationPackage !== 'function') { + throw Object.assign( + new Error( + `Permission set '${source.name}' is shipped by read-only package '${sourcePackageId}', so its copy belongs in ` + + 'that package\'s paired customization package, and this deployment\'s metadata protocol cannot ensure one.', + ), + { statusCode: 501 }, + ); + } + const pair = await protocol.ensureCustomizationPackage({ basePackageId: sourcePackageId }); + return { packageId: pair.packageId, created: pair.created }; +} + function nameTakenMessage(name: string): string { return ( `Permission set '${name}' already exists — a clone must take a NEW machine name. ` @@ -163,18 +283,21 @@ function nameTakenMessage(name: string): string { ); } -function readCloneBody(body: unknown): { name: string; label: string } { +/** The body keys the door reads — the closed set. */ +const CLONE_BODY_KEYS: readonly string[] = ['name', 'label', 'package']; + +function readCloneBody(body: unknown): { name: string; label: string; package?: string } { const cloneBody = body ?? {}; if (typeof cloneBody !== 'object' || Array.isArray(cloneBody)) { - throw validationFailure('Invalid clone body — expected { name: string, label: string }', [ + throw validationFailure('Invalid clone body — expected { name: string, label: string, package?: string }', [ { field: '(body)', code: 'invalid_type', message: 'expected an object' }, ]); } - const unknownKeys = Object.keys(cloneBody).filter((k) => k !== 'name' && k !== 'label'); + const unknownKeys = Object.keys(cloneBody).filter((k) => !CLONE_BODY_KEYS.includes(k)); if (unknownKeys.length > 0) { throw validationFailure( - `Unknown key${unknownKeys.length > 1 ? 's' : ''} ${unknownKeys.map((k) => `\`${k}\``).join(', ')} — the clone body is { name: string, label: string }`, - unknownKeys.map((k) => ({ field: k, code: 'unknown_field', message: 'not a clone field — the clone body is { name, label }' })), + `Unknown key${unknownKeys.length > 1 ? 's' : ''} ${unknownKeys.map((k) => `\`${k}\``).join(', ')} — the clone body is { name: string, label: string, package?: string }`, + unknownKeys.map((k) => ({ field: k, code: 'unknown_field', message: 'not a clone field — the clone body is { name, label, package? }' })), ); } const name = (cloneBody as { name?: unknown }).name; @@ -205,7 +328,18 @@ function readCloneBody(body: unknown): { name: string; label: string } { [{ field: 'label', code: label === undefined ? 'required' : 'invalid_type', message: 'expected a non-empty string' }], ); } - return { name, label }; + if (!('package' in cloneBody)) return { name, label }; + // Optional, but never empty: an empty or non-string target is a malformed + // request, not "omitted" — omission is spelled by leaving the key out. + const target = (cloneBody as { package?: unknown }).package; + if (typeof target !== 'string' || target.trim() === '') { + throw validationFailure( + '`package` names the installed, writable package the copy is saved in — a non-empty package id. ' + + 'Leave the key out to let the server choose (the source package\'s paired customization package).', + [{ field: 'package', code: 'invalid_type', message: 'expected a non-empty string' }], + ); + } + return { name, label, package: target }; } /** @@ -217,19 +351,23 @@ function readCloneBody(body: unknown): { name: string; label: string } { * lookup, so a refused caller learns nothing: neither the body contract nor * whether the source exists. * 2. **Shape**: `['permission-sets', source, 'clone']`. - * 3. **Body**: closed `{ name, label }`, the name meeting the metadata name - * rule — refused before the catalog is consulted. + * 3. **Body**: closed `{ name, label, package? }`, the name meeting the + * metadata name rule — refused before the catalog is consulted. * 4. **Source**, through the catalog reader the security plugin binds to the * engine — the read `resolveUserAuthzGrants` uses, registry first. ⛔ No * `sys_permission_set` row is read. No reader bound, or one that cannot * answer ⇒ 503; unknown ⇒ 404. * 5. **Target**: a name the catalog holds ⇒ 409. - * 6. **The save**, through the metadata protocol's `saveMetaItem`, create-only - * (`parentVersion: null`), environment-wide, no package: the same save the - * `/meta` door makes, so the same schema, publish gates and hooks judge it, - * and the catalog serves the copy from where it serves every - * environment-authored set. A save that loses the race for the name (a row - * written under it after step 5's read) is step 5's 409, in step 5's words. + * 6. **Landing**: the package the copy is saved in ({@link resolveCloneLanding}): + * a given target is judged (refused with nothing written), an omitted one + * follows the source — `.custom` through the ensure door for a + * read-only X, the source's own writable package, or environment-wide. + * 7. **The save**, through the metadata protocol's `saveMetaItem`, create-only + * (`parentVersion: null`), with the landing package as the save's own + * `packageId` (none for environment-wide): the same save the `/meta` door + * makes, so the same schema, publish gates and hooks judge it. A save that + * loses the race for the name (a row written under it after step 5's read) + * is step 5's 409, in step 5's words. */ export async function handlePermissionSetCloneWrite( deps: DomainHandlerDeps, @@ -324,9 +462,8 @@ export async function handlePermissionSetCloneWrite( }; } - // ── 6. the save ───────────────────────────────────────────────────── const clone = clonePermissionSetDefinition(source.definition, target); - const protocol: Partial> | undefined = + const protocol: Partial> | undefined = await deps.resolveServiceOrLoud(context, 'protocol'); if (typeof protocol?.saveMetaItem !== 'function') { return { @@ -337,10 +474,15 @@ export async function handlePermissionSetCloneWrite( ), }; } - // [ADR-0131 D6] No organization and no package: the copy lands - // environment-wide, as every `/meta` write does. `parentVersion: null` - // is `If-None-Match: *` — save only where no row exists — so a row - // written under the target name after the catalog read is refused, not + // ── 6. landing ────────────────────────────────────────────────────── + const landing = await resolveCloneLanding(ql, protocol, source, target.package); + + // ── 7. the save ───────────────────────────────────────────────────── + // [ADR-0131 D6] No organization: the catalog is environment-level. The + // package is the landing's, stated as the SAVE's `packageId` — the row's + // binding — and never inside the item. `parentVersion: null` is + // `If-None-Match: *` — save only where no row exists — so a row written + // under the target name after the catalog read is refused, not // overwritten. // // ⚠️ The save is NOT what keeps the source's provenance off the copy. @@ -355,6 +497,7 @@ export async function handlePermissionSetCloneWrite( name: target.name, item: clone, parentVersion: null, + ...(landing.packageId !== null ? { packageId: landing.packageId } : {}), }); } catch (err: unknown) { // [#23577] The create-only pin refused: a row was written under the @@ -372,8 +515,16 @@ export async function handlePermissionSetCloneWrite( } // ⛔ No ancestry on the way out (ADR-0126 §9): the response names the - // set that was created and nothing about what it was copied from. - return { handled: true, response: deps.success({ permissionSet: clone, notice: PERMISSION_SET_CLONE_NOTICE }) }; + // set that was created and the package it landed in, and nothing about + // what it was copied from. + return { + handled: true, + response: deps.success({ + permissionSet: clone, + package: landing.packageId, + notice: permissionSetCloneNotice(landing), + }), + }; } catch (err: unknown) { // A validation failure keeps its 400; a save refusal keeps its own // status and code (a precondition conflict 409, a schema refusal 422). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f62ec70cd0..c11f56608b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2544,6 +2544,9 @@ importers: '@objectstack/formula': specifier: workspace:* version: link:../formula + '@objectstack/lint': + specifier: workspace:* + version: link:../lint '@objectstack/metadata': specifier: workspace:* version: link:../metadata From d5d32a0686fe006b03adda4157c6579d2db51d5d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:44:54 +0000 Subject: [PATCH 2/6] feat(runtime): reconcile the new-similar target rule to the shared helper, add the objects limb, ledger note, tests, changeset Claude-Session: https://claude.ai/code/session_01FEkQ5khiLzZmecWFfjSZLc Co-authored-by: Claude --- ...3-permission-set-clone-lands-in-package.md | 23 ++ .../permission-set-clone-door.dogfood.test.ts | 13 + .../runtime/src/domains/new-similar-target.ts | 263 +++++++++++------- .../src/domains/permission-set-clone.test.ts | 185 +++++++++++- .../src/domains/permission-set-clone.ts | 48 ++-- packages/runtime/src/route-ledger.ts | 14 +- 6 files changed, 403 insertions(+), 143 deletions(-) create mode 100644 .changeset/28563-permission-set-clone-lands-in-package.md diff --git a/.changeset/28563-permission-set-clone-lands-in-package.md b/.changeset/28563-permission-set-clone-lands-in-package.md new file mode 100644 index 00000000000..1958e118928 --- /dev/null +++ b/.changeset/28563-permission-set-clone-lands-in-package.md @@ -0,0 +1,23 @@ +--- +'@objectstack/runtime': patch +--- + +feat(runtime)!: the permission-set clone door lands "new similar" in a writable target package — by default the source package's `.custom`, never package-less (#28563) + +Clause-②: yes (narrowing) — `POST /security/permission-sets/:name/clone` takes an optional `package` and its answer names the package the copy landed in (widening); a copy of a set shipped by a read-only package now lands in that package's paired `.custom`, ensured through the pair's one creation path, so a source whose package the ensure door refuses (it declares no namespace, its version is not SemVer, or another package owns the pair's namespace) is refused 409/422 where it was copied package-less before, and a given target that is unknown, read-only or out of reach is refused. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing at the permission-set clone door (`@objectstack/runtime`). + +- **Before.** Every copy was saved environment-wide with no package, whatever package shipped the set it was copied from. The body was `{ name, label }`. +- **Now (ADR-0146 D3/D6).** The body is `{ name, label, package? }` and the answer is `{ permissionSet, package, notice }`. + - `package` omitted, the set shipped by a read-only package X: the copy is saved in X's paired customization package `.custom`, created through the ensure door (`ensureCustomizationPackage`) on the first copy and reused after. The stored row is bound to the pair; the copy's definition still names no package. + - `package` omitted, the set already in a writable package (a base you own, or `.custom`): the copy is saved in that package. + - `package` omitted, the set authored in the environment: unchanged, environment-wide (`package: null`). + - `package` given: it must be installed and writable (else 422 `WRITABLE_PACKAGE_REQUIRED`), and its dependency closure must reach the source's package and every object the set grants on (else 409 `RESOURCE_CONFLICT`). The rule is the one the flow clone door uses. +- **Unchanged.** A new name is mandatory and a taken one is 409; `manage_metadata` and the walled catalog refusal answer first; the copy is assignable by its bare name through a position or a direct assignment. +- **Who sees it.** An administrator copying a packaged set from Setup: the copy now travels with the package's customizations. A copy of a set from a package the ensure door cannot pair is refused with the ensure door's own sentence. + +**The remedy.** For a refused pair: give the base package a namespace (and a SemVer version), or name a writable target with `package`. diff --git a/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts index 50391c40e53..3e9ce0713a7 100644 --- a/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts @@ -221,6 +221,19 @@ describe('the permission-set clone door, on a booted showcase', () => { } }); + it('a platform set (the lock-and-clone rule\'s commonest source) copies into its shipping package\'s pair', async () => { + const reader = securityCatalogReaderOf(ql)!; + const source = await reader.resolve('permission', 'member_default'); + expect(source?.packageId, 'member_default is not packaged on this boot').toBeTruthy(); + + const res = await clone(token, 'member_default', { name: 'my_member_baseline', label: 'My Member Baseline' }); + const text = await res.clone().text(); + expect(res.status, text).toBe(200); + const landed = ((await res.json()) as any).data.package; + expect(landed).toBe(`${source!.packageId}.custom`); + expect((await reader.resolve('permission', 'my_member_baseline'))?.packageId).toBe(landed); + }); + it('CONTROL — an environment-authored source still copies environment-wide, with no package', async () => { const saved = await stack.apiAs(token, 'PUT', '/meta/permission/env_authored_source', { name: 'env_authored_source', label: 'Env Authored', objects: { showcase_task: { allowRead: true } }, diff --git a/packages/runtime/src/domains/new-similar-target.ts b/packages/runtime/src/domains/new-similar-target.ts index eaa4ed7d191..9b2d648be6f 100644 --- a/packages/runtime/src/domains/new-similar-target.ts +++ b/packages/runtime/src/domains/new-similar-target.ts @@ -1,132 +1,185 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [ADR-0146 D3/D6] THE ONE CHECK OF A "NEW SIMILAR" DOOR'S GIVEN TARGET - * PACKAGE — the permission-set clone door (`./permission-set-clone.ts`) and - * the flow clone door ask it; ⛔ no door spells its own. + * [#28149, ADR-0146 D4] THE TARGET RULE of a "new similar" door — the one + * judgement of a package a copy is asked to land in, shared by every door that + * writes a copy of an item into a package (the flow clone door today, the + * permission-set "new similar" door next). ⛔ No door re-spells it. * - * A "new similar" copy is a new item of a package the customer can author - * into. When the caller names that package (`package` in the door's body), - * the target must be: + * ADR-0146 D4: "The door takes a target package: given, it must be an + * installed **writable** package inside whose dependency closure the flow's + * objects lie". Three refusals follow, each thrown before anything is + * written (refuse first, then mutate), each an ADR-0112 error carrying its + * `code` and `status`: * - * 1. **installed and writable** — an id the package registry holds, and - * writable by ADR-0070 D2's one predicate (`isWritablePackage`, - * `@objectstack/metadata-protocol`). Unknown and read-only are one - * refusal, `422 WRITABLE_PACKAGE_REQUIRED`, the save door's own code for - * both: `isWritablePackage` alone answers "writable" for a bare id no - * registry holds (an ADR-0048 authoring workspace), and a copy must land - * in a package that exists, so installation is asked first; - * 2. **able to reach what the copy references** — its dependency closure - * (`resolvePackageClosure`, `@objectstack/lint/runtime`: the one walk the - * save gate and Studio's pickers use) holds the source's package, and, - * when the door names the objects the copy grants on, every one of them - * survives the save gate's own closure predicate - * (`narrowObjectsToPackageClosure`: a platform/system object, an - * unpackaged one, or one owned inside the closure). Otherwise - * `409 RESOURCE_CONFLICT`, naming what is out of reach and the remedy (a - * declared dependency). + * - **Not installed** — `422 WRITABLE_PACKAGE_REQUIRED`, the code the + * metadata save door answers for a save naming a package no registry + * holds (`uninstalledNamedBaseRefusal`, `@objectstack/metadata-protocol`): + * one condition, one code, at both doors. + * - **Read-only** — `422 WRITABLE_PACKAGE_REQUIRED`, ADR-0070 D2's one + * predicate ({@link isWritablePackage}), the code the save door and the + * `/packages` lifecycle routes answer for a write into a code- or + * platform-provided package. + * - **Outside the closure** — `409 RESOURCE_CONFLICT`: the target is + * writable, but its manifest declares no dependency path to the source's + * package, so the copy's references — the source's, which resolve inside + * the source package's closure — need not resolve from the target. The + * packages' declared state rules the target out, as the ensure door's + * `409` says of a base whose state rules its pair out. * - * Pure over the engine's registry: it writes nothing and resolves no default - * target — each door decides its own default (the paired `.custom` - * through N-13's ensure door) and asks this only of a target its caller gave. + * `DUPLICATE_SOURCE_NOT_A_BASE` (`domains/packages.ts`) is the neighbouring + * family, and deliberately not this code: it says the SOURCE of a duplicate + * is not a base, while every refusal here is about the TARGET. The shape is + * that family's — a `[tag]` sentence, `packageId` and the ADR in the details + * ({@link newSimilarTargetRefusalAnswer}). + * + * ## The closure + * + * `resolvePackageClosure` (`@objectstack/lint/runtime`), the ONE walk the + * save door's publish gate (`resolveWritePackageScope`) and Studio's object + * pickers use, over the same registry. A source with no package (an item + * authored in the deployment) asks no closure question here: its references + * are judged by the save into the target, as every save into a package is. + * + * ## The objects a copy names — when the door can read them off the item + * + * [#28563] A door whose item DECLARES the objects it reaches (a permission + * set's `objects`, `fields` and row-level rules) passes them as `objects`, and + * each must survive the save gate's own closure predicate, + * `narrowObjectsToPackageClosure` (`@objectstack/lint/runtime`): a platform / + * system object, an unpackaged one, or one owned by the target or a package in + * its closure. That is what a set copied from an environment-authored source + * (no package, so no closure question above) is judged by, and what catches a + * packaged set that grants on another package's object. An object no registry + * holds is the save's to judge, not this rule's. A door with no honest object + * list (a flow) passes none. + * + * Pure over the registry the `objectql` service carries; it writes nothing. */ -import { narrowObjectsToPackageClosure, resolvePackageClosure } from '@objectstack/lint/runtime'; import { isWritablePackage } from '@objectstack/metadata-protocol'; +import { narrowObjectsToPackageClosure, resolvePackageClosure } from '@objectstack/lint/runtime'; +import type { HttpDispatcherResult } from '../http-dispatcher.js'; +import type { DomainHandlerDeps } from '../domain-handler-registry.js'; -/** The ADR-0146 record the refusals point at. */ -const NEW_SIMILAR_TARGET_DOCS = 'docs/adr/0146-studio-designs-setup-administers-one-editor-per-item-paired-customize-package.md'; +/** The ADR the target rule is written in, named by every refusal it throws. */ +const NEW_SIMILAR_DOCS = 'docs/adr/0146-studio-designs-setup-administers-one-editor-per-item-paired-customize-package.md'; -/** What a door asks about its caller's target. */ -export interface NewSimilarTargetRequest { - /** The package the caller named. */ - readonly target: string; - /** The package that ships the source, when it has one (an environment-authored source has none). */ - readonly sourcePackageId?: string; - /** The source, in words, for the sentences: `permission set 'crm_sales_rep'`, `flow 'x'`. */ - readonly subject: string; - /** - * The objects the copy grants on or reads, when the door can name them - * from the item's own declaration; each must be reachable from the target. - */ - readonly objects?: readonly string[]; -} +/** An ADR-0112 refusal of a target package. */ +export type NewSimilarTargetRefusal = Error & { + code: 'WRITABLE_PACKAGE_REQUIRED' | 'RESOURCE_CONFLICT'; + status: number; + statusCode: number; + /** The target package refused, as `uninstalledNamedBaseRefusal` carries it. */ + packageId: string; + docs: string; +}; -/** The engine surface this check reads — the ObjectQL engine satisfies it as it stands. */ -interface TargetEngine { - manifests?: { has?(id: string): boolean }; - registry?: { - getPackage?(id: string): { manifest?: unknown } | undefined; - getObject?(name: string): unknown; - getObjectOwner?(name: string): { packageId?: string } | undefined; - }; +/** The registry slice the rule reads. */ +interface TargetRegistry { + getPackage?(id: string): { manifest?: unknown } | undefined; + getObject?(name: string): unknown; + getObjectOwner?(name: string): { packageId?: string } | undefined; } -function refusal(status: number, code: string, message: string, packageId: string): Error { - return Object.assign(new Error(message), { - status, - statusCode: status, - code, - details: { code, packageId, docs: NEW_SIMILAR_TARGET_DOCS }, - }); +function refusal( + code: NewSimilarTargetRefusal['code'], + status: number, + message: string, + packageId: string, +): NewSimilarTargetRefusal { + return Object.assign(new Error(message), { code, status, statusCode: status, packageId, docs: NEW_SIMILAR_DOCS }); } /** - * Why `request.target` cannot take the copy, as an ADR-0112 error (status + - * code + `packageId` + `docs`), or `undefined` when it can. + * A door's answer for a {@link NewSimilarTargetRefusal}: its sentence, status + * and code, with `packageId` and `docs` in the envelope's `details` — the + * shape `requireDuplicableSource` and `requireWritablePackage` + * (`domains/packages.ts`) answer, which `errorFromThrown` would drop. */ -export function newSimilarTargetRefusal(engine: unknown, request: NewSimilarTargetRequest): Error | undefined { - const { target, sourcePackageId, subject } = request; - const e = engine as TargetEngine | null | undefined; - const registry = e?.registry; - const getPackage = (id: string) => registry?.getPackage?.call(registry, id); +export function newSimilarTargetRefusalAnswer( + deps: Pick, + refused: NewSimilarTargetRefusal, +): HttpDispatcherResult { + return { + handled: true, + response: deps.error(refused.message, refused.status, { + code: refused.code, + packageId: refused.packageId, + docs: refused.docs, + }), + }; +} - if (!getPackage(target) || !isWritablePackage(engine, target)) { - const installed = getPackage(target) !== undefined; - return refusal( - 422, - 'WRITABLE_PACKAGE_REQUIRED', - `Cannot save the copy of ${subject} in package '${target}': ` - + (installed - ? 'it is read-only (provided by code or an installed app), and a copy is a new item of a package you can author into. ' - : 'no package with that id is installed. ') - + 'Name an installed, writable package, or leave `package` out to save the copy in the paired customization ' - + 'package of the package the original came from (ADR-0146 D3).', - target, - ); +/** + * Why `target` cannot take a copy of `subject`, or `undefined` when it can. + * + * - `ql` — the `objectql` service: its `registry` answers which packages are + * installed, and with its `manifests` it is what {@link isWritablePackage} + * reads. + * - `subject` — the copied item as the sentences name it (`flow 'x'`). + * - `sourcePackageId` — the package the source belongs to, when it belongs + * to one; absent asks no closure question (see the module header). + * - `objects` — the objects the copy names, when the item declares them; each + * must be reachable from the target (see the module header). + */ +export function newSimilarTargetRefusal( + ql: unknown, + request: { target: string; subject: string; sourcePackageId?: string; objects?: readonly string[] }, +): NewSimilarTargetRefusal | undefined { + const { target, subject, sourcePackageId } = request; + const registry = (ql as { registry?: TargetRegistry } | null | undefined)?.registry; + const getPackage = typeof registry?.getPackage === 'function' + ? (id: string) => registry.getPackage!(id) + : undefined; + if (!getPackage?.(target)) { + return refusal('WRITABLE_PACKAGE_REQUIRED', 422, + `[new_similar_target_not_installed] Cannot land a copy of ${subject} in package '${target}': no package ` + + 'with that id is installed in this deployment. Name an installed writable package, or omit `package` ' + + 'to land the copy in the source\'s paired customization package. Nothing was written.', + target); } - - const closure = resolvePackageClosure(target, (id) => getPackage(id)); - const reachable = new Set(closure ? [closure.packageId, ...closure.dependencies] : [target]); - const outOfReach: string[] = []; - if (sourcePackageId !== undefined && !reachable.has(sourcePackageId)) { - outOfReach.push(`the package '${sourcePackageId}' the original comes from`); + if (!isWritablePackage(ql, target)) { + return refusal('WRITABLE_PACKAGE_REQUIRED', 422, + `[new_similar_target_read_only] Cannot land a copy of ${subject} in package '${target}': it is read-only ` + + '(provided by code or an installed app), and a copy is authored into a writable package. Name a ' + + 'writable package, or omit `package` to land the copy in the source\'s paired customization package ' + + '(ADR-0146 D3). Nothing was written.', + target); + } + if (sourcePackageId !== undefined && sourcePackageId !== target) { + const closure = resolvePackageClosure(target, (id) => getPackage(id)); + if (!closure?.dependencies.includes(sourcePackageId)) { + return refusal('RESOURCE_CONFLICT', 409, + `[new_similar_target_outside_closure] Cannot land a copy of ${subject} in package '${target}': ` + + `'${target}' declares no dependency on '${sourcePackageId}', the package ${subject} belongs to, ` + + 'so the copy\'s references to that package\'s items would not resolve from it. Declare the ' + + `dependency in '${target}'s manifest, name a package that depends on '${sourcePackageId}', or ` + + 'omit `package` to land the copy in the source\'s paired customization package. Nothing was ' + + 'written.', + target); + } } - if (request.objects && request.objects.length > 0) { - const entries: Array> = []; + if (request.objects !== undefined && request.objects.length > 0) { + const entries: Array<{ name: string; isSystem?: unknown; _packageId?: string }> = []; for (const name of request.objects) { - const object = registry?.getObject?.call(registry, name) as Record | undefined; - // An object no registry holds is the save's to judge, not this check's. + const object = registry?.getObject?.(name) as { isSystem?: unknown } | undefined; if (!object) continue; - entries.push({ name, isSystem: object.isSystem, _packageId: registry?.getObjectOwner?.call(registry, name)?.packageId }); + entries.push({ name, isSystem: object.isSystem, _packageId: registry?.getObjectOwner?.(name)?.packageId }); } - const kept = new Set(narrowObjectsToPackageClosure(entries, closure)); - const dropped = entries.filter((entry) => !kept.has(entry)); - if (dropped.length > 0) { - outOfReach.push(dropped.length === 1 - ? `object '${String(dropped[0].name)}' (package '${String(dropped[0]._packageId)}')` - : `objects ${dropped.map((d) => `'${String(d.name)}' (package '${String(d._packageId)}')`).join(', ')}`); + const kept = new Set(narrowObjectsToPackageClosure(entries, resolvePackageClosure(target, (id) => getPackage(id)))); + const outside = entries.filter((entry) => !kept.has(entry)); + if (outside.length > 0) { + const named = outside.map((o) => `'${o.name}' (package '${o._packageId}')`).join(', '); + return refusal('RESOURCE_CONFLICT', 409, + `[new_similar_target_outside_closure] Cannot land a copy of ${subject} in package '${target}': it names ` + + `${outside.length === 1 ? 'object' : 'objects'} ${named}, and '${target}' declares no dependency path ` + + `to ${outside.length === 1 ? 'that package' : 'those packages'}, so the copy's references would not ` + + `resolve from it. Declare the dependency in '${target}'s manifest, name a package that already depends ` + + 'on it, or omit `package` to land the copy in the source\'s paired customization package. Nothing ' + + 'was written.', + target); } } - if (outOfReach.length === 0) return undefined; - return refusal( - 409, - 'RESOURCE_CONFLICT', - `Cannot save the copy of ${subject} in package '${target}': it cannot reach ${outOfReach.join(', and ')}. ` - + `A package references only itself, the packages its manifest declares in \`dependencies\` (and theirs), and ` - + `platform objects. Declare the dependency on '${target}', name a package that already declares it, or leave ` - + '`package` out to save the copy in the paired customization package of the package the original came from ' - + '(ADR-0146 D3).', - target, - ); + return undefined; } diff --git a/packages/runtime/src/domains/permission-set-clone.test.ts b/packages/runtime/src/domains/permission-set-clone.test.ts index 88229833470..759f403cbee 100644 --- a/packages/runtime/src/domains/permission-set-clone.test.ts +++ b/packages/runtime/src/domains/permission-set-clone.test.ts @@ -14,7 +14,12 @@ // asserted against the same constant the door applies, so no facet can be // dropped by omission and no dropped key can ride across; // - every refusal answers in the ADR-0112 envelope (status + code) and saves -// nothing. +// nothing; +// - [ADR-0146 D3/D6] WHERE the copy lands: a packaged source with no target +// lands in `.custom` through the ensure door, bound by the save's own +// `packageId` and never a body key; a source in a writable package lands +// there; a given target is judged by the shared new-similar check; an +// environment-authored source is unchanged. import { describe, it, expect } from 'vitest'; @@ -26,6 +31,8 @@ import { PERMISSION_SET_CLONE_MUTATED_FIELDS, PERMISSION_SET_CLONE_NOTICE, clonePermissionSetDefinition, + permissionSetCloneNotice, + permissionSetObjectNames, } from './permission-set-clone.js'; /** A packaged set carrying every facet AND every key the server drops. */ @@ -49,10 +56,30 @@ const PACKAGED = { _lock: 'full', }; const ENV_SET = { name: 'env_reader', label: 'Env Reader', objects: { account: { allowRead: true } } }; +/** A set already in a WRITABLE package (a base the customer owns). */ +const OWNED_SET = { name: 'own_reader', label: 'Own Reader', objects: { account: { allowRead: true } }, _packageId: 'com.acme.base' }; + +/** An environment-authored set that grants on a platform object only. */ +const SYS_ONLY_SET = { name: 'sys_only', label: 'Sys Only', objects: { sys_user: { allowRead: true } } }; + +const CATALOG: TestSecurityCatalog = { permissions: [PACKAGED, ENV_SET, OWNED_SET, SYS_ONLY_SET] }; + +/** + * The package registry the door reads: `crm` is a booted CODE package + * (read-only, it owns `account`), `crm.custom` its pair once ensured, + * `com.acme.base` a writable base, `com.acme.ext` a writable package that + * declares `crm`, `com.acme.island` a writable package that declares nothing, + * `com.vendor.app` an installed read-only app (`scope: 'cloud'`). + */ +const PACKAGES: Record }> = { + crm: { manifest: { id: 'crm', namespace: 'crm', version: '1.2.0' } }, + 'com.acme.base': { manifest: { id: 'com.acme.base' } }, + 'com.acme.ext': { manifest: { id: 'com.acme.ext', dependencies: { crm: '^1' } } }, + 'com.acme.island': { manifest: { id: 'com.acme.island' } }, + 'com.vendor.app': { manifest: { id: 'com.vendor.app', scope: 'cloud' } }, +}; -const CATALOG: TestSecurityCatalog = { permissions: [PACKAGED, ENV_SET] }; - -interface Saved { type: string; name: string; item: Record; parentVersion?: string | null } +interface Saved { type: string; name: string; item: Record; parentVersion?: string | null; packageId?: string } interface BootOptions { unbound?: boolean; @@ -61,13 +88,23 @@ interface BootOptions { catalogError?: Error; /** The `tenancy` service's posture; absent ⇒ no `tenancy` service (the `single` shape). */ posture?: 'single' | 'isolated' | 'group'; + /** What the ensure door throws, when it refuses. */ + ensureError?: Error; + /** The protocol has no `ensureCustomizationPackage`. */ + noEnsure?: boolean; } function boot(opts: BootOptions = {}) { // ZERO `sys_permission_set` rows: every data read throws, so the door // passing proves it reads no row. + const packages: Record }> = structuredClone(PACKAGES); const engine: any = { - registry: { getObject: () => undefined }, + manifests: new Set(['crm']), + registry: { + getPackage: (id: string) => packages[id], + getObject: (name: string) => (name === 'account' ? { name: 'account' } : name === 'sys_user' ? { name: 'sys_user' } : undefined), + getObjectOwner: (name: string) => (name === 'account' ? { packageId: 'crm' } : name === 'sys_user' ? { packageId: 'com.objectstack.platform' } : undefined), + }, find: async (object: string) => { throw new Error(`the clone door read a ${object} row`); }, }; if (opts.catalogError) { @@ -76,13 +113,27 @@ function boot(opts: BootOptions = {}) { bindTestSecurityCatalog(engine, CATALOG); } const saves: Saved[] = []; - const protocol = { + const ensures: string[] = []; + // The ensure door's contract, modeled: the pair `.custom`, created + // (and registered as a writable package depending on the base) when + // absent, returned unchanged when present. + const protocol: Record = { async saveMetaItem(request: Saved) { if (opts.saveError) throw opts.saveError; saves.push(structuredClone(request)); return { success: true }; }, }; + if (!opts.noEnsure) { + protocol.ensureCustomizationPackage = async ({ basePackageId }: { basePackageId: string }) => { + ensures.push(basePackageId); + if (opts.ensureError) throw opts.ensureError; + const pairId = `${basePackageId}.custom`; + const created = packages[pairId] === undefined; + if (created) packages[pairId] = { manifest: { id: pairId, dependencies: { [basePackageId]: '^1' } } }; + return { basePackageId, packageId: pairId, namespace: 'crm_custom', dependencyRange: '^1', created, writable: true }; + }; + } const services: Record = { objectql: engine, data: engine }; if (!opts.noProtocol) services.protocol = protocol; if (opts.posture) services.tenancy = { posture: opts.posture }; @@ -92,7 +143,7 @@ function boot(opts: BootOptions = {}) { getServiceAsync: async (name: string) => resolve(name), context: { getService: resolve }, }; - return { dispatcher: new HttpDispatcher(kernel as never), saves }; + return { dispatcher: new HttpDispatcher(kernel as never), saves, ensures }; } const ctx = (executionContext: Record): HttpProtocolContext => @@ -108,7 +159,7 @@ const clone = (h: ReturnType, c: HttpProtocolContext, source: strin h.dispatcher.handleSecurity(`/permission-sets/${source}/clone`, 'POST', body, {}, c); describe('the permission-set clone door — success', () => { - it('copies the whole definition under the new name, drops the declared keys, and saves create-only as an environment item', async () => { + it('copies the whole definition under the new name, drops the declared keys, and saves create-only in the source package\'s pair', async () => { const h = boot(); const res = await clone(h, HOLDER(), PACKAGED.name, { name: 'my_sales_rep', label: 'My Sales Rep' }); @@ -120,8 +171,11 @@ describe('the permission-set clone door — success', () => { expect(save.name).toBe('my_sales_rep'); // Create-only: a row written under the name in between is refused. expect(save.parentVersion).toBeNull(); - // No organization and no package on the save. - expect(Object.keys(save).sort()).toEqual(['item', 'name', 'parentVersion', 'type']); + // [ADR-0146 D3/D6] No organization; the package is the SAVE's — the + // read-only source's pair, ensured through the ensure door. + expect(Object.keys(save).sort()).toEqual(['item', 'name', 'packageId', 'parentVersion', 'type']); + expect(save.packageId).toBe('crm.custom'); + expect(h.ensures).toEqual(['crm']); // Whole-definition copy: the source minus the declared keys, the two // mutated fields set. Deep equality, so a dropped facet fails here. @@ -134,19 +188,79 @@ describe('the permission-set clone door — success', () => { expect(save.item, key).not.toHaveProperty(key); } - // ⛔ No ancestry in the answer. + // ⛔ No ancestry in the answer; the landing package is named. const data = (res as any).response.body.data; - expect(data).toEqual({ permissionSet: save.item, notice: PERMISSION_SET_CLONE_NOTICE }); + expect(data).toEqual({ + permissionSet: save.item, + package: 'crm.custom', + notice: permissionSetCloneNotice({ packageId: 'crm.custom', created: true }), + }); + expect(data.notice).toContain("'crm.custom'"); + expect(data.notice.endsWith(PERMISSION_SET_CLONE_NOTICE)).toBe(true); expect(JSON.stringify(data.permissionSet)).not.toContain(PACKAGED.name); }); - it('clones an environment-authored set the same way', async () => { + it('a second copy of the same packaged source reuses the pair the first created', async () => { + const h = boot(); + + await clone(h, HOLDER(), PACKAGED.name, { name: 'first_copy', label: 'First' }); + const second = await clone(h, HOLDER(), PACKAGED.name, { name: 'second_copy', label: 'Second' }); + + expect(statusOf(second)).toBe(200); + expect(h.saves.map((s) => s.packageId)).toEqual(['crm.custom', 'crm.custom']); + expect((second as any).response.body.data.notice).toBe(permissionSetCloneNotice({ packageId: 'crm.custom', created: false })); + }); + + it('CONTROL — an environment-authored set copies environment-wide, as before: no package, no ensure', async () => { const h = boot(); const res = await clone(h, HOLDER(), ENV_SET.name, { name: 'env_reader_copy', label: 'Env Reader Copy' }); expect(statusOf(res)).toBe(200); expect(h.saves[0].item).toEqual({ ...ENV_SET, name: 'env_reader_copy', label: 'Env Reader Copy' }); + expect(Object.keys(h.saves[0]).sort()).toEqual(['item', 'name', 'parentVersion', 'type']); + expect(h.ensures).toEqual([]); + expect((res as any).response.body.data.package).toBeNull(); + }); + + it('a set already in a writable package copies into that package, with no ensure', async () => { + const h = boot(); + + const res = await clone(h, HOLDER(), OWNED_SET.name, { name: 'own_reader_copy', label: 'Own Reader Copy' }); + + expect(statusOf(res)).toBe(200); + expect(h.saves[0].packageId).toBe('com.acme.base'); + expect(h.saves[0].item).not.toHaveProperty('_packageId'); + expect(h.ensures).toEqual([]); + }); + + it('a given writable target that reaches the set lands there, with no ensure', async () => { + const h = boot(); + + const res = await clone(h, HOLDER(), PACKAGED.name, { name: 'ext_copy', label: 'Ext Copy', package: 'com.acme.ext' }); + + expect(statusOf(res)).toBe(200); + expect(h.saves[0].packageId).toBe('com.acme.ext'); + expect(h.saves[0].item).not.toHaveProperty('packageId'); + expect((res as any).response.body.data.package).toBe('com.acme.ext'); + expect(h.ensures).toEqual([]); + }); + + it('an environment-authored set given a target lands there when the target reaches its objects', async () => { + const h = boot(); + + const res = await clone(h, HOLDER(), ENV_SET.name, { name: 'env_in_ext', label: 'X', package: 'com.acme.ext' }); + + expect(statusOf(res)).toBe(200); + expect(h.saves[0].packageId).toBe('com.acme.ext'); + }); + + it('names every object a set grants on: objects, fields and row-level rules', () => { + expect(permissionSetObjectNames(PACKAGED).sort()).toEqual(['account']); + expect(permissionSetObjectNames({ + objects: { a: {} }, fields: { 'b.x': {}, 'a.y': {} }, rowLevelSecurity: [{ object: 'c' }, { object: '' }, null], + }).sort()).toEqual(['a', 'b', 'c']); + expect(permissionSetObjectNames({})).toEqual([]); }); it('the copy shares no object with the catalog definition it was read from', () => { @@ -283,6 +397,49 @@ describe('the permission-set clone door — refusals', () => { expect(message).toBe(String((held as any).response.body.error.message).replaceAll(ENV_SET.name, 'x_copy')); }); + it('a given target that is unknown or read-only is 422 WRITABLE_PACKAGE_REQUIRED, naming the package, with no ensure and no save', async () => { + for (const target of ['com.acme.nowhere', 'crm', 'com.vendor.app']) { + const res = await refusal({}, PACKAGED.name, { name: 'x_copy', label: 'X', package: target }, 422, 'WRITABLE_PACKAGE_REQUIRED'); + expect((res as any).response.body.error.details?.packageId ?? (res as any).response.body.error.packageId, target).toBe(target); + } + }); + + it('a given writable target that cannot reach the source\'s package is 409, with no save', async () => { + const res = await refusal({}, PACKAGED.name, { name: 'x_copy', label: 'X', package: 'com.acme.island' }, 409, 'RESOURCE_CONFLICT'); + expect(String((res as any).response.body.error.message)).toContain("'crm'"); + }); + + it('a given target that cannot reach an object the set grants on is 409, even for an environment-authored source', async () => { + // ENV_SET has no package — so no source-package question — but grants on + // `account`, which `crm` owns and `com.acme.island` does not declare. + const res = await refusal({}, ENV_SET.name, { name: 'x_copy', label: 'X', package: 'com.acme.island' }, 409, 'RESOURCE_CONFLICT'); + expect(String((res as any).response.body.error.message)).toContain("'account' (package 'crm')"); + }); + + it('CONTROL — a platform object is reachable from any target, as at the save gate', async () => { + const h = boot(); + const res = await clone(h, HOLDER(), SYS_ONLY_SET.name, { name: 'sys_only_copy', label: 'X', package: 'com.acme.island' }); + expect(statusOf(res), JSON.stringify((res as any)?.response?.body)).toBe(200); + expect(h.saves[0].packageId).toBe('com.acme.island'); + }); + + it('an empty or non-string `package` is 400, never read as omitted', async () => { + await refusal({}, PACKAGED.name, { name: 'x_copy', label: 'X', package: '' }, 400, 'VALIDATION_FAILED'); + await refusal({}, PACKAGED.name, { name: 'x_copy', label: 'X', package: 7 }, 400, 'VALIDATION_FAILED'); + }); + + it('a pair the ensure door refuses keeps the ensure door\'s status and code, and nothing is saved', async () => { + const refused = Object.assign(new Error("Package 'crm' declares no namespace"), { statusCode: 409, code: 'RESOURCE_CONFLICT' }); + await refusal({ ensureError: refused }, PACKAGED.name, { name: 'x_copy', label: 'X' }, 409, 'RESOURCE_CONFLICT'); + }); + + it('a packaged source with no ensure door to call is 501 — never a package-less save', async () => { + const h = boot({ noEnsure: true }); + const res = await clone(h, HOLDER(), PACKAGED.name, { name: 'x_copy', label: 'X' }); + expect(statusOf(res)).toBe(501); + expect(h.saves).toEqual([]); + }); + it('no metadata protocol to save through is 501', async () => { const h = boot({ noProtocol: true }); const res = await clone(h, HOLDER(), PACKAGED.name, { name: 'x_copy', label: 'X' }); @@ -305,6 +462,8 @@ describe('the permission-set clone door — the walled catalog refusal (ADR-0131 expect(codeOf(res)).toBe('PERMISSION_DENIED'); expect(String((res as any)?.response?.body?.error?.message)).toContain(`'${posture}' tenancy posture`); expect(h.saves).toHaveLength(0); + // The walled refusal answers before the pair is ensured. + expect(h.ensures).toEqual([]); }); it(`${posture}: CONTROL — the platform operator rung clones`, async () => { diff --git a/packages/runtime/src/domains/permission-set-clone.ts b/packages/runtime/src/domains/permission-set-clone.ts index c57e927a467..12aec39a5a0 100644 --- a/packages/runtime/src/domains/permission-set-clone.ts +++ b/packages/runtime/src/domains/permission-set-clone.ts @@ -67,7 +67,7 @@ * ## Where the copy lands — ADR-0146 D3/D6 ("new similar") * * A customer's own set is a NEW set in the package that holds their changes - * (ADR-0146 D6), so the door decides one package, {@link resolveCloneLanding}: + * (ADR-0146 D6), so the door decides one package: * * - **`package` given** — it must be an installed, writable package whose * dependency closure reaches the source's package and every object the set @@ -110,7 +110,7 @@ import { metaWriteCapabilityVerdict, walledCatalogWriteVerdict } from '@objectst import { readEffectiveTenancyPosture } from './activation-gate.js'; import { securityCatalogReaderOf, type SecurityCatalogEntry } from '@objectstack/core'; import { isWritablePackage, type ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; -import { newSimilarTargetRefusal } from './new-similar-target.js'; +import { newSimilarTargetRefusal, newSimilarTargetRefusalAnswer } from './new-similar-target.js'; import { validationFailure } from '../validation-failure.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps } from '../domain-handler-registry.js'; @@ -150,7 +150,7 @@ export const PERMISSION_SET_CLONE_NOTICE = + 'The copy is held by nobody yet: add it to a position, or assign it to users.'; /** - * Where a clone landed, as the door decided it ({@link resolveCloneLanding}): + * Where a clone landed, as the door decided it (module doc, "Where the copy lands"): * the package, or `null` for an environment-wide save, and whether the door * created that package for this copy (only ever the paired `.custom`). */ @@ -239,26 +239,17 @@ export function permissionSetObjectNames(definition: Readonly>, source: SecurityCatalogEntry, - requested: string | undefined, ): Promise { - if (requested !== undefined) { - const refusal = newSimilarTargetRefusal(ql, { - target: requested, - sourcePackageId: source.packageId, - subject: `permission set '${source.name}'`, - objects: permissionSetObjectNames(source.definition), - }); - if (refusal) throw refusal; - return { packageId: requested, created: false }; - } const sourcePackageId = source.packageId; if (sourcePackageId === undefined) return { packageId: null, created: false }; if (isWritablePackage(ql, sourcePackageId)) return { packageId: sourcePackageId, created: false }; @@ -358,8 +349,9 @@ function readCloneBody(body: unknown): { name: string; label: string; package?: * `sys_permission_set` row is read. No reader bound, or one that cannot * answer ⇒ 503; unknown ⇒ 404. * 5. **Target**: a name the catalog holds ⇒ 409. - * 6. **Landing**: the package the copy is saved in ({@link resolveCloneLanding}): - * a given target is judged (refused with nothing written), an omitted one + * 6. **Landing**: the package the copy is saved in: a given target is judged + * by the shared rule (refused with nothing written), an omitted one + * ({@link resolveDefaultCloneLanding}) * follows the source — `.custom` through the ensure door for a * read-only X, the source's own writable package, or environment-wide. * 7. **The save**, through the metadata protocol's `saveMetaItem`, create-only @@ -475,7 +467,21 @@ export async function handlePermissionSetCloneWrite( }; } // ── 6. landing ────────────────────────────────────────────────────── - const landing = await resolveCloneLanding(ql, protocol, source, target.package); + let landing: PermissionSetCloneLanding; + if (target.package !== undefined) { + // The shared "new similar" target rule, asked of the objects the + // set grants on as well as of the source's package. + const refused = newSimilarTargetRefusal(ql, { + target: target.package, + subject: `permission set '${source.name}'`, + sourcePackageId: source.packageId, + objects: permissionSetObjectNames(source.definition), + }); + if (refused) return newSimilarTargetRefusalAnswer(deps, refused); + landing = { packageId: target.package, created: false }; + } else { + landing = await resolveDefaultCloneLanding(ql, protocol, source); + } // ── 7. the save ───────────────────────────────────────────────────── // [ADR-0131 D6] No organization: the catalog is environment-level. The diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index fbd7d85db2d..82946ff1494 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -427,15 +427,21 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'POST /security/permission-sets/:name/clone', domain: '/security', disposition: 'server-only', note: "ADR-0126 §7.1 — copy a permission set's whole definition under a NEW machine name: the sanctioned way to customize a " + "packaged set whose base is locked (the lock-and-clone rule), and the clone that keeps working once no `sys_permission_set` " - + "row exists. Body `{ name, label }`, both mandatory, closed; the name must meet the metadata name rule (snake_case), else 400. " + + "row exists. Body `{ name, label, package? }`, `name` and `label` mandatory, closed; the name must meet the metadata name rule (snake_case), else 400. " + "The SOURCE is read through the security catalog the resolver reads (registry first, then the metadata service) — ⛔ never a " + "`sys_permission_set` row; unknown 404, no catalog bound or an unreadable one 503. A target name the catalog holds (the " + "source's own included) answers 409 `RESOURCE_CONFLICT`, and the save is create-only (`parentVersion: null`), so a row " - + "written in between is refused rather than overwritten. The copy is saved through the metadata protocol's `saveMetaItem` as an " - + "environment-authored `permission` item — no organization, no package — so `/meta`'s schema, gates and hooks judge it. " + + "written in between is refused rather than overwritten. The copy is saved through the metadata protocol's `saveMetaItem` as a " + + "`permission` item with no organization, so `/meta`'s schema, gates and hooks judge it, in ONE package (ADR-0146 D3/D6, \"new similar\"): " + + "a given `package` must be installed and writable (else 422 `WRITABLE_PACKAGE_REQUIRED`) and reach the source's package and every " + + "object the set grants on (else 409 `RESOURCE_CONFLICT`) — the rule the flow clone door shares, `domains/new-similar-target.ts`; " + + "omitted, a source shipped by a read-only package X lands in `.custom` through the ensure door's body " + + "(`ensureCustomizationPackage`, the pair's only creation path), a source in a writable package lands there, and an " + + "environment-authored source stays environment-wide. The package is the save's own `packageId`, never a key of the item. " + "Whole-definition copy minus ONE server-declared list, `PERMISSION_SET_CLONE_DROPPED_KEYS` (`domains/permission-set-clone.ts`): " + "the provenance envelope (derived from the spec), `packageId` / `managedBy` / `protection`, and the two privilege decisions " - + "`adminScope` and `isDefault`; the response `{ permissionSet, notice }` names what was not copied. ⛔ No ancestry on the " + + "`adminScope` and `isDefault`; the response `{ permissionSet, package, notice }` names the landing package (`null` " + + "environment-wide) and what was not copied. ⛔ No ancestry on the " + "definition or the response (ADR-0126 §9). ⚑ `manage_metadata` — the metadata door's own verdict " + "(`metaWriteCapabilityVerdict`), because the clone is a metadata save; fail-closed, only `isSystem` bypassing; the domain-wide " + "anonymous floor answers 401 first. ⛔ NOT the ADR-0126 §5 activation gate: a clone takes nothing away from any tenant. NOT " From 913aa6b10ac17fd2456eb8fbf9dca64b3a3988f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:46:19 +0000 Subject: [PATCH 3/6] test(runtime): pin the new-similar source-package limb on its own Claude-Session: https://claude.ai/code/session_01FEkQ5khiLzZmecWFfjSZLc Co-authored-by: Claude --- packages/runtime/src/domains/permission-set-clone.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/domains/permission-set-clone.test.ts b/packages/runtime/src/domains/permission-set-clone.test.ts index 759f403cbee..3e8dbc4514b 100644 --- a/packages/runtime/src/domains/permission-set-clone.test.ts +++ b/packages/runtime/src/domains/permission-set-clone.test.ts @@ -62,7 +62,10 @@ const OWNED_SET = { name: 'own_reader', label: 'Own Reader', objects: { account: /** An environment-authored set that grants on a platform object only. */ const SYS_ONLY_SET = { name: 'sys_only', label: 'Sys Only', objects: { sys_user: { allowRead: true } } }; -const CATALOG: TestSecurityCatalog = { permissions: [PACKAGED, ENV_SET, OWNED_SET, SYS_ONLY_SET] }; +/** A set `crm` ships that grants on a platform object only: its package is the one thing out of an island's reach. */ +const CRM_SYS_SET = { name: 'crm_user_reader', label: 'CRM User Reader', objects: { sys_user: { allowRead: true } }, _packageId: 'crm' }; + +const CATALOG: TestSecurityCatalog = { permissions: [PACKAGED, ENV_SET, OWNED_SET, SYS_ONLY_SET, CRM_SYS_SET] }; /** * The package registry the door reads: `crm` is a booted CODE package @@ -407,6 +410,9 @@ describe('the permission-set clone door — refusals', () => { it('a given writable target that cannot reach the source\'s package is 409, with no save', async () => { const res = await refusal({}, PACKAGED.name, { name: 'x_copy', label: 'X', package: 'com.acme.island' }, 409, 'RESOURCE_CONFLICT'); expect(String((res as any).response.body.error.message)).toContain("'crm'"); + // The source-package limb alone: every object this set grants on is a + // platform object, so only its package is out of the island's reach. + await refusal({}, CRM_SYS_SET.name, { name: 'y_copy', label: 'Y', package: 'com.acme.island' }, 409, 'RESOURCE_CONFLICT'); }); it('a given target that cannot reach an object the set grants on is 409, even for an environment-authored source', async () => { From 76b93c1809c56e054a2de6830a8d640c6280fba0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 08:04:46 +0000 Subject: [PATCH 4/6] chore(gates): declare the new-similar target rule in the route-envelope dispatcher-domain roster Claude-Session: https://claude.ai/code/session_01FEkQ5khiLzZmecWFfjSZLc Co-authored-by: Claude --- scripts/check-route-envelope.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 5bfbb6b4900..8e8d8e7c225 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -1039,6 +1039,9 @@ const DISPATCHER_DOMAINS = { handBuilt: 2, note: 'the translations 200, which must carry `ETag` and `Cache-Control: private, no-cache` (`deps.success` takes none) and whose body is `deps.success`\'s envelope spread; and the body-less 304 a matching `If-None-Match` is answered, carrying the same two headers', }, + // The "new similar" target rule both clone doors share (ADR-0146 D3/D6): + // its one answer, `newSimilarTargetRefusalAnswer`, goes through `deps.error`. + 'new-similar-target.ts': { handBuilt: 0 }, 'notifications.ts': { handBuilt: 0 }, 'packages.ts': { handBuilt: 0 }, // The `/security` domain's permission-set clone door (ADR-0126 §7.1), beside From 7fd5d2f151fcdd3750977fcebc01584c67f3b4c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 08:06:15 +0000 Subject: [PATCH 5/6] docs(permissions): the clone door's package target and landing Claude-Session: https://claude.ai/code/session_01FEkQ5khiLzZmecWFfjSZLc Co-authored-by: Claude --- content/docs/concepts/metadata-lifecycle.mdx | 2 +- content/docs/permissions/permission-sets.mdx | 27 +++++++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index ffbd75d5aa2..4e613377cf4 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -148,7 +148,7 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/ | :--- | :--- | | `flow` | Clone it under a new name (`POST /api/v1/automation/:name/clone`, body `{name, label}`), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). A flow that holds a credential (an inbound hook's `secret`, an `http` node's `signingSecret`) is not cloned in one step: the clone door refuses it with `409`, because a copy would share the secret, and the copy is authored as a new flow with its own. | | `action` | Switch it off (`POST /api/v1/actions/_activation/:object/:action`, body `{enabled: false}`; `:object` is `global` for an object-less action). No clone is offered. | -| `permission` | Clone it under a new name: the "Clone" action on the permission set, or the clone door `POST /api/v1/security/permission-sets/:name/clone` (body `{name, label}`) with a new name. The clone door copies the whole definition from the security catalog. | +| `permission` | Clone it under a new name: the "Clone" action on the permission set, or the clone door `POST /api/v1/security/permission-sets/:name/clone` (body `{name, label, package?}`) with a new name. The clone door copies the whole definition from the security catalog; a copy of a packaged set lands in the package's paired `.custom` (ADR-0146 D3/D6). | A permission set and a position also have a switch, outside the refusal: `POST /api/v1/security/_activation/:type/:name` (`:type` = `permission` or `position`), body `{enabled: false}`, switches one off for the deployment. The refusal does not name it: a packaged position's refusal keeps the managed seal's edit remedy. The audience anchors `everyone` and `guest` are not switchable. diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 12f113b3813..617f392ac63 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -425,16 +425,29 @@ package and re-publish it (ADR-0086 two-doors). ### Cloning a permission set (ADR-0126 §7.1) ```text -POST /api/v1/security/permission-sets/:name/clone body: { "name": "...", "label": "..." } +POST /api/v1/security/permission-sets/:name/clone body: { "name": "...", "label": "...", "package"?: "..." } ``` The clone door copies a set's whole definition under a new machine name. It reads the source from the security catalog, where every packaged and environment-authored set lives. It saves the copy through the metadata layer -as a set authored in this system: no package, and no link back to the set -it was copied from, so changes to the source never reach the copy. - -- **The body is `{ name, label }`, both required, and nothing else.** `name` is +as a **new** set beside the original, with no link back to it, so changes to +the source never reach the copy. + +- **Where the copy lands (ADR-0146 D3/D6).** With no `package`, a copy of a + set shipped by a read-only package X is saved in X's paired customization + package `.custom`, the package that holds everything you changed about X. + The pair is created on the first copy, by the same ensure door as + `POST /api/v1/packages/:id/ensure-customization` (its only creation path), + and reused after. A copy of a set already in a writable package is + saved in that package, and a copy of a set authored in this system stays + environment-wide, in no package. With `package`, the copy is saved there: it + must be an installed, writable package (else `422` + `WRITABLE_PACKAGE_REQUIRED`) whose declared dependencies reach the source's + package and every object the set grants on (else `409` `RESOURCE_CONFLICT`). + The response's `package` names where the copy went (`null` for + environment-wide), and the copy is assigned by its bare name like any set. +- **The body is `{ name, label, package? }`, `name` and `label` required, and nothing else.** `name` is a new snake_case machine name: a name failing that rule answers `400`, and a name any permission set already uses (the source's own included) answers `409` `RESOURCE_CONFLICT`. An unknown source answers `404`. @@ -444,8 +457,8 @@ it was copied from, so changes to the source never reach the copy. (`packageId`, `protection` and the internal protection envelope), and two privilege decisions — `adminScope` (delegated administration) and `isDefault` (the `everyone` suggestion). Grant either deliberately on the copy - if it needs one. The response, `{ permissionSet, notice }`, says what was not - copied. + if it needs one. The response, `{ permissionSet, package, notice }`, says + where the copy went and what was not copied. - **The copy is held by nobody** until you add it to a position or assign it. - **Who may call it** is who may save a permission set through the metadata API: a holder of `manage_metadata`. Anyone else is refused `403` From 40a37314195729e9336ee7247401a7e90e903570 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 09:13:38 +0000 Subject: [PATCH 6/6] fix(runtime): the new-similar objects limb hands the registered object through whole, and runtime tests alias @objectstack/lint/runtime to source Claude-Session: https://claude.ai/code/session_01FEkQ5khiLzZmecWFfjSZLc Co-authored-by: Claude --- packages/runtime/src/domains/new-similar-target.ts | 10 ++++++---- packages/runtime/vitest.config.ts | 4 ++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/domains/new-similar-target.ts b/packages/runtime/src/domains/new-similar-target.ts index 9b2d648be6f..34f68366568 100644 --- a/packages/runtime/src/domains/new-similar-target.ts +++ b/packages/runtime/src/domains/new-similar-target.ts @@ -161,11 +161,13 @@ export function newSimilarTargetRefusal( } } if (request.objects !== undefined && request.objects.length > 0) { - const entries: Array<{ name: string; isSystem?: unknown; _packageId?: string }> = []; + // Each entry is the registered object as the save gate's predicate + // reads it, with its owner stamped the way `getAllObjects` tags one. + const entries: Array<{ name: string; _packageId?: string }> = []; for (const name of request.objects) { - const object = registry?.getObject?.(name) as { isSystem?: unknown } | undefined; - if (!object) continue; - entries.push({ name, isSystem: object.isSystem, _packageId: registry?.getObjectOwner?.(name)?.packageId }); + const object = registry?.getObject?.(name); + if (!object || typeof object !== 'object') continue; + entries.push({ ...object, name, _packageId: registry?.getObjectOwner?.(name)?.packageId }); } const kept = new Set(narrowObjectsToPackageClosure(entries, resolvePackageClosure(target, (id) => getPackage(id)))); const outside = entries.filter((entry) => !kept.has(entry)); diff --git a/packages/runtime/vitest.config.ts b/packages/runtime/vitest.config.ts index f48e8a2a312..3a9d0d59856 100644 --- a/packages/runtime/vitest.config.ts +++ b/packages/runtime/vitest.config.ts @@ -148,6 +148,10 @@ export default defineConfig({ replacement: path.resolve(__dirname, '../platform-objects/src/index.ts'), }, { find: '@objectstack/rest', replacement: path.resolve(__dirname, '../rest/src/index.ts') }, + // The clone door's target rule (`domains/new-similar-target.ts`) walks the + // dependency closure with `resolvePackageClosure`, the one walk the save + // door's publish gate uses; its tests judge the source, never `dist/`. + { find: /^@objectstack\/lint\/runtime$/, replacement: path.resolve(__dirname, '../lint/src/runtime.ts') }, // #9457: ONE anchored rule for every `@objectstack/spec` namespace, in // place of the hand-maintained list of the twelve subpaths tests happened // to reach on the day it was written. That list had gone stale in three