Skip to content

Commit f4d9c09

Browse files
feat(metadata-protocol, cloud-connection): an unmanaged install can take a target namespace (#30997)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D53oweAj47PTfWyBvvWKdXL)_ domain:server#1 · os-elon-musk · session_016WV4cxRibYnMTw289qPx3H Part of #30794. This PR is Ask items 1–7 and 9. Ask item 8 (the ADR-0048 §3.5 amendment and the ADR-0131 D6 note) is PR B, which opens after this one lands. A managed install after a *renamed* copy is waiting on the maintainer (see Acceptance notes). Clause-②: yes (widening) Metadata-protocol: added Skills-docs: none — `skills/**` teaches no install mode (no `installMode`, `--install-mode` or unmanaged copy appears in it), and this PR adds no CLI flag. The docs page that teaches install modes, `content/docs/deployment/install-modes.mdx`, is updated here. Claim: 6676579365 (amended 6676622319) · Branch: `claude/project-thread-j4jgmg` Spec consent: the maintainer, 2026-11-06T15:07Z, recorded on #30794 (comment 6688079820): `targetNamespace` on the request and `sourceNamespace` on the response ## 维护者速读 - 改了什么:非托管安装可以指定 `targetNamespace`,副本整体改名进新命名空间。对象名、视图、翻译和所有引用都会跟着改,规则与「复制包」完全相同,两者共用同一个改名引擎。入口有两个:`importUnmanagedPackage`,以及 `POST /api/v1/marketplace/install-local` 带 `installMode: 'unmanaged'`。不传时行为与以前一样。 - 为什么改:同一个包要装两份可编辑副本,或者想放在原包旁边各用各的名字,以前做不到,因为副本必须沿用源包的命名空间。ADR-0048 §3.5 把这件事记为待办,本卡就是来做它的。 - 风险与回滚:中等。复制包的改名逻辑原样搬进了一个共享函数,原有复制测试 20/20 仍全绿。顺带修了一处:裸名对象(如 `ticket`)的视图容器,复制后不再停在旧名上,两个入口都修了。托管安装带 `targetNamespace` 一律返回 400。回滚即撤 PR。 - 先看:`packages/metadata-protocol/src/package-namespace-rename.ts`(唯一的改名引擎);`package-unmanaged-install.ts` 里 `planUnmanagedImport` 的改名一段(容器展开之后才改名,所有拒绝都在写入之前);`protocol-package-commits.ts` 里 `duplicatePackage` 改为调用这个引擎的地方。 - 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/package-unmanaged-install.test.ts -t parity`。预期:同一个包分别走「复制」和「带 targetNamespace 的导入」,存下的条目一模一样。 - 需要你做的:spec 改动你已在 15:07Z 同意(#30794 评论 6688079820)。还剩一件等你拍板:改名后的副本旁边,再装同一个源包的托管版要不要拒绝(Q6 决策卡)。本 PR 不改这条规则。其余由席位复核、落地。 ## What changes **`@objectstack/metadata-protocol`: one rename engine, two callers** (`package-namespace-rename.ts`, new) - `planNamespaceRename({ sourcePackageId, sourceNamespace, targetNamespace, items, retryVerb })` is the rename `duplicatePackage` applied inline before this PR, moved verbatim with its comments. It is a pure function over item bodies. It returns the object rename map, the up-front object refusals, `copyName(type, name)`, `copyNameRefusal(type, name)` and `rewrite(type, name, body)`. `rewrite` covers the reference rewrite, the record-key collisions and the permission `fields` keys. - `duplicatePackage` calls it. Only the remedy verb is a parameter ("then duplicate again" / "then install again"). The ADR-0086 D3 permission `packageId` rebind stays in the duplicate, where it was. - **One fix, in both doors: a view named by its object follows the object whole.** A view container is named by the object it binds. The import names every container that way, and a stored base holds it under that name. So a view whose name *is* a copied object's name now takes that object's copy name. For a prefixed object this is the name the prefix swap already gave. For a bare one (`ticket` → `dupdst_ticket`), the swap kept `ticket` while the body's `object` followed the copy. The copy's container then sat under the name the source's container holds, the same shadowing the qualified-view rule already closes for `ticket.board`. The objectql replay found it (below). **`@objectstack/metadata-protocol`: the unmanaged import takes `targetNamespace`** - `planUnmanagedImport(manifest, { targetNamespace })`: - It judges the value by `ManifestSchema.shape.namespace` and refuses the platform-reserved `base`, `system` and `sys` (400 `VALIDATION_ERROR` through the door, whose answer says the reason is in the server log; the log line names `targetNamespace`. The changeset and `install-modes.mdx` still say `INVALID_REQUEST`: a known prose mismatch, carried to the card's handoff with the option of giving these two throws the `INVALID_REQUEST` code). - It enumerates the items under the source namespace, expanding view containers. Only then does it run the engine, so every name a container expands into takes its object's copy name. - Before the base exists it refuses (400 `INVALID_REQUEST`, every item named, ending "Nothing was installed."): an object whose copy name is claimed, and a copy name that breaks `METADATA_ITEM_NAME_PATTERN`, the 63-character table identifier limit (objects) or the `sys_metadata.name` column width (255, read off its declaration). - Translations are named `<targetNamespace>_<locale>`, with the object keys inside them renamed. - `importUnmanagedPackage`: - Each body is converted to today's protocol under the name the package declares, then rewritten by the engine, the order the duplicate rewrites in. A reference collision the converted body meets refuses the install before any write. - The same-name check (`unmanagedImportCollisions`, 409) runs on the renamed names. The owned-namespace 409 names `targetNamespace` when one was given. - The answer carries `sourceNamespace` when the copy was renamed, and has no such key otherwise. **`@objectstack/cloud-connection`: the install-local door** (`MarketplaceInstallLocalPlugin`) - `targetNamespace` is forwarded to the import on both sources (inline manifest and cloud fetch). - With a managed install, or no `installMode`, it answers 400 `INVALID_REQUEST` before anything is fetched: a managed install is never renamed. - `installedFrom` gains `namespace` (the package's own) beside `packageId` and `version`, which is the provenance Ask 5 names. The operator log line says `renamed from '<source>'`. Both are unchanged without `targetNamespace`. **`@objectstack/spec`** (`api/package-lifecycle.zod.ts`): `ImportUnmanagedPackageRequestSchema.targetNamespace` (optional) and `ImportUnmanagedPackageResponseSchema.sourceNamespace` (optional). The response `namespace` describe now covers both cases. The authorable-surface shard and the package-lifecycle reference are regenerated. **Docs**: `content/docs/deployment/install-modes.mdx` gains "Installing the copy under a namespace of its own" (the body, the rename rules, every refusal, the provenance) and one row in the door's refusal table. ## Tests - **The parity anchor** (`package-unmanaged-install.test.ts`, "the rename parity"). One package goes two ways. In one, it is imported as a base and then `duplicatePackage`d into `com.example.acme` / `acme`. In the other, it is imported straight into `com.example.acme` with `targetNamespace: 'acme'`. The active rows of the two copies are identical: objects, a lookup, free text, a view item, a container, a translation and a form action. - **The duplicate battery, replayed through the import on a real kernel** (`packages/objectql/src/protocol-unmanaged-import-rename-battery.test.ts`, new, 9 cases). Each fixture of the four `protocol-duplicate-package-*` files is declared as a package body. Both doors run on a real `ObjectKernel` + `ObjectQLPlugin`. - The three copy fixtures (names in step, the bare object, permission `fields` keys) store identical rows through both doors, and each also asserts the battery's own pins on the import's rows. - Every item the duplicate reports in `failed[]` is refused by the import, with no base, no row and no history row naming the target. The cases are: the claimed bare-object name, a `fields` key collision, an `objects` key collision in both key orders, a translation key collision, and a hook entry collision with its negative control. - **One test per refusal**: `package-unmanaged-install.test.ts` (8 planner cases + 8 import cases) and `package-namespace-rename.test.ts` (new, 6 engine cases, including the container rule). - **The door**: `marketplace-install-local-install-modes.test.ts`, 3 new cases. `targetNamespace` is forwarded and `installedFrom.namespace` is recorded. Without it, nothing changes. Beside a default or a managed install it is refused with no fetch, no import, no register and no ledger. - **Unchanged**: the four `protocol-duplicate-package-*` files are green as they are, 20/20, after the extraction. ## Acceptance notes - **The managed install after a *renamed* copy (Q6) is not decided here.** The door's managed-after-unmanaged refusal (step 1f) is namespace based. A renamed copy holds `targetNamespace`, not the package's, so that refusal does not see it. The decision card is with Elon. Step 1f is untouched, the docs say nothing about that case, and no test pins either outcome. - **The spec hunk, under the maintainer's consent.** The import's request is a strict spec schema, so `packages/spec/src/api/package-lifecycle.zod.ts` changes: two optional keys and one describe, in commit `340bd51a`, with the regenerated artifacts in `af4a5ad9`. No `domain:spec` seat held the hunk while it waited, so the maintainer consented directly (#30794 comment 6688079820). The consent covers the spec hunk only. The contract review and the other landing conditions are still owed. The hunk raises this PR's review to CONTRACT_REVIEW_TIER. - **Ask 4's name judgement (grammar, 63, 255) and the reserved-namespace refusal are import-only.** The duplicate's refusals are exactly what they were before this PR, so the duplicate is not narrowed here. - **The import does not rebind a permission set's `packageId`.** The duplicate rebinds a set that declares its source package (ADR-0086 D3). The import never did, with or without a rename, and a package body that declares `packageId` on a set is judged by the save door as before. - **No CLI flag.** `os package install` does not send `targetNamespace`. The docs give the HTTP body. - **The reserved namespaces are spelled a third time.** `PLATFORM_RESERVED_NAMESPACES` (`base`, `system`, `sys`) sits beside objectql's `RESERVED_NAMESPACES` and the door's inline check. All three agree today, and unifying them is outside this card's surface. - **The replay lives in a new objectql file** rather than in the four battery files, so the battery files are untouched. A container is named by its object in the import, so the bare-object fixture holds its container as `ticket` where the battery uses `ticket_views`. - **cloud#3600 comment 6676044775 was not readable** from this session (403), so it is NOT MEASURED here. ## Verification Head `a39d3108` adds one commit on top of `8b377514`: a module-top `import './lint-root-preload.testkit.js';` in `package-unmanaged-install.test.ts`, for main's Test-source alias gate (source-reach half, #30969). The measurements below are on `8b377514`. <details><summary>Local tests and type-checks (head 8b37751, main 1aeeafd merged in; #30687 landed before it)</summary> - `pnpm --filter @objectstack/metadata-protocol test`: 321 files passed (4 skipped); 22510 tests passed. - `pnpm --filter @objectstack/cloud-connection test`: 72 files, 921 tests passed. - `pnpm --filter @objectstack/objectql exec vitest run src/protocol-duplicate-package src/protocol-unmanaged-import-rename-battery.test.ts`: 5 files, 29 tests passed (the 20 battery cases unchanged, plus the 9 replay cases). - `typecheck` for metadata-protocol, cloud-connection, objectql and spec: exit 0 (objectql and spec include `check:test-typecheck`). - `pnpm --filter @objectstack/spec check:generated`: exit 0. - Whole-tree `pnpm build` under the verify lock: 118/118 tasks. - `pnpm lint`: exit 0. `check:test-typecheck` for spec ran out of heap at the default Node limit in this container. With `NODE_OPTIONS=--max-old-space-size=8192` it exits 0 (240 errors / 131 signatures held, unchanged). Every gate below ran with that heap. </details> <details><summary>Ablations (17, each turns a test red; scripts/ablation-replace.mjs, file restored to HEAD after each)</summary> | Removed | Red | |:---|:---| | The import's call to the engine (`rename.rewrite` in `importUnmanagedPackage`) | 5, the parity among them | | The planner's rename branch | 11 | | The reserved-namespace refusal | 1 | | The `targetNamespace` declaration check | 1 | | The planner's claimed-object refusal | 1 | | The engine's item-grammar refusal | 1 | | The 63-character table limit | 2 | | The 255-character item-name limit | 2 | | The expanded-view refusal | 1 | | The import's throw on a reference collision | 1 | | The same-name check on the renamed names (judged on source names instead) | 1 | | The answer's `sourceNamespace` | 2 | | The container-follows-its-object rule | 1 | | The door's managed refusal | 2 | | The door's `installedFrom.namespace` | 1 | | The door forwarding `targetNamespace` to the import | 1 | | The door forwarding `targetNamespace` to its unmanaged installer | 1 | </details> <details><summary>Derived gates (dispatch-gates --commands, reconciled with --ran)</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 108 families on head `8b377514`. Each was run with its exit code captured before any pipe. ``` ✓ dispatch-gates --ran: 108 derived famil(ies) accounted for — 108 run, 0 NOT-MEASURED (a DERIVED zero — all 108 recorded an exit code and none of them is 3). ``` All 108 exited 0 on this head, with the whole-tree build and the teardown self-test's pinned fixture commit in place. On the earlier head those three exited 3 until their prerequisites were met. The whole `Lint · docs & code guards` job (52 run steps from `lint.yml`, `check-cli-command-ids` included, the lane verdict step left out) was also run locally: 52/52 exit 0. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Two optional keys and one describe, at `340bd51a`.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [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: its generated artifacts are regenerated and committed (`af4a5ad9`); `check:generated` passes. - [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. --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cxRibYnMTw289qPx3H)_ <!-- signed: domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3fe5f2a commit f4d9c09

14 files changed

Lines changed: 1968 additions & 361 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/cloud-connection': patch
5+
---
6+
7+
feat(metadata-protocol,cloud-connection): an unmanaged install can take a target namespace
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: added
11+
12+
- **An unmanaged copy can be installed under a namespace of its own.** `importUnmanagedPackage` and `POST /api/v1/marketplace/install-local` (with `installMode: 'unmanaged'`) take an optional `targetNamespace`. The copy's base takes it as its `namespace`, every copied object name takes it in place of the package's (a name without the package's prefix takes it in front), and every reference to a renamed name follows. Absent, the copy keeps the package's namespace exactly as before.
13+
- **One rename, two doors.** The copy is renamed by the rules `duplicatePackage` already applies, now one function both doors call: view items and view containers follow their object, translations are named `<targetNamespace>_<locale>` with the object keys inside them renamed, a field keeps its key, and free text is renamed only at a whole prefixed object name.
14+
- **Refused before anything is installed.** A `targetNamespace` the `manifest.namespace` rule refuses, or a platform-reserved one (`base`, `system`, `sys`), answers `400 INVALID_REQUEST`. So does a copy the names cannot make: an object whose copy name another object's copy takes, a copy name that is not a legal item name or is longer than its storage allows (63 characters for an object's table, 255 for an item name), and a renamed key that meets a key the item already holds — each item named. The same-name check (`409 RESOURCE_CONFLICT`) is judged on the renamed names, and a `targetNamespace` another package owns is refused `409`.
15+
- **A managed install is never renamed.** `targetNamespace` beside a managed install (or no `installMode`) answers `400 INVALID_REQUEST` before anything is fetched.
16+
- **The rename is recorded.** The import's answer carries `sourceNamespace`, and the install-local answer's `installedFrom` carries the package's own `namespace` beside its id and version. Neither appears when the copy kept the package's namespace.
17+
- **A bare object's view container follows it on a duplicate too.** A view named by an object of the package takes that object's copy name, so `duplicatePackage` no longer leaves a bare object's container under the name the source's container holds.

‎content/docs/deployment/install-modes.mdx‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ runtime installs managed. The door judges the mode before it writes anything:
9292
| A mode the package's `installModes` does not permit | `422 INSTALL_MODE_NOT_PERMITTED`, naming what it permits |
9393
| `unmanaged` on a deployment whose requested tenancy posture walls organizations (`group`, `isolated`) | `409 UNMANAGED_INSTALL_WALLED_POSTURE`, naming the posture |
9494
| `managed`, while an unmanaged copy of the package holds its namespace | `409 RESOURCE_CONFLICT`, naming the copy's base |
95+
| `targetNamespace` on a `managed` install, or with no `installMode` | `400 INVALID_REQUEST`: a managed install is never renamed |
9596

9697
A runtime that predates install modes ignores the field and installs the
9798
package managed. If you asked for `unmanaged`, the CLI says so and exits `1`.
@@ -144,7 +145,8 @@ delete, and `full` both.
144145
## What an unmanaged copy keeps, and what it drops
145146

146147
The package's metadata is copied **once** into a new writable base,
147-
`<package id>-copy`, which keeps the package's namespace. Every item is written
148+
`<package id>-copy`, which keeps the package's namespace unless the install
149+
[names another](#installing-the-copy-under-a-namespace-of-its-own). Every item is written
148150
through the save door a Studio edit takes, environment-wide, and is served as
149151
environment-authored metadata: the next save edits it.
150152

@@ -215,6 +217,51 @@ dash and reason left off when there is none:
215217

216218
Read it before you edit: it is why the vendor locked each item.
217219

220+
### Installing the copy under a namespace of its own
221+
222+
An unmanaged install can rename the copy into a namespace you name, so it sits
223+
beside the package, or beside another copy of it, under names of its own. Send
224+
`targetNamespace` with `installMode: 'unmanaged'` in the body of
225+
`POST /api/v1/marketplace/install-local` (the CLI has no flag for it):
226+
227+
```json
228+
{ "packageId": "com.acme.crm", "versionId": "1.2.0", "installMode": "unmanaged", "targetNamespace": "acme" }
229+
```
230+
231+
The copy is renamed by the same rules as duplicating a package:
232+
233+
- **Every object takes the new namespace.** `crm_lead` copies as `acme_lead`,
234+
and an object whose name lacks the package's prefix takes the new one in
235+
front (`ticket` copies as `acme_ticket`).
236+
- **What names an object follows it.** A lookup's `reference`, a view's
237+
`object`, a permission set's `objects` keys and the object part of its
238+
`fields` keys, a translation's object keys, a form action's view target.
239+
A view item follows its object (`crm_lead.board` copies as
240+
`acme_lead.board`), and so does a view container and every view it expands
241+
into. A field keeps its key, and a word in free text is renamed only when it
242+
is a whole prefixed object name (`crm_lead`, never `x_crm_lead`).
243+
- **Translations are named `acme_<locale>`**, with the object keys inside them
244+
renamed.
245+
- **An object of another package keeps its name**, and so do its view items.
246+
247+
`targetNamespace` follows the rule for a manifest `namespace`, and the
248+
platform's shared namespaces `base`, `system` and `sys` are refused. Every
249+
refusal is answered before the base exists, so nothing is installed:
250+
251+
| The rename | The answer |
252+
|:---|:---|
253+
| A `targetNamespace` the namespace rule refuses, or a shared one | `400 INVALID_REQUEST`, naming `targetNamespace` |
254+
| An object whose copy name another object's copy takes (`ticket` beside `crm_ticket`) | `400 INVALID_REQUEST`, naming the object and the rename that clears it |
255+
| A copy name that is not a legal item name, longer than an object's table name may be (63 characters), or longer than an item name may be (255) | `400 INVALID_REQUEST`, naming each item |
256+
| A renamed key that meets a key the item already holds (a permission set granting both `crm_lead` and `acme_lead`) | `400 INVALID_REQUEST`, naming the item and both keys |
257+
| A renamed name the environment or a managed package already holds | `409 RESOURCE_CONFLICT`, naming each |
258+
| A `targetNamespace` another package owns | `409 RESOURCE_CONFLICT`, naming it |
259+
260+
The install answer's `installedFrom` gains `namespace`, the package's own,
261+
beside its id and version, so the answer records the rename it applied: names
262+
in `installedFrom.namespace` were copied into the copy's `namespace`. Without
263+
`targetNamespace` the answer is unchanged.
264+
218265
---
219266

220267
## Choosing what to permit

‎content/docs/references/api/package-lifecycle.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ A navigation item another installed package contributes to an app the base owns,
246246
| **manifest** | `Record<string, any>` | ✅ | The package to import: ONE package's assembled body, flattened — its manifest fields beside its metadata collections (`objects`, `views`, …), the shape the install door hands the manifest service. An artifact carrying `packages` is refused: import each package body on its own. |
247247
| **targetPackageId** | `string` | optional | Id of the NEW writable base the items are written into, in reverse-domain form. It must differ from the source id and name no installed package. Defaults to `<manifest.id>-copy`. |
248248
| **targetName** | `string` | optional | Display name of the new base. Defaults to the source package's `name`. |
249+
| **targetNamespace** | `string` | optional | The namespace the copy is renamed into: the new base's `namespace`, and the prefix every copied object name takes in place of the source's, with every reference to a renamed name rewritten (the rename `duplicatePackage` applies). Judged by `manifest.namespace`'s own declaration; a platform-reserved namespace (`base`, `system`, `sys`) is refused. Absent, the copy keeps the source package's namespace. |
249250

250251

251252
---
@@ -259,7 +260,8 @@ A navigation item another installed package contributes to an app the base owns,
259260
| **sourcePackageId** | `string` | ✅ | The id of the package the items were copied from. |
260261
| **sourceVersion** | `string` | optional | The version the imported body declared, when it declared one. |
261262
| **targetPackageId** | `string` | ✅ | The new writable base that owns every copied item. Nothing is registered under the source id. |
262-
| **namespace** | `string` | ✅ | The base's namespace — the SOURCE package's, kept so every copied object name stays valid. |
263+
| **namespace** | `string` | ✅ | The base's namespace — the request's `targetNamespace` when it named one, else the SOURCE package's, kept so every copied object name stays valid. |
264+
| **sourceNamespace** | `string` | optional | The SOURCE package's namespace, present when the copy was renamed into `namespace` — the provenance of every copied name. Absent when the copy kept the source's namespace. |
263265
| **copiedCount** | `number` | ✅ | How many items were copied. |
264266
| **copied** | `{ type: string; name: string }[]` | ✅ | Every item that was copied, in the order it was written. |
265267
| **strippedLocks** | `{ type: string; name: string; lock: Enum<'no-overlay' \| 'no-delete' \| 'full'>; reason?: string }[]` | ✅ | Every protection lock the package shipped on a copied item and the copy does not keep, in the order the items were written. Empty when the package locked nothing. |

‎packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,3 +506,64 @@ describe('[#23914] a truncated install body is refused, and nothing is written',
506506
expect(empty.payload).toEqual(none.payload);
507507
});
508508
});
509+
510+
describe('install-local — `targetNamespace` renames an unmanaged copy, and only an unmanaged one (#30794)', () => {
511+
/** The import as it answers a renamed copy: the request's namespace, and the source's beside it. */
512+
const renamingImporter = async (request: any) => ({
513+
sourcePackageId: request.manifest.id,
514+
sourceVersion: request.manifest.version,
515+
targetPackageId: `${request.manifest.id}-copy`,
516+
namespace: request.targetNamespace ?? request.manifest.namespace,
517+
...(request.targetNamespace !== undefined ? { sourceNamespace: request.manifest.namespace } : {}),
518+
copiedCount: 1,
519+
copied: [{ type: 'object', name: `${request.targetNamespace ?? request.manifest.namespace}_lead` }],
520+
strippedLocks: [],
521+
});
522+
523+
it('hands `targetNamespace` to the import, and records the source namespace beside `installedFrom`', async () => {
524+
const h = await mount(dir, { importer: renamingImporter });
525+
const res = await h.install({ manifest: crm({ installModes: BOTH }), installMode: 'unmanaged', targetNamespace: 'acme' });
526+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
527+
528+
expect(h.importUnmanagedPackage).toHaveBeenCalledTimes(1);
529+
expect(h.importUnmanagedPackage!.mock.calls[0]![0]).toMatchObject({ targetNamespace: 'acme', actor: INSTALLER });
530+
// The mapping the rename applied: `installedFrom.namespace` → `namespace`.
531+
expect(res.payload.data).toMatchObject({
532+
installMode: 'unmanaged',
533+
namespace: 'acme',
534+
installedFrom: { packageId: 'com.example.crm', version: '4.0.0', namespace: 'crm' },
535+
copied: [{ type: 'object', name: 'acme_lead' }],
536+
});
537+
const info = h.logger.info.mock.calls.map((call: any[]) => String(call[0])).join('\n');
538+
expect(info).toContain("(namespace 'acme', renamed from 'crm')");
539+
});
540+
541+
it('without it the import is asked as before, and `installedFrom` carries no namespace — the key set unchanged', async () => {
542+
const h = await mount(dir, { importer: renamingImporter });
543+
const res = await h.install({ manifest: crm({ installModes: BOTH }), installMode: 'unmanaged' });
544+
expect(res.status).toBe(200);
545+
expect(h.importUnmanagedPackage!.mock.calls[0]![0]).not.toHaveProperty('targetNamespace');
546+
expect(res.payload.data.installedFrom).toEqual({ packageId: 'com.example.crm', version: '4.0.0' });
547+
});
548+
549+
for (const installMode of [undefined, 'managed'] as const) {
550+
it(`beside a ${installMode ?? 'default (managed)'} install: 400 INVALID_REQUEST — a managed install is never renamed; nothing fetched or written`, async () => {
551+
const h = await mount(dir, { importer: renamingImporter });
552+
const fetchSpy = vi.fn();
553+
vi.stubGlobal('fetch', fetchSpy);
554+
const res = await h.install({
555+
manifest: crm({ installModes: BOTH }),
556+
...(installMode ? { installMode } : {}),
557+
targetNamespace: 'acme',
558+
});
559+
expect(res.status).toBe(400);
560+
expect(res.payload.error.code).toBe('INVALID_REQUEST');
561+
expect(res.payload.error.message).toContain('a managed install is never renamed');
562+
expect(fetchSpy).not.toHaveBeenCalled();
563+
expect(h.importUnmanagedPackage).not.toHaveBeenCalled();
564+
expect(h.register).not.toHaveBeenCalled();
565+
expect(h.syncSchemas).not.toHaveBeenCalled();
566+
expect(h.ledgerHas('com.example.crm')).toBe(false);
567+
});
568+
}
569+
});

