Skip to content

Commit 6e11a92

Browse files
feat(runtime)!: the permission-set clone door lands "new similar" in a writable target package, by default the source package's .custom (#28563) (#28636)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XGSvgWmGsEvSht2WRwVcj7u)_ Fixes #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 `<X>.custom`, ensured through the pair's one creation path, so a source whose package the ensure door refuses (no namespace, a version that is not SemVer, or the pair's namespace owned by another package) is refused 409/422 where it was copied package-less before; a given target that is unknown, read-only or out of reach is refused. Metadata-protocol: none Skills-docs: updated — `content/docs/permissions/permission-sets.mdx` (the clone section: the `package` key, where a copy lands, the two refusals, the answer's `package`) and the `permission` row of `content/docs/concepts/metadata-lifecycle.mdx`; no page under `skills/**` names the door. **BREAKING** accept-set narrowing at the permission-set clone door (`@objectstack/runtime`). ## 维护者速读 - **改了什么**:权限集「复制」接口(`POST /security/permission-sets/:name/clone`)现在把副本放进一个可写的软件包:不指定 `package` 时,复制某个只读包 X 发布的权限集,副本落在 X 的配对定制包 `X.custom`(第一次复制时经 N-13 的 ensure 门创建,之后复用);源权限集本就在可写包里,就落在那个包;环境里自建的权限集照旧不进任何包。指定 `package` 时,它必须已安装、可写,且其依赖闭包够得着源包和该权限集授权的每个对象,否则拒收(422 / 409),什么都不写。答复多一个 `package` 字段,说明落在哪。 - **为什么改**:卡 #28563(epic #28236 的 N-20a,维护者「方向定为包级覆盖:代码和 Studio 用同一机制,定制都进 .custom。」):以前副本不属于任何包,客户对 HotCRM 的改动导出时会漏掉它。 - **风险与回滚**:收窄只有一种:源包本身无法配对(没有 namespace、版本不是 SemVer、配对 namespace 已被别的包占用)时,以前能复制成「无包」副本,现在按 ensure 门原话拒收。showcase 与平台自带权限集(`member_default` 等)实测都能正常配对落地。回滚即 revert。 - **先看**:`packages/runtime/src/domains/permission-set-clone.ts`(第 6 步落点与保存时的 `packageId`);`packages/runtime/src/domains/new-similar-target.ts`(与 #28149 共用的目标包规则,本 PR 加了对象闭包这一条);`packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/dogfood exec vitest run test/permission-set-clone-door.dogfood.test.ts`(先按依赖闭包 build)→ 11/11 通过,其中第一条断言副本行的 `package_id` 是 `com.example.showcase.custom`。 - **需要你做的**:无,席位落地(收窄需先过合约评审)。 ## What changes **The door** (`packages/runtime/src/domains/permission-set-clone.ts`). - Body `{ name, label, package? }`, still closed. An empty or non-string `package` is 400 `VALIDATION_FAILED` on field `package`, never read as omitted. - New step 6, landing, after the name checks and before the save: - `package` given: the shared rule `newSimilarTargetRefusal` (below) judges it, asked of the source's package and of every object the set grants on (`permissionSetObjectNames`: the `objects` keys, the object half of each `fields` key, each row-level rule's `object`). A refusal answers through `newSimilarTargetRefusalAnswer`, so `packageId` and `docs` reach the envelope's details. - omitted, source shipped by a read-only package X: `protocol.ensureCustomizationPackage({ basePackageId: X })`, N-13's body, the pair's only creation path (⛔ no second one). Its refusals keep their own status and code. A protocol with no ensure verb answers 501, never a package-less save. - omitted, source in a writable package: that package (the ensure door refuses a writable base, whose changes go into it directly). - omitted, environment-authored source: environment-wide, unchanged. - The save passes the landing as `saveMetaItem`'s own `packageId`. The item still drops `packageId` (`PERMISSION_SET_CLONE_DROPPED_KEYS` is unchanged), so `permissionSetOwnerBindingRefusal` never sees a body key disagreeing with the row. - The answer is `{ permissionSet, package, notice }`; `package` is `null` environment-wide. `permissionSetCloneNotice(landing)` puts the landing sentence ahead of the unchanged `PERMISSION_SET_CLONE_NOTICE`. - Order kept: `manage_metadata`, then the walled catalog refusal, both before any read and before the pair is ensured, so a tenant administrator under `isolated` / `group` gets the walled sentence and no pair is created. **The shared target rule** (`packages/runtime/src/domains/new-similar-target.ts`). Byte-identical to #28149's file at `claude/project-thread-sdqbrt@97489b2` (unknown or read-only → 422 `WRITABLE_PACKAGE_REQUIRED`; a closure that does not reach the source's package → 409 `RESOURCE_CONFLICT`, through `resolvePackageClosure`), plus one limb this door needs: an optional `objects` list, each judged by the save gate's own closure predicate `narrowObjectsToPackageClosure` (platform/system objects and unpackaged ones admitted; an object no registry holds left to the save). That is what judges an environment-authored source given a target, and a packaged set granting on another package's object. `@objectstack/lint` joins runtime's dependencies for the two walks (the same line #28149 adds). The flow door passes no `objects` (it has no honest object list). **Contract.** The route ledger lists the door as `server-only` with no `requestSchema` (`packages/runtime/src/route-ledger.ts`), so no spec schema is invented; the row's note now states the body, the landing rules, both refusals and the answer's `package`. No error code is minted (both codes are registered). Docs: the two pages named on the `Skills-docs:` line. **Gate roster.** `scripts/check-route-envelope.mjs` `DISPATCHER_DOMAINS` declares `new-similar-target.ts` `{ handBuilt: 0 }` (the gate reds on an undeclared file in `domains/`); the #28149 thread has the same two lines. **Test alias.** `packages/runtime/vitest.config.ts` aliases `@objectstack/lint/runtime` to `../lint/src/runtime.ts`, byte-identical to #28149's line, so the rule's tests judge the source (`check:test-source-alias` reds without it once runtime imports the subpath). **System-context census.** The objects limb hands the registered object to `narrowObjectsToPackageClosure` whole (`{ ...object, name, _packageId }`) rather than reading `isSystem` off it, which `check:system-context-census` reads as an elevation read; the predicate reads the same flags either way. ## Why Card #28563 (N-20a of epic #28236): a copy made by the clone door belonged to no package, so a customer's change to a shipped set (for example HotCRM's) fell out of the package export. The maintainer's direction on the epic is package-level override, one mechanism for code and Studio, with customizations in `.custom` (ADR-0146 D3/D6). This door now lands its copy there, through N-13's ensure door only. ## Tests - **Red first** (dogfood, against the unchanged base door, runtime built at base `cd065d36`): 4 of 10 red. The packaged copy landed package-less: `expected undefined to be 'com.example.showcase.custom'` (twice); the environment control's answer had no `package` key; the target case (its fixture ids were then fixed from underscores to hyphens). - **Green** (dogfood `permission-set-clone-door.dogfood.test.ts`, real showcase boot): 11/11. Measured there: - the first copy of a showcase set creates `com.example.showcase.custom` (404 before, 200 after), the stored `sys_metadata` row's `package_id` is the pair, its `metadata` has no `packageId`, and the catalog serves the copy with `packageId` = the pair; - the second copy reuses it: one pair in `GET /packages`; - **Wanted 3, measured:** the pair-bound copy is granted through a position saved by name AND through a direct `sys_user_permission_set` row (`resolveUserAuthzGrants` lists it; the direct leg reads it absent first); - a platform set (`member_default`, shipped by the security plugin's manifest, namespace `sys`) lands in its own pair; - a writable target declaring the showcase lands there; the showcase itself (read-only) and an unknown id are 422 `WRITABLE_PACKAGE_REQUIRED`, a writable target declaring nothing is 409 `RESOURCE_CONFLICT`, each with no catalog entry and no `sys_metadata` row; - an environment-authored source still copies with `package: null` and a row whose `package_id` is null. - **Unit** (`permission-set-clone.test.ts`, 35/35): the save's `packageId` and the ensure call per landing; pair reuse; environment control (save keys exactly as before, no ensure); writable source; given target in closure, including an environment-authored source; unknown / code-package / `scope: 'cloud'` targets 422 naming the package; out-of-closure by the source's package alone (a `crm` set granting only `sys_user`) and by an object alone (an environment set granting `account`); a platform-only set reachable from any target; empty `package` 400; an ensure refusal keeps its status; no ensure verb 501; walled tenant refused with no ensure call. - **Ablation** (`node scripts/ablation-replace.mjs`; the runtime suite imports the door and the rule from `src/`, so no build sits between mutation and run; each restore read blob == HEAD and an empty `git diff HEAD`): - ensure bypassed (`const pair = await protocol.ensureCustomizationPackage(...)` → a null pair): 3 red (default landing, pair reuse, ensure refusal); - objects limb off (`if (request.objects !== undefined && request.objects.length > 0) {` → `if (false as boolean) {`): 1 red (the environment set given an island target); - source-package limb off (`if (sourcePackageId !== undefined && sourcePackageId !== target) {` → `if (false as boolean) {`): the first run was GREEN, because the only fixture for it also tripped the objects limb; I added the `crm`-ships-`sys_user`-only case and re-ran: 1 red. - `pnpm --filter @objectstack/runtime typecheck` exit 0; `pnpm --filter @objectstack/runtime test` on the final head: Test Files 502 passed (502), Tests 8170 passed | 19 skipped (8189). ## Acceptance notes - **Clause-② arm.** The claim expected `yes (widening)` with a narrowing arm if `CLAUSE2_ARMS` reads it so. It does: one arm only, and a request that answered 200 now refuses for a package the ensure door cannot pair, so the line is `yes (narrowing)` with the `**BREAKING**` banner and the ADR-0087 `not-required (no-migration-prescription)` marker in the changeset. Not measured on a live package: no booted package in the showcase or the platform lacks a namespace (the platform sets pair as `sys_custom`); the refusal path is the ensure door's own, read from its code. - **Shared helper (order's coordination line).** Agreed with the #28149 card thread: their file location, signature and codes; this PR carries their file byte-identical plus the `objects` limb and the route-envelope roster line. Whichever lands first owns the file; the other merges `origin/main` and takes it. - **Default closure.** The default landings (`<X>.custom`, the source's writable package) are not asked the target rule: the pair depends on X, and the source already resolved inside its own package. The save into the package is judged by the save door's closure-narrowed authoring gate, as every package save is. - **Overlap.** PR #28554 edits `packages/metadata-protocol/src/protocol.ts` and `protocol-packaged-base-refusal.ts`; this diff touches neither. ## Verification All on head `40a37314` unless named. - `node scripts/pm/dispatch-gates.mjs --ran … --repo objectstack-ai/objectstack`: `✓ dispatch-gates --ran: 124 derived famil(ies) accounted for — 124 run, 0 NOT-MEASURED`. Every family exit 0, `check:type-check-debt` included (`0 raw tsc error(s) total, none above its recorded number`, under `os-verify-lock`). - Runtime: typecheck exit 0; tests 502/502 files, 8170 passed | 19 skipped. Unit file `permission-set-clone.test.ts` 35/35. - Dogfood, after building the runtime at this head: `vitest run test/permission-set-clone-door.dogfood.test.ts test/route-ledger-live-mount-parity.dogfood.test.ts` → Tests 1 failed | 18 passed (19). The clone file is 11/11; every one of its cases is an HTTP call to `POST /api/v1/security/permission-sets/:name/clone` through the real dispatcher (this PR changes that route-ledger row's note). The one red is `route-ledger-live-mount-parity`: `GET /packages/:id/customizations — LEDGERED BUT NOT MOUNTED`, the row #28595 added on `main`; this diff touches neither that row nor its mount. - Full dogfood on the merged head `56f66311`: Test Files 1 failed | 364 passed | 3 skipped, Tests 2942 passed; the one failed file is the same parity red. On the pre-merge head `76b93c18` (base before #28595): 364 passed | 3 skipped, 2938 tests, no failure. - `check-platform-checklist --changed-from origin/main` exit 0; `check-commit-card-trailers` exit 0. - `origin/main` has moved 7 commits since the last merge; none touches a file in this diff and `git merge-tree` is clean. ## Human Minimum Each item is one rule from [CONTRIBUTING.md › Human Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum), which links the AGENTS.md text that states it. - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` is not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran. Neither touched package has a `lint` script; `eslint` on every touched file exits 0. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched, nothing to regenerate. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English, except the `维护者速读` summary, which the maintainer asks for in Chinese. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5khiLzZmecWFfjSZLc)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 004871f commit 6e11a92

