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
22 changes: 22 additions & 0 deletions .changeset/27810-plugin-publish-parses-compiled-manifest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/cli': minor
---

fix(cli)!: `os plugin publish` parses the artifact's compiled manifest through `ManifestSchema` before anything is uploaded, so a stale `.osplugin` (e.g. `packaging: 'manifest-deps'`) is refused at the publisher's terminal with the schema's prescription instead of failing later at its consumer's install

Clause-②: no (narrowing)
Metadata-protocol: narrowed

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: `ManifestSchema` is consumed as it stands, and what changes is that a third door, `os plugin publish`, holds the compiled manifest inside an artifact to it, as `os plugin build` holds the source manifest and `os plugin install` and the boot-time loader hold the compiled one. The refusal is the schema's own message (for `manifest-deps`, the retirement prescription), and the fix is to the source manifest and a rebuild, so there is nothing new for a metadata migration to rewrite and no tombstone. -->

**BREAKING** accept-set narrowing at `os plugin publish` (`@objectstack/cli`).

- **Before.** `os plugin publish` `JSON.parse`d the artifact's `objectstack.plugin.json` and checked only that `id` and `version` were present and that `integrity` was an object. An artifact built before a schema change uploaded: one carrying the retired `packaging: 'manifest-deps'` reached the registry and was refused only at its consumer's `os plugin install`. Any other compiled-manifest drift (a missing `name` or `type`, an unknown key) uploaded the same way.
- **Now.** The publish parses the compiled manifest with `readCompiledPluginManifest` (`@objectstack/runtime`), the one helper `os plugin install` and the boot-time loader already use, before any request leaves the machine.
- A manifest the schema refuses stops the publish with `The artifact's objectstack.plugin.json is not a valid plugin manifest, so nothing was published:` and one line per schema issue. For `manifest-deps` that line is the retirement prescription. Neither the package row nor the version is posted.
- The hand-rolled malformed-`integrity` refusal is gone: a malformed map is a schema issue now. `Artifact manifest is missing id or version.` is now reached only by an empty `--manifest-id`.
- A valid artifact publishes unchanged: the uploaded `plugin_manifest` is still the manifest the artifact carries, byte for byte, without the defaults the parse stamps (`scope`, `defaultDatasource`).
- `readOspluginManifest` (internal to the CLI) parses through the same helper and is now async.
- **Who sees it.** Anyone publishing an `.osplugin` whose compiled manifest `ManifestSchema` refuses: one built by an `os plugin build` from before a schema change, or one assembled by hand.

