Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/30794-unmanaged-install-target-namespace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/spec': minor
'@objectstack/metadata-protocol': patch
'@objectstack/cloud-connection': patch
---

feat(metadata-protocol,cloud-connection): an unmanaged install can take a target namespace

Clause-②: yes (widening)
Metadata-protocol: added

- **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.
- **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.
- **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`.
- **A managed install is never renamed.** `targetNamespace` beside a managed install (or no `installMode`) answers `400 INVALID_REQUEST` before anything is fetched.
- **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.
- **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.
49 changes: 48 additions & 1 deletion content/docs/deployment/install-modes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ runtime installs managed. The door judges the mode before it writes anything:
| A mode the package's `installModes` does not permit | `422 INSTALL_MODE_NOT_PERMITTED`, naming what it permits |
| `unmanaged` on a deployment whose requested tenancy posture walls organizations (`group`, `isolated`) | `409 UNMANAGED_INSTALL_WALLED_POSTURE`, naming the posture |
| `managed`, while an unmanaged copy of the package holds its namespace | `409 RESOURCE_CONFLICT`, naming the copy's base |
| `targetNamespace` on a `managed` install, or with no `installMode` | `400 INVALID_REQUEST`: a managed install is never renamed |

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

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

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

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

### Installing the copy under a namespace of its own

An unmanaged install can rename the copy into a namespace you name, so it sits
beside the package, or beside another copy of it, under names of its own. Send
`targetNamespace` with `installMode: 'unmanaged'` in the body of
`POST /api/v1/marketplace/install-local` (the CLI has no flag for it):

```json
{ "packageId": "com.acme.crm", "versionId": "1.2.0", "installMode": "unmanaged", "targetNamespace": "acme" }
```

The copy is renamed by the same rules as duplicating a package:

- **Every object takes the new namespace.** `crm_lead` copies as `acme_lead`,
and an object whose name lacks the package's prefix takes the new one in
front (`ticket` copies as `acme_ticket`).
- **What names an object follows it.** A lookup's `reference`, a view's
`object`, a permission set's `objects` keys and the object part of its
`fields` keys, a translation's object keys, a form action's view target.
A view item follows its object (`crm_lead.board` copies as
`acme_lead.board`), and so does a view container and every view it expands
into. A field keeps its key, and a word in free text is renamed only when it
is a whole prefixed object name (`crm_lead`, never `x_crm_lead`).
- **Translations are named `acme_<locale>`**, with the object keys inside them
renamed.
- **An object of another package keeps its name**, and so do its view items.

`targetNamespace` follows the rule for a manifest `namespace`, and the
platform's shared namespaces `base`, `system` and `sys` are refused. Every
refusal is answered before the base exists, so nothing is installed:

| The rename | The answer |
|:---|:---|
| A `targetNamespace` the namespace rule refuses, or a shared one | `400 INVALID_REQUEST`, naming `targetNamespace` |
| 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 |
| 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 |
| 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 |
| A renamed name the environment or a managed package already holds | `409 RESOURCE_CONFLICT`, naming each |
| A `targetNamespace` another package owns | `409 RESOURCE_CONFLICT`, naming it |

The install answer's `installedFrom` gains `namespace`, the package's own,
beside its id and version, so the answer records the rename it applied: names
in `installedFrom.namespace` were copied into the copy's `namespace`. Without
`targetNamespace` the answer is unchanged.

---

## Choosing what to permit
Expand Down
4 changes: 3 additions & 1 deletion content/docs/references/api/package-lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ A navigation item another installed package contributes to an app the base owns,
| **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. |
| **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`. |
| **targetName** | `string` | optional | Display name of the new base. Defaults to the source package's `name`. |
| **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. |


---
Expand All @@ -259,7 +260,8 @@ A navigation item another installed package contributes to an app the base owns,
| **sourcePackageId** | `string` | ✅ | The id of the package the items were copied from. |
| **sourceVersion** | `string` | optional | The version the imported body declared, when it declared one. |
| **targetPackageId** | `string` | ✅ | The new writable base that owns every copied item. Nothing is registered under the source id. |
| **namespace** | `string` | ✅ | The base's namespace — the SOURCE package's, kept so every copied object name stays valid. |
| **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. |
| **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. |
| **copiedCount** | `number` | ✅ | How many items were copied. |
| **copied** | `{ type: string; name: string }[]` | ✅ | Every item that was copied, in the order it was written. |
| **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. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -506,3 +506,64 @@ describe('[#23914] a truncated install body is refused, and nothing is written',
expect(empty.payload).toEqual(none.payload);
});
});

