From 125f430f2b09dbd93dcc75e16333ab5c4143718f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 01:03:13 +0000 Subject: [PATCH 1/5] wip(cloud-connection,cli): install a .custom export into its writable pair Claude-Session: https://claude.ai/code/session_01Utp6CXfNPQKEBNuyohXjfg Co-authored-by: Claude --- packages/cli/src/commands/package/install.ts | 40 +- ...tplace-install-local-customization-pair.ts | 176 +++++++++ .../src/marketplace-install-local-plugin.ts | 369 +++++++++++++++++- 3 files changed, 569 insertions(+), 16 deletions(-) create mode 100644 packages/cloud-connection/src/marketplace-install-local-customization-pair.ts diff --git a/packages/cli/src/commands/package/install.ts b/packages/cli/src/commands/package/install.ts index 0e2e99b694e..e3b442e989f 100644 --- a/packages/cli/src/commands/package/install.ts +++ b/packages/cli/src/commands/package/install.ts @@ -43,7 +43,7 @@ import { readFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { resolve as resolvePath } from 'node:path'; import { Args, Command, Flags } from '@oclif/core'; -import { INSTALL_MODES, type InstallMode } from '@objectstack/spec/kernel'; +import { INSTALL_MODES, customizationBasePackageId, type InstallMode } from '@objectstack/spec/kernel'; import { printHeader, printKV, printSuccess, printWarning, printError, printStep, isExitSignal } from '../../utils/format.js'; /** @@ -306,6 +306,44 @@ export default class PackageInstall extends Command { const data = res.body?.data ?? res.body ?? {}; + // [ADR-0146 D3, #30106] A base's paired customization package answers + // its own shape too: the writable pair its items were written into, + // never a kernel registration or a cached manifest — so, as for an + // unmanaged install, none of the managed lines below is true of it. + const pair = data.customizationPair; + if (pair && typeof pair === 'object' && typeof pair.packageId === 'string') { + console.log(''); + printSuccess( + `Customization package installed into the writable pair of '${String(pair.basePackageId)}' — ` + + 'its overrides are served now, and the pair stays editable on this runtime', + ); + printKV(' Package', String(data.manifestId ?? label)); + printKV(' Version', String(data.version ?? flags.version)); + printKV(' Runtime', runtime); + printKV(' Pair', `${pair.packageId}${pair.created === true ? ' (created by this install)' : ''}`); + if (typeof data.writtenCount === 'number') printKV(' Items written', String(data.writtenCount)); + if (data.seeded?.mode === 'skipped' && typeof data.seeded.reason === 'string') { + printKV(' Sample data', `not loaded — ${data.seeded.reason}`); + } + printWithheldMaps(data.withheldMaps); + console.log(''); + return; + } + // Sent a pair, answered as a managed install: the runtime predates the + // pair install and registered the pair as CODE — read-only, serving none + // of its overrides. Said out loud, never printed as a success: that + // answer is what #30106 measured. + if (customizationBasePackageId(String(data.manifestId ?? label)) !== null) { + const pairId = String(data.manifestId ?? label); + printError( + `The runtime at ${runtime} installed ${pairId} as code, not into its writable pair: it serves none of the ` + + 'package\'s overrides and leaves the pair read-only. Upgrade the runtime, uninstall the package ' + + `(\`DELETE ${runtime}/api/v1/marketplace/install-local/${pairId}\`), then install it again.`, + ); + this.exit(1); + return; + } + // [ADR-0131 D6] An unmanaged install answers its own shape: what was // copied and where, never a kernel registration — so none of the // managed lines below (hot-load, the cached-manifest directory that diff --git a/packages/cloud-connection/src/marketplace-install-local-customization-pair.ts b/packages/cloud-connection/src/marketplace-install-local-customization-pair.ts new file mode 100644 index 00000000000..3e40e21d0df --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-customization-pair.ts @@ -0,0 +1,176 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0146 D2 / D3, #30106] The PURE half of installing a base's paired + * customization package (`.custom`) through + * `POST /api/v1/marketplace/install-local`: which items one package body + * carries, in the order they are written, and every refusal the body alone + * decides. The orchestration — the pair's ensure door, one save per item, the + * rollback — is `MarketplaceInstallLocalPlugin#installCustomizationPair`. + * + * ## Why a pair is never installed as code + * + * ADR-0146 D3: the pair is "an ordinary writable package", and D2: what an + * administrator authors into it is "exported (`GET /packages/:id/export`), + * committed, reviewed and deployed to another environment as any package is". + * On the environment that authored it the pair is a writable package and every + * item is a stored `sys_metadata` row bound to it; every read that serves a + * declared override reads those rows (`isServedOverrideRowOf`), and the + * customization read lists them (`listServedOverrides`). A managed install + * registers a package's items in the registry as code instead, so a pair + * installed that way served none of its overrides, listed none of them, and + * made the pair read-only, which closed the customization door behind it — + * while the install answered success. So a pair's install lands what the + * export describes: the target's own writable pair (its ensure door, the one + * creation path D3 names) holding the items, each written through the save + * door a Studio edit takes, which judges it as it judges that edit. + * + * ## What the body carries + * + * The keys `GET /packages/:id/export` writes (`PackageExportManifestSchema`): + * + * - one array per metadata collection, spelled as the manifest spells it + * (`PLURAL_TO_SINGULAR`) — each element one item of that type, a `views:` + * container saved under the object it binds, as the artifact loader names + * it; + * - `viewItems` (`ASSEMBLED_VIEW_ITEMS_KEY`), the export's channel for a + * standalone view — where a view override travels; + * - `objectExtensions`, each one an `object_extension` item named by the + * object it extends (ADR-0146 D10; the export reads the pair back the same + * way, `packages/runtime/src/domains/packages.ts`); + * - the package's own keys, classified by `UNMANAGED_INSTALL_KEY_DISPOSITIONS` + * (`@objectstack/spec/kernel`), the one table of a package body's + * non-collection keys: `identity` / `base` keys describe the package, `door` + * keys are this door's (seed `data` is reported as not loaded), and + * `deployment` keys are read by a stack's boot and by no install. + * + * Every other key that carries content — code, a `translations` bundle, a + * contribution the item rows cannot hold, an artifact envelope, a key the + * table does not classify — is refused before anything is written, naming + * each key: an export of a pair never writes one, and dropping it would be the + * silent partial install this door exists to refuse. + */ + +import { + DEFAULT_METADATA_TYPE_REGISTRY, + UNMANAGED_INSTALL_KEY_DISPOSITIONS, + unmanagedInstallValueCarries, +} from '@objectstack/spec/kernel'; +import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; +import { ASSEMBLED_VIEW_ITEMS_KEY, deriveViewContainerObject, isAggregatedViewContainer } from '@objectstack/spec/ui'; + +/** + * The collection a pair's object extensions travel in, and the metadata type + * each entry is stored as. `PLURAL_TO_SINGULAR` carries no such pair — an + * extension merges into its target rather than registering under a type of + * its own — so the export states the pair where it reads the items out + * (`assemblePackageManifest`), and this reading states it where they go back. + */ +export const PAIR_OBJECT_EXTENSIONS_KEY = 'objectExtensions'; +export const PAIR_OBJECT_EXTENSION_TYPE = 'object_extension'; + +/** One item a pair install writes, in write order. */ +export interface PairInstallItem { + /** Canonical (singular) metadata type. */ + readonly type: string; + /** The name it is saved under. */ + readonly name: string; + /** The body, as the export carries it. */ + readonly item: Record; + /** Where it sits in the body, e.g. `viewItems[0]`. */ + readonly path: string; +} + +/** What {@link planCustomizationPairInstall} answers. */ +export type PairInstallPlan = + | { + readonly ok: true; + readonly items: readonly PairInstallItem[]; + /** The body carries seed `data`, which a pair install does not load (it is data, not an item). */ + readonly carriesSeedData: boolean; + } + | { + readonly ok: false; + /** One located sentence per problem, every one of them — nothing is written. */ + readonly problems: readonly string[]; + }; + +const isPlainRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +/** The keys of a pair body that are NOT items and whose content no pair install can write. */ +const REFUSED_DISPOSITIONS: ReadonlySet = new Set(['code', 'uncopyable', 'envelope', 'converted']); + +function loadOrderOf(type: string): number { + return DEFAULT_METADATA_TYPE_REGISTRY.find((entry) => entry.type === type)?.loadOrder ?? Number.MAX_SAFE_INTEGER; +} + +/** The name an item of `type` is saved under, or `undefined` when it carries none. */ +function itemNameOf(type: string, item: Record): string | undefined { + if (type === PAIR_OBJECT_EXTENSION_TYPE) { + return typeof item.extend === 'string' && item.extend !== '' ? item.extend : undefined; + } + if (type === 'view' && (isAggregatedViewContainer(item) || item.name === undefined)) { + const object = deriveViewContainerObject(item); + return typeof object === 'string' && object !== '' ? object : undefined; + } + return typeof item.name === 'string' && item.name !== '' ? item.name : undefined; +} + +/** + * The items one flat pair body carries, in write order (the registry's + * `loadOrder`, so an object and its extensions are written before a view that + * names their fields), or every problem the body alone shows. Pure. + */ +export function planCustomizationPairInstall(body: Record): PairInstallPlan { + const problems: string[] = []; + const ranked: Array = []; + let carriesSeedData = false; + Object.entries(body).forEach(([key, value], keyIndex) => { + const type = key === ASSEMBLED_VIEW_ITEMS_KEY + ? 'view' + : key === PAIR_OBJECT_EXTENSIONS_KEY + ? PAIR_OBJECT_EXTENSION_TYPE + : (Object.prototype.hasOwnProperty.call(PLURAL_TO_SINGULAR, key) ? PLURAL_TO_SINGULAR[key] : undefined); + if (type === undefined) { + if (!unmanagedInstallValueCarries(value)) return; + const disposition = Object.prototype.hasOwnProperty.call(UNMANAGED_INSTALL_KEY_DISPOSITIONS, key) + ? UNMANAGED_INSTALL_KEY_DISPOSITIONS[key] + : undefined; + if (key === 'data' && disposition === 'door') carriesSeedData = true; + if (disposition === undefined || REFUSED_DISPOSITIONS.has(disposition)) { + problems.push(`\`${key}\` is not part of a paired customization package: its items are written as ` + + 'stored items of the pair, and this key has no item form'); + } + return; + } + if (value === undefined || value === null) return; + if (!Array.isArray(value)) { + problems.push(`\`${key}\` is not an array of ${type} definitions`); + return; + } + value.forEach((item: unknown, index) => { + const path = `${key}[${index}]`; + if (!isPlainRecord(item)) { + problems.push(`\`${path}\` is not a ${type} definition object`); + return; + } + const name = itemNameOf(type, item); + if (name === undefined) { + problems.push(`\`${path}\` has no name`); + return; + } + ranked.push({ type, name, item, path, rank: [loadOrderOf(type), keyIndex, index] }); + }); + }); + const seen = new Map(); + for (const entry of ranked) { + const slot = `${entry.type} '${entry.name}'`; + const first = seen.get(slot); + if (first !== undefined) problems.push(`two items claim ${slot} (\`${first}\` and \`${entry.path}\`)`); + else seen.set(slot, entry.path); + } + if (problems.length > 0) return { ok: false, problems }; + ranked.sort((a, b) => a.rank[0] - b.rank[0] || a.rank[1] - b.rank[1] || a.rank[2] - b.rank[2]); + return { ok: true, items: ranked.map(({ rank: _rank, ...entry }) => entry), carriesSeedData }; +} diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 61e7c9fb877..ce0bcaeca74 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -191,6 +191,9 @@ import { resolveInstallModes, deriveNamespaceFromPackageId, unmanagedInstallValueCarries, + // [ADR-0146 D3, #30106] The one spelling of the pair suffix: which ids are a + // base's paired customization package. + customizationBasePackageId, type InstallMode, } from '@objectstack/spec/kernel'; // [#28640 ruling ③A] The export's list of the connector maps it withheld whole, @@ -236,6 +239,7 @@ import { ConnectionCredentialStore } from './connection-credential-store.js'; import { readJsonRequestBody } from './request-json-body.js'; import { MARKETPLACE_INSTALLED_UI_BUNDLE } from './marketplace-ui.js'; import { matchSeedRows, purgeSeedRows, type PurgeSeedDataset } from './marketplace-install-local-purge.js'; +import { planCustomizationPairInstall, type PairInstallItem } from './marketplace-install-local-customization-pair.js'; import type { AuthSessionApi, ConnectorCredentialRef, IAuthService, IAutomationService, IHttpServer, IMetadataService, IObjectQLEngine, } from '@objectstack/spec/contracts'; @@ -252,6 +256,10 @@ import type { // before any write), the read-only-package rule (a namespace held by a WRITABLE base is // this environment's own metadata), and the client-facing sentence rule. import { unmanagedInstallPostureRefusal, isWritablePackage, clientFacingFailureText, captureUninstallDeclared } from '@objectstack/metadata-protocol'; +// [ADR-0146 D3, #30106] What a pair-shaped package's manifest must declare — +// the predicate every package install asks of such an id — asked by the pair +// install before the pair's ensure door. +import { customizationPairManifestRefusal } from '@objectstack/metadata-protocol'; // [ADR-0146 D2, #28410] The override write gate's one rule and its item walk, // asked at the managed install exactly as the publish doors ask them. import { judgeOverrideWrite, overrideWriteIdentityOf, overrideWriteItemsOf, type OverrideWriteItem, type OverrideWriteRule } from '@objectstack/metadata-protocol'; @@ -522,6 +530,19 @@ type UnmanagedImporter = { importUnmanagedPackage?: ObjectStackProtocolImplementation['importUnmanagedPackage']; }; +/** + * [ADR-0146 D2 / D3, #30106] The three verbs a pair install composes, typed + * from the producer's own method signatures and asked as a capability + * question at the call site, as {@link UnmanagedImporter} is: the pair's + * ensure door (its one creation path), the save door each item is written + * through, and the package delete that undoes a pair this install created. + */ +type CustomizationPairInstaller = { + ensureCustomizationPackage?: ObjectStackProtocolImplementation['ensureCustomizationPackage']; + saveMetaItem?: ObjectStackProtocolImplementation['saveMetaItem']; + deletePackage?: ObjectStackProtocolImplementation['deletePackage']; +}; + /** * [ADR-0131 D6] What the managed-after-unmanaged refusal reads off the * `objectql` slot: who holds a namespace, and — through ADR-0070's one @@ -576,6 +597,27 @@ export interface OverrideUpgradeCollision { message: string; } +/** + * The answer of a managed or pair install the override write gate refused + * (status `403`): `NOT_OVERRIDABLE`, the publish doors' code, the first item's + * sentence, and every refusal in `details`. One builder, so both installs that + * ask the gate answer it alike. + */ +function overrideGateRefusalBody(manifestId: string, refusals: readonly OverrideInstallRefusal[]) { + const n = refusals.length; + return { + success: false, + error: { + code: 'NOT_OVERRIDABLE', + message: `Package '${manifestId}' cannot be installed: ${n} of its item${n > 1 ? 's are' : ' is'} refused by ` + + `the override write gate (ADR-0146 D2). ${refusals[0].message}` + + (n > 1 ? ` (${n - 1} more in \`details.refusals\`.)` : '') + + ' Nothing was installed or written.', + details: { refusals }, + }, + }; +} + function dependencyIdsOf(manifest: unknown): string[] { const declared = (manifest as { dependencies?: unknown } | null | undefined)?.dependencies; if (Array.isArray(declared)) return declared.filter((d): d is string => typeof d === 'string' && d !== ''); @@ -655,7 +697,11 @@ function judgeWithheldMaps( * withheld maps: each one named, once, at `warn` — the connector is installed * and runs, without the headers or environment variables the export held back. */ -function withheldMapsLogLine(manifestId: string, mode: InstallMode, withheldMaps: readonly WithheldMap[]): string { +function withheldMapsLogLine( + manifestId: string, + mode: InstallMode | 'into its writable pair', + withheldMaps: readonly WithheldMap[], +): string { const named = withheldMaps.map((m) => `${m.type} '${m.name}' ${m.path}`).join(', '); return `[MarketplaceInstallLocal] installed ${manifestId} ${mode} with ${withheldMaps.length} connector map(s) the ` + `package export withheld whole, each now absent: ${named}. The connector sends none of those headers or ` @@ -2013,6 +2059,51 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }); } + // ── [ADR-0146 D2 / D3, #30106] A base's PAIRED CUSTOMIZATION PACKAGE + // is installed as what it is, never as code: the target's own + // writable pair, created by its ensure door when absent, holding + // every item of the body as a stored item written through the save + // door ({@link installCustomizationPair}). Registered as code, a + // pair served none of its overrides, listed none on the + // customization read and left the pair read-only, while this door + // answered success. Asked here, before the conflict check and every + // later step of the managed install: those steps register, ledger + // and sync the package as code, and none of them is a pair's. + // ⛔ A multi-package artifact never carries a pair: each pair is + // installed from its own export, after its base. + const pairBaseId = customizationBasePackageId(manifestId); + const bundledPairs: string[] = Array.isArray(manifest.packages) + ? manifest.packages + .map((entry: unknown) => (isPlainObject(entry) && isPlainObject((entry as any).manifest) ? manifestIdOf((entry as any).manifest) : undefined)) + .filter((id: unknown): id is string => typeof id === 'string' && customizationBasePackageId(id) !== null) + : []; + if (Array.isArray(manifest.packages) && (pairBaseId !== null || bundledPairs.length > 0)) { + const named = pairBaseId !== null ? [manifestId, ...bundledPairs] : bundledPairs; + return c.json({ + success: false, + error: { + code: UNRUNNABLE_REFUSAL_CODE, + message: `Package '${manifestId}' cannot be installed: it carries the paired customization ` + + `package${named.length > 1 ? 's' : ''} ${named.map((id) => `'${id}'`).join(', ')} inside a ` + + 'multi-package artifact. A pair is installed on its own, from its own export, after its base ' + + '(ADR-0146 D3), so that its items are written into the writable pair. Nothing was installed or ' + + 'written.', + details: { pairs: named }, + }, + }, UNRUNNABLE_REFUSAL_STATUS); + } + if (pairBaseId !== null) { + return this.installCustomizationPair(c, ctx, { + manifest, + manifestId, + baseId: pairBaseId, + version, + versionId: resolvedVersionId, + userId, + withheldMaps, + }); + } + // 1f. [ADR-0131 D6] ⭐ A MANAGED INSTALL NEVER TAKES OVER AN UNMANAGED // COPY, on BOTH branches, before any write. A copy keeps the // package's namespace under a writable base of its own, so a @@ -2186,20 +2277,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // this version now ships are listed on the answer // (`overrideCollisions`), never refused. const overrideGate = await this.evaluateOverrideWriteGate(ctx, manifestId, converted); - if (overrideGate.refusals.length > 0) { - const n = overrideGate.refusals.length; - return c.json({ - success: false, - error: { - code: 'NOT_OVERRIDABLE', - message: `Package '${manifestId}' cannot be installed: ${n} of its item${n > 1 ? 's are' : ' is'} refused by ` - + `the override write gate (ADR-0146 D2). ${overrideGate.refusals[0].message}` - + (n > 1 ? ` (${n - 1} more in \`details.refusals\`.)` : '') - + ' Nothing was installed or written.', - details: { refusals: overrideGate.refusals }, - }, - }, 403); - } + if (overrideGate.refusals.length > 0) return c.json(overrideGateRefusalBody(manifestId, overrideGate.refusals), 403); // 2e. [ADR-0146 D10, second rule] ⭐ THE FIELD-PATCH TARGET CHECK, // beside the override gate and before any write: a patch naming a @@ -3722,6 +3800,267 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }, 200); }; + /** + * [ADR-0146 D2 / D3, #30106] The install of a base's paired customization + * package `.custom` — what `GET /packages/.custom/export` + * wrote on another environment — into THIS environment's own writable + * pair. The body's items become items of the pair exactly as Studio makes + * them: stored rows bound to the pair, each written through the save door, + * so every read that serves a declared override serves it, the + * customization read lists it, and an administrator here keeps + * customizing. The module header of + * `marketplace-install-local-customization-pair.ts` says why a pair is never + * registered as code. + * + * In order, nothing written before step 4: + * 1. the body is converted forward ({@link convertPackageBodyForward}, as a + * managed install converts its artifact) and walked into its items + * ({@link planCustomizationPairInstall}); a key with no item form, a + * nameless item or two items claiming one name refuse it whole; + * 2. the override write gate and the field-patch target check — the + * managed install's steps 2d and 2e, the same answers; + * 3. the pair here: its manifest must declare what the ensure door + * writes (`customizationPairManifestRefusal`, the predicate every + * package install asks of a pair-shaped id); a pair already installed + * READ-ONLY — registered as code by an earlier install through this + * door, or provided by this runtime's code — is refused, naming the + * uninstall that clears it; otherwise the ensure door answers the + * writable pair, creating it when absent (ADR-0146 D3: its one + * creation path), and its refusals are answered as it states them; + * 4. every item, in load order, through `saveMetaItem` with `mode: + * 'publish'` and the pair as its package — the save door a Studio edit + * takes, which judges each item as it judges that edit. + * + * A write the save door refuses answers failure, never success, naming the + * item, with the save's own code and status. A pair THIS install created + * is deleted again (its ensure and every write undone), so nothing of the + * package remains; a pair that existed before is kept, and the answer + * lists the items already written (they stay published) and the ones not + * written — the install is written to be run again: every item is saved + * again on the next run. + * + * Nothing is registered as code, ledgered under the pair's id or + * rehydrated at boot: the pair's rows and its `sys_packages` record are its + * persistence, as on the environment that authored it. An item the pair + * already holds that the body does not carry is left as it is — the install + * writes the package's items, it deletes none. Seed `data` is not loaded + * (it is data, not an item), and the answer says so. + */ + private installCustomizationPair = async ( + c: any, + ctx: PluginContext, + args: { + manifest: Record; manifestId: string; baseId: string; + version: string; versionId: string; userId: string | null; + /** [#28640 ruling ③A] The connector maps the export withheld ({@link judgeWithheldMaps}). */ + withheldMaps: readonly WithheldMap[]; + }, + ): Promise => { + const { manifest, manifestId, baseId, version, versionId, userId, withheldMaps } = args; + const refuse = (status: number, code: string, message: string, details?: Record) => + c.json({ success: false, error: { code, message, ...(details ? { details } : {}) } }, status as any); + + let protocol: CustomizationPairInstaller | undefined; + try { protocol = ctx.getService('protocol'); } catch { /* no protocol service */ } + const ensure = protocol?.ensureCustomizationPackage; + const save = protocol?.saveMetaItem; + const remove = protocol?.deletePackage; + if (typeof ensure !== 'function' || typeof save !== 'function' || typeof remove !== 'function') { + return refuse(503, 'SERVICE_UNAVAILABLE', + `Package '${manifestId}' is the paired customization package of '${baseId}', and this runtime cannot ` + + 'install it: its metadata protocol has no ensure door for the pair or no save door for its items. ' + + 'Upgrade @objectstack/metadata-protocol alongside @objectstack/cloud-connection. Nothing was installed ' + + 'or written.'); + } + + // 1. The body, converted forward, walked into its items. + const converted = flattenPackageBody(this.convertPackageBodyForward(ctx, manifest)); + const plan = planCustomizationPairInstall(converted); + if (!plan.ok) { + const shown = plan.problems.slice(0, 5).join('; '); + return refuse(UNRUNNABLE_REFUSAL_STATUS, UNRUNNABLE_REFUSAL_CODE, + `Package '${manifestId}' cannot be installed as the paired customization package of '${baseId}': ` + + `${shown}${plan.problems.length > 5 ? ` (+${plan.problems.length - 5} more)` : ''}. Nothing was installed ` + + 'or written.', + { problems: [...plan.problems] }); + } + + // 2. The managed install's 2d and 2e, before any write. + const overrideGate = await this.evaluateOverrideWriteGate(ctx, manifestId, converted); + if (overrideGate.refusals.length > 0) return c.json(overrideGateRefusalBody(manifestId, overrideGate.refusals), 403); + const patchTargetRefusals = this.fieldPatchTargetRefusalsOf(ctx, converted); + if (patchTargetRefusals.length > 0) { + return refuse(UNRUNNABLE_REFUSAL_STATUS, UNRUNNABLE_REFUSAL_CODE, + fieldPatchTargetRefusalMessage(manifestId, patchTargetRefusals), { refusals: patchTargetRefusals }); + } + + // 3. The pair here: declared as the ensure door writes it, writable. + let ql: PackageWritabilityEngine | undefined; + try { ql = ctx.getService('objectql'); } catch { /* no data engine */ } + const getInstalled = (id: string) => ql?.registry?.getPackage?.(id); + const manifestRefusal = customizationPairManifestRefusal(declaredPackageManifest(converted), getInstalled); + if (manifestRefusal !== undefined) { + return refuse(400, 'PLUGIN_MANIFEST_INVALID', `${manifestRefusal} Nothing was installed or written.`); + } + if (getInstalled(manifestId) !== undefined && !isWritablePackage(ql, manifestId)) { + return refuse(409, 'RESOURCE_CONFLICT', this.ledger.has(manifestId) + ? `Package '${manifestId}' is installed here as code, by an earlier install of it through this door, so ` + + 'its items are read-only and none of its overrides is served. Uninstall it — ' + + `\`DELETE ${ROUTE_BASE}/${manifestId}\` — and install it again: a paired customization package is ` + + `installed into the writable pair of '${baseId}' (ADR-0146 D3). Nothing was installed or written.` + : `Package '${manifestId}' is installed here read-only — provided by this runtime's code or an installed ` + + `app — so its items cannot be written into it. The paired customization package of '${baseId}' is a ` + + 'writable package (ADR-0146 D3): remove the read-only one first. Nothing was installed or written.', + { packageId: manifestId }); + } + let created: boolean; + try { + const ensured = await ensure.call(protocol, { basePackageId: baseId }); + created = ensured.created === true; + if (ensured.writable !== true) { + return refuse(409, 'RESOURCE_CONFLICT', + `Package '${manifestId}' is installed here read-only, so its items cannot be written into it. The ` + + `paired customization package of '${baseId}' is a writable package (ADR-0146 D3): remove the ` + + 'read-only one first. Nothing was installed or written.', + { packageId: manifestId }); + } + } catch (err: any) { + const resolved = resolveThrownHttpError(err, 500); + const fallback = `The paired customization package '${manifestId}' could not be ensured; the reason is in the server log.`; + const message = clientFacingFailureText(err, fallback); + if (message === fallback) { + ctx.logger?.warn?.(`[MarketplaceInstallLocal] ensuring ${manifestId} for its install failed (${resolved.status} ` + + `${resolved.code}): ${err?.message ?? err}`); + } + return refuse(resolved.status, resolved.code, + `Package '${manifestId}' cannot be installed: ${message} Nothing was installed or written.`); + } + + // 4. Every item, through the save door. + const written: Array<{ type: string; name: string }> = []; + for (const [at, entry] of plan.items.entries()) { + try { + await save.call(protocol, { + type: entry.type, + name: entry.name, + item: entry.item, + mode: 'publish', + packageId: manifestId, + ...(userId ? { actor: userId } : {}), + }); + written.push({ type: entry.type, name: entry.name }); + } catch (err: any) { + return this.pairInstallWriteFailure(c, ctx, remove.bind(protocol), { + manifestId, baseId, created, userId, err, failed: entry, written, + notWritten: plan.items.slice(at + 1).map((i) => ({ type: i.type, name: i.name })), + }); + } + } + + ctx.logger?.info?.( + `[MarketplaceInstallLocal] installed ${manifestId} into the writable pair of '${baseId}'` + + `${created ? ', created by its ensure door,' : ''}: ${written.length} item(s) written through the save door` + + `${userId ? ` by ${userId}` : ''}. Nothing is registered as code or ledgered under '${manifestId}'.`, + ); + if (withheldMaps.length > 0) ctx.logger?.warn?.(withheldMapsLogLine(manifestId, 'into its writable pair', withheldMaps)); + return c.json({ + success: true, + data: { + manifestId, + version, + versionId, + installedAt: new Date().toISOString(), + // What a pair install did, in place of a managed install's + // `hotLoaded` / `storageDir`: the pair the items went into. + customizationPair: { basePackageId: baseId, packageId: manifestId, created, writable: true }, + writtenCount: written.length, + written, + // [#28640 ruling ③A] Omitted when the export withheld none. + ...(withheldMaps.length > 0 ? { withheldMaps: [...withheldMaps] } : {}), + ...(plan.carriesSeedData + ? { + seeded: { + mode: 'skipped', + reason: 'a paired customization package installs its items, not data: its sample data was not loaded', + }, + } + : {}), + note: `The package's items are items of the writable pair '${manifestId}' of '${baseId}': served now, and ` + + 'editable here. Refresh the console to see them.', + }, + }, 200); + }; + + /** + * [ADR-0146 D3, #30106] The answer of a pair install whose item `failed` + * the save door refused — failure, never success, with the save's own + * code and status, naming the item. A pair the install `created` is + * deleted again, so nothing of the package remains; a deletion that does + * not finish is logged at `error`, since the environment then holds a + * partial pair while the install answered failure. A pair that existed + * before is kept: the answer lists what was written and what was not. + */ + private pairInstallWriteFailure = async ( + c: any, + ctx: PluginContext, + deletePackage: NonNullable, + args: { + manifestId: string; baseId: string; created: boolean; userId: string | null; err: any; + failed: PairInstallItem; + written: ReadonlyArray<{ type: string; name: string }>; + notWritten: ReadonlyArray<{ type: string; name: string }>; + }, + ): Promise => { + const { manifestId, baseId, created, userId, err, failed, written, notWritten } = args; + const resolved = resolveThrownHttpError(err, 500); + const fallback = 'the reason is in the server log'; + const reason = clientFacingFailureText(err, fallback); + if (reason === fallback) { + ctx.logger?.warn?.(`[MarketplaceInstallLocal] installing ${manifestId}: ${failed.type} '${failed.name}' could not be ` + + `written into the pair (${resolved.status} ${resolved.code}): ${err?.message ?? err}`); + } + let outcome: string; + if (created) { + let residue: string | undefined; + try { + const undone = await deletePackage({ packageId: manifestId, ...(userId ? { actor: userId } : {}) }); + if (undone.failedCount > 0) residue = `${undone.failedCount} of its items could not be removed`; + } catch (deleteError) { + residue = `its deletion failed: ${clientFacingFailureText(deleteError, 'deletion failed')}`; + } + if (residue !== undefined) { + ctx.logger?.error?.( + `[MarketplaceInstallLocal] the install of ${manifestId} failed and the pair it created was NOT fully ` + + `removed (${residue}): this environment still holds a partial '${manifestId}' while the install ` + + `answered failure. Delete the package '${manifestId}' to remove it.`, + ); + } + outcome = residue === undefined + ? 'The pair this install created was removed again, so nothing of the package remains.' + : `The pair this install created could NOT be fully removed (${residue}): delete the package ` + + `'${manifestId}' to finish.`; + } else { + outcome = `The pair '${manifestId}' existed before this install, so it is kept: the ${written.length} item(s) ` + + 'written before this one stay published (`details.written`), and the rest were not written ' + + '(`details.notWritten`). Correct the item and install again — every item is written again.'; + } + const details = isPlainObject(err?.details) ? err.details : undefined; + return c.json({ + success: false, + error: { + code: resolved.code, + message: `Package '${manifestId}' was not installed into the pair of '${baseId}': ${failed.type} ` + + `'${failed.name}' (\`${failed.path}\`) could not be written — ${reason}. ${outcome}`, + details: { + failed: { type: failed.type, name: failed.name, path: failed.path }, + written: [...written], + notWritten: [...notWritten], + ...(details ? { cause: details } : {}), + }, + }, + }, resolved.status as any); + }; + /** * Detect whether `manifestId` is already known to the kernel and classify * the source so we can refuse vs upgrade gracefully. From c039b0edf5e4ec20501eab99dae59d62277fcedf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 01:20:43 +0000 Subject: [PATCH 2/5] fix(cloud-connection,cli): install a .custom export into the target's writable pair An exported `.custom` installed through install-local was registered as code: none of its overrides was served, the customization read listed none, and the pair turned read-only, while the install answered success. A pair is now installed as what it is (ADR-0146 D2 / D3): the target's own pair through its ensure door, every item written through the save door with `mode: 'publish'`, nothing registered, synced or ledgered. Refusals the body or the target decide are answered before any write; a save refused part-way answers failure, removing a pair the install created. A pair already read-only on the target is refused 409 naming the way back. The CLI prints the pair it wrote, and prints an older runtime's as-code answer as an error. Claude-Session: https://claude.ai/code/session_01Utp6CXfNPQKEBNuyohXjfg Co-authored-by: Claude --- ...-install-custom-pair-into-writable-pair.md | 15 + packages/cli/src/commands/package/install.ts | 14 +- ...l-local-customization-pair-install.test.ts | 370 ++++++++++++++++++ ...e-install-local-customization-pair.test.ts | 129 ++++++ ...tplace-install-local-customization-pair.ts | 10 +- ...stall-local-extension-field-suffix.test.ts | 28 +- ...tplace-install-local-override-gate.test.ts | 46 ++- .../src/marketplace-install-local-plugin.ts | 101 +++-- 8 files changed, 660 insertions(+), 53 deletions(-) create mode 100644 .changeset/30106-install-custom-pair-into-writable-pair.md create mode 100644 packages/cloud-connection/src/marketplace-install-local-customization-pair-install.test.ts create mode 100644 packages/cloud-connection/src/marketplace-install-local-customization-pair.test.ts diff --git a/.changeset/30106-install-custom-pair-into-writable-pair.md b/.changeset/30106-install-custom-pair-into-writable-pair.md new file mode 100644 index 00000000000..e31f367bc49 --- /dev/null +++ b/.changeset/30106-install-custom-pair-into-writable-pair.md @@ -0,0 +1,15 @@ +--- +'@objectstack/cloud-connection': patch +'@objectstack/cli': patch +--- + +fix(cloud-connection,cli): an exported `.custom` installed on another environment lands in that environment's writable pair, and its overrides are served (ADR-0146 D2 / D3) + +Clause-②: no +Metadata-protocol: none + +- **Before.** `os package install /objectstack.json` of a base's paired customization package answered success and printed "Package installed into the running kernel", but the install registered the pair as code. None of its overrides was served, `GET /packages//customizations` listed none of them, and the pair turned read-only, so `ensure-customization` answered `writable: false` and every further save into it was refused `WRITABLE_PACKAGE_REQUIRED`. +- **Now.** `POST /api/v1/marketplace/install-local` installs a pair as what it is. The target's own pair is answered by its ensure door, which creates it when absent. Every item of the body is then written into it through the save door a Studio edit takes, with `mode: 'publish'` and the installer as actor, in load order. The overrides are served at once, the customization read lists them, and the pair stays editable. Nothing is registered as code or written to the cached-manifest directory. The answer carries `customizationPair` (`basePackageId`, `packageId`, `created`, `writable`), `writtenCount` and `written`, and the CLI prints them. +- **Refusals, nothing written.** A body key with no item form (code, a `translations` bundle, a key the install-key table does not classify), a nameless item, or two items claiming one name: `422`, every problem listed. A pair manifest the ensure door would not write: `400 PLUGIN_MANIFEST_INVALID`. A pair inside a multi-package artifact: `422`. A pair already read-only on the target: `409 RESOURCE_CONFLICT`, asked before the ensure door. If an earlier install registered it as code, the message names the uninstall and the restart that clear it. The ensure door's own refusal is relayed with its status and code. +- **A save refused part-way.** The answer is a failure with the save's code and status, naming the item and where it sits in the body. A pair this install created is deleted again. A pair that existed before is kept, and the answer lists the items written and the ones not written. +- **The CLI.** A runtime that predates this answers a pair as a managed install. The CLI now prints that as an error with the way back (uninstall, upgrade and restart, install again) and exits 1, instead of printing success. diff --git a/packages/cli/src/commands/package/install.ts b/packages/cli/src/commands/package/install.ts index e3b442e989f..a83d1ffc96e 100644 --- a/packages/cli/src/commands/package/install.ts +++ b/packages/cli/src/commands/package/install.ts @@ -32,6 +32,15 @@ * "Source" and "mode" are two words for two questions on purpose: where the * package comes from, and how it is installed. * + * A base's paired customization package — `.custom`, what + * `os package export` writes for the overrides an administrator authored + * (ADR-0146 D2 / D3) — is neither: the runtime writes its items into ITS OWN + * writable pair of that base, as Studio would, so the overrides are served, + * listed by the base's customization read, and stay editable there. Printed + * as such; a runtime that answers it as a managed install (one that predates + * the pair install, and registered it as read-only code) is printed as the + * failure it is, with the way back. + * * This complements `os package publish` (which uploads to the CLOUD): the * pairing is publish-to-cloud / install-into-runtime. The target runtime * authenticates the call with its own better-auth session — pass @@ -337,8 +346,9 @@ export default class PackageInstall extends Command { const pairId = String(data.manifestId ?? label); printError( `The runtime at ${runtime} installed ${pairId} as code, not into its writable pair: it serves none of the ` - + 'package\'s overrides and leaves the pair read-only. Upgrade the runtime, uninstall the package ' - + `(\`DELETE ${runtime}/api/v1/marketplace/install-local/${pairId}\`), then install it again.`, + + 'package\'s overrides and leaves the pair read-only. Uninstall it ' + + `(\`DELETE ${runtime}/api/v1/marketplace/install-local/${pairId}\`), upgrade and restart the runtime, ` + + 'then install it again.', ); this.exit(1); return; diff --git a/packages/cloud-connection/src/marketplace-install-local-customization-pair-install.test.ts b/packages/cloud-connection/src/marketplace-install-local-customization-pair-install.test.ts new file mode 100644 index 00000000000..0b22c0a39db --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-customization-pair-install.test.ts @@ -0,0 +1,370 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0146 D2 / D3] install-local of a base's PAIRED CUSTOMIZATION PACKAGE + * (`.custom`, what `GET /packages/.custom/export` writes), at the + * plugin's own route, mounted through `start()` + `kernel:ready`. + * + * Registered as code, a pair served none of its overrides, listed none of + * them and left the pair read-only, while the install answered success. + * Pinned: + * - the items are written into the target's own pair: the ensure door, then + * one `saveMetaItem` per item in load order (`mode: 'publish'`, the pair as + * package, the installer as actor); nothing is registered, synced or + * ledgered, and the answer names the pair and every item written; + * - a save the door refuses answers failure with the save's own code and + * status, naming the item: a pair the install created is deleted again; a + * pair that existed is kept, and the answer lists written / not written; + * - a pair already read-only here — ledgered as code by an earlier install, + * withdrawn but still registered until a restart, or provided by code — is + * refused 409 before the ensure door is asked, each naming its way back; + * - the ensure door's refusal is relayed; a body with no item form, a pair + * manifest the ensure door would not write, a pair inside a multi-package + * artifact, and a runtime without the doors are refused before any write. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +// The runtime binder the door's step 1d loads lazily — paid here, at module +// top, never inside a clocked `it` (`scripts/check-test-source-alias.mjs`). +import '@objectstack/runtime'; +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { LocalManifestSource } from './local-manifest-source.js'; + +const ROUTE = '/api/v1/marketplace/install-local'; +const INSTALLER = 'usr_installer'; +const BASE = 'com.example.showcase'; +const PAIR = `${BASE}.custom`; +const OVERRIDES = { package: BASE, basedOn: '0.1.0' }; + +type Handler = (c: any) => Promise; + +function makeC(body: any, id?: string) { + const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); + const url = `http://localhost:3000${ROUTE}${id ? `/${id}` : ''}`; + return { + req: { url, raw: new Request(url), json: async () => body, param: (n: string) => (n === 'id' ? id : undefined) }, + json, + }; +} + +/** What `GET /packages/com.example.showcase.custom/export` wrote: flat, as `os package install ` sends it. */ +function exportedPair(extra: Record = {}): Record { + return { + dashboards: [{ name: 'showcase_chart_gallery', label: 'Gallery (ours)', overrides: OVERRIDES }], + viewItems: [{ name: 'showcase_task.in_progress', label: 'In progress (ours)', overrides: OVERRIDES }], + id: PAIR, + name: 'ObjectStack Showcase (custom)', + version: '0.1.0', + namespace: 'showcase_custom', + dependencies: { [BASE]: '^0' }, + ...extra, + }; +} + +interface MountOptions { + /** `ensureCustomizationPackage`'s answer, or a throw. */ + ensure?: (request: any) => Promise; + /** `saveMetaItem`, per call. */ + save?: (request: any) => Promise; + /** Booted code packages — `engine.manifests`. */ + codePackages?: string[]; + /** Registered packages beside the base. */ + packages?: Record }>; + /** Leave the protocol without its pair doors. */ + noDoors?: boolean; + /** A ledger entry written before boot, so `kernel:ready` rehydrates it. */ + ledgered?: Record; +} + +async function mount(dir: string, opts: MountOptions = {}) { + const routes = new Map(); + const rawApp = { + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; + if (opts.ledgered) { + new LocalManifestSource(dir).write({ + packageId: PAIR, versionId: 'local', manifestId: PAIR, version: '0.1.0', + manifest: opts.ledgered, installedAt: new Date(0).toISOString(), installedBy: INSTALLER, + }); + } + const hooks = new Map(); + const register = vi.fn(async () => undefined); + const syncSchemas = vi.fn(async () => undefined); + const ensureCustomizationPackage = vi.fn(opts.ensure ?? (async () => ({ + basePackageId: BASE, packageId: PAIR, namespace: 'showcase_custom', dependencyRange: '^0', created: true, writable: true, + }))); + const saveMetaItem = vi.fn(opts.save ?? (async (request: any) => ({ success: true, type: request.type, name: request.name }))); + const deletePackage = vi.fn(async () => ({ success: true, deletedCount: 2, failedCount: 0 })); + const packages: Record }> = { + [BASE]: { manifest: { id: BASE, namespace: 'showcase', version: '0.1.0', name: 'Showcase' } }, + ...(opts.packages ?? {}), + }; + const services: Record = { + manifest: { register }, + auth: installerAuthService(INSTALLER), + objectql: withInstallerGrants({ + syncSchemas, + manifests: new Map((opts.codePackages ?? []).map((id) => [id, { id }])), + registry: { + getAllPackages: () => [], + getNamespaceOwners: () => [], + getPackage: (id: string) => packages[id], + }, + }, INSTALLER), + protocol: opts.noDoors ? {} : { ensureCustomizationPackage, saveMetaItem, deletePackage }, + }; + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const ctx = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + const svc = services[name]; + if (svc === undefined) throw new Error(`no ${name}`); + return svc; + }, + logger, + }; + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + await plugin.start(ctx as any); + await hooks.get('kernel:ready')?.(); + register.mockClear(); + syncSchemas.mockClear(); + return { + install: (manifest: unknown) => routes.get(`POST ${ROUTE}`)!(makeC({ manifest })), + register, + syncSchemas, + ensureCustomizationPackage, + saveMetaItem, + deletePackage, + logger, + ledgerHas: (id: string) => new LocalManifestSource(dir).read(id).entry !== null, + }; +} + +let dir: string; +let savedPosture: string | undefined; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mil-customization-pair-')); + savedPosture = process.env.OS_TENANCY_POSTURE; + delete process.env.OS_TENANCY_POSTURE; +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + if (savedPosture === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = savedPosture; + vi.restoreAllMocks(); +}); + +/** Nothing was registered, synced or ledgered as code. */ +function expectNoCodeInstall(h: Awaited>) { + expect(h.register).not.toHaveBeenCalled(); + expect(h.syncSchemas).not.toHaveBeenCalled(); + expect(h.ledgerHas(PAIR)).toBe(false); +} + +describe('install-local — a paired customization package is installed into the writable pair', () => { + it('writes every item through the save door into the ensured pair, and registers nothing as code', async () => { + const h = await mount(dir); + const res = await h.install(exportedPair()); + expect(res.status).toBe(200); + expect(h.ensureCustomizationPackage).toHaveBeenCalledWith({ basePackageId: BASE }); + expect(h.saveMetaItem.mock.calls.map(([r]: any[]) => r)).toEqual([ + { + type: 'view', name: 'showcase_task.in_progress', mode: 'publish', packageId: PAIR, actor: INSTALLER, + item: { name: 'showcase_task.in_progress', label: 'In progress (ours)', overrides: OVERRIDES }, + }, + { + type: 'dashboard', name: 'showcase_chart_gallery', mode: 'publish', packageId: PAIR, actor: INSTALLER, + item: { name: 'showcase_chart_gallery', label: 'Gallery (ours)', overrides: OVERRIDES }, + }, + ]); + expect(res.payload.data).toMatchObject({ + manifestId: PAIR, + version: '0.1.0', + customizationPair: { basePackageId: BASE, packageId: PAIR, created: true, writable: true }, + writtenCount: 2, + written: [{ type: 'view', name: 'showcase_task.in_progress' }, { type: 'dashboard', name: 'showcase_chart_gallery' }], + }); + expect(res.payload.data).not.toHaveProperty('hotLoaded'); + expect(res.payload.data).not.toHaveProperty('seeded'); + expectNoCodeInstall(h); + expect(h.deletePackage).not.toHaveBeenCalled(); + }); + + it('writes into a pair that already exists, and says it was not created', async () => { + const h = await mount(dir, { + ensure: async () => ({ basePackageId: BASE, packageId: PAIR, namespace: 'showcase_custom', created: false, writable: true }), + }); + const res = await h.install(exportedPair()); + expect(res.status).toBe(200); + expect(res.payload.data.customizationPair.created).toBe(false); + expect(h.saveMetaItem).toHaveBeenCalledTimes(2); + expectNoCodeInstall(h); + }); + + it('reports seed `data` as not loaded, and writes the items', async () => { + const h = await mount(dir); + const res = await h.install(exportedPair({ data: [{ object: 'showcase_task', records: [{ title: 'First' }] }] })); + expect(res.status).toBe(200); + expect(res.payload.data.seeded).toMatchObject({ mode: 'skipped' }); + expect(h.saveMetaItem).toHaveBeenCalledTimes(2); + }); +}); + +describe('install-local — a pair item the save door refuses answers failure, never success', () => { + const refusal = Object.assign(new Error("Field 'nope' is not a field of 'showcase_task'."), { + status: 422, code: 'VALIDATION_ERROR', + }); + + it('deletes the pair this install created, so nothing of the package remains', async () => { + const h = await mount(dir, { + save: async (request: any) => { if (request.type === 'dashboard') throw refusal; return { success: true }; }, + }); + const res = await h.install(exportedPair()); + expect(res.status).toBe(422); + expect(res.payload.success).toBe(false); + expect(res.payload.error.code).toBe('VALIDATION_ERROR'); + expect(res.payload.error.message).toContain( + "dashboard 'showcase_chart_gallery' (`dashboards[0]`) could not be written — Field 'nope' is not a field of 'showcase_task'. ", + ); + expect(res.payload.error.message).toContain('was removed again'); + expect(res.payload.error.details).toMatchObject({ + failed: { type: 'dashboard', name: 'showcase_chart_gallery', path: 'dashboards[0]' }, + written: [{ type: 'view', name: 'showcase_task.in_progress' }], + notWritten: [], + }); + expect(h.deletePackage).toHaveBeenCalledWith({ packageId: PAIR, actor: INSTALLER }); + expectNoCodeInstall(h); + }); + + it('keeps a pair that existed before, and lists what was and was not written', async () => { + const h = await mount(dir, { + ensure: async () => ({ basePackageId: BASE, packageId: PAIR, created: false, writable: true }), + save: async (request: any) => { if (request.type === 'view') throw refusal; return { success: true }; }, + }); + const res = await h.install(exportedPair()); + expect(res.status).toBe(422); + expect(res.payload.error.message).toContain('existed before this install, so it is kept'); + expect(res.payload.error.details).toMatchObject({ + written: [], + notWritten: [{ type: 'dashboard', name: 'showcase_chart_gallery' }], + }); + expect(h.deletePackage).not.toHaveBeenCalled(); + }); + + it('logs at error when the created pair cannot be fully removed', async () => { + const h = await mount(dir, { save: async () => { throw refusal; } }); + h.deletePackage.mockResolvedValueOnce({ success: false, deletedCount: 0, failedCount: 1 }); + const res = await h.install(exportedPair()); + expect(res.status).toBe(422); + expect(res.payload.error.message).toContain('could NOT be fully removed'); + expect(h.logger.error).toHaveBeenCalledWith(expect.stringContaining('was NOT fully removed')); + }); +}); + +describe('install-local — a pair already read-only here is refused before the ensure door', () => { + it('ledgered as code by an earlier install: names the uninstall and the restart', async () => { + const h = await mount(dir, { ledgered: exportedPair(), codePackages: [PAIR], packages: { [PAIR]: { manifest: exportedPair({ scope: 'cloud' }) } } }); + const res = await h.install(exportedPair()); + expect(res.status).toBe(409); + expect(res.payload.error.code).toBe('RESOURCE_CONFLICT'); + expect(res.payload.error.message).toContain(`DELETE ${ROUTE}/${PAIR}`); + expect(res.payload.error.message).toContain('restart this runtime'); + expect(h.ensureCustomizationPackage).not.toHaveBeenCalled(); + expect(h.saveMetaItem).not.toHaveBeenCalled(); + expect(h.register).not.toHaveBeenCalled(); + }); + + it('uninstalled, but still registered as code until a restart: names the restart', async () => { + const h = await mount(dir, { codePackages: [PAIR] }); + const res = await h.install(exportedPair()); + expect(res.status).toBe(409); + expect(res.payload.error.message).toContain('stays in this runtime until it restarts'); + expect(h.ensureCustomizationPackage).not.toHaveBeenCalled(); + expectNoCodeInstall(h); + }); + + it('uninstalled, then recorded by its ensure door before a restart: still names the restart', async () => { + const h = await mount(dir, { + codePackages: [PAIR], + packages: { [PAIR]: { manifest: { id: PAIR, namespace: 'showcase_custom', dependencies: { [BASE]: '^0' } } } }, + }); + const res = await h.install(exportedPair()); + expect(res.status).toBe(409); + expect(res.payload.error.message).toContain('stays in this runtime until it restarts'); + expect(h.ensureCustomizationPackage).not.toHaveBeenCalled(); + }); + + it('provided read-only by this runtime\'s code or an installed app', async () => { + const h = await mount(dir, { packages: { [PAIR]: { manifest: exportedPair({ scope: 'cloud' }) } } }); + const res = await h.install(exportedPair()); + expect(res.status).toBe(409); + expect(res.payload.error.message).toContain('is provided read-only here'); + expect(h.ensureCustomizationPackage).not.toHaveBeenCalled(); + expectNoCodeInstall(h); + }); +}); + +describe('install-local — a pair install refused before any write', () => { + it('relays the ensure door\'s refusal with its own status and code', async () => { + const h = await mount(dir, { + ensure: async () => { + throw Object.assign(new Error(`Package '${BASE}' is writable: customize it directly.`), { statusCode: 409, code: 'RESOURCE_CONFLICT' }); + }, + }); + const res = await h.install(exportedPair()); + expect(res.status).toBe(409); + expect(res.payload.error.message).toContain('customize it directly'); + expect(h.saveMetaItem).not.toHaveBeenCalled(); + expectNoCodeInstall(h); + }); + + it('refuses a body with no item form, naming each key', async () => { + const h = await mount(dir); + const res = await h.install(exportedPair({ translations: [{ en: {} }] })); + expect(res.status).toBe(422); + expect(res.payload.error.details.problems).toEqual([ + '`translations` is not part of a paired customization package: its items are written as stored items of the pair, and this key has no item form', + ]); + expect(h.ensureCustomizationPackage).not.toHaveBeenCalled(); + expectNoCodeInstall(h); + }); + + it('refuses a pair manifest the ensure door would not write', async () => { + const h = await mount(dir); + const res = await h.install(exportedPair({ dependencies: {} })); + expect(res.status).toBe(400); + expect(res.payload.error.code).toBe('PLUGIN_MANIFEST_INVALID'); + expect(res.payload.error.message).toContain(`a dependency on '${BASE}'`); + expect(h.ensureCustomizationPackage).not.toHaveBeenCalled(); + }); + + it('refuses a pair carried inside a multi-package artifact', async () => { + const h = await mount(dir); + const res = await h.install({ + id: 'com.example.bundle', namespace: 'bundle', version: '1.0.0', name: 'Bundle', + type: 'app', + packages: [{ + manifest: { id: PAIR, type: 'app', name: 'Showcase (custom)', version: '0.1.0', namespace: 'showcase_custom', dependencies: { [BASE]: '^0' } }, + }], + }); + expect(res.status).toBe(422); + expect(res.payload.error.details.pairs).toEqual([PAIR]); + expect(h.ensureCustomizationPackage).not.toHaveBeenCalled(); + expectNoCodeInstall(h); + }); + + it('answers 503 on a runtime whose protocol has no pair doors', async () => { + const h = await mount(dir, { noDoors: true }); + const res = await h.install(exportedPair()); + expect(res.status).toBe(503); + expect(res.payload.error.code).toBe('SERVICE_UNAVAILABLE'); + expectNoCodeInstall(h); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-customization-pair.test.ts b/packages/cloud-connection/src/marketplace-install-local-customization-pair.test.ts new file mode 100644 index 00000000000..62bcc94f4aa --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-customization-pair.test.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0146 D2 / D3] `planCustomizationPairInstall` — the pure walk of a + * paired customization package's body into the items its install writes. + * + * Pinned: + * - the keys `GET /packages/:id/export` writes for a pair — `viewItems`, a + * collection (`dashboards`), `objectExtensions` — each become items of the + * canonical type, named as the export names them, in the registry's load + * order (an extension before a view that names its fields); + * - a `views:` container is saved under the object it binds; + * - the package's own keys (identity, base, deployment) are not items, and + * seed `data` is reported, not refused; + * - every key with no item form, a nameless item, a non-array collection and + * two items claiming one name are refused, every one of them at once. + */ + +import { describe, it, expect } from 'vitest'; +import { planCustomizationPairInstall } from './marketplace-install-local-customization-pair.js'; + +const OVERRIDES = { package: 'com.example.showcase', basedOn: '0.1.0' }; + +/** What `GET /packages/com.example.showcase.custom/export` wrote, flat. */ +function exportedPair(extra: Record = {}): Record { + return { + dashboards: [{ name: 'showcase_overview', label: 'Overview (ours)', overrides: OVERRIDES }], + viewItems: [{ name: 'showcase_task.in_progress', label: 'In progress (ours)', overrides: OVERRIDES }], + id: 'com.example.showcase.custom', + name: 'ObjectStack Showcase (custom)', + version: '0.1.0', + namespace: 'showcase_custom', + dependencies: { 'com.example.showcase': '^0' }, + ...extra, + }; +} + +describe('planCustomizationPairInstall — the items of an exported pair', () => { + it('reads every item the export carries, typed and named as the export writes them, in load order', () => { + const plan = planCustomizationPairInstall(exportedPair({ + objectExtensions: [{ extend: 'showcase_task', fields: { showcase_custom_flag: { type: 'boolean' } } }], + })); + expect(plan.ok).toBe(true); + if (!plan.ok) return; + expect(plan.items.map((i) => [i.type, i.name, i.path])).toEqual([ + ['object_extension', 'showcase_task', 'objectExtensions[0]'], + ['view', 'showcase_task.in_progress', 'viewItems[0]'], + ['dashboard', 'showcase_overview', 'dashboards[0]'], + ]); + expect(plan.items[1]!.item).toEqual({ name: 'showcase_task.in_progress', label: 'In progress (ours)', overrides: OVERRIDES }); + expect(plan.carriesSeedData).toBe(false); + }); + + it('saves a `views:` container under the object it binds', () => { + const plan = planCustomizationPairInstall(exportedPair({ + viewItems: undefined, + views: [{ list: { type: 'grid', data: { provider: 'object', object: 'showcase_task' }, columns: ['title'] } }], + })); + expect(plan.ok).toBe(true); + if (!plan.ok) return; + expect(plan.items.find((i) => i.type === 'view')).toMatchObject({ name: 'showcase_task', path: 'views[0]' }); + }); + + it('treats the package\'s own keys as no items, and reports seed `data` without refusing it', () => { + const plan = planCustomizationPairInstall(exportedPair({ + label: 'Showcase (custom)', + data: [{ object: 'showcase_task', records: [{ title: 'First' }] }], + })); + expect(plan.ok).toBe(true); + if (!plan.ok) return; + expect(plan.items).toHaveLength(2); + expect(plan.carriesSeedData).toBe(true); + }); + + it('ignores an empty collection', () => { + const plan = planCustomizationPairInstall(exportedPair({ reports: [] })); + expect(plan.ok).toBe(true); + }); + + it('refuses every key with no item form, every nameless item and every duplicate, at once', () => { + const plan = planCustomizationPairInstall(exportedPair({ + translations: [{ en: { showcase_task: { label: 'Task' } } }], + unclassifiedKey: { anything: true }, + reports: { not: 'an array' }, + apps: [{ label: 'no name' }], + dashboards: [ + { name: 'showcase_overview', overrides: OVERRIDES }, + { name: 'showcase_overview', overrides: OVERRIDES }, + ], + })); + expect(plan.ok).toBe(false); + if (plan.ok) return; + const text = plan.problems.join('\n'); + expect(text).toContain('`translations` is not part of a paired customization package'); + expect(text).toContain('`unclassifiedKey` is not part of a paired customization package'); + expect(text).toContain('`reports` is not an array of report definitions'); + expect(text).toContain('`apps[0]` has no name'); + expect(text).toContain("two items claim dashboard 'showcase_overview' (`dashboards[0]` and `dashboards[1]`)"); + expect(plan.problems).toHaveLength(5); + }); + + it('keys an email template by its locale: two locales are two items, one locale twice is refused', () => { + const two = planCustomizationPairInstall(exportedPair({ + emailTemplates: [ + { name: 'showcase_welcome', locale: 'en-US', subject: 'Ours', overrides: OVERRIDES }, + { name: 'showcase_welcome', locale: 'fr-FR', subject: 'Bienvenue' }, + ], + })); + expect(two.ok).toBe(true); + const twice = planCustomizationPairInstall(exportedPair({ + emailTemplates: [ + { name: 'showcase_welcome', locale: 'fr-FR', subject: 'A' }, + { name: 'showcase_welcome', locale: 'fr-FR', subject: 'B' }, + ], + })); + expect(twice.ok).toBe(false); + if (twice.ok) return; + expect(twice.problems).toEqual([ + "two items claim email_template 'showcase_welcome' (locale 'fr-FR') (`emailTemplates[0]` and `emailTemplates[1]`)", + ]); + }); + + it('refuses an object extension that names no object', () => { + const plan = planCustomizationPairInstall(exportedPair({ objectExtensions: [{ fields: {} }] })); + expect(plan.ok).toBe(false); + if (plan.ok) return; + expect(plan.problems).toEqual(['`objectExtensions[0]` has no name']); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-customization-pair.ts b/packages/cloud-connection/src/marketplace-install-local-customization-pair.ts index 3e40e21d0df..e2d706a2a61 100644 --- a/packages/cloud-connection/src/marketplace-install-local-customization-pair.ts +++ b/packages/cloud-connection/src/marketplace-install-local-customization-pair.ts @@ -57,6 +57,7 @@ import { unmanagedInstallValueCarries, } from '@objectstack/spec/kernel'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; +import { ITEM_KEY_DISCRIMINATORS, itemDiscriminator } from '@objectstack/metadata-core'; import { ASSEMBLED_VIEW_ITEMS_KEY, deriveViewContainerObject, isAggregatedViewContainer } from '@objectstack/spec/ui'; /** @@ -163,9 +164,16 @@ export function planCustomizationPairInstall(body: Record): Pai ranked.push({ type, name, item, path, rank: [loadOrderOf(type), keyIndex, index] }); }); }); + // One item per identity: `name`, and for a type whose identity is a pair + // (`ITEM_KEY_DISCRIMINATORS` — an `email_template` is one item per + // `(name, locale)`) the member the save door keys its row by, read off + // the body as that door reads it. Two locales of one template are two + // items; two bodies of one locale would be one row written twice. const seen = new Map(); for (const entry of ranked) { - const slot = `${entry.type} '${entry.name}'`; + const member = itemDiscriminator(entry.type, entry.item); + const slot = `${entry.type} '${entry.name}'` + + (member !== undefined ? ` (${ITEM_KEY_DISCRIMINATORS[entry.type]!.field} '${member}')` : ''); const first = seen.get(slot); if (first !== undefined) problems.push(`two items claim ${slot} (\`${first}\` and \`${entry.path}\`)`); else seen.set(slot, entry.path); diff --git a/packages/cloud-connection/src/marketplace-install-local-extension-field-suffix.test.ts b/packages/cloud-connection/src/marketplace-install-local-extension-field-suffix.test.ts index 2dc11c9ad6c..c25b1cd98ed 100644 --- a/packages/cloud-connection/src/marketplace-install-local-extension-field-suffix.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-extension-field-suffix.test.ts @@ -17,7 +17,8 @@ * - each `packages[]` entry is judged under its own id; * - the refusal comes before the install-mode judgement, so the `unmanaged` * branch is refused too; - * - the pair's `region__c` and a vendor extension's plain field install. + * - the pair's `region__c` (into its writable pair) and a vendor extension's + * plain field install. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -61,7 +62,12 @@ function makeRawApp() { function makeCtx(rawApp: any, installed: Array<{ id: string; dependencies?: Record }>, holdings: Holdings) { const hooks = new Map(); const registered: any[] = []; - const records = installed.map((p) => ({ manifest: { id: p.id, version: '1.0.0', ...(p.dependencies ? { dependencies: p.dependencies } : {}) } })); + const saved: Array<[string, string]> = []; + // [ADR-0146 D3] BASE declares the namespace its pair's is derived from, + // as `customizationPairManifestRefusal` reads it. + const records = installed.map((p) => ({ + manifest: { id: p.id, version: '1.0.0', ...(p.id === BASE ? { namespace: 'crm' } : {}), ...(p.dependencies ? { dependencies: p.dependencies } : {}) }, + })); const services: Record = { manifest: { register: (m: any) => registered.push({ manifest: m }) }, auth: installerAuthService(), @@ -78,11 +84,17 @@ function makeCtx(rawApp: any, installed: Array<{ id: string; dependencies?: Reco type, items: (holdings[packageId ?? '']?.[type] ?? []).map((i) => ({ ...i, _packageId: packageId })), }), + // [ADR-0146 D3] The pair's doors: a pair is installed into its + // writable pair, each item through the save door. + ensureCustomizationPackage: async () => ({ packageId: PAIR, created: true, writable: true }), + saveMetaItem: async ({ type, name }: { type: string; name: string }) => { saved.push([type, name]); return { success: true }; }, + deletePackage: async () => ({ success: true, deletedCount: 0, failedCount: 0 }), }, metadata: { get: async () => undefined }, }; return { registered, + saved, ctx: { hook: (e: string, h: any) => hooks.set(e, h), getService: (name: string) => { @@ -127,7 +139,7 @@ async function install( await k.fire(); const res = await rawApp.routes.get(ROUTE)!(makeC({ manifest, ...extraBody })); const registered = k.registered.map((r) => r.manifest.id).filter((id) => id === manifest.id); - return { res, registered, ledger: readdirSync(dir) }; + return { res, registered, saved: k.saved, ledger: readdirSync(dir) }; } const extension = (field: string) => ({ extend: 'crm_account', fields: { [field]: { type: 'text', label: 'Region' } } }); @@ -158,7 +170,7 @@ describe("install-local 1c'' — the `__c` extension-field reservation at the ma }); it('(b) the `.custom` pair extending the base with a plain `region` is refused 422', async () => { - const manifest = { id: PAIR, version: '1.0.0', dependencies: deps(BASE), objectExtensions: [extension('region')] }; + const manifest = { id: PAIR, version: '1.0.0', namespace: 'crm_custom', dependencies: deps(BASE), objectExtensions: [extension('region')] }; const out = await install(manifest); expectRefused(out, judgeExtensionFieldSuffix({ packageId: PAIR, objectExtensions: manifest.objectExtensions })); expect(out.res.payload.error.details.refusals[0].rule).toBe('unsuffixed_in_pair'); @@ -169,7 +181,7 @@ describe("install-local 1c'' — the `__c` extension-field reservation at the ma id: 'com.acme.bundle', version: '1.0.0', packages: [ { manifest: { id: PARTNER, version: '1.0.0', dependencies: deps(BASE), objectExtensions: [extension('tier')] } }, - { manifest: { id: PAIR, version: '1.0.0', dependencies: deps(BASE), objectExtensions: [extension('region')] } }, + { manifest: { id: PAIR, version: '1.0.0', namespace: 'crm_custom', dependencies: deps(BASE), objectExtensions: [extension('region')] } }, ], }); expect(out.res.status, JSON.stringify(out.res.payload)).toBe(422); @@ -189,9 +201,11 @@ describe("install-local 1c'' — the `__c` extension-field reservation at the ma }); it('CONTROL: the `.custom` pair\'s extension field `region__c` installs, and so does a vendor extension\'s plain field', async () => { - const pair = await install({ id: PAIR, version: '1.0.0', dependencies: deps(BASE), objectExtensions: [extension('region__c')] }); + const pair = await install({ id: PAIR, version: '1.0.0', namespace: 'crm_custom', dependencies: deps(BASE), objectExtensions: [extension('region__c')] }); expect(pair.res.status, JSON.stringify(pair.res.payload)).toBe(200); - expect(pair.registered).toEqual([PAIR]); + // [ADR-0146 D3] Into its writable pair, through the save door. + expect(pair.registered).toEqual([]); + expect(pair.saved).toEqual([['object_extension', 'crm_account']]); const vendor = await install({ id: PARTNER, version: '1.0.0', dependencies: deps(BASE), objectExtensions: [extension('region')] }); expect(vendor.res.status, JSON.stringify(vendor.res.payload)).toBe(200); diff --git a/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts b/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts index 884739f5b85..4ca9e793025 100644 --- a/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts @@ -13,7 +13,9 @@ * - an incoming item the gate refuses stops the install `403 * NOT_OVERRIDABLE`, one entry per item in `details.refusals`, before any * write — nothing registered, no ledger entry; - * - the base's paired `.custom` installs its admitted declarations; + * - the base's paired `.custom` installs its admitted declarations — into + * its writable pair, each item through the save door (ADR-0146 D3), never + * registered as code; * - an install of a base lists, on its answer (`overrideCollisions`), the * names installed dependents already hold, undeclared, that it now ships — * and omits the key when there are none. @@ -60,7 +62,12 @@ function makeRawApp() { function makeCtx(rawApp: any, installed: Array<{ id: string; dependencies?: Record }>, holdings: Holdings) { const hooks = new Map(); const registered: any[] = []; - const records = installed.map((p) => ({ manifest: { id: p.id, version: '1.0.0', ...(p.dependencies ? { dependencies: p.dependencies } : {}) } })); + const saved: Array<[string, string]> = []; + // [ADR-0146 D3] BASE declares the namespace its pair's is derived from, + // as `customizationPairManifestRefusal` reads it. + const records = installed.map((p) => ({ + manifest: { id: p.id, version: '1.0.0', ...(p.id === BASE ? { namespace: 'crm' } : {}), ...(p.dependencies ? { dependencies: p.dependencies } : {}) }, + })); const services: Record = { manifest: { register: (m: any) => registered.push({ manifest: m }) }, auth: installerAuthService(), @@ -77,11 +84,17 @@ function makeCtx(rawApp: any, installed: Array<{ id: string; dependencies?: Reco type, items: (holdings[packageId ?? '']?.[type] ?? []).map((i) => ({ ...i, _packageId: packageId })), }), + // [ADR-0146 D3] The pair's doors: a pair is installed into its + // writable pair, each item through the save door. + ensureCustomizationPackage: async () => ({ packageId: PAIR, created: true, writable: true }), + saveMetaItem: async ({ type, name }: { type: string; name: string }) => { saved.push([type, name]); return { success: true }; }, + deletePackage: async () => ({ success: true, deletedCount: 0, failedCount: 0 }), }, metadata: { get: async () => undefined }, }; return { registered, + saved, ctx: { hook: (e: string, h: any) => hooks.set(e, h), getService: (name: string) => { @@ -127,7 +140,7 @@ async function install( await k.fire(); const res = await rawApp.routes.get(ROUTE)!(makeC({ manifest })); const registered = k.registered.map((r) => r.manifest.id).filter((id) => id === manifest.id); - return { res, registered, ledger: readdirSync(dir) }; + return { res, registered, saved: k.saved, ledger: readdirSync(dir) }; } const refusedOf = (res: any) => (res.payload?.error?.details?.refusals ?? []).map((r: any) => [r.type, r.name, r.rule]); @@ -164,34 +177,36 @@ describe('install-local 2d — the override write gate at the managed install (A it('the paired .custom declaring an object override is refused (objects are extended)', async () => { const { res } = await install({ - id: PAIR, version: '1.0.0', dependencies: deps(BASE), + id: PAIR, version: '1.0.0', namespace: 'crm_custom', dependencies: deps(BASE), objects: [{ name: 'crm_account', overrides: { package: BASE, basedOn: '1.0.0' }, fields: {} }], }); expect(res.status).toBe(403); expect(refusedOf(res)).toEqual([['object', 'crm_account', 'declared_object']]); }); - it('the paired .custom installs its declared override of an item the base ships', async () => { - const { res, registered } = await install({ - id: PAIR, version: '1.0.0', dependencies: deps(BASE), + it('the paired .custom installs its declared override of an item the base ships, into its writable pair', async () => { + const { res, registered, saved } = await install({ + id: PAIR, version: '1.0.0', namespace: 'crm_custom', dependencies: deps(BASE), dashboards: [{ name: 'crm_board', label: 'Ours', overrides: { package: BASE, basedOn: '1.0.0' } }], }); expect(res.status, JSON.stringify(res.payload)).toBe(200); - expect(registered).toEqual([PAIR]); + expect(registered).toEqual([]); + expect(saved).toEqual([['dashboard', 'crm_board']]); expect(res.payload.data).not.toHaveProperty('overrideCollisions'); }); it('[#28412] the paired .custom installs its declared FLOW override; a declared action override still waits', async () => { // ADR-0146 D9: flow's conditions are delivered, so the gate admits it. const flow = await install({ - id: PAIR, version: '1.0.0', dependencies: deps(BASE), + id: PAIR, version: '1.0.0', namespace: 'crm_custom', dependencies: deps(BASE), flows: [{ name: 'crm_followup', label: 'Ours', overrides: { package: BASE, basedOn: '1.0.0' } }], }); expect(flow.res.status, JSON.stringify(flow.res.payload)).toBe(200); - expect(flow.registered).toEqual([PAIR]); + expect(flow.registered).toEqual([]); + expect(flow.saved).toEqual([['flow', 'crm_followup']]); const action = await install({ - id: PAIR, version: '1.0.0', dependencies: deps(BASE), + id: PAIR, version: '1.0.0', namespace: 'crm_custom', dependencies: deps(BASE), actions: [{ name: 'crm_call', label: 'Ours', overrides: { package: BASE, basedOn: '1.0.0' } }], }); expect(action.res.status).toBe(403); @@ -243,7 +258,7 @@ describe('install-local 2d — the override write gate at the managed install (A it('[#28746] the paired .custom declaring an override of a locale the base does not ship is refused 403, and nothing is written', async () => { const { res, registered, ledger } = await install({ - id: PAIR, version: '1.0.0', dependencies: deps(BASE), + id: PAIR, version: '1.0.0', namespace: 'crm_custom', dependencies: deps(BASE), emailTemplates: [{ name: 'crm_welcome', locale: 'fr-FR', subject: 'Bienvenue', overrides: { ...DECLARED } }], }, [{ id: BASE }], TEMPLATE_HOLDINGS); expect(res.status, JSON.stringify(res.payload)).toBe(403); @@ -254,14 +269,15 @@ describe('install-local 2d — the override write gate at the managed install (A }); it('[#28746] CONTROL: the pair overrides the locale the base ships, and adds an undeclared locale of its own', async () => { - const { res, registered } = await install({ - id: PAIR, version: '1.0.0', dependencies: deps(BASE), + const { res, registered, saved } = await install({ + id: PAIR, version: '1.0.0', namespace: 'crm_custom', dependencies: deps(BASE), emailTemplates: [ { name: 'crm_welcome', locale: 'en-US', subject: 'Ours', overrides: { ...DECLARED } }, { name: 'crm_welcome', locale: 'fr-FR', subject: 'Bienvenue' }, ], }, [{ id: BASE }], TEMPLATE_HOLDINGS); expect(res.status, JSON.stringify(res.payload)).toBe(200); - expect(registered).toEqual([PAIR]); + expect(registered).toEqual([]); + expect(saved).toEqual([['email_template', 'crm_welcome'], ['email_template', 'crm_welcome']]); }); }); diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index ce0bcaeca74..e51bdfd41d4 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -255,7 +255,7 @@ import type { // import verb raises (answered here after the payload, id and handshake checks, // before any write), the read-only-package rule (a namespace held by a WRITABLE base is // this environment's own metadata), and the client-facing sentence rule. -import { unmanagedInstallPostureRefusal, isWritablePackage, clientFacingFailureText, captureUninstallDeclared } from '@objectstack/metadata-protocol'; +import { unmanagedInstallPostureRefusal, isWritablePackage, READ_ONLY_PACKAGE_SCOPES, clientFacingFailureText, captureUninstallDeclared } from '@objectstack/metadata-protocol'; // [ADR-0146 D3, #30106] What a pair-shaped package's manifest must declare — // the predicate every package install asks of such an id — asked by the pair // install before the pair's ensure door. @@ -597,6 +597,25 @@ export interface OverrideUpgradeCollision { message: string; } +/** + * [ADR-0146 D3] The sentence a pair install answers for a refusal of the + * pair's ensure door or of the save door: the refusal's own text when it + * declared a client status, and `fallback` otherwise. `clientFacingFailureText` + * reads the declaration off `status`; the ensure door spells its refusals with + * `statusCode` (`protocol-customization-package.ts`), and its own route, + * `POST /packages/:id/ensure-customization`, answers them with their text — + * so this door says what that route says, never "see the log" for a refusal + * whose remedy is the caller's. A server-band status is withheld, as there. + */ +function pairDoorRefusalText(err: any, resolved: { status: number }, fallback: string): string { + const quoted = clientFacingFailureText(err, fallback); + if (quoted !== fallback) return quoted; + const declared = typeof err?.statusCode === 'number' && err.statusCode === resolved.status; + return declared && resolved.status >= 400 && resolved.status < 500 && typeof err?.message === 'string' && err.message !== '' + ? err.message + : fallback; +} + /** * The answer of a managed or pair install the override write gate refused * (status `403`): `NOT_OVERRIDABLE`, the publish doors' code, the first item's @@ -3812,7 +3831,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * `marketplace-install-local-customization-pair.ts` says why a pair is never * registered as code. * - * In order, nothing written before step 4: + * In order, nothing written before step 4 (a runtime whose protocol + * lacks the pair's ensure door, the save door or the package delete is + * answered `503` after the refusals below, before step 4): * 1. the body is converted forward ({@link convertPackageBodyForward}, as a * managed install converts its artifact) and walked into its items * ({@link planCustomizationPairInstall}); a key with no item form, a @@ -3823,8 +3844,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * writes (`customizationPairManifestRefusal`, the predicate every * package install asks of a pair-shaped id); a pair already installed * READ-ONLY — registered as code by an earlier install through this - * door, or provided by this runtime's code — is refused, naming the - * uninstall that clears it; otherwise the ensure door answers the + * door (until a restart, even once uninstalled), or provided by this + * runtime's code — is refused before the ensure door is asked, naming + * the uninstall and restart that clear it; otherwise the ensure door answers the * writable pair, creating it when absent (ADR-0146 D3: its one * creation path), and its refusals are answered as it states them; * 4. every item, in load order, through `saveMetaItem` with `mode: @@ -3860,19 +3882,6 @@ export class MarketplaceInstallLocalPlugin implements Plugin { const refuse = (status: number, code: string, message: string, details?: Record) => c.json({ success: false, error: { code, message, ...(details ? { details } : {}) } }, status as any); - let protocol: CustomizationPairInstaller | undefined; - try { protocol = ctx.getService('protocol'); } catch { /* no protocol service */ } - const ensure = protocol?.ensureCustomizationPackage; - const save = protocol?.saveMetaItem; - const remove = protocol?.deletePackage; - if (typeof ensure !== 'function' || typeof save !== 'function' || typeof remove !== 'function') { - return refuse(503, 'SERVICE_UNAVAILABLE', - `Package '${manifestId}' is the paired customization package of '${baseId}', and this runtime cannot ` - + 'install it: its metadata protocol has no ensure door for the pair or no save door for its items. ' - + 'Upgrade @objectstack/metadata-protocol alongside @objectstack/cloud-connection. Nothing was installed ' - + 'or written.'); - } - // 1. The body, converted forward, walked into its items. const converted = flattenPackageBody(this.convertPackageBodyForward(ctx, manifest)); const plan = planCustomizationPairInstall(converted); @@ -3902,17 +3911,53 @@ export class MarketplaceInstallLocalPlugin implements Plugin { if (manifestRefusal !== undefined) { return refuse(400, 'PLUGIN_MANIFEST_INVALID', `${manifestRefusal} Nothing was installed or written.`); } - if (getInstalled(manifestId) !== undefined && !isWritablePackage(ql, manifestId)) { - return refuse(409, 'RESOURCE_CONFLICT', this.ledger.has(manifestId) + if (!isWritablePackage(ql, manifestId)) { + // Asked BEFORE the ensure door, which would otherwise create a + // pair row here only to answer it read-only. An uninstall through + // this door withdraws a package from the registry, but the + // engine keeps its code registration until a restart — the + // signal `isWritablePackage` reads first — so a pair installed + // as code stays read-only until then. + // Read-only by its manifest scope is an installed app's posture; + // read-only with neither that scope, a ledger entry nor a boot + // registration is a code registration this door made and + // withdrew — even when the pair's ensure door has been asked + // since, which records the pair without lifting it. + const ledgered = this.ledger.has(manifestId); + const scope = (getInstalled(manifestId) as { manifest?: { scope?: unknown } } | undefined)?.manifest?.scope; + const readOnlyByScope = typeof scope === 'string' && READ_ONLY_PACKAGE_SCOPES.includes(scope); + const withdrawn = !ledgered && !this.bootUserCodeIds.has(manifestId) && !readOnlyByScope; + return refuse(409, 'RESOURCE_CONFLICT', ledgered ? `Package '${manifestId}' is installed here as code, by an earlier install of it through this door, so ` - + 'its items are read-only and none of its overrides is served. Uninstall it — ' - + `\`DELETE ${ROUTE_BASE}/${manifestId}\` — and install it again: a paired customization package is ` - + `installed into the writable pair of '${baseId}' (ADR-0146 D3). Nothing was installed or written.` - : `Package '${manifestId}' is installed here read-only — provided by this runtime's code or an installed ` - + `app — so its items cannot be written into it. The paired customization package of '${baseId}' is a ` - + 'writable package (ADR-0146 D3): remove the read-only one first. Nothing was installed or written.', + + 'its items are read-only and none of its overrides is served. Uninstall it ' + + `(\`DELETE ${ROUTE_BASE}/${manifestId}\`), restart this runtime (the uninstall withdraws the package, ` + + 'but its code registration stays until a restart), and install it again: a paired customization ' + + `package is installed into the writable pair of '${baseId}' (ADR-0146 D3). Nothing was installed or written.` + : withdrawn + ? `Package '${manifestId}' was installed here as code earlier and has been uninstalled, but its code ` + + 'registration stays in this runtime until it restarts, so the pair is still read-only. Restart ' + + `this runtime and install it again: it is then installed into the writable pair of '${baseId}' ` + + '(ADR-0146 D3). Nothing was installed or written.' + : `Package '${manifestId}' is provided read-only here — by this runtime's code or an installed app — so ` + + `its items cannot be written into it. The paired customization package of '${baseId}' is a ` + + 'writable package (ADR-0146 D3): remove the read-only one first. Nothing was installed or written.', { packageId: manifestId }); } + // 3b. The doors the write takes — asked once every refusal the body + // and this environment decide has been asked. + let protocol: CustomizationPairInstaller | undefined; + try { protocol = ctx.getService('protocol'); } catch { /* no protocol service */ } + const ensure = protocol?.ensureCustomizationPackage; + const save = protocol?.saveMetaItem; + const remove = protocol?.deletePackage; + if (typeof ensure !== 'function' || typeof save !== 'function' || typeof remove !== 'function') { + return refuse(503, 'SERVICE_UNAVAILABLE', + `Package '${manifestId}' is the paired customization package of '${baseId}', and this runtime cannot ` + + 'install it: its metadata protocol has no ensure door for the pair or no save door for its items. ' + + 'Upgrade @objectstack/metadata-protocol alongside @objectstack/cloud-connection. Nothing was installed ' + + 'or written.'); + } + let created: boolean; try { const ensured = await ensure.call(protocol, { basePackageId: baseId }); @@ -3927,7 +3972,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { } catch (err: any) { const resolved = resolveThrownHttpError(err, 500); const fallback = `The paired customization package '${manifestId}' could not be ensured; the reason is in the server log.`; - const message = clientFacingFailureText(err, fallback); + const message = pairDoorRefusalText(err, resolved, fallback); if (message === fallback) { ctx.logger?.warn?.(`[MarketplaceInstallLocal] ensuring ${manifestId} for its install failed (${resolved.status} ` + `${resolved.code}): ${err?.message ?? err}`); @@ -4014,7 +4059,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { const { manifestId, baseId, created, userId, err, failed, written, notWritten } = args; const resolved = resolveThrownHttpError(err, 500); const fallback = 'the reason is in the server log'; - const reason = clientFacingFailureText(err, fallback); + const reason = pairDoorRefusalText(err, resolved, fallback); if (reason === fallback) { ctx.logger?.warn?.(`[MarketplaceInstallLocal] installing ${manifestId}: ${failed.type} '${failed.name}' could not be ` + `written into the pair (${resolved.status} ${resolved.code}): ${err?.message ?? err}`); @@ -4050,7 +4095,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { error: { code: resolved.code, message: `Package '${manifestId}' was not installed into the pair of '${baseId}': ${failed.type} ` - + `'${failed.name}' (\`${failed.path}\`) could not be written — ${reason}. ${outcome}`, + + `'${failed.name}' (\`${failed.path}\`) could not be written — ${reason.replace(/[.\s]+$/, '')}. ${outcome}`, details: { failed: { type: failed.type, name: failed.name, path: failed.path }, written: [...written], From bddd1adc2ba7832f9d9a6b458b3890e598aaf126 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 01:25:46 +0000 Subject: [PATCH 3/5] test(cli): pin the install output of a paired customization package Claude-Session: https://claude.ai/code/session_01Utp6CXfNPQKEBNuyohXjfg Co-authored-by: Claude --- ...package-install-customization-pair.test.ts | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 packages/cli/test/package-install-customization-pair.test.ts diff --git a/packages/cli/test/package-install-customization-pair.test.ts b/packages/cli/test/package-install-customization-pair.test.ts new file mode 100644 index 00000000000..c6e7480afc3 --- /dev/null +++ b/packages/cli/test/package-install-customization-pair.test.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0146 D3] `os package install` of a base's paired customization + * package (`BASE.custom`, what `os package export` writes for the overrides + * an administrator authored). + * + * The runtime writes the pair's items into its own writable pair and answers + * `customizationPair`. Before, a pair was registered as code, and this + * command printed "Package installed into the running kernel" while none of + * its overrides was served. Pinned: + * - an answer carrying `customizationPair` prints the pair it wrote into and + * the items written, and none of the managed install's lines; + * - a pair answered as a managed install (a runtime that predates the pair + * install, which registered it as code) prints an error naming the way + * back, and exits 1, never success. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import PackageInstall from '../src/commands/package/install.js'; + +const PAIR = 'com.example.showcase.custom'; + +/** Stub the runtime's install POST with the given `data` block. */ +function stubRuntime(data: Record): void { + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null }, + json: async () => ({ success: true, data }), + }) as any)); +} + +/** Run the command; return everything it printed and how it exited. */ +async function runInstall(args: string[]): Promise<{ out: string; exit: number | undefined }> { + const lines: string[] = []; + const capture = (...a: unknown[]) => { lines.push(a.map(String).join(' ')); }; + vi.spyOn(console, 'log').mockImplementation(capture); + vi.spyOn(console, 'error').mockImplementation(capture); + let exit: number | undefined; + try { + await PackageInstall.run([...args, '--runtime', 'http://runtime.test']); + } catch (err: any) { + exit = err?.oclif?.exit ?? err?.code ?? 1; + } + return { out: lines.join('\n'), exit }; +} + +/** The pair's `objectstack.json`, as `os package export` writes it. */ +function writePairExport(): { file: string; dir: string } { + const dir = mkdtempSync(join(tmpdir(), 'os-install-pair-')); + const file = join(dir, 'objectstack.json'); + writeFileSync(file, JSON.stringify({ + viewItems: [{ name: 'showcase_task.in_progress', label: 'Ours', overrides: { package: 'com.example.showcase', basedOn: '0.1.0' } }], + id: PAIR, name: 'Showcase (custom)', version: '0.1.0', namespace: 'showcase_custom', + dependencies: { 'com.example.showcase': '^0' }, + })); + return { file, dir }; +} + +let dir: string | undefined; +afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = undefined; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('os package install — a paired customization package', () => { + it('prints the writable pair its items were written into, and none of the managed lines', async () => { + const exported = writePairExport(); + dir = exported.dir; + stubRuntime({ + manifestId: PAIR, version: '0.1.0', versionId: 'local', installedAt: '2026-11-05T00:00:00.000Z', + customizationPair: { basePackageId: 'com.example.showcase', packageId: PAIR, created: true, writable: true }, + writtenCount: 1, written: [{ type: 'view', name: 'showcase_task.in_progress' }], + }); + const { out, exit } = await runInstall([exported.file]); + expect(exit).toBeUndefined(); + expect(out).toContain("Customization package installed into the writable pair of 'com.example.showcase'"); + expect(out).toContain(`${PAIR} (created by this install)`); + expect(out).toMatch(/Items written:?\s+1/); + expect(out).not.toContain('running kernel'); + expect(out).not.toContain('installed-packages'); + }); + + it('prints a pair answered as a managed install as the failure it is, and exits 1', async () => { + const exported = writePairExport(); + dir = exported.dir; + stubRuntime({ + manifestId: PAIR, version: '0.1.0', versionId: 'local', installedAt: '2026-11-05T00:00:00.000Z', + hotLoaded: true, storageDir: '/srv/runtime/.objectstack/installed-packages', + }); + const { out, exit } = await runInstall([exported.file]); + expect(exit).toBe(1); + expect(out).toContain(`installed ${PAIR} as code, not into its writable pair`); + expect(out).toContain(`DELETE http://runtime.test/api/v1/marketplace/install-local/${PAIR}`); + expect(out).not.toContain('Package installed into the running kernel'); + }); +}); From 98ef68d61db66c32758ff81dcf84d67a093778ed Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 01:32:18 +0000 Subject: [PATCH 4/5] test(cli): answer the exported pair as the runtime now installs it Claude-Session: https://claude.ai/code/session_01Utp6CXfNPQKEBNuyohXjfg Co-authored-by: Claude --- packages/cli/test/package-export.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/package-export.test.ts b/packages/cli/test/package-export.test.ts index f6208246964..b49011691dc 100644 --- a/packages/cli/test/package-export.test.ts +++ b/packages/cli/test/package-export.test.ts @@ -127,7 +127,13 @@ describe('os package export', () => { const posted: any[] = []; vi.stubGlobal('fetch', vi.fn(async (_url: string, init: any) => { posted.push(JSON.parse(init.body)); - return new Response(JSON.stringify({ success: true, data: { manifestId: entry.id, version: entry.version, hotLoaded: true } }), { status: 200 }); + // [ADR-0146 D3] A pair is answered as the runtime installs it: into + // its writable pair, never as code (which the command prints as the + // failure it is). + const answered = entry === PAIR + ? { manifestId: entry.id, version: entry.version, customizationPair: { basePackageId: BASE.id, packageId: entry.id, created: true, writable: true }, writtenCount: 0, written: [] } + : { manifestId: entry.id, version: entry.version, hotLoaded: true }; + return new Response(JSON.stringify({ success: true, data: answered }), { status: 200 }); })); const file = join(out, exportedPackageDirectory(i, 3, entry.id), EXPORTED_PACKAGE_FILE); const r = await run(PackageInstall, [file, '--runtime', 'http://target.test']); From 6be0aea6a64d61bac1d2831d33da398c967deee7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 01:44:09 +0000 Subject: [PATCH 5/5] fix(cloud-connection): log a pair item the save door refuses at error, in its catch The install the operator asked for did not land, and the pair is not what the export describes; the durability gate reads the catch, so the line is written there with the save's own words. Claude-Session: https://claude.ai/code/session_01Utp6CXfNPQKEBNuyohXjfg Co-authored-by: Claude --- .../src/marketplace-install-local-plugin.ts | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index e51bdfd41d4..304abdb60d5 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -3995,9 +3995,26 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }); written.push({ type: entry.type, name: entry.name }); } catch (err: any) { + const notWritten = plan.items.slice(at + 1).map((i) => ({ type: i.type, name: i.name })); + // Logged here, at `error`, with the save's own words: the + // install the operator asked for did not land, and the pair + // this environment holds is not what the export describes — + // removed again when this install created it, partly written + // when it existed before. The answer says the same, but its + // text withholds a reason the save door did not declare. + const status = resolveThrownHttpError(err, 500); + ctx.logger.error( + `[MarketplaceInstallLocal] the install of ${manifestId} into the pair of '${baseId}' failed: ` + + `${entry.type} '${entry.name}' (${entry.path}) was refused by the save door (${status.status} ` + + `${status.code}): ${err?.message ?? err}. ` + + (created + ? 'The pair this install created is removed again.' + : `The pair existed before and is kept: ${written.length} item(s) written before it stay ` + + `published, ${notWritten.length} were not written.`) + + ' Correct the item and install the package again.', + ); return this.pairInstallWriteFailure(c, ctx, remove.bind(protocol), { - manifestId, baseId, created, userId, err, failed: entry, written, - notWritten: plan.items.slice(at + 1).map((i) => ({ type: i.type, name: i.name })), + manifestId, baseId, created, userId, err, failed: entry, written, notWritten, }); } } @@ -4058,12 +4075,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin { ): Promise => { const { manifestId, baseId, created, userId, err, failed, written, notWritten } = args; const resolved = resolveThrownHttpError(err, 500); + // The caller logged the save's own words at `error` before handing + // the failure here, so the fallback's promise of a log line holds. const fallback = 'the reason is in the server log'; const reason = pairDoorRefusalText(err, resolved, fallback); - if (reason === fallback) { - ctx.logger?.warn?.(`[MarketplaceInstallLocal] installing ${manifestId}: ${failed.type} '${failed.name}' could not be ` - + `written into the pair (${resolved.status} ${resolved.code}): ${err?.message ?? err}`); - } let outcome: string; if (created) { let residue: string | undefined;