**The remedy.** Fix the plugin's source `objectstack.plugin.json` as the refusal says (for `manifest-deps`: delete the `packaging` key; `bundled` is the default and the only strategy), rebuild the artifact with `os plugin build`, re-sign it with `os plugin sign`, and publish it again. An artifact `os plugin build` produced from a valid manifest publishes unchanged.
14 changes: 10 additions & 4 deletions docs/qa/platform-checklist/areas/cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -1716,7 +1716,7 @@
"title": "os plugin build enforces the manifest at the packaging boundary: an invalid manifest is refused with located errors, `bundled` is the one packaging strategy (the retired `manifest-deps` is refused at the manifest, a native addon is refused by name), and the compiled manifest the artifact carries is readable back",
"since": "v15",
"status": "active",
"revision": 6,
"revision": 7,
"priority": "P2",
"surface": "cli",
"personas": [
Expand Down Expand Up @@ -1772,9 +1772,9 @@
"evidence": "the three refusals with their exit codes and the listings taken after them"
},
{
"clause": "the artifact carries a COMPILED manifest — the authored manifest plus `main: \"dist/index.mjs\"` and an `integrity` map of archive-relative path → sha256 SRI digest, with the manifest itself and SIGNATURE excluded from the map — and os plugin publish reads that manifest back out of the artifact BEFORE any network call, refusing 'Artifact manifest is missing id or version.' when it cannot",
"clause": "the artifact carries a COMPILED manifest — the authored manifest plus `main: \"dist/index.mjs\"` and an `integrity` map of archive-relative path → sha256 SRI digest, with the manifest itself and SIGNATURE excluded from the map — and os plugin publish reads that manifest back out of the artifact BEFORE any network call and parses it through ManifestSchema (readCompiledPluginManifest, the parse os plugin install and the boot loader share), refusing one the schema refuses with 'The artifact's objectstack.plugin.json is not a valid plugin manifest, so nothing was published:' and one line per schema issue (a missing version reads `version: …`; a pre-retirement `manifest-deps` artifact reads the retirement prescription) when it cannot",
"oracle": "build",
"verify": "read the objectstack.plugin.json entry out of each archive and diff it against the authored file; then run publish against a good artifact (expect it to reach the auth refusal, proving the manifest was read) and against one whose manifest lacks a version (expect the artifact-manifest refusal, before any auth). ⚠️ The publish leg is scored on WHICH refusal arrives, not on success",
"verify": "read the objectstack.plugin.json entry out of each archive and diff it against the authored file; then run publish against a good artifact (expect it to reach the auth refusal, proving the manifest was read) and against one whose manifest lacks a version (expect the schema refusal naming `version`, before any auth). ⚠️ The publish leg is scored on WHICH refusal arrives, not on success",
"evidence": "the compiled manifest of each artifact, and the two publish outputs distinguished by their refusal"
},
{
Expand All @@ -1798,7 +1798,7 @@
"source": [
"packages/cli/src/commands/plugin/build.ts#formatZodErrors — manifest read + named refusal; ManifestSchema.safeParse + formatZodErrors; the explicit id/version guard; packaging default and the Loaded line; entry resolution; the bundled-only esbuild call (only `@objectstack/*` external); findNativeAddons and the native-addon refusal ahead of the integrity map; computeIntegrity + the compiled manifest; the artifact write and its printed block",
"packages/cli/src/utils/osplugin.ts#MANIFEST_FILENAME — MANIFEST_FILENAME = 'objectstack.plugin.json'; OSPLUGIN_EXT; computeIntegrity (manifest and SIGNATURE excluded, keys sorted); the ustar writer with mtime pinned to 0; readOspluginManifest",
"packages/cli/src/commands/plugin/publish.ts#version — artifact discovery; readOspluginManifest before any network call; the id/version refusal; the unsigned-artifact notice; the auth refusal",
"packages/cli/src/commands/plugin/publish.ts#version — artifact discovery; the compiled manifest parsed through readCompiledPluginManifest (ManifestSchema) before any network call and the schema refusal; the empty `--manifest-id` refusal; the unsigned-artifact notice; the auth refusal",
"packages/cli/src/commands/plugin/install.ts#PluginInstall — the install-time re-verification the integrity negative points at (verifyIntegrity over the unpacked entries, `integrity_mismatch`; a compiled manifest ManifestSchema refuses, a malformed integrity map included, is `manifest_invalid` one step earlier through readCompiledPluginManifest; nothing installed) and the install-time tier warning; not scored here",
"packages/core/src/security/plugin-artifact-integrity.ts#verifyIntegrity — the one integrity verifier, shared by the publish preflight and the install-time check; not scored here",
"packages/runtime/src/installed-plugin-loader.ts#dispatchPluginRuntime — the boot-time tier dispatch point the trust-tier negative points at (`node` only; `sandbox` / `worker` refused by name; an undeclared tier refused); not scored here",
Expand Down Expand Up @@ -1844,6 +1844,12 @@
"date": "2026-10-30",
"change": "one source row re-pointed, no clause changed: os plugin install no longer has an `integrity_malformed` refusal. The compiled manifest is parsed through ManifestSchema first (readCompiledPluginManifest, the parse install and the boot loader share), so a malformed integrity map is refused as `manifest_invalid` before the integrity step; `integrity_mismatch` is unchanged. Re-verified against install.ts and compiled-plugin-manifest.ts at this PR's head, no qa-run record",
"ref": "#27686"
},
{
"revision": 7,
"date": "2026-10-30",
"change": "the compiled-manifest clause's publish half (clause and verify) and the publish.ts source row re-pointed: os plugin publish no longer hand-parses the compiled manifest. It parses it through ManifestSchema with readCompiledPluginManifest (the helper os plugin install and the boot loader share), so a manifest that lacks a version is refused as 'not a valid plugin manifest' with the schema's issue line (`version: …`), not 'Artifact manifest is missing id or version.' — that sentence is now reachable only from an empty `--manifest-id`. The uploaded plugin_manifest stays the artifact's own manifest, unchanged",
"ref": "#27810"
}
]
},
Expand Down
66 changes: 42 additions & 24 deletions packages/cli/src/commands/plugin/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@
* Flow:
* 1. Read the `.osplugin` bytes + the detached `.sig` (publisher signature).
* 2. Extract the compiled `objectstack.plugin.json` from inside the
* artifact (id / version / name / runtime / permissions / integrity),
* then preflight the artifact files against the manifest's declared
* per-file `integrity` digests (ADR-0025 §3.2) — refuse on any
* artifact and parse it through `ManifestSchema`
* (`readCompiledPluginManifest`, the read `os plugin install` and the
* boot-time loader make too): a manifest the schema refuses (one built
* before a value was retired, such as `packaging: 'manifest-deps'`) is
* refused here with the schema's own words, before anything leaves the
* machine. Then preflight the artifact files against the manifest's
* declared per-file `integrity` digests (ADR-0025 §3.2) — refuse on any
* mismatch / missing / extra file; an absent map skips the check
* (the field is optional).
* 3. POST /cloud/packages — ensure the sys_package row exists
Expand Down Expand Up @@ -39,6 +43,7 @@ import {
type ArchiveFile,
} from '../../utils/osplugin.js';
import { verifyIntegrity, formatIntegrityViolation } from '@objectstack/core';
import { readCompiledPluginManifest } from '@objectstack/runtime';