describe('install-local — `targetNamespace` renames an unmanaged copy, and only an unmanaged one (#30794)', () => {
/** The import as it answers a renamed copy: the request's namespace, and the source's beside it. */
const renamingImporter = async (request: any) => ({
sourcePackageId: request.manifest.id,
sourceVersion: request.manifest.version,
targetPackageId: `${request.manifest.id}-copy`,
namespace: request.targetNamespace ?? request.manifest.namespace,
...(request.targetNamespace !== undefined ? { sourceNamespace: request.manifest.namespace } : {}),
copiedCount: 1,
copied: [{ type: 'object', name: `${request.targetNamespace ?? request.manifest.namespace}_lead` }],
strippedLocks: [],
});

it('hands `targetNamespace` to the import, and records the source namespace beside `installedFrom`', async () => {
const h = await mount(dir, { importer: renamingImporter });
const res = await h.install({ manifest: crm({ installModes: BOTH }), installMode: 'unmanaged', targetNamespace: 'acme' });
expect(res.status, JSON.stringify(res.payload)).toBe(200);

expect(h.importUnmanagedPackage).toHaveBeenCalledTimes(1);
expect(h.importUnmanagedPackage!.mock.calls[0]![0]).toMatchObject({ targetNamespace: 'acme', actor: INSTALLER });
// The mapping the rename applied: `installedFrom.namespace` → `namespace`.
expect(res.payload.data).toMatchObject({
installMode: 'unmanaged',
namespace: 'acme',
installedFrom: { packageId: 'com.example.crm', version: '4.0.0', namespace: 'crm' },
copied: [{ type: 'object', name: 'acme_lead' }],
});
const info = h.logger.info.mock.calls.map((call: any[]) => String(call[0])).join('\n');
expect(info).toContain("(namespace 'acme', renamed from 'crm')");
});

it('without it the import is asked as before, and `installedFrom` carries no namespace — the key set unchanged', async () => {
const h = await mount(dir, { importer: renamingImporter });
const res = await h.install({ manifest: crm({ installModes: BOTH }), installMode: 'unmanaged' });
expect(res.status).toBe(200);
expect(h.importUnmanagedPackage!.mock.calls[0]![0]).not.toHaveProperty('targetNamespace');
expect(res.payload.data.installedFrom).toEqual({ packageId: 'com.example.crm', version: '4.0.0' });
});

for (const installMode of [undefined, 'managed'] as const) {
it(`beside a ${installMode ?? 'default (managed)'} install: 400 INVALID_REQUEST — a managed install is never renamed; nothing fetched or written`, async () => {
const h = await mount(dir, { importer: renamingImporter });
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
const res = await h.install({
manifest: crm({ installModes: BOTH }),
...(installMode ? { installMode } : {}),
targetNamespace: 'acme',
});
expect(res.status).toBe(400);
expect(res.payload.error.code).toBe('INVALID_REQUEST');
expect(res.payload.error.message).toContain('a managed install is never renamed');
expect(fetchSpy).not.toHaveBeenCalled();
expect(h.importUnmanagedPackage).not.toHaveBeenCalled();
expect(h.register).not.toHaveBeenCalled();
expect(h.syncSchemas).not.toHaveBeenCalled();
expect(h.ledgerHas('com.example.crm')).toBe(false);
});
}
});
42 changes: 40 additions & 2 deletions packages/cloud-connection/src/marketplace-install-local-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@
* payload check, step 1b (package id), the ADR-0131 D3
* walled-catalog refusal and step 1c (protocol handshake),
* none of which writes, and before any write.
* [#30794] `targetNamespace` (optional) renames the copy into
* a namespace of its own — the import's rename-on-install:
* every copied object name takes it, every reference follows,
* and the answer's `installedFrom.namespace` records the
* source's. Absent, the copy keeps the package's namespace.
* `targetNamespace` beside a `managed` install (or no `installMode`)
* answers `400 INVALID_REQUEST` before anything is fetched: a
* managed install is never renamed, it keeps its upgrade channel.
* A mode the package's `installModes` does not permit answers
* `422 INSTALL_MODE_NOT_PERMITTED`, before any write.
* Either mode reads the body's `withheldMaps` — the connector header
Expand Down Expand Up @@ -1841,6 +1849,25 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
}, 400);
}