‎packages/cloud-connection/src/marketplace-install-local-plugin.ts‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@
7373
* payload check, step 1b (package id), the ADR-0131 D3
7474
* walled-catalog refusal and step 1c (protocol handshake),
7575
* none of which writes, and before any write.
76+
* [#30794] `targetNamespace` (optional) renames the copy into
77+
* a namespace of its own — the import's rename-on-install:
78+
* every copied object name takes it, every reference follows,
79+
* and the answer's `installedFrom.namespace` records the
80+
* source's. Absent, the copy keeps the package's namespace.
81+
* `targetNamespace` beside a `managed` install (or no `installMode`)
82+
* answers `400 INVALID_REQUEST` before anything is fetched: a
83+
* managed install is never renamed, it keeps its upgrade channel.
7684
* A mode the package's `installModes` does not permit answers
7785
* `422 INSTALL_MODE_NOT_PERMITTED`, before any write.
7886
* Either mode reads the body's `withheldMaps` — the connector header
@@ -1841,6 +1849,25 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
18411849
}, 400);
18421850
}
18431851

1852+
// 0″. [#30794] ⭐ RENAME-ON-INSTALL IS UNMANAGED ONLY. A `targetNamespace`
1853+
// renames the copy an `unmanaged` install writes; a MANAGED install
1854+
// is the package itself, registered under the namespace it ships
1855+
// and upgraded through it, so it is never renamed — refused here,
1856+
// before the cloud round-trip, never ignored. The value itself is
1857+
// judged by the import, against the manifest key's own declaration.
1858+
const targetNamespace: unknown = body?.targetNamespace;
1859+
if (targetNamespace !== undefined && installMode !== 'unmanaged') {
1860+
return c.json({
1861+
success: false,
1862+
error: {
1863+
code: 'INVALID_REQUEST',
1864+
message: '`targetNamespace` renames an unmanaged copy, and this install is managed: a managed install '
1865+
+ 'is never renamed — it keeps the package\'s namespace and its upgrade channel. Install with '
1866+
+ '`installMode: \'unmanaged\'` to take a copy under a namespace of its own, or omit `targetNamespace`.',
1867+
},
1868+
}, 400);
1869+
}
1870+
18441871
// ── Offline path: an inline manifest was supplied (file import). ──
18451872
// Bypass the cloud-fetch entirely; no OS_CLOUD_URL required.
18461873
const inlineManifest = body?.manifest && typeof body.manifest === 'object' ? body.manifest : null;
@@ -2233,6 +2260,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
22332260
userId,
22342261
withheldMaps,
22352262
protocolCheckSkipped,
2263+
...(targetNamespace !== undefined ? { targetNamespace } : {}),
22362264
});
22372265
}
22382266