interface PostResult { ok: boolean; status: number; body: any; error?: string }

Expand Down Expand Up @@ -103,21 +108,42 @@ export default class PluginPublish extends Command {
const checksum = sha256Hex(bytes);
const base64 = Buffer.from(bytes).toString('base64');

// 2. Extract the compiled manifest from inside the artifact. ────────
let manifest: Record<string, any>;
// 2. Extract the compiled manifest from inside the artifact, through
// `ManifestSchema` (the read `os plugin install` and the boot-time
// loader make too): an artifact built before a schema change is
// refused here, with the schema's own words, not at its consumer's
// install. ⛔ No manifest rule is restated in this command. ────────
let archiveFiles: ArchiveFile[];
try {
archiveFiles = readTarGz(bytes);
const entry = archiveFiles.find((f) => f.path === MANIFEST_FILENAME);
if (!entry) throw new Error(`${MANIFEST_FILENAME} not found in artifact`);
manifest = JSON.parse(Buffer.from(entry.data).toString('utf8')) as Record<string, any>;
} catch (err: any) {
printError(`Cannot read manifest from artifact: ${err?.message ?? err}`);
this.exit(1);
return;
}
const id = String(flags['manifest-id'] ?? manifest.id ?? '').trim();
const version = String(manifest.version ?? '').trim();
const read = readCompiledPluginManifest(archiveFiles);
if (!read.ok) {
if (read.reason === 'unreadable') {
printError(`Cannot read manifest from artifact: ${read.message}`);
} else {
printError(`The artifact's ${MANIFEST_FILENAME} is not a valid plugin manifest, so nothing was published:`);
for (const issue of read.issues) console.log(` • ${issue}`);
console.log('\n Fix the source manifest as above and rebuild the artifact with `os plugin build` (then re-sign it with `os plugin sign`), and publish the fresh artifact.');
}
this.exit(1);
return;
}
const manifest = read.manifest;
// What is uploaded is the manifest the artifact carries, as it carries
// it: the parse above stamps the schema's defaults (`scope`,
// `defaultDatasource`), which would make the declared manifest differ
// from the signed bytes' own. The entry is there and is a JSON object:
// the read above answered `ok`.
const declaredManifest = JSON.parse(
Buffer.from(archiveFiles.find((f) => f.path === MANIFEST_FILENAME)!.data).toString('utf8'),
) as Record<string, unknown>;
const id = String(flags['manifest-id'] ?? manifest.id).trim();
const version = manifest.version;
const displayName = String(flags['display-name'] ?? manifest.name ?? id).trim();
if (!id || !version) { printError('Artifact manifest is missing id or version.'); this.exit(1); return; }
printStep(`${id}@${version} (${(bytes.byteLength / 1024).toFixed(1)} KB, runtime: ${manifest.runtime ?? 'unset'})`);
Expand All @@ -126,21 +152,13 @@ export default class PluginPublish extends Command {
// bytes against the manifest's own declared per-file digests before
// upload. Absent map = permissive by contract (the field is
// `.optional()`; artifacts built before integrity computation stay
// publishable). The installing side re-verifies at unpack
// (`os plugin install`, ADR-0025 §3.5 M1); this preflight is the
// publisher's own check, and the cloud control plane runs none.
const declaredIntegrity = manifest.integrity;
if (
declaredIntegrity !== undefined && declaredIntegrity !== null
&& (typeof declaredIntegrity !== 'object' || Array.isArray(declaredIntegrity))
) {
printError('Artifact manifest has a malformed `integrity` map (expected an object of path → digest). Rebuild with `os plugin build`.');
this.exit(1);
return;
}
// publishable). The map's shape is `ManifestSchema`'s, checked above.
// The installing side re-verifies at unpack (`os plugin install`,
// ADR-0025 §3.5 M1); this preflight is the publisher's own check, and
// the cloud control plane runs none.
const integrityCheck = verifyIntegrity(
archiveFiles,
declaredIntegrity as Record<string, string> | undefined,
manifest.integrity,
{ exempt: [MANIFEST_FILENAME, SIGNATURE_FILENAME] },
);
if (!integrityCheck.ok) {
Expand Down Expand Up @@ -203,7 +221,7 @@ export default class PluginPublish extends Command {
version,
artifact_kind: 'plugin',
osplugin: base64,
plugin_manifest: manifest,
plugin_manifest: declaredManifest,
artifact_checksum: checksum,
is_pre_release: flags['pre-release'] || /-(alpha|beta|rc|dev|preview|staging|pr)/i.test(version),
};
Expand Down
23 changes: 17 additions & 6 deletions packages/cli/src/utils/osplugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import { createHash } from 'node:crypto';
import { gzipSync } from 'node:zlib';
import { readTarGz, type ArchiveFile } from '@objectstack/core';
import type { ObjectStackManifestParsed } from '@objectstack/spec/kernel';

export { readTar, readTarGz, sha256Hex, MAX_UNPACKED_ARTIFACT_BYTES, type ArchiveFile } from '@objectstack/core';

Expand Down Expand Up @@ -135,11 +136,21 @@ export function createTarGz(files: ArchiveFile[]): Buffer {
}

/**
* Extract + parse the compiled `objectstack.plugin.json` from an `.osplugin`
* blob. Throws if the manifest is absent or not valid JSON.
* Extract the compiled `objectstack.plugin.json` from an `.osplugin` blob and
* parse it through `ManifestSchema` — `readCompiledPluginManifest`, the one
* read `os plugin install` and the boot-time loader make too. Throws with the
* helper's message when the manifest is absent, not a JSON object, or refused
* by the schema (a retired value such as `packaging: 'manifest-deps'` carries
* its prescription in that message).
*
* The helper is imported on call, not at the top: every `os plugin` command
* loads this module, and `@objectstack/runtime`'s root entry would add about
* a second to `os plugin build` and `os plugin sign`, which never read a
* compiled manifest back.
*/
export function readOspluginManifest(blob: Uint8Array): Record<string, unknown> {
const entry = readTarGz(blob).find((f) => f.path === MANIFEST_FILENAME);
if (!entry) throw new Error(`${MANIFEST_FILENAME} not found in artifact`);
return JSON.parse(Buffer.from(entry.data).toString('utf8')) as Record<string, unknown>;
export async function readOspluginManifest(blob: Uint8Array): Promise<ObjectStackManifestParsed> {
const { readCompiledPluginManifest } = await import('@objectstack/runtime');
const read = readCompiledPluginManifest(readTarGz(blob));
if (!read.ok) throw new Error(read.message);
return read.manifest;
}
Loading
Loading