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
12 changes: 12 additions & 0 deletions .changeset/30326-install-answer-protocol-check-skipped.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/cloud-connection': patch
'@objectstack/metadata-core': patch
---

feat(cloud-connection,metadata-core): an install the protocol handshake admitted without checking says so in its answer, not only in the server log

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

- **`@objectstack/cloud-connection`: `POST /api/v1/marketplace/install-local` answers `data.protocolCheckSkipped`** when the ADR-0087 handshake admitted the package without checking it: `{ reason, packageId, protocolVersion, message }`, plus `requiredRange` and `rangeSource` for `reason: 'unparsed-range'`. `reason` is the handshake's own verdict: `no-range` (the package declares no `engines.protocol`) or `unparsed-range` (it declares a range the handshake cannot read). `protocolVersion` is the protocol the package was installed under unchecked, and `message` says so in plain words for the person who pressed Install. It is on all three success answers (managed, `unmanaged`, and a paired customization package), and omitted, not nulled, when the range was checked, so that answer's key set is unchanged. The `warn` log line stays as it was.
- **`@objectstack/metadata-core`: `assertProtocolCompat` returns the verdict it admitted the package under** (new exported type `ProtocolCompatAdmitted`; it returned `void`), and the `no-range` and `unparsed-range` verdicts of `checkProtocolCompat` carry `protocolVersion`, the version they were judged against. A door that answers a caller reads the skipped check from the handshake itself instead of judging the range a second time.
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ 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';
import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '@objectstack/spec/kernel';
// 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';
Expand Down Expand Up @@ -368,3 +369,21 @@ describe('install-local — a pair install refused before any write', () => {
expectNoCodeInstall(h);
});
});