@@ -4007,9 +4035,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
40074035
withheldMaps: readonly WithheldMap[];
40084036
/** [#30326] The handshake admitted the package unchecked ({@link protocolCheckSkippedOf}). */
40094037
protocolCheckSkipped: ProtocolCheckSkipped | undefined;
4038+
/** [#30794] The namespace the copy is renamed into, as the request wrote it; the import judges it. */
4039+
targetNamespace?: unknown;
40104040
},
40114041
): Promise<Response> => {
4012-
const { manifest, manifestId, version, versionId, userId, withheldMaps, protocolCheckSkipped } = args;
4042+
const { manifest, manifestId, version, versionId, userId, withheldMaps, protocolCheckSkipped, targetNamespace } = args;
40134043
let protocol: UnmanagedImporter | undefined;
40144044
try { protocol = ctx.getService<UnmanagedImporter>('protocol'); } catch { /* no protocol service */ }
40154045
if (!protocol || typeof protocol.importUnmanagedPackage !== 'function') {
@@ -4029,6 +4059,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
40294059
try {
40304060
result = await protocol.importUnmanagedPackage({
40314061
manifest: packageBody,
4062+
...(targetNamespace !== undefined ? { targetNamespace } : {}),
40324063
...(userId ? { actor: userId } : {}),
40334064
} as Parameters<NonNullable<UnmanagedImporter['importUnmanagedPackage']>>[0]);
40344065
} catch (err: any) {
@@ -4059,13 +4090,20 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
40594090
}, resolved.status as any);
40604091
}
40614092

