diff --git a/.changeset/28535-install-local-nested-artifact-manifest.md b/.changeset/28535-install-local-nested-artifact-manifest.md new file mode 100644 index 00000000000..91addbdc6af --- /dev/null +++ b/.changeset/28535-install-local-nested-artifact-manifest.md @@ -0,0 +1,14 @@ +--- +'@objectstack/cloud-connection': patch +--- + +fix(cloud-connection)!: install-local reads the manifest of a compiled artifact as `os package install ` sends it, so the protocol handshake and the dependency check run on it (#28535) + +Clause-②: no (narrowing) — `POST /api/v1/marketplace/install-local` refuses, with the 422 `OS_PROTOCOL_INCOMPATIBLE` (or `DEPENDENCY_MISSING`) the flat manifest already got, a compiled artifact sent with `id` and `version` lifted beside its nested `manifest`, whose `engines.protocol` excludes this runtime or whose `dependencies` this runtime does not satisfy. That body installed before. Nothing is widened. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing at `POST /api/v1/marketplace/install-local` (`@objectstack/cloud-connection`), reached by `os package install ./dist/objectstack.json`. + +The door flattened a compiled artifact (`{ manifest: { … }, objects, … }`) only when the body had no top-level `id` or `name`. Every published CLI lifts `id` and `version` to the top beside the nested `manifest`, so that body was never flattened and the keys the install checks read at the manifest top level stayed one level down: an artifact built for another protocol installed with a "declares no engines.protocol range" warning, and an unmet dependency was never judged. The door now detects a compiled artifact by its nested `manifest` object alone, through the one flatten it already uses for the unmanaged import, so every sender's body is judged the same way. A compatible artifact installs as before, without the false warning. diff --git a/packages/cloud-connection/src/marketplace-install-local-dependency-missing.test.ts b/packages/cloud-connection/src/marketplace-install-local-dependency-missing.test.ts index 62945bdff20..c7c606b16f0 100644 --- a/packages/cloud-connection/src/marketplace-install-local-dependency-missing.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-dependency-missing.test.ts @@ -180,6 +180,29 @@ describe('POST install-local: ADR-0141 D2 refuses an unsatisfied dependency, and }); }); +describe('[#28535] a compiled artifact as `os package install ` sends it: the check reads its nested manifest', () => { + /** The body `packages/cli/src/commands/package/install.ts` builds: the artifact, `id` and `version` lifted beside its nested manifest. */ + const cliInstallBody = (manifest: Record) => { + const artifact = { manifest, objects: [{ name: 'psa_task', label: 'Task', fields: { name: { type: 'text', label: 'Name' } } }] }; + return { manifest: { ...artifact, id: artifact.manifest.id, version: artifact.manifest.version } }; + }; + + it('refuses an absent dependency the compiled artifact declares, and installs nothing', async () => { + const h = await mount(dir); + expectDependencyRefusal(await h.install(cliInstallBody(PSA)), [ + { packageId: 'com.example.psa', dependency: 'com.example.sales', range: '6.x', found: 'absent' }, + ]); + expectNothingWritten(h); + }); + + it('lit control: installs once the dependency is installed inside the range', async () => { + const h = await mount(dir, { 'com.example.sales': '6.2.0' }); + const res = await h.install(cliInstallBody(PSA)); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(h.installRegistrations()).toHaveLength(1); + }); +}); + describe('POST install-local: a multi-package artifact judges each entry, presence-only inside the artifact', () => { const artifact = (ordersDeps: Record) => ({ id: 'com.example.multi', name: 'Multi', version: '1.0.0', scope: 'project', type: 'app', diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index d582fa92760..45d260fb57a 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -1444,13 +1444,19 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // while ObjectQL's registerApp expects the FLAT app shape (top-level // id + sections). Flatten when detected — otherwise every install of // a published compiled bundle dies with "Invalid manifest payload". - const normalizeBundle = (m: any): any => { - if (m && !m.id && !m.name && m.manifest && typeof m.manifest === 'object' && (m.manifest.id || m.manifest.name)) { - const { manifest: meta, ...sections } = m; - return { ...meta, ...sections }; - } - return m; - }; + // + // [#28535] ⭐ Detected by the nested `manifest` object ALONE, through + // the one flatten this door already owns (`flattenPackageBody`). The + // detection used to also require NO top-level `id`/`name`, and + // `os package install ` (every published CLI) lifts `id` and + // `version` to the top beside the nested `manifest` — so that body + // was never flattened, and every key the checks below read at the + // manifest top level (`engines` for the ADR-0087 handshake, + // `dependencies` for the ADR-0141 check) stayed one level down: a + // package built for another protocol installed with a "declares no + // engines.protocol range" warning. The door reads the shape it + // receives; it does not depend on which sender built it. + const normalizeBundle = (m: unknown): any => (isPlainObject(m) ? flattenPackageBody(m) : m); let manifest: any; let resolvedVersionId: string; diff --git a/packages/cloud-connection/src/marketplace-install-local-protocol-handshake.test.ts b/packages/cloud-connection/src/marketplace-install-local-protocol-handshake.test.ts index 4d3fb0910df..72239c7adcd 100644 --- a/packages/cloud-connection/src/marketplace-install-local-protocol-handshake.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-protocol-handshake.test.ts @@ -24,6 +24,10 @@ * - the two doors give the SAME answer for the same manifest: status, code, * message and `details`, byte-compared. Both answer through one helper, * `protocolIncompatibleAnswer` in `@objectstack/metadata-core`; + * - [#28535] the same refusal for a compiled artifact in the body + * `os package install ` sends (manifest nested, `id` and `version` + * lifted beside it), where the range used to stay one level down and the + * package installed with a "declares no engines.protocol range" warning; * - the `kernel:ready` rehydrate of an incompatible ledger entry, as * measured: on `origin/main` before this change it registered the package, * ran `syncSchemas` and logged only `info` "rehydrated". Now it is NOT @@ -268,6 +272,78 @@ describe('POST install-local: an incompatible protocol range is refused, and not }); }); +/** + * [#28535] The body `os package install ./dist/objectstack.json` sends, built + * the way `packages/cli/src/commands/package/install.ts` builds it: the whole + * compiled artifact (manifest nested under `manifest:` beside its collections, + * ADR-0130 D4) with `id` and `version` lifted to the top. Every published CLI + * sends this shape, so the door is judged on it, not on a body only a test + * would build. + */ +function cliInstallBody(artifact: { manifest: Record; [k: string]: any }) { + return { manifest: { ...artifact, id: artifact.manifest.id ?? artifact.id, version: artifact.manifest.version ?? artifact.version } }; +} + +/** A compiled artifact: the manifest nested under `manifest:`, one object beside it. */ +function compiledArtifact(manifest: Record) { + return { + manifest, + objects: [{ name: 'qa_task', label: 'Task', fields: { name: { type: 'text', label: 'Name' } } }], + }; +} + +describe('[#28535] a compiled artifact as `os package install ` sends it: the handshake reads its nested manifest', () => { + const protocolWarnings = (h: { logger: { warn: { mock: { calls: unknown[][] } } } }) => + h.logger.warn.mock.calls.map(([m]) => String(m)).filter((m) => m.includes('[protocol]')); + + it(`refuses ${OLD_RANGE} on protocol ${PROTOCOL_MAJOR}: 422 with the five diagnostic fields, and nothing is installed`, async () => { + const h = await mount(dir); + const res = await h.install(cliInstallBody(compiledArtifact(INCOMPATIBLE))); + expectProtocolRefusal(res); + expectNothingWritten(h); + expect(protocolWarnings(h)).toEqual([]); + }); + + it('answers exactly what the flat manifest answers, byte for byte', async () => { + const h = await mount(dir); + const viaCli = await h.install(cliInstallBody(compiledArtifact(INCOMPATIBLE))); + const viaFlat = await h.install({ manifest: INCOMPATIBLE }); + expect(JSON.stringify(viaCli)).toBe(JSON.stringify(viaFlat)); + }); + + it('refuses the bare compiled artifact (no lifted id) the same way', async () => { + const h = await mount(dir); + expectProtocolRefusal(await h.install({ manifest: compiledArtifact(INCOMPATIBLE) })); + expectNothingWritten(h); + }); + + it(`lit control: ${CURRENT_RANGE} installs with no protocol warning, registered and ledgered flat`, async () => { + const h = await mount(dir); + const res = await h.install(cliInstallBody(compiledArtifact(COMPATIBLE))); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(res.payload.data.manifestId).toBe(COMPATIBLE.id); + expect(protocolWarnings(h)).toEqual([]); + const [registered] = h.installRegistrations() as any[]; + expect(h.installRegistrations()).toHaveLength(1); + expect(registered.id).toBe(COMPATIBLE.id); + expect(registered.engines).toEqual({ protocol: CURRENT_RANGE }); + expect(registered.objects.map((o: any) => o.name)).toEqual(['qa_task']); + expect(registered.manifest).toBeUndefined(); + expect(readdirSync(dir)).toEqual([`${COMPATIBLE.id}.json`]); + expect(new LocalManifestSource(dir).read(COMPATIBLE.id).entry?.manifest.engines).toEqual({ protocol: CURRENT_RANGE }); + }); + + it('lit control: an artifact with no range is admitted with the one warning, as before', async () => { + const { engines: _omitted, ...noRange } = COMPATIBLE; + const h = await mount(dir); + const res = await h.install(cliInstallBody(compiledArtifact(noRange))); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + const warned = protocolWarnings(h); + expect(warned).toHaveLength(1); + expect(warned[0]).toContain(COMPATIBLE.id); + }); +}); + describe('the two package-install doors give the same answer for the same manifest', () => { /** * `POST /api/v1/packages` through the runtime's real dispatcher, on its