12 files changed

Lines changed: 738 additions & 57 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
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)
6+
7+
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 `<X>.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.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No metadata key, value or type changes: the door's HTTP body gains an optional key and its answer gains one, and the copy's own definition is the same whole-definition copy as before. A refused clone writes nothing and its refusal names the remedy, so there is nothing stored for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing at the permission-set clone door (`@objectstack/runtime`).
13+
14+
- **Before.** Every copy was saved environment-wide with no package, whatever package shipped the set it was copied from. The body was `{ name, label }`.
15+
- **Now (ADR-0146 D3/D6).** The body is `{ name, label, package? }` and the answer is `{ permissionSet, package, notice }`.
16+
- `package` omitted, the set shipped by a read-only package X: the copy is saved in X's paired customization package `<X>.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.
17+
- `package` omitted, the set already in a writable package (a base you own, or `<X>.custom`): the copy is saved in that package.
18+
- `package` omitted, the set authored in the environment: unchanged, environment-wide (`package: null`).
19+
- `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.
20+
- **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.
21+
- **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.
22+
23+
**The remedy.** For a refused pair: give the base package a namespace (and a SemVer version), or name a writable target with `package`.

‎content/docs/concepts/metadata-lifecycle.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/
180180
| :--- | :--- |
181181
| `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. |
182182
| `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. |
183-
| `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. |
183+
| `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 `<X>.custom` (ADR-0146 D3/D6). |
184184

185185
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.
186186

‎content/docs/permissions/permission-sets.mdx‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -425,16 +425,29 @@ package and re-publish it (ADR-0086 two-doors).
425425
### Cloning a permission set (ADR-0126 §7.1)
426426

427427
```text
428-
POST /api/v1/security/permission-sets/:name/clone body: { "name": "...", "label": "..." }
428+
POST /api/v1/security/permission-sets/:name/clone body: { "name": "...", "label": "...", "package"?: "..." }
429429
```
430430

431431
The clone door copies a set's whole definition under a new machine name. It
432432
reads the source from the security catalog, where every packaged and
433433
environment-authored set lives. It saves the copy through the metadata layer
434-
as a set authored in this system: no package, and no link back to the set
435-
it was copied from, so changes to the source never reach the copy.
436-
437-
- **The body is `{ name, label }`, both required, and nothing else.** `name` is
434+
as a **new** set beside the original, with no link back to it, so changes to
435+
the source never reach the copy.
436+
437+
- **Where the copy lands (ADR-0146 D3/D6).** With no `package`, a copy of a
438+
set shipped by a read-only package X is saved in X's paired customization
439+
package `<X>.custom`, the package that holds everything you changed about X.
440+
The pair is created on the first copy, by the same ensure door as
441+
`POST /api/v1/packages/:id/ensure-customization` (its only creation path),
442+
and reused after. A copy of a set already in a writable package is
443+
saved in that package, and a copy of a set authored in this system stays
444+
environment-wide, in no package. With `package`, the copy is saved there: it
445+
must be an installed, writable package (else `422`
446+
`WRITABLE_PACKAGE_REQUIRED`) whose declared dependencies reach the source's
447+
package and every object the set grants on (else `409` `RESOURCE_CONFLICT`).
448+
The response's `package` names where the copy went (`null` for
449+
environment-wide), and the copy is assigned by its bare name like any set.
450+
- **The body is `{ name, label, package? }`, `name` and `label` required, and nothing else.** `name` is
438451
a new snake_case machine name: a name failing that rule answers `400`, and a
439452
name any permission set already uses (the source's own included) answers `409`
440453
`RESOURCE_CONFLICT`. An unknown source answers `404`.
@@ -444,8 +457,8 @@ it was copied from, so changes to the source never reach the copy.
444457
(`packageId`, `protection` and the internal protection envelope),
445458
and two privilege decisions — `adminScope` (delegated administration) and
446459
`isDefault` (the `everyone` suggestion). Grant either deliberately on the copy
447-
if it needs one. The response, `{ permissionSet, notice }`, says what was not
448-
copied.
460+
if it needs one. The response, `{ permissionSet, package, notice }`, says
461+
where the copy went and what was not copied.
449462
- **The copy is held by nobody** until you add it to a position or assign it.
450463
- **Who may call it** is who may save a permission set through the metadata
451464
API: a holder of `manage_metadata`. Anyone else is refused `403`

‎packages/qa/dogfood/test/permission-set-clone-door.dogfood.test.ts‎

Lines changed: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,15 @@
1616
// - the copy GRANTS: a holder of it resolves its object grants;
1717
// - the source is read from the catalog, not from a `sys_permission_set` row:
1818
// the source's rows are removed first, and the clone still lands;
19-
// - a caller without `manage_metadata` is refused 403 and nothing is saved.
19+
// - a caller without `manage_metadata` is refused 403 and nothing is saved;
20+
// - [ADR-0146 D3/D6] "new similar": a copy of a PACKAGED set with no target
21+
// lands in the source package's paired `<X>.custom`, created by N-13's
22+
// ensure door on the first copy and reused on the next — the stored row
23+
// bound to the pair, the item itself naming no package — and is granted
24+
// through a position AND a direct assignment by its bare name; a given
25+
// target is judged (writable and in closure lands, read-only / unknown /
26+
// out-of-closure are refused with no row); an environment-authored source
27+
// still copies environment-wide.
2028

2129
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2230
import { resolveUserAuthzGrants, securityCatalogReaderOf } from '@objectstack/core';
@@ -34,6 +42,12 @@ const DEFAULT_COPY = 'my_member_default';
3442
const MEMBER_EMAIL = 'clone.member@example.com';
3543
const HOLDER_EMAIL = 'clone.holder@example.com';
3644
const COPY_POSITION = 'clone_door_position';
45+
/** The showcase's manifest id, and its paired customization package (ADR-0146 D3). */
46+
const SHOWCASE = 'com.example.showcase';
47+
const SHOWCASE_PAIR = `${SHOWCASE}.custom`;
48+
/** A Studio-created writable package that declares the showcase, and one that declares nothing. */
49+
const IN_CLOSURE = 'com.acme.clone-in-closure';
50+
const OUT_OF_CLOSURE = 'com.acme.clone-out-of-closure';
3751

3852
interface Engine {
3953
find(object: string, options?: unknown): Promise<Array<Record<string, unknown>>>;
@@ -82,17 +96,35 @@ describe('the permission-set clone door, on a booted showcase', () => {
8296
// stage 8 retired the `sys_permission_set` row object.
8397
expect(ql.getObject?.('sys_permission_set')).toBeFalsy();
8498

99+
// Anti-vacuity for the landing: the source is the showcase's, and no
100+
// pair exists before the first copy.
101+
expect(source?.packageId).toBe(SHOWCASE);
102+
expect((await stack.apiAs(token, 'GET', `/packages/${SHOWCASE_PAIR}`)).status).toBe(404);
103+
85104
const res = await clone(token, DELEGATE_SET, { name: DELEGATE_COPY, label: 'My Field Ops Delegate' });
86105
const text = await res.clone().text();
87106
expect(res.status, text).toBe(200);
88-
const data = JSON.parse(text).data as { permissionSet: Record<string, unknown>; notice: string };
107+
const data = JSON.parse(text).data as { permissionSet: Record<string, unknown>; package: string | null; notice: string };
89108
expect(data.permissionSet.name).toBe(DELEGATE_COPY);
90109
expect(data.permissionSet).not.toHaveProperty('adminScope');
110+
expect(data.permissionSet).not.toHaveProperty('packageId');
91111
expect(data.notice).toContain('adminScope');
112+
// [ADR-0146 D3/D6] The copy landed in the showcase's pair, which the
113+
// ensure door created for it.
114+
expect(data.package).toBe(SHOWCASE_PAIR);
115+
expect(data.notice).toContain(`'${SHOWCASE_PAIR}'`);
116+
const pair = await stack.apiAs(token, 'GET', `/packages/${SHOWCASE_PAIR}`);
117+
expect(pair.status, await pair.clone().text()).toBe(200);
118+
119+
// The stored row is bound to the pair (its `package_id`), and the item
120+
// names no package of its own.
121+
const rows = await ql.find('sys_metadata', { where: { type: 'permission', name: DELEGATE_COPY }, context: SYSTEM_CTX });
122+
expect(rows.map((r) => r.package_id)).toEqual([SHOWCASE_PAIR]);
123+
expect(JSON.parse(String(rows[0].metadata))).not.toHaveProperty('packageId');
92124

93125
const copy = await reader!.resolve('permission', DELEGATE_COPY);
94126
expect(copy?.name, 'the copy does not resolve through the catalog').toBe(DELEGATE_COPY);
95-
expect(copy?.packageId).toBeUndefined();
127+
expect(copy?.packageId).toBe(SHOWCASE_PAIR);
96128
expect(copy?.definition).not.toHaveProperty('adminScope');
97129
expect(copy?.definition.label).toBe('My Field Ops Delegate');
98130
// The facets came across whole.
@@ -106,9 +138,16 @@ describe('the permission-set clone door, on a booted showcase', () => {
106138

107139
const res = await clone(token, DEFAULT_SET, { name: DEFAULT_COPY, label: 'My Member Default' });
108140
expect(res.status, await res.clone().text()).toBe(200);
141+
// The second copy REUSES the pair the first created: one pair, the same id.
142+
expect(((await res.json()) as any).data.package).toBe(SHOWCASE_PAIR);
143+
const listed = ((await (await stack.apiAs(token, 'GET', '/packages')).json()) as any).data;
144+
const pairs = (Array.isArray(listed) ? listed : listed?.packages ?? [])
145+
.filter((p: any) => (p?.manifest?.id ?? p?.id) === SHOWCASE_PAIR);
146+
expect(pairs).toHaveLength(1);
109147

110148
const copy = await reader.resolve('permission', DEFAULT_COPY);
111149
expect(copy?.name).toBe(DEFAULT_COPY);
150+
expect(copy?.packageId).toBe(SHOWCASE_PAIR);
112151
expect(copy?.definition.isDefault === undefined || copy?.definition.isDefault === false).toBe(true);
113152
expect(copy?.definition.objects).toEqual(source!.definition.objects);
114153

@@ -131,6 +170,86 @@ describe('the permission-set clone door, on a booted showcase', () => {
131170
expect(grants.permissions).toContain(DEFAULT_COPY);
132171
});
133172

173+
it('a copy in the pair is granted through a direct assignment too, by its bare name', async () => {
174+
const email = 'clone.direct@example.com';
175+
await stack.signUp(email);
176+
const [user] = await ql.find('sys_user', { where: { email }, limit: 1, context: SYSTEM_CTX });
177+
expect(user?.id, `no sys_user row for ${email}`).toBeTruthy();
178+
const tenantId = await sessionOrgOf(stack);
179+
// Anti-vacuity: the user holds no copy before the assignment.
180+
const before = await resolveUserAuthzGrants(ql as never, String(user.id), { tenantId });
181+
expect(before.permissions).not.toContain(DELEGATE_COPY);
182+
await ql.insert(
183+
'sys_user_permission_set',
184+
{ id: 'ups_clone_door_direct', user_id: String(user.id), permission_set: DELEGATE_COPY },
185+
{ context: SYSTEM_CTX },
186+
);
187+
const after = await resolveUserAuthzGrants(ql as never, String(user.id), { tenantId });
188+
expect(after.permissions).toContain(DELEGATE_COPY);
189+
});
190+
191+
it('a given writable target inside the closure lands there; read-only, unknown and out-of-closure targets are refused with no row', async () => {
192+
const reader = securityCatalogReaderOf(ql)!;
193+
for (const [id, namespace, dependencies] of [
194+
[IN_CLOSURE, 'clone_in', { [SHOWCASE]: '*' }],
195+
[OUT_OF_CLOSURE, 'clone_out', {}],
196+
] as const) {
197+
const created = await stack.apiAs(token, 'POST', '/packages', {
198+
manifest: { id, name: id, version: '0.1.0', type: 'app', namespace, dependencies },
199+
});
200+
expect(created.status, await created.clone().text()).toBe(201);
201+
}
202+
203+
const landed = await clone(token, DEFAULT_SET, { name: 'in_closure_copy', label: 'In Closure', package: IN_CLOSURE });
204+
expect(landed.status, await landed.clone().text()).toBe(200);
205+
expect(((await landed.json()) as any).data.package).toBe(IN_CLOSURE);
206+
expect((await reader.resolve('permission', 'in_closure_copy'))?.packageId).toBe(IN_CLOSURE);
207+
208+
const refused: Array<[string, string, number, string]> = [
209+
['read_only_copy', SHOWCASE, 422, 'WRITABLE_PACKAGE_REQUIRED'],
210+
['unknown_copy', 'com.acme.no_such_package', 422, 'WRITABLE_PACKAGE_REQUIRED'],
211+
['out_closure_copy', OUT_OF_CLOSURE, 409, 'RESOURCE_CONFLICT'],
212+
];
213+
for (const [name, target, status, code] of refused) {
214+
const res = await clone(token, DEFAULT_SET, { name, label: 'X', package: target });
215+
const text = await res.clone().text();
216+
expect(res.status, `${target}: ${text}`).toBe(status);
217+
expect(((await res.json()) as any).error?.code, target).toBe(code);
218+
expect(await reader.resolve('permission', name), target).toBeUndefined();
219+
const rows = await ql.find('sys_metadata', { where: { type: 'permission', name }, context: SYSTEM_CTX });
220+
expect(rows, target).toEqual([]);
221+
}
222+
});
223+
224+
it('a platform set (the lock-and-clone rule\'s commonest source) copies into its shipping package\'s pair', async () => {
225+
const reader = securityCatalogReaderOf(ql)!;
226+
const source = await reader.resolve('permission', 'member_default');
227+
expect(source?.packageId, 'member_default is not packaged on this boot').toBeTruthy();
228+
229+
const res = await clone(token, 'member_default', { name: 'my_member_baseline', label: 'My Member Baseline' });
230+
const text = await res.clone().text();
231+
expect(res.status, text).toBe(200);
232+
const landed = ((await res.json()) as any).data.package;
233+
expect(landed).toBe(`${source!.packageId}.custom`);
234+
expect((await reader.resolve('permission', 'my_member_baseline'))?.packageId).toBe(landed);
235+
});
236+
237+
it('CONTROL — an environment-authored source still copies environment-wide, with no package', async () => {
238+
const saved = await stack.apiAs(token, 'PUT', '/meta/permission/env_authored_source', {
239+
name: 'env_authored_source', label: 'Env Authored', objects: { showcase_task: { allowRead: true } },
240+
});
241+
expect(saved.status, await saved.clone().text()).toBe(200);
242+
const source = await securityCatalogReaderOf(ql)!.resolve('permission', 'env_authored_source');
243+
expect(source?.packageId, 'the control source is packaged').toBeUndefined();
244+
245+
const res = await clone(token, 'env_authored_source', { name: 'env_authored_copy', label: 'Env Authored Copy' });
246+
expect(res.status, await res.clone().text()).toBe(200);
247+
expect(((await res.json()) as any).data.package).toBeNull();
248+
expect((await securityCatalogReaderOf(ql)!.resolve('permission', 'env_authored_copy'))?.packageId).toBeUndefined();
249+
const rows = await ql.find('sys_metadata', { where: { type: 'permission', name: 'env_authored_copy' }, context: SYSTEM_CTX });
250+
expect(rows.map((r) => r.package_id ?? null)).toEqual([null]);
251+
});
252+
134253
it('a taken name is 409, an unknown source 404, a bad name 400', async () => {
135254
const taken = await clone(token, DEFAULT_SET, { name: DELEGATE_SET, label: 'X' });
136255
expect(taken.status, await taken.clone().text()).toBe(409);

‎packages/runtime/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"@objectstack/driver-sql": "workspace:*",
4545
"@objectstack/driver-sqlite-wasm": "workspace:*",
4646
"@objectstack/formula": "workspace:*",
47+
"@objectstack/lint": "workspace:*",
4748
"@objectstack/metadata": "workspace:*",
4849
"@objectstack/objectql": "workspace:*",
4950
"@objectstack/metadata-protocol": "workspace:*",

0 commit comments

Comments
 (0)