4093+
// [#30794] A renamed copy's provenance carries the namespace its names
4094+
// came from beside the package and version: `installedFrom.namespace`
4095+
// → the answer's `namespace` is the mapping the rename applied.
4096+
// Omitted when the copy kept the source's namespace, so that answer's
4097+
// key set is unchanged.
40624098
const installedFrom = {
40634099
packageId: result.sourcePackageId,
40644100
...(result.sourceVersion !== undefined ? { version: result.sourceVersion } : {}),
4101+
...(result.sourceNamespace !== undefined ? { namespace: result.sourceNamespace } : {}),
40654102
};
40664103
ctx.logger?.info?.(
40674104
`[MarketplaceInstallLocal] installed ${manifestId} unmanaged: ${result.copiedCount} item(s) copied into the `
4068-
+ `environment base '${result.targetPackageId}' (namespace '${result.namespace}'), installed from `
4105+
+ `environment base '${result.targetPackageId}' (namespace '${result.namespace}'`
4106+
+ `${result.sourceNamespace !== undefined ? `, renamed from '${result.sourceNamespace}'` : ''}), installed from `
40694107
+ `${installedFrom.packageId}${installedFrom.version ? `@${installedFrom.version}` : ''}`
40704108
+ `${userId ? ` by ${userId}` : ''}. Nothing is registered under '${manifestId}'; the copy follows no upgrade.`,
40714109
);

0 commit comments

Comments
 (0)