// 0″. [#30794] ⭐ RENAME-ON-INSTALL IS UNMANAGED ONLY. A `targetNamespace`
// renames the copy an `unmanaged` install writes; a MANAGED install
// is the package itself, registered under the namespace it ships
// and upgraded through it, so it is never renamed — refused here,
// before the cloud round-trip, never ignored. The value itself is
// judged by the import, against the manifest key's own declaration.
const targetNamespace: unknown = body?.targetNamespace;
if (targetNamespace !== undefined && installMode !== 'unmanaged') {
return c.json({
success: false,
error: {
code: 'INVALID_REQUEST',
message: '`targetNamespace` renames an unmanaged copy, and this install is managed: a managed install '
+ 'is never renamed — it keeps the package\'s namespace and its upgrade channel. Install with '
+ '`installMode: \'unmanaged\'` to take a copy under a namespace of its own, or omit `targetNamespace`.',
},
}, 400);
}

// ── Offline path: an inline manifest was supplied (file import). ──
// Bypass the cloud-fetch entirely; no OS_CLOUD_URL required.
const inlineManifest = body?.manifest && typeof body.manifest === 'object' ? body.manifest : null;
Expand Down Expand Up @@ -2233,6 +2260,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
userId,
withheldMaps,
protocolCheckSkipped,
...(targetNamespace !== undefined ? { targetNamespace } : {}),
});
}

Expand Down Expand Up @@ -4007,9 +4035,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
withheldMaps: readonly WithheldMap[];
/** [#30326] The handshake admitted the package unchecked ({@link protocolCheckSkippedOf}). */
protocolCheckSkipped: ProtocolCheckSkipped | undefined;
/** [#30794] The namespace the copy is renamed into, as the request wrote it; the import judges it. */
targetNamespace?: unknown;
},
): Promise<Response> => {
const { manifest, manifestId, version, versionId, userId, withheldMaps, protocolCheckSkipped } = args;
const { manifest, manifestId, version, versionId, userId, withheldMaps, protocolCheckSkipped, targetNamespace } = args;
let protocol: UnmanagedImporter | undefined;
try { protocol = ctx.getService<UnmanagedImporter>('protocol'); } catch { /* no protocol service */ }
if (!protocol || typeof protocol.importUnmanagedPackage !== 'function') {
Expand All @@ -4029,6 +4059,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
try {
result = await protocol.importUnmanagedPackage({
manifest: packageBody,
...(targetNamespace !== undefined ? { targetNamespace } : {}),
...(userId ? { actor: userId } : {}),
} as Parameters<NonNullable<UnmanagedImporter['importUnmanagedPackage']>>[0]);
} catch (err: any) {
Expand Down Expand Up @@ -4059,13 +4090,20 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
}, resolved.status as any);
}

// [#30794] A renamed copy's provenance carries the namespace its names
// came from beside the package and version: `installedFrom.namespace`
// → the answer's `namespace` is the mapping the rename applied.
// Omitted when the copy kept the source's namespace, so that answer's
// key set is unchanged.
const installedFrom = {
packageId: result.sourcePackageId,
...(result.sourceVersion !== undefined ? { version: result.sourceVersion } : {}),
...(result.sourceNamespace !== undefined ? { namespace: result.sourceNamespace } : {}),
};
ctx.logger?.info?.(
`[MarketplaceInstallLocal] installed ${manifestId} unmanaged: ${result.copiedCount} item(s) copied into the `
+ `environment base '${result.targetPackageId}' (namespace '${result.namespace}'), installed from `
+ `environment base '${result.targetPackageId}' (namespace '${result.namespace}'`
+ `${result.sourceNamespace !== undefined ? `, renamed from '${result.sourceNamespace}'` : ''}), installed from `
+ `${installedFrom.packageId}${installedFrom.version ? `@${installedFrom.version}` : ''}`
+ `${userId ? ` by ${userId}` : ''}. Nothing is registered under '${manifestId}'; the copy follows no upgrade.`,
);
Expand Down
Loading
Loading