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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/28535-install-local-nested-artifact-manifest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/cloud-connection': patch
---

fix(cloud-connection)!: install-local reads the manifest of a compiled artifact as `os package install <file>` 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

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: the install door's existing ADR-0087 handshake and ADR-0141 dependency check now read the keys of a compiled artifact the CLI sends, as they already read a flat manifest and a bare compiled artifact. The refusals are the existing shared answers, so there is nothing for a migration to rewrite. -->

**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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` 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<string, any>) => {
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<string, string>) => ({
id: 'com.example.multi', name: 'Multi', version: '1.0.0', scope: 'project', type: 'app',
Expand Down
20 changes: 13 additions & 7 deletions packages/cloud-connection/src/marketplace-install-local-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` (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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` 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
Expand Down Expand Up @@ -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<string, any>; [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<string, any>) {
return {
manifest,
objects: [{ name: 'qa_task', label: 'Task', fields: { name: { type: 'text', label: 'Name' } } }],
};
}

describe('[#28535] a compiled artifact as `os package install <file>` 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
Expand Down
Loading