describe('[#30326] a pair install the handshake admitted unchecked says so in its answer', () => {
it('a pair export with no engines.protocol answers `protocolCheckSkipped` reason `no-range`', async () => {
const h = await mount(dir);
const res = await h.install(exportedPair());
expect(res.status, JSON.stringify(res.payload)).toBe(200);
const { message, ...machine } = res.payload.data.protocolCheckSkipped;
expect(machine).toEqual({ reason: 'no-range', packageId: PAIR, protocolVersion: PROTOCOL_VERSION });
expect(message).toContain(`'${PAIR}'`);
});

it(`control: a pair export that declares ^${PROTOCOL_MAJOR} answers no \`protocolCheckSkipped\` key`, async () => {
const h = await mount(dir);
const res = await h.install(exportedPair({ engines: { protocol: `^${PROTOCOL_MAJOR}` } }));
expect(res.status, JSON.stringify(res.payload)).toBe(200);
expect(Object.keys(res.payload.data)).not.toContain('protocolCheckSkipped');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { tmpdir } from 'node:os';
// the plugin reaches the same module through a dynamic `import()`.
import '@objectstack/runtime';
import { SCHEDULED_WORK_ENV } from '@objectstack/types';
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js';
import { LocalManifestSource } from './local-manifest-source.js';
Expand All @@ -66,7 +67,13 @@ const HANDLER_JOB = { name: 'jobs_app_tick_handler', schedule: INTERVAL, handler
/** The compiled-artifact shape `os build` writes and `os package install` sends. */
function artifact(jobs: unknown[] | undefined, id: string = APP_ID) {
return {
manifest: { id, namespace: 'jobs_app', version: '0.1.0', type: 'app', name: 'Jobs App' },
// A range this runtime admits, as a scaffolded app declares one: the
// answer then carries no `protocolCheckSkipped` notice, and the key
// set the jobs-free case pins is the jobs gate's alone.
manifest: {
id, namespace: 'jobs_app', version: '0.1.0', type: 'app', name: 'Jobs App',
engines: { protocol: `^${PROTOCOL_MAJOR}` },
},
objects: [{ name: TICK, label: 'Tick', fields: { name: { type: 'text', label: 'Name' } } }],
...(jobs ? { jobs } : {}),
};
Expand Down
80 changes: 77 additions & 3 deletions packages/cloud-connection/src/marketplace-install-local-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ import { PackageExportManifestSchema, type PackageExportManifest } from '@object
import {
assertProtocolCompat,
checkProtocolCompat,
type ProtocolCompatAdmitted,
// [ADR-0141 D2] The pre-install dependency check: the one predicate and
// the one answer every package-install door gives its refusal.
checkDeclaredDependencies,
Expand Down Expand Up @@ -728,6 +729,58 @@ function withheldMapsLogLine(
+ 'environment variables until each map is supplied again where the connector is authored.';
}

/**
* [#30326] What an install answer carries when ADR-0087 D1's handshake
* admitted the package WITHOUT checking it: the person who pressed Install is
* the one who needs to know, and the `warn` line alone reaches only whoever
* reads the server log. Present on a success answer only then; omitted, not
* nulled, for a package whose range was checked, so that answer's key set is
* unchanged.
*
* `reason` is the handshake's own verdict, never a second reading of the
* range: `no-range` (the package declares no `engines.protocol`, nor either
* legacy spelling) or `unparsed-range` (it declares a range the handshake's
* grammar does not read). `protocolVersion` is the protocol the package was
* loaded under unchecked.
*/
export type ProtocolCheckSkipped =
| { reason: 'no-range'; packageId: string; protocolVersion: string; message: string }
| {
reason: 'unparsed-range';
packageId: string;
protocolVersion: string;
requiredRange: string;
rangeSource: string;
message: string;
};

/** [#30326] The notice for a verdict the handshake admitted unchecked; `undefined` for a checked one. */
function protocolCheckSkippedOf(verdict: ProtocolCompatAdmitted, packageId: string): ProtocolCheckSkipped | undefined {
if (verdict.status === 'ok') return undefined;
const consequence = `so it was installed under platform protocol ${verdict.protocolVersion} without a compatibility `
+ 'check and may not work on this platform version. A build of the package that declares the protocol it '
+ 'supports (`engines.protocol`) is checked at install.';
switch (verdict.status) {
case 'no-range':
return {
reason: 'no-range',
packageId,
protocolVersion: verdict.protocolVersion,
message: `Package '${packageId}' does not state which platform protocol it supports, ${consequence}`,
};
case 'unparsed-range':
return {
reason: 'unparsed-range',
packageId,
protocolVersion: verdict.protocolVersion,
requiredRange: verdict.requiredRange,
rangeSource: verdict.source,
message: `Package '${packageId}' states its platform protocol as '${verdict.requiredRange}' `
+ `(${verdict.source}), which this runtime cannot read, ${consequence}`,
};
}
}

/**
* [ADR-0131 D6] The package manifest an install body DECLARES — where each of
* the two single-package shapes this door accepts carries it: a compiled
Expand Down Expand Up @@ -1911,8 +1964,14 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
// hooks with THIS runtime's binder: ADR-0087 D1 checks "before
// loading a package's metadata". An absent or unrecognised range is
// admitted with a warning, as at every seam the handshake guards.
//
// [#30326] The log line is the operator's; the verdict it admitted
// the package under is also the answer's: every success answer
// below carries `protocolCheckSkipped` when the check did not run.
let protocolCheckSkipped: ProtocolCheckSkipped | undefined;
try {
assertProtocolCompat(manifest, undefined, (m) => ctx.logger?.warn?.(`[MarketplaceInstallLocal] ${m}`));
const admitted = assertProtocolCompat(manifest, undefined, (m) => ctx.logger?.warn?.(`[MarketplaceInstallLocal] ${m}`));
protocolCheckSkipped = protocolCheckSkippedOf(admitted, manifestId);
} catch (err) {
if (!isProtocolIncompatibleError(err)) throw err;
const refusal = protocolIncompatibleAnswer(err);
Expand Down Expand Up @@ -2090,6 +2149,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
versionId: resolvedVersionId,
userId,
withheldMaps,
protocolCheckSkipped,
});
}

Expand Down Expand Up @@ -2135,6 +2195,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
versionId: resolvedVersionId,
userId,
withheldMaps,
protocolCheckSkipped,
});
}

Expand Down Expand Up @@ -2520,6 +2581,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
// withheld whole, so it arrived absent. Omitted when there are
// none, so that answer's key set is unchanged.
...(withheldMaps.length > 0 ? { withheldMaps } : {}),
// [#30326] The handshake admitted the package without checking
// it (no `engines.protocol`, or a range it cannot read): the
// installer is told, not only the server log. Omitted when the
// range was checked, so that answer's key set is unchanged.
...(protocolCheckSkipped !== undefined ? { protocolCheckSkipped } : {}),
upgradedFrom: conflict === 'marketplace' ? 'previous-marketplace-version' : null,
// [ADR-0146 D2, #28410] Names installed dependents already held,
// undeclared, that this version now ships (step 2d). Omitted when
Expand Down Expand Up @@ -3738,9 +3804,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
manifest: Record<string, any>; manifestId: string; version: string; versionId: string; userId: string | null;
/** [#28640 ruling ③A] The connector maps the export withheld ({@link judgeWithheldMaps}). */
withheldMaps: readonly WithheldMap[];
/** [#30326] The handshake admitted the package unchecked ({@link protocolCheckSkippedOf}). */
protocolCheckSkipped: ProtocolCheckSkipped | undefined;
},
): Promise<Response> => {
const { manifest, manifestId, version, versionId, userId, withheldMaps } = args;
const { manifest, manifestId, version, versionId, userId, withheldMaps, protocolCheckSkipped } = args;
let protocol: UnmanagedImporter | undefined;
try { protocol = ctx.getService<UnmanagedImporter>('protocol'); } catch { /* no protocol service */ }
if (!protocol || typeof protocol.importUnmanagedPackage !== 'function') {
Expand Down Expand Up @@ -3820,6 +3888,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
// withheld whole, so its copy carries it absent. Omitted when
// there are none, so that answer's key set is unchanged.
...(withheldMaps.length > 0 ? { withheldMaps: [...withheldMaps] } : {}),
// [#30326] Omitted when the handshake checked the package's range.
...(protocolCheckSkipped !== undefined ? { protocolCheckSkipped } : {}),
...(declaresSeeds
? {
seeded: {
Expand Down Expand Up @@ -3891,9 +3961,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
version: string; versionId: string; userId: string | null;
/** [#28640 ruling ③A] The connector maps the export withheld ({@link judgeWithheldMaps}). */
withheldMaps: readonly WithheldMap[];
/** [#30326] The handshake admitted the package unchecked ({@link protocolCheckSkippedOf}). */
protocolCheckSkipped: ProtocolCheckSkipped | undefined;
},
): Promise<Response> => {
const { manifest, manifestId, baseId, version, versionId, userId, withheldMaps } = args;
const { manifest, manifestId, baseId, version, versionId, userId, withheldMaps, protocolCheckSkipped } = args;
const refuse = (status: number, code: string, message: string, details?: Record<string, unknown>) =>
c.json({ success: false, error: { code, message, ...(details ? { details } : {}) } }, status as any);

Expand Down Expand Up @@ -4054,6 +4126,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
written,
// [#28640 ruling ③A] Omitted when the export withheld none.
...(withheldMaps.length > 0 ? { withheldMaps: [...withheldMaps] } : {}),
// [#30326] Omitted when the handshake checked the package's range.
...(protocolCheckSkipped !== undefined ? { protocolCheckSkipped } : {}),
...(plan.carriesSeedData
? {
seeded: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,28 @@ function makeRawApp() {
* hand back every writer the install path can reach, so a refusal can be
* checked for having written nothing at all.
*/
async function mount(dir: string, opts: { controlPlaneUrl?: string } = {}) {
async function mount(dir: string, opts: { controlPlaneUrl?: string; unmanagedImport?: boolean } = {}) {
const rawApp = makeRawApp();
const hooks = new Map<string, any>();
const registry: any[] = [];
const register = vi.fn((m: any) => { registry.push({ manifest: m }); });
const syncSchemas = vi.fn(async () => undefined);
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
// [#30326] The protocol's copy-once import, for an `unmanaged` install.
const importUnmanagedPackage = vi.fn(async (request: any) => ({
sourcePackageId: request.manifest.id,
sourceVersion: request.manifest.version,
targetPackageId: `${request.manifest.id}-copy`,
namespace: request.manifest.namespace,
copiedCount: 0,
copied: [],
strippedLocks: [],
}));
const services: Record<string, any> = {
manifest: { register },
auth: installerAuthService(),
objectql: withInstallerGrants({ syncSchemas, registry: { getAllPackages: () => registry } }),
...(opts.unmanagedImport ? { protocol: { importUnmanagedPackage } } : {}),
};
const ctx = {
hook: (e: string, h: any) => hooks.set(e, h),
Expand Down Expand Up @@ -520,3 +531,67 @@ describe('[#26480] a package AHEAD of this runtime is told to upgrade, never to
expect(said[0]).toMatch(new RegExp(`upgrade the runtime to protocol ${NEW_MAJOR}`, 'i'));
});
});

describe('[#30326] an install the handshake admitted UNCHECKED says so in its answer, not only in the server log', () => {
/** The card's reproduction: a package that declares no protocol range at all. */
const UNDECLARED_ID = 'com.example.qanorange';
const UNDECLARED = { id: UNDECLARED_ID, name: 'No range', version: '6.0.0', scope: 'project', type: 'app' };

/** The notice the answer carries for a package the handshake could not check. */
function expectSkipped(res: { payload: any; status: number }, expected: Record<string, unknown>) {
expect(res.status, JSON.stringify(res.payload)).toBe(200);
expect(res.payload.success).toBe(true);
const notice = res.payload.data.protocolCheckSkipped;
expect(notice, JSON.stringify(res.payload.data)).toBeDefined();
const { message, ...machine } = notice;
expect(machine).toEqual({ protocolVersion: PROTOCOL_VERSION, ...expected });
expect(message).toContain(`'${expected.packageId}'`);
expect(message).toContain(`protocol ${PROTOCOL_VERSION} without a compatibility check`);
}

it('no engines.protocol (inline manifest): 200 with `protocolCheckSkipped` reason `no-range`, and the log line stays', async () => {
const h = await mount(dir);
const res = await h.install({ manifest: UNDECLARED });
expectSkipped(res, { reason: 'no-range', packageId: UNDECLARED_ID });
expect(h.installRegistrations().map((m) => m.id)).toEqual([UNDECLARED_ID]);
const said = h.logger.warn.mock.calls.map(([m]) => String(m)).filter((m) => m.includes('[protocol]'));
expect(said).toHaveLength(1);
expect(said[0]).toContain(`package '${UNDECLARED_ID}' declares no engines.protocol range`);
});

it('the compiled artifact `os package install <file>` sends answers the same notice', async () => {
const h = await mount(dir);
const res = await h.install({
manifest: { manifest: UNDECLARED, id: UNDECLARED_ID, version: UNDECLARED.version, objects: [] },
});
expectSkipped(res, { reason: 'no-range', packageId: UNDECLARED_ID });
});

it('a range the handshake cannot read: reason `unparsed-range`, with the range and where it was declared', async () => {
const h = await mount(dir);
const res = await h.install({ manifest: { ...UNDECLARED, engines: { protocol: 'workspace:*' } } });
expectSkipped(res, {
reason: 'unparsed-range',
packageId: UNDECLARED_ID,
requiredRange: 'workspace:*',
rangeSource: 'engines.protocol',
});
});

it('an `unmanaged` install of the same package carries the notice too', async () => {
const h = await mount(dir, { unmanagedImport: true });
const res = await h.install({
manifest: { ...UNDECLARED, namespace: 'qanorange', installModes: ['managed', 'unmanaged'] },
installMode: 'unmanaged',
});
expect(res.payload.data?.installMode, JSON.stringify(res.payload)).toBe('unmanaged');
expectSkipped(res, { reason: 'no-range', packageId: UNDECLARED_ID });
});

it(`control: under ${CURRENT_RANGE} the range is checked, and the answer has no \`protocolCheckSkipped\` key`, async () => {
const h = await mount(dir);
const res = await h.install({ manifest: { ...UNDECLARED, engines: { protocol: CURRENT_RANGE } } });
expect(res.status, JSON.stringify(res.payload)).toBe(200);
expect(Object.keys(res.payload.data)).not.toContain('protocolCheckSkipped');
});
});
21 changes: 21 additions & 0 deletions packages/metadata-core/src/protocol-handshake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,3 +544,24 @@ describe('[#26546] the refusal reads the comparator direction, so an exclusive b
});
});
});

describe('[#30326] assertProtocolCompat answers the verdict it admitted the package under', () => {
it('no-range: the handshake\'s own verdict, naming the protocol the package loads under unchecked', () => {
expect(assertProtocolCompat({ id: 'p' }, RT, () => {})).toEqual({
status: 'no-range', runtimeMajor: 11, protocolVersion: RT,
});
});

it('unparsed-range: the verdict with the range and its source', () => {
expect(assertProtocolCompat({ id: 'p', engines: { protocol: '???' } }, RT, () => {})).toEqual({
status: 'unparsed-range', runtimeMajor: 11, protocolVersion: RT, requiredRange: '???', source: 'engines.protocol',
});
});

it('ok: the checked verdict, equal to checkProtocolCompat\'s for the same manifest', () => {
const manifest = { id: 'p', engines: { protocol: '^11' } };
const admitted = assertProtocolCompat(manifest, RT, () => {});
expect(admitted.status).toBe('ok');
expect(admitted).toEqual(checkProtocolCompat(manifest, RT));
});
});
Loading
Loading