diff --git a/.changeset/27123-integrity-reverified-at-unpack.md b/.changeset/27123-integrity-reverified-at-unpack.md new file mode 100644 index 00000000000..582d1d8d739 --- /dev/null +++ b/.changeset/27123-integrity-reverified-at-unpack.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': patch +'@objectstack/core': patch +--- + +docs(spec, core): the plugin manifest's `integrity` map is described as re-verified at unpack by `os plugin install`, and the platform-object registry lists `sys_trusted_key` (#27123) + +Clause-②: no — no authorable key or accepted value changes. `PLATFORM_OBJECTS_BY_PACKAGE['platform-objects']` gains the `sys_trusted_key` row its maintenance contract requires for an object `@objectstack/platform-objects` now registers (a list of platform-object names, not an authorable surface); the rest is prose: the TSDoc of `PluginIntegritySchema` and the manifest's `integrity` field, and comments in `@objectstack/core`'s security module, with no `.describe()` text changed +Metadata-protocol: none + +The TSDoc said nothing in the repo re-verified the map after publish and that there was no `os plugin install`. `os plugin install` now re-hashes the unpacked files against the map before it installs anything, and refuses the install on a mismatch, so the text says that. An absent map is still a permissive pass. The liveness ledger's `integrity` row moves from `dead` to `live` on that reader. + +`sys_trusted_key` joins the `platform-objects` group of `PLATFORM_OBJECTS_BY_PACKAGE`, so `PLATFORM_PROVIDED_OBJECT_NAMES` counts it as platform-provided like its siblings. diff --git a/.changeset/27123-local-plugin-install.md b/.changeset/27123-local-plugin-install.md new file mode 100644 index 00000000000..7f84a14f327 --- /dev/null +++ b/.changeset/27123-local-plugin-install.md @@ -0,0 +1,25 @@ +--- +'@objectstack/cli': patch +'@objectstack/runtime': patch +'@objectstack/platform-objects': patch +--- + +feat(cli, runtime, platform-objects): `os plugin install ./x.osplugin` installs a signed plugin artifact locally, after verifying its signature against the environment's trusted publisher keys and its files against the manifest's `integrity` map (#27123) + +Clause-②: no — no key, accepted value or export of `@objectstack/spec` changes; the new object and commands are platform code, not authorable metadata +Metadata-protocol: none + +The first slice of ADR-0025's local install loop (§3.5 M1). Before this, a built and signed `.osplugin` had no command that installed it. + +- **`os plugin keygen --out PATH`** writes a publisher Ed25519 key pair: `PATH.key.pem` (private, mode `0600`, stays with the publisher) and `PATH.pub.pem`, and prints the key id and the public key's fingerprint (`sha256:` + hex of the SPKI DER bytes). It never overwrites a key file without `--force`. +- **`os plugin trust add PUB.pem --key-id ID`**, **`trust list`**, **`trust revoke ID`** manage the environment's trusted keys in the new deployment-level object `sys_trusted_key` (registered by `PlatformObjectsPlugin`, beside `sys_secret`; no tenant column; `manage_platform_settings`; the API offers reads only). The writes run in system context and are recorded by the audit mirror. Only a public key is accepted: a PEM holding a private key, or a key that is not Ed25519, is refused. One key id names one key and one key carries one key id. A revoked row stays, and neither its key id nor its key is trusted again. +- **`os plugin install ARTIFACT`** reads the archive and refuses, by name and with exit status 1, before anything is written: + - an entry that could land outside the install directory, two entries that differ only in letter case, or a file that is also the directory of another entry (`artifact_entries_refused`); + - a file whose digest is not the one the manifest's `integrity` map declares, a declared file that is missing, or a packaged file the map does not name (`integrity_mismatch`); + - a missing signature (`signature_missing`), one that is there but cannot be read (`signature_unreadable`), a malformed one (`signature_malformed`), a key id the environment does not trust (`signature_key_untrusted`) or has revoked (`signature_key_revoked`), a trusted-key row whose fingerprint is not its key's (`trusted_key_invalid`), and a signature that does not verify over the artifact's bytes (`signature_invalid`); + - an installations file it cannot read (`record_unreadable`), which a write would clobber, and an install directory that already holds other bytes no installation of this project put there (`install_dir_taken`): two projects sharing one `OS_HOME` and environment share the directory, and one does not overwrite the other's install. + + It then unpacks the artifact to `/plugins////`, keeping the original `artifact.osplugin` and `artifact.osplugin.sig` beside the files, and writes an installation record (the version, the key id and fingerprint it verified against) to `/plugin-installations/`, keyed by environment and project like the package disable-state store. A swap that fails puts the earlier install of the same version back. `--trust-unsigned` installs an artifact that has no signature, with a warning on stderr; it never makes a present signature that fails verification pass. `--json` prints one document. +- **Not yet.** `os serve` does not load installed plugins: the boot-time loader is the next slice of the same loop. + +`@objectstack/runtime` exports the two stores both sides read: `addTrustedKey`, `listTrustedKeys`, `revokeTrustedKey`, `resolveTrustedKey`, `readPublisherPublicKey`, `publicKeyFingerprint`, and `pluginInstallDir`, `loadPluginInstallations`, `recordPluginInstallation`. diff --git a/content/docs/protocol/kernel/lifecycle.mdx b/content/docs/protocol/kernel/lifecycle.mdx index b42a3360adf..b1ec8e9166a 100644 --- a/content/docs/protocol/kernel/lifecycle.mdx +++ b/content/docs/protocol/kernel/lifecycle.mdx @@ -16,12 +16,13 @@ partially implemented in the control plane and exposed only on hosted plans. Treat those command snippets as design intent: the `os` CLI has no `upgrade` command, so no `objectstack upgrade …` line on this page is runnable. -**Code-plugin installation is not implemented.** The `os plugin` group is -`build`, `sign` and `publish` only; there is no `os plugin install`, `enable` -or `test`. [ADR-0025](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0025-plugin-package-distribution.md) -records the install half (download, verify, materialize, load) as not yet -implemented; see [Plugin Installation](#plugin-installation) for what an author -does today. +**Code-plugin installation is local and partial.** `os plugin install +./x.osplugin` verifies a local artifact's signature against the environment's +trusted keys and its per-file integrity, then unpacks it; nothing loads an +installed plugin at boot yet, and there is no `os plugin enable` or `test`. +[ADR-0025](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0025-plugin-package-distribution.md) +§3.5 M1 records the loader as the next step; see +[Plugin Installation](#plugin-installation) for what an author does today. ObjectStack manages the complete **lifecycle** of the platform runtime—from initial boot through schema evolution and graceful shutdown, and describes the design intent for plugin installation, upgrades, and rollbacks. The boot sequence is implemented as described; the installation and upgrade flows are design intent (see the callout above). @@ -195,11 +196,15 @@ class AppPlugin implements Plugin { ## Plugin Installation -**Not implemented.** Installing a code plugin is a design that +**Partly implemented.** Installing a code plugin is a design that [ADR-0025](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0025-plugin-package-distribution.md) -records but the platform does not yet ship: the install half (download, -verify, materialize, load) has no command and no runtime path. The sections -below up to *Upgrades* describe that design, not a command you can run. See +records and the platform ships only in part: `os plugin install ./x.osplugin` +verifies a local artifact (its signature against the environment's trusted +keys, its files against the manifest's `integrity` map), unpacks it and +records the installation, writing nothing until every check passes. Download +from the cloud, loading an installed plugin at boot, and the install hooks and +registry below have no command and no runtime path. The sections below up to +*Upgrades* describe that design, not a command you can run. See [What an author does today](#what-an-author-does-today). @@ -369,7 +374,9 @@ transitions (this page, above), not code hooks; boot-time code goes in ### What an author does today -There is no install command for a code plugin. What exists, per the +The one install command for a code plugin is `os plugin install`, for a +signed artifact on local disk, and nothing loads what it installs yet. What +exists, per the [CLI reference](https://github.com/objectstack-ai/objectstack/blob/main/packages/cli/README.md): - **Runtime plugins** declared in `objectstack.config.ts` `plugins` are loaded @@ -381,9 +388,10 @@ There is no install command for a code plugin. What exists, per the publishing with `os package publish … --env --install`. - **Code-bearing plugins** (a directory carrying an `objectstack.plugin.json` manifest) are packaged and shipped with `os plugin build`, `os plugin sign` - and `os plugin publish`. The group stops there: ADR-0025 records the install - half as not yet implemented, so a published `.osplugin` has no `os` command - that installs it. + and `os plugin publish`. A signed `.osplugin` on local disk is installed with + `os plugin install` once its publisher key is trusted (`os plugin keygen`, + `os plugin trust add`); `os serve` does not load installed plugins yet, and a + published `.osplugin` has no `os` command that fetches it from the cloud. See [Plugin System](/docs/plugins) for how kernel plugins are created and registered. @@ -912,7 +920,7 @@ Plugin authors must document breaking changes in CHANGELOG.md. ObjectStack lifecycle management provides: - **Deterministic Boot:** 7-phase boot sequence with clear error handling -- **Atomic Installations (design):** ADR-0025 calls for transactional, all-or-nothing plugin installs; code-plugin installation is not implemented yet +- **Atomic Installations (design):** ADR-0025 calls for transactional, all-or-nothing plugin installs; `os plugin install` verifies a local artifact in full before it writes anything, but nothing loads an installed plugin at boot yet - **Zero-Downtime Upgrades:** Blue-green and rolling strategies for production - **Automatic Rollback:** Failed upgrades auto-revert to previous version - **Health Monitoring:** Built-in health checks validate system state diff --git a/packages/cli/README.md b/packages/cli/README.md index 5f2985c0739..b8e89a44838 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -142,15 +142,18 @@ in there with `--email` / `--password` (env `OS_RUNTIME_EMAIL` / Runtime plugins (declared in `objectstack.config.ts` `plugins`) are loaded automatically by `os serve` / `os dev`. Runtime plugins are bundled into the build artifact. To distribute a build, publish it as a package with `os package publish` (see [Cloud — publish & install](#cloud--publish--install)); the `os environments bind --artifact dist/objectstack.json` path still binds an artifact directly into an environment without going through the package registry. -A code-bearing plugin — a directory carrying an `objectstack.plugin.json` manifest — is packaged and shipped through the `os plugin` command group (ADR-0025 §3.4, build → sign → publish): +A code-bearing plugin — a directory carrying an `objectstack.plugin.json` manifest — is packaged and shipped through the `os plugin` command group (ADR-0025 §3.4, build → sign → publish), and installed locally from a signed artifact (ADR-0025 §3.5 M1, keygen → trust → install): | Command | Description | |---------|-------------| | `os plugin build [dir]` | Compile a plugin into a signed-ready `.osplugin` artifact (`--entry`, `--out`, `--minify`) | | `os plugin sign --key ` | Sign a built `.osplugin` with a publisher Ed25519 key, writing a detached `.sig` | | `os plugin publish [artifact]` | Publish a signed `.osplugin` to ObjectOS Cloud | +| `os plugin keygen --out ` | Generate a publisher Ed25519 key pair: `.key.pem` (private, stays with you) and `.pub.pem` | +| `os plugin trust add --key-id ` | Trust a publisher public key in this environment's database (`sys_trusted_key`); `trust list` shows the keys, `trust revoke ` revokes one | +| `os plugin install ` | Verify a local `.osplugin`'s detached signature against the trusted keys and its per-file `integrity`, then unpack it to `/plugins////`; `--trust-unsigned` is the explicit dev override for an unsigned artifact | -The group has no `install`: ADR-0025 records the code-plugin install half (download, verify, materialize, load) as not yet implemented. `os plugin` (singular) is unrelated to `os plugins` (plural), oclif's plugin manager, which this package does not ship — see [`os plugins` and `os help`](#os-plugins-and-os-help-not-commands). +`os plugin install` refuses, by name, an unknown or revoked key, a signature over other bytes, a missing signature and an integrity mismatch. `os serve` does not load installed plugins yet: loading them at boot is the next step of ADR-0025's M1 install loop. `os plugin` (singular) is unrelated to `os plugins` (plural), oclif's plugin manager, which this package does not ship — see [`os plugins` and `os help`](#os-plugins-and-os-help-not-commands). ### Quality diff --git a/packages/cli/src/commands/plugin/install.integration.test.ts b/packages/cli/src/commands/plugin/install.integration.test.ts new file mode 100644 index 00000000000..748a4ad5d28 --- /dev/null +++ b/packages/cli/src/commands/plugin/install.integration.test.ts @@ -0,0 +1,403 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The local T0 install loop end to end (ADR-0025 §3.5 M1, #27123), in process + * through oclif, against a real SQLite file and an OS_HOME of its own: + * `os plugin trust add` writes the trusted-key row (and the audit mirror's row + * for it), `os plugin install` verifies the artifact's detached signature + * against that row, re-verifies its integrity at unpack, and materializes it + * with an installation record naming the key. + * + * Every refusal is pinned by its NAME and exit status 1, and none of them + * leaves an install directory or a record behind: an unknown key, a revoked + * key, a key row that cannot verify, a signature over other bytes, a missing, + * malformed or unreadable signature, an integrity mismatch, an entry that + * would land outside the install directory, an installations file that cannot + * be read, and a directory another project's install holds. The one override, + * `--trust-unsigned`, installs an UNSIGNED artifact and says so on stderr; it + * never makes a present signature pass, whatever is wrong with it. + * + * Each case builds its own home, project and database, so the cases hold in + * any order and one at a time. + * + * The database is read back on a connection of our own (`SqlDriver`), which is + * also what places this file in the integration tier (`vitest-tiers.ts`). + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { generateEd25519KeyPair, signPayload } from '@objectstack/core'; +import { loadPluginInstallations, pluginInstallDir, pluginInstallationsFile, readPublisherPublicKey } from '@objectstack/runtime'; +import { isExitSignal } from '../../utils/format.js'; +import { MANIFEST_FILENAME, computeIntegrity, createTarGz, type ArchiveFile } from '../../utils/osplugin.js'; +import { INSTALLED_ARTIFACT_FILE, INSTALLED_SIGNATURE_FILE } from '../../utils/plugin-install.js'; +import PluginInstall from './install.js'; +import PluginTrustAdd from './trust/add.js'; +import PluginTrustRevoke from './trust/revoke.js'; + +const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const CASE_TIMEOUT_MS = 120_000; +const ENV = ['OS_HOME', 'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_ARTIFACT_PATH', 'OS_ENVIRONMENT_ID'] as const; + +interface Run { + payload: any; + /** What reached `process.stdout.write`. */ + stdout: string; + /** Everything else: `process.stderr.write` and the console. */ + stderr: string; + exitCode: number; +} + +/** One command through oclif, its stdout document and exit status captured. */ +async function run(command: { run: (argv: string[], opts: { root: string }) => Promise }, argv: string[]): Promise { + const savedExit = process.exitCode; + const out: string[] = []; + const err: string[] = []; + const capture = (into: string[]) => ((chunk: unknown, ...rest: unknown[]) => { + into.push(String(chunk)); + const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined; + if (cb) cb(); + return true; + }) as typeof process.stdout.write; + vi.spyOn(process.stdout, 'write').mockImplementation(capture(out)); + vi.spyOn(process.stderr, 'write').mockImplementation(capture(err)); + // Boot chatter goes through console; the `--json` document is the one + // thing written to stdout itself, as `emitJson` writes it. + vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => void err.push(`${a.join(' ')}\n`)); + vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => void err.push(`${a.join(' ')}\n`)); + vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => void err.push(`${a.join(' ')}\n`)); + let thrownExit: number | undefined; + try { + try { + await command.run(argv, { root: CLI_ROOT }); + } catch (error) { + if (!isExitSignal(error)) throw error; + thrownExit = (error as { oclif?: { exit?: number } }).oclif?.exit; + } + const stdout = out.join(''); + let payload: any; + try { payload = JSON.parse(stdout.trim()); } catch { payload = undefined; } + return { payload, stdout, stderr: err.join(''), exitCode: thrownExit ?? (process.exitCode as number | undefined) ?? 0 }; + } finally { + process.exitCode = savedExit; + vi.restoreAllMocks(); + } +} + +let root: string; +let home: string; +let project: string; +let dbFile: string; +const savedEnv: Record = {}; +const savedCwd = process.cwd(); +const publisher = generateEd25519KeyPair(); +const stranger = generateEd25519KeyPair(); +const KEY_ID = 'acme-2026'; +const text = (t: string) => new TextEncoder().encode(t); + +/** A `.osplugin` built as `os plugin build` builds one, its integrity map computed unless given. */ +function artifact(name: string, opts: { id: string; version?: string; files?: ArchiveFile[]; integrity?: Record; dir?: string }): string { + const files: ArchiveFile[] = opts.files ?? [{ path: 'dist/index.mjs', data: text(`export const id = ${JSON.stringify(opts.id)};\n`) }]; + const manifest = { + id: opts.id, + version: opts.version ?? '1.0.0', + type: 'plugin', + runtime: 'node', + packaging: 'bundled', + integrity: opts.integrity ?? computeIntegrity(files), + }; + const path = join(opts.dir ?? project, `${name}.osplugin`); + writeFileSync(path, createTarGz([...files, { path: MANIFEST_FILENAME, data: text(JSON.stringify(manifest)) }])); + return path; +} + +function sign(path: string, privateKeyPem = publisher.privateKeyPem, keyId = KEY_ID): string { + const signature = signPayload(new Uint8Array(readFileSync(path)), privateKeyPem, keyId); + writeFileSync(`${path}.sig`, signature); + return signature; +} + +/** `path` with the signature `signature` made over OTHER bytes of the same id and version beside it. */ +function signedOverOtherBytes(name: string, id: string): string { + const original = artifact(`${name}-original`, { id }); + const signature = sign(original); + const path = artifact(name, { id, files: [{ path: 'dist/index.mjs', data: text('export const changed = 1;\n') }] }); + writeFileSync(`${path}.sig`, signature); + return path; +} + +async function withDriver(fn: (knex: any) => Promise): Promise { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true }); + try { + return await fn((driver as any).knex); + } finally { + await driver.disconnect(); + } +} + +const readTable = (table: string): Promise => withDriver((knex) => knex(table).select('*')); + +/** The refusal is the named one, exit 1, and the artifact's version was not installed. */ +function expectRefused(result: Run, error: string, id: string, version = '1.0.0'): void { + expect(result.payload, result.stdout.slice(-400)).toMatchObject({ error }); + expect(result.exitCode).toBe(1); + expect(existsSync(pluginInstallDir('env_local', id, version))).toBe(false); + expect(loadPluginInstallations('env_local').some((r) => r.id === id)).toBe(false); +} + +const db = () => ['--database-url', `file:${dbFile}`]; + +/** Trust the publisher's key under {@link KEY_ID} in this case's database. */ +async function trustPublisher(): Promise { + const added = await run(PluginTrustAdd, [join(project, 'acme.pub.pem'), '--key-id', KEY_ID, ...db()]); + expect(added.exitCode, added.stdout + added.stderr.slice(-400)).toBe(0); +} + +beforeAll(() => { + for (const key of ENV) savedEnv[key] = process.env[key]; +}); + +afterAll(() => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +beforeEach(() => { + for (const key of ENV) delete process.env[key]; + root = mkdtempSync(join(tmpdir(), 'os-plugin-install-')); + home = join(root, 'home'); + project = join(root, 'project'); + mkdirSync(home); + mkdirSync(project); + process.env.OS_HOME = home; + dbFile = join(project, 'app.db'); + writeFileSync(join(project, 'acme.pub.pem'), publisher.publicKeyPem); + process.chdir(project); +}); + +afterEach(() => { + process.chdir(savedCwd); + rmSync(root, { recursive: true, force: true }); +}); + +describe('os plugin install (ADR-0025 §3.5 M1)', () => { + it('refuses a key the store does not hold, and its read-only read creates no database', async () => { + const path = artifact('untrusted', { id: 'com.acme.untrusted' }); + sign(path); + const result = await run(PluginInstall, [path, ...db(), '--json']); + expectRefused(result, 'signature_key_untrusted', 'com.acme.untrusted'); + expect(result.payload.message).toContain(KEY_ID); + expect(existsSync(dbFile)).toBe(false); + }, CASE_TIMEOUT_MS); + + it('trust add writes the row and its audit row; a second add of the same key writes nothing', async () => { + await trustPublisher(); + const read = readPublisherPublicKey(publisher.publicKeyPem); + if (!read.ok) throw new Error(read.message); + const rows = await readTable('sys_trusted_key'); + expect(rows).toEqual([expect.objectContaining({ key_id: KEY_ID, fingerprint: read.fingerprint, status: 'active', source: 'local' })]); + // The audit mirror records the admin write, by the object it touched. + const audit = (await readTable('sys_audit_log')).filter((r) => JSON.stringify(r).includes('sys_trusted_key')); + expect(audit.length).toBeGreaterThan(0); + + const again = await run(PluginTrustAdd, [join(project, 'acme.pub.pem'), '--key-id', KEY_ID, ...db()]); + expect(again.exitCode).toBe(0); + expect(await readTable('sys_trusted_key')).toHaveLength(1); + }, CASE_TIMEOUT_MS); + + it('refuses to trust a private key, writing no row', async () => { + await trustPublisher(); + const keyFile = join(project, 'acme.key.pem'); + writeFileSync(keyFile, publisher.privateKeyPem); + const result = await run(PluginTrustAdd, [keyFile, '--key-id', 'acme-private', ...db()]); + expect(result.exitCode).toBe(1); + expect((await readTable('sys_trusted_key')).map((r) => r.key_id)).toEqual([KEY_ID]); + }, CASE_TIMEOUT_MS); + + it('installs a signed artifact: the files, the original bytes and signature, and a record naming the key', async () => { + await trustPublisher(); + const path = artifact('good', { id: 'com.acme.good' }); + const signature = sign(path); + const result = await run(PluginInstall, [path, ...db(), '--json']); + expect(result.exitCode, result.stdout.slice(-400)).toBe(0); + + const read = readPublisherPublicKey(publisher.publicKeyPem); + if (!read.ok) throw new Error(read.message); + const dir = pluginInstallDir('env_local', 'com.acme.good', '1.0.0'); + expect(result.payload.installed).toMatchObject({ + id: 'com.acme.good', + version: '1.0.0', + dir, + signature: { keyId: KEY_ID, fingerprint: read.fingerprint }, + trustUnsigned: false, + }); + expect(dir.startsWith(join(home, 'plugins', 'env_local'))).toBe(true); + expect(readFileSync(join(dir, 'dist', 'index.mjs'), 'utf8')).toContain('com.acme.good'); + expect(new Uint8Array(readFileSync(join(dir, INSTALLED_ARTIFACT_FILE)))).toEqual(new Uint8Array(readFileSync(path))); + expect(readFileSync(join(dir, INSTALLED_SIGNATURE_FILE), 'utf8').trim()).toBe(signature); + expect(loadPluginInstallations('env_local')).toEqual([result.payload.installed]); + }, CASE_TIMEOUT_MS); + + it('refuses a signature made over other bytes', async () => { + await trustPublisher(); + const path = signedOverOtherBytes('other-bytes', 'com.acme.other'); + expectRefused(await run(PluginInstall, [path, ...db(), '--json']), 'signature_invalid', 'com.acme.other'); + }, CASE_TIMEOUT_MS); + + it('refuses a trusted-key row that cannot verify: its fingerprint is not its public key\'s', async () => { + await trustPublisher(); + // The row's PEM swapped for another key: the stored fingerprint no longer names it. + const updated = await withDriver((knex) => knex('sys_trusted_key').where({ key_id: KEY_ID }).update({ public_key: stranger.publicKeyPem })); + expect(updated).toBe(1); + const path = artifact('invalid-row', { id: 'com.acme.invalid-row' }); + sign(path); + const result = await run(PluginInstall, [path, ...db(), '--json']); + expectRefused(result, 'trusted_key_invalid', 'com.acme.invalid-row'); + expect(result.payload.message).toContain('fingerprint'); + }, CASE_TIMEOUT_MS); + + it('refuses an artifact path that is not a readable file, by name', async () => { + const path = join(project, 'a-directory.osplugin'); + mkdirSync(path); + const result = await run(PluginInstall, [path, '--trust-unsigned', ...db(), '--json']); + expect(result.payload, result.stdout.slice(-400)).toMatchObject({ error: 'artifact_unreadable' }); + expect(result.exitCode).toBe(1); + expect(existsSync(join(home, 'plugins'))).toBe(false); + }, CASE_TIMEOUT_MS); + + it('refuses a malformed signature by name', async () => { + const path = artifact('malformed', { id: 'com.acme.malformed' }); + writeFileSync(`${path}.sig`, 'not-a-signature'); + expectRefused(await run(PluginInstall, [path, ...db(), '--json']), 'signature_malformed', 'com.acme.malformed'); + }, CASE_TIMEOUT_MS); + + it('refuses a signature path it cannot read, naming it, and never takes it for an absent one', async () => { + const path = artifact('sig-dir', { id: 'com.acme.sig-dir' }); + mkdirSync(`${path}.sig`); + for (const extra of [[], ['--trust-unsigned']]) { + const result = await run(PluginInstall, [path, ...extra, ...db(), '--json']); + expectRefused(result, 'signature_unreadable', 'com.acme.sig-dir'); + expect(result.payload.message).toContain(`${path}.sig`); + } + }, CASE_TIMEOUT_MS); + + it('refuses an artifact whose files are not the ones its manifest names, though the signature over it is good', async () => { + const files: ArchiveFile[] = [{ path: 'dist/index.mjs', data: text('export const shipped = 1;\n') }]; + const integrity = computeIntegrity([{ path: 'dist/index.mjs', data: text('export const declared = 1;\n') }]); + const path = artifact('mismatch', { id: 'com.acme.mismatch', files, integrity }); + sign(path); + const result = await run(PluginInstall, [path, ...db(), '--json']); + expectRefused(result, 'integrity_mismatch', 'com.acme.mismatch'); + expect(JSON.stringify(result.payload.details)).toContain('dist/index.mjs'); + }, CASE_TIMEOUT_MS); + + it('refuses an entry that would land outside the install directory', async () => { + const files: ArchiveFile[] = [{ path: '../escape.txt', data: text('x') }]; + const path = artifact('escape', { id: 'com.acme.escape', files }); + sign(path); + expectRefused(await run(PluginInstall, [path, ...db(), '--json']), 'artifact_entries_refused', 'com.acme.escape'); + expect(existsSync(join(home, 'plugins', 'env_local', 'com.acme.escape', 'escape.txt'))).toBe(false); + expect(existsSync(join(home, 'plugins', 'env_local', 'escape.txt'))).toBe(false); + }, CASE_TIMEOUT_MS); + + it('refuses an unsigned artifact, and installs it only under --trust-unsigned, saying so on stderr', async () => { + const path = artifact('unsigned', { id: 'com.acme.unsigned' }); + expectRefused(await run(PluginInstall, [path, ...db(), '--json']), 'signature_missing', 'com.acme.unsigned'); + + const result = await run(PluginInstall, [path, '--trust-unsigned', ...db(), '--json']); + expect(result.exitCode, result.stdout.slice(-400)).toBe(0); + expect(result.payload.installed).toMatchObject({ id: 'com.acme.unsigned', signature: null, signatureFile: null, trustUnsigned: true }); + expect(result.stderr).toMatch(/--trust-unsigned/); + expect(existsSync(join(pluginInstallDir('env_local', 'com.acme.unsigned', '1.0.0'), INSTALLED_SIGNATURE_FILE))).toBe(false); + }, CASE_TIMEOUT_MS); + + it('--trust-unsigned never makes a present signature pass: untrusted, malformed, over other bytes, or revoked', async () => { + const untrusted = artifact('override-untrusted', { id: 'com.acme.override-untrusted' }); + sign(untrusted, stranger.privateKeyPem, 'stranger'); + expectRefused(await run(PluginInstall, [untrusted, '--trust-unsigned', ...db(), '--json']), 'signature_key_untrusted', 'com.acme.override-untrusted'); + + const malformed = artifact('override-malformed', { id: 'com.acme.override-malformed' }); + writeFileSync(`${malformed}.sig`, 'ed25519:acme-2026'); + expectRefused(await run(PluginInstall, [malformed, '--trust-unsigned', ...db(), '--json']), 'signature_malformed', 'com.acme.override-malformed'); + + await trustPublisher(); + const otherBytes = signedOverOtherBytes('override-other', 'com.acme.override-other'); + expectRefused(await run(PluginInstall, [otherBytes, '--trust-unsigned', ...db(), '--json']), 'signature_invalid', 'com.acme.override-other'); + + expect((await run(PluginTrustRevoke, [KEY_ID, ...db()])).exitCode).toBe(0); + const revoked = artifact('override-revoked', { id: 'com.acme.override-revoked' }); + sign(revoked); + expectRefused(await run(PluginInstall, [revoked, '--trust-unsigned', ...db(), '--json']), 'signature_key_revoked', 'com.acme.override-revoked'); + }, CASE_TIMEOUT_MS); + + it('after trust revoke, an artifact signed under that key id is refused by name', async () => { + await trustPublisher(); + const before = artifact('before-revoke', { id: 'com.acme.before-revoke' }); + sign(before); + expect((await run(PluginInstall, [before, ...db(), '--json'])).exitCode).toBe(0); + + const revoked = await run(PluginTrustRevoke, [KEY_ID, ...db()]); + expect(revoked.exitCode, revoked.stdout).toBe(0); + expect(await readTable('sys_trusted_key')).toEqual([expect.objectContaining({ key_id: KEY_ID, status: 'revoked' })]); + + const path = artifact('after-revoke', { id: 'com.acme.after-revoke' }); + sign(path); + expectRefused(await run(PluginInstall, [path, ...db(), '--json']), 'signature_key_revoked', 'com.acme.after-revoke'); + // What was installed before the revoke stays as it was. + expect(loadPluginInstallations('env_local').map((r) => r.id)).toEqual(['com.acme.before-revoke']); + expect(existsSync(join(pluginInstallDir('env_local', 'com.acme.before-revoke', '1.0.0'), INSTALLED_ARTIFACT_FILE))).toBe(true); + }, CASE_TIMEOUT_MS); + + it('refuses before writing anything when the installations file cannot be read, and leaves it as it was', async () => { + const file = pluginInstallationsFile('env_local'); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, '{ not json'); + const path = artifact('corrupt-record', { id: 'com.acme.corrupt-record' }); + const result = await run(PluginInstall, [path, '--trust-unsigned', ...db(), '--json']); + expect(result.payload, result.stdout.slice(-400)).toMatchObject({ error: 'record_unreadable' }); + expect(result.payload.message).toContain(file); + expect(result.exitCode).toBe(1); + expect(existsSync(pluginInstallDir('env_local', 'com.acme.corrupt-record', '1.0.0'))).toBe(false); + expect(readFileSync(file, 'utf8')).toBe('{ not json'); + }, CASE_TIMEOUT_MS); + + it('two projects sharing one home and environment: neither overwrites an install the other made from other bytes', async () => { + const other = join(root, 'other-project'); + mkdirSync(other); + const id = 'com.acme.shared'; + const dir = pluginInstallDir('env_local', id, '1.0.0'); + const first = artifact('shared-first', { id, files: [{ path: 'dist/index.mjs', data: text('export const build = 1;\n') }] }); + const second = artifact('shared-second', { id, files: [{ path: 'dist/index.mjs', data: text('export const build = 2;\n') }] }); + const installedBytes = () => new Uint8Array(readFileSync(join(dir, INSTALLED_ARTIFACT_FILE))); + + expect((await run(PluginInstall, [first, '--trust-unsigned', '--json'])).exitCode).toBe(0); + + process.chdir(other); + const taken = await run(PluginInstall, [second, '--trust-unsigned', '--json']); + expect(taken.payload, taken.stdout.slice(-400)).toMatchObject({ error: 'install_dir_taken' }); + expect(taken.exitCode).toBe(1); + expect(taken.payload.message).toContain(dir); + expect(installedBytes()).toEqual(new Uint8Array(readFileSync(first))); + expect(readFileSync(join(dir, 'dist', 'index.mjs'), 'utf8')).toContain('build = 1'); + expect(loadPluginInstallations('env_local')).toEqual([]); + + // The project whose record owns the directory may replace it. + process.chdir(project); + const replaced = await run(PluginInstall, [second, '--trust-unsigned', '--json']); + expect(replaced.exitCode, replaced.stdout.slice(-400)).toBe(0); + expect(replaced.payload.replaced).toBe(true); + expect(installedBytes()).toEqual(new Uint8Array(readFileSync(second))); + + // Control: the other project installing the very bytes the directory holds is no overwrite at all. + process.chdir(other); + const same = await run(PluginInstall, [second, '--trust-unsigned', '--json']); + expect(same.exitCode, same.stdout.slice(-400)).toBe(0); + expect(loadPluginInstallations('env_local').map((r) => r.id)).toEqual([id]); + }, CASE_TIMEOUT_MS); +}); diff --git a/packages/cli/src/commands/plugin/install.ts b/packages/cli/src/commands/plugin/install.ts new file mode 100644 index 00000000000..bb5d7df41ca --- /dev/null +++ b/packages/cli/src/commands/plugin/install.ts @@ -0,0 +1,439 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os plugin install ./x.osplugin` — verify a built plugin artifact and + * install it into this environment (ADR-0025 §3.5 steps 4–6, M1: the local T0 + * loop, first-party / self-signed plugins only). + * + * 1. Read the archive and its compiled `objectstack.plugin.json`; refuse an + * entry that could land outside the install directory. + * 2. Re-verify every file against the manifest's `integrity` map; refuse a + * digest mismatch, a declared file that is missing, or a file the map + * does not declare. + * 3. Verify the detached signature (`.sig`, from `os plugin sign`) + * over the whole artifact. Its key id is resolved in this environment's + * trusted keys (`sys_trusted_key`, through a READ-ONLY one-shot boot of + * the environment's database), and the bytes are verified with that key by + * `verifyPluginArtifact`. Refused by name: no signature, a malformed one, + * a key id this environment does not trust, a revoked key id, and a + * signature that does not match the bytes. + * 4. Before anything is written, read this project's installation records + * (refusing a file it cannot read, which a write would clobber) and + * refuse an install directory that already holds other bytes no record + * of this project owns: two projects sharing one `OS_HOME` and one + * environment id share the directory, and one must not overwrite the + * other's install. + * 5. Materialize to `/plugins////`, keeping the + * original artifact and its signature beside the unpacked files. + * 6. Record the installation — key id and fingerprint included — in the + * local installation store (`@objectstack/runtime`). + * + * `--trust-unsigned` is the explicit, loud dev override for an artifact that + * carries NO signature. ⛔ It never relaxes a signature that is present: a + * malformed or failing signature, or one whose key id is unknown or revoked, + * is refused with or without it. There is no "unsigned with a warning" arm + * without the flag. + * + * Writes no database row in any mode. M1 is first-party / self-signed only and + * is listed on no marketplace surface: a T0 plugin is author code in the host + * process (ADR-0025 §3.6). + */ + +import { readFile } from 'node:fs/promises'; +import { existsSync, statSync } from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; +import { Args, Command, Flags } from '@oclif/core'; +import { + formatIntegrityViolation, + parseSignature, + verifyIntegrity, + verifyPluginArtifact, +} from '@objectstack/core'; +import { + DEFAULT_PLUGIN_ENVIRONMENT_ID, + TRUSTED_KEY_OBJECT, + loadPluginInstallations, + pluginInstallDir, + recordPluginInstallation, + resolveTrustedKey, + type PluginInstallationRecord, + type TrustedKeyResolution, +} from '@objectstack/runtime'; +import { + emitJson, + isExitSignal, + printError, + printHeader, + printKV, + printStep, + printSuccess, + printWarningToStderr, +} from '../../utils/format.js'; +import { MANIFEST_FILENAME, SIGNATURE_FILENAME, readTarGz, sha256Hex, type ArchiveFile } from '../../utils/osplugin.js'; +import { bootSchemaStack } from '../../utils/schema-migrate.js'; +import { absentTableReads } from '../../utils/absent-table-reads.js'; +import { NO_TRUST_STORE_ENGINE, trustStoreEngine, trustStorePlugins } from '../../utils/plugin-trust-store.js'; +import { + INSTALLED_ARTIFACT_FILE, + INSTALLED_SIGNATURE_FILE, + archiveEntryProblems, + installedArtifactSha, + materializePlugin, +} from '../../utils/plugin-install.js'; + +/** One refusal: a machine-readable name and the sentence an operator reads. */ +interface Refusal { + error: string; + message: string; + details?: string[]; +} + +export default class PluginInstall extends Command { + static override description = + 'Verify a built .osplugin (signature against this environment\'s trusted keys, per-file integrity) and install it (ADR-0025 §3.5)'; + + static override examples = [ + '$ os plugin install ./com.acme.stripe-1.0.0.osplugin', + '$ os plugin install ./x.osplugin --sig ./x.osplugin.sig', + '$ os plugin install ./x.osplugin --trust-unsigned # dev only: an artifact with no signature', + ]; + + static override args = { + artifact: Args.string({ description: 'Path to the .osplugin artifact', required: true }), + }; + + static override flags = { + sig: Flags.string({ description: 'Path to the detached signature (default: .sig)' }), + 'trust-unsigned': Flags.boolean({ + description: + 'DEV ONLY: install an artifact that carries no signature. Logged loudly and recorded on the installation. ' + + 'Never relaxes a signature that is present.', + default: false, + }), + 'database-url': Flags.string({ + description: "The environment's database, where its trusted keys live (defaults to $OS_DATABASE_URL / the project DB)", + env: 'OS_DATABASE_URL', + }), + json: Flags.boolean({ description: 'Output as JSON', default: false }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(PluginInstall); + const json = flags.json; + if (!json) printHeader('Install Plugin'); + + const refuse = async (r: Refusal): Promise => { + if (json) { + await emitJson(r, 1, { compact: true }); + return; + } + printError(r.message); + for (const line of r.details ?? []) console.log(` • ${line}`); + this.exit(1); + }; + + // 1. The archive and its manifest. ───────────────────────────────── + const artifactPath = resolvePath(process.cwd(), args.artifact); + if (!existsSync(artifactPath)) { + await refuse({ error: 'artifact_not_found', message: `Artifact not found: ${args.artifact}` }); + return; + } + let bytes: Uint8Array; + let files: ArchiveFile[]; + try { + if (!statSync(artifactPath).isFile()) throw new Error('not a regular file'); + bytes = new Uint8Array(await readFile(artifactPath)); + files = readTarGz(bytes); + } catch (error) { + await refuse({ + error: 'artifact_unreadable', + message: `${args.artifact} is not a readable .osplugin (gzipped tar): ${(error as Error).message}`, + }); + return; + } + const entryProblems = archiveEntryProblems(files); + if (entryProblems.length > 0) { + await refuse({ + error: 'artifact_entries_refused', + message: `${args.artifact} has ${entryProblems.length} entr${entryProblems.length === 1 ? 'y' : 'ies'} that cannot be unpacked safely; nothing was installed:`, + details: entryProblems, + }); + return; + } + let manifest: Record; + try { + const entry = files.find((f) => f.path === MANIFEST_FILENAME); + if (!entry) throw new Error(`${MANIFEST_FILENAME} not found in the artifact`); + const parsed = JSON.parse(Buffer.from(entry.data).toString('utf8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(`${MANIFEST_FILENAME} is not a JSON object`); + manifest = parsed as Record; + } catch (error) { + await refuse({ error: 'manifest_unreadable', message: `Cannot read the artifact's manifest: ${(error as Error).message}` }); + return; + } + const id = typeof manifest.id === 'string' ? manifest.id.trim() : ''; + const version = typeof manifest.version === 'string' ? manifest.version.trim() : ''; + if (!id || !version) { + await refuse({ error: 'manifest_incomplete', message: 'The artifact manifest is missing `id` or `version`. Rebuild it with `os plugin build`.' }); + return; + } + const environmentId = process.env.OS_ENVIRONMENT_ID?.trim() || DEFAULT_PLUGIN_ENVIRONMENT_ID; + let installDir: string; + try { + installDir = pluginInstallDir(environmentId, id, version); + } catch (error) { + await refuse({ error: 'manifest_unsafe_identity', message: (error as Error).message }); + return; + } + if (!json) printStep(`${id}@${version} (${(bytes.byteLength / 1024).toFixed(1)} KB, runtime: ${manifest.runtime ?? 'unset'})`); + + // 2. Per-file integrity, re-verified at unpack (ADR-0025 §3.2). ───── + const declaredIntegrity = manifest.integrity; + if ( + declaredIntegrity !== undefined && declaredIntegrity !== null + && (typeof declaredIntegrity !== 'object' || Array.isArray(declaredIntegrity)) + ) { + await refuse({ + error: 'integrity_malformed', + message: 'The artifact manifest has a malformed `integrity` map (expected an object of path → digest). Rebuild it with `os plugin build`.', + }); + return; + } + const integrity = verifyIntegrity(files, declaredIntegrity as Record | undefined, { + exempt: [MANIFEST_FILENAME, SIGNATURE_FILENAME], + }); + if (!integrity.ok) { + await refuse({ + error: 'integrity_mismatch', + message: + `Integrity check failed — the artifact's files do not match its manifest \`integrity\` digests ` + + `(${integrity.violations.length} violation${integrity.violations.length === 1 ? '' : 's'}); nothing was installed:`, + details: integrity.violations.map((v) => formatIntegrityViolation(v)), + }); + return; + } + + // 3. The signature, against this environment's trusted keys. ──────── + const sigPath = resolvePath(process.cwd(), flags.sig ?? `${args.artifact}.sig`); + let signature: string | null = null; + if (existsSync(sigPath)) { + // A signature that is there but cannot be read is refused, never taken + // for an absent one: --trust-unsigned must not install around it. + try { + if (!statSync(sigPath).isFile()) throw new Error('not a regular file'); + signature = (await readFile(sigPath, 'utf-8')).trim(); + } catch (error) { + await refuse({ + error: 'signature_unreadable', + message: `Cannot read the signature ${sigPath}: ${(error as Error).message}. Nothing was installed.`, + }); + return; + } + } else if (flags.sig) { + await refuse({ error: 'signature_not_found', message: `Signature not found: ${flags.sig}` }); + return; + } + + let trusted: { keyId: string; fingerprint: string } | null = null; + if (signature === null) { + if (!flags['trust-unsigned']) { + await refuse({ + error: 'signature_missing', + message: + `${args.artifact} carries no signature (no ${flags.sig ?? `${args.artifact}.sig`}), so it was not installed. ` + + 'Sign it with `os plugin sign --key PATH.key.pem --key-id ID` and trust that key with ' + + '`os plugin trust add PATH.pub.pem --key-id ID`, or pass --trust-unsigned to install it unsigned (dev only).', + }); + return; + } + printWarningToStderr( + `--trust-unsigned: installing ${id}@${version} WITHOUT a signature. Nothing proves who built these bytes; ` + + 'the installation record keeps this override. Dev use only.', + ); + } else { + const parsed = parseSignature(signature); + if (!parsed) { + await refuse({ + error: 'signature_malformed', + message: `The signature in ${sigPath} is malformed (expected the \`ed25519:KEYID:SIG\` that \`os plugin sign\` writes); nothing was installed.`, + }); + return; + } + if (flags['trust-unsigned'] && !json) { + printStep('--trust-unsigned has no effect here: the artifact carries a signature, and a present signature is always verified.'); + } + + const resolved = await this.resolveKey(parsed.keyId, flags['database-url'], json); + if ('error' in resolved) { + await refuse(resolved); + return; + } + if (resolved.verdict === 'unknown') { + await refuse({ + error: 'signature_key_untrusted', + message: + `The artifact is signed with key id '${parsed.keyId}', which this environment does not trust; nothing was installed. ` + + `Trust the publisher's public key first: \`os plugin trust add PUB.pem --key-id ${parsed.keyId}\`.`, + }); + return; + } + if (resolved.verdict === 'revoked') { + await refuse({ + error: 'signature_key_revoked', + message: `The artifact is signed with key id '${parsed.keyId}', which was revoked in this environment (${resolved.row.fingerprint}); nothing was installed.`, + }); + return; + } + if (resolved.verdict === 'invalid') { + await refuse({ + error: 'trusted_key_invalid', + message: `The trusted key '${parsed.keyId}' cannot verify anything: ${resolved.message}. Nothing was installed.`, + }); + return; + } + const publicKey = resolved.publicKey; + let verdict; + try { + verdict = await verifyPluginArtifact( + { artifact: bytes, version: { package_id: id, version, signature } }, + { + getPublisherPublicKey: (keyId) => (keyId === parsed.keyId ? publicKey : null), + // M1 is local: no marketplace counter-signature exists for a self-signed artifact. + requirePlatform: false, + }, + ); + } catch (error) { + await refuse({ error: 'signature_verification_failed', message: `Signature verification failed: ${(error as Error).message}` }); + return; + } + if (!verdict.ok || !verdict.publisherVerified) { + await refuse({ + error: 'signature_invalid', + message: + `The signature does not verify against the trusted key '${parsed.keyId}' (${verdict.reason ?? 'not verified'}): ` + + 'these are not the bytes that key signed. Nothing was installed.', + }); + return; + } + trusted = { keyId: parsed.keyId, fingerprint: resolved.row.fingerprint }; + } + + // 4. Before anything is written. ────────────────────────────────── + let installations: PluginInstallationRecord[]; + try { + installations = loadPluginInstallations(environmentId); + } catch (error) { + await refuse({ error: 'record_unreadable', message: `${(error as Error).message} Nothing was installed.` }); + return; + } + const artifactSha = sha256Hex(bytes); + const presentSha = installedArtifactSha(installDir); + if ( + presentSha !== undefined + && presentSha !== artifactSha + && !installations.some((r) => r.id === id && r.dir === installDir && r.artifact?.sha256 === presentSha) + ) { + await refuse({ + error: 'install_dir_taken', + message: + `${installDir} already holds ${id}@${version} ` + + (presentSha === null ? `with no readable ${INSTALLED_ARTIFACT_FILE}` : `built from other bytes (artifact sha256 ${presentSha})`) + + ', and no installation of this project put it there: another project sharing this OS_HOME and environment did, ' + + 'or something else wrote it. Nothing was installed. Give this project its own environment (OS_ENVIRONMENT_ID) ' + + 'or home (OS_HOME), or publish the change under a new version.', + }); + return; + } + + // 5. Materialize. 6. Record. ─────────────────────────────────────── + let replaced: boolean; + try { + ({ replaced } = materializePlugin({ dir: installDir, files, artifact: bytes, signature })); + } catch (error) { + await refuse({ error: 'materialize_failed', message: `Cannot write ${installDir}: ${(error as Error).message}` }); + return; + } + const permissions = manifest.permissions; + const record: PluginInstallationRecord = { + id, + version, + versionId: `${id}@${version}`, + runtime: typeof manifest.runtime === 'string' ? manifest.runtime : null, + status: 'installed', + grantedPermissions: permissions && typeof permissions === 'object' && !Array.isArray(permissions) ? permissions : null, + dir: installDir, + artifact: { file: INSTALLED_ARTIFACT_FILE, sha256: artifactSha }, + signatureFile: signature === null ? null : INSTALLED_SIGNATURE_FILE, + signature: trusted, + trustUnsigned: signature === null, + installedAt: new Date().toISOString(), + }; + try { + recordPluginInstallation(environmentId, record); + } catch (error) { + await refuse({ + error: 'record_failed', + message: `${installDir} was written, but the installation was not recorded: ${(error as Error).message}`, + }); + return; + } + + if (json) { + await emitJson({ installed: record, replaced, integrity: { checked: integrity.checked, skipped: integrity.skipped } }, 0, { compact: true }); + return; + } + printSuccess(`Installed ${id}@${version}${replaced ? ' (replaced the copy installed before)' : ''}`); + printKV(' Directory', installDir); + printKV(' Environment', environmentId); + printKV( + ' Integrity', + integrity.skipped + ? 'no `integrity` map in the manifest — per-file check skipped' + : `${integrity.checked} file(s) verified against the manifest digests`, + ); + printKV(' Signature', trusted ? `verified with trusted key '${trusted.keyId}' (${trusted.fingerprint})` : 'NONE — installed under --trust-unsigned'); + printStep('`os serve` does not load installed plugins yet: the boot-time loader is the next step of this install loop.'); + } + + /** + * Resolve `keyId` in the environment's `sys_trusted_key` through the + * READ-ONLY one-shot boot. A table the boot measured absent has never + * trusted a key, so the key is unknown there. + */ + private async resolveKey( + keyId: string, + databaseUrl: string | undefined, + json: boolean, + ): Promise { + let stack; + try { + stack = await bootSchemaStack({ + jsonOutput: json, + databaseUrl, + extraPlugins: await trustStorePlugins(false), + deferSchemaDdl: true, + readOnlyProbe: true, + }); + } catch (error) { + return { error: 'boot_failed', message: error instanceof Error ? error.message : String(error) }; + } + try { + const engine = trustStoreEngine(stack); + if (!engine) return { error: 'no_engine', message: NO_TRUST_STORE_ENGINE }; + const reads = absentTableReads(stack); + if (reads.absent(TRUSTED_KEY_OBJECT)) { + reads.notice(json); + return { verdict: 'unknown' }; + } + return await resolveTrustedKey(engine, keyId); + } catch (error) { + if (isExitSignal(error)) throw error; + return { + error: 'trusted_keys_unreadable', + message: `Cannot read this environment's trusted keys: ${error instanceof Error ? error.message : String(error)}`, + }; + } finally { + await stack.shutdown(); + } + } +} diff --git a/packages/cli/src/commands/plugin/keygen.ts b/packages/cli/src/commands/plugin/keygen.ts new file mode 100644 index 00000000000..9331ad45b0a --- /dev/null +++ b/packages/cli/src/commands/plugin/keygen.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os plugin keygen` — generate a publisher Ed25519 key pair as two files + * (ADR-0025 §3.4 step 2, §3.5 M1). + * + * PATH.key.pem PKCS#8 private key — stays with the publisher, mode 0600 + * PATH.pub.pem SPKI public key — what `os plugin trust add` takes + * + * and prints the key id and the public key's fingerprint, then the two + * commands that use them: `os plugin sign --key PATH.key.pem --key-id ID` + * embeds the id in the signature, and `os plugin trust add PATH.pub.pem + * --key-id ID` makes an environment accept it. + * + * ⛔ The private key is written to the developer's disk and nowhere else: the + * platform never generates, receives or stores a publisher's private key + * (ADR-0025 §7, "publisher keys stay with the publisher"). Neither file is + * overwritten unless `--force` says so — a private key replaced by accident + * is a signing identity lost. + */ + +import { existsSync } from 'node:fs'; +import { chmod, writeFile } from 'node:fs/promises'; +import { createPublicKey } from 'node:crypto'; +import { dirname, resolve as resolvePath } from 'node:path'; +import { Command, Flags } from '@oclif/core'; +import { generateEd25519KeyPair } from '@objectstack/core'; +import { publicKeyFingerprint, TRUSTED_KEY_ID_PATTERN } from '@objectstack/runtime'; +import { printError, printHeader, printKV, printStep, printSuccess } from '../../utils/format.js'; + +export default class PluginKeygen extends Command { + static override description = + 'Generate a publisher Ed25519 key pair as PATH.key.pem (private, stays with you) and PATH.pub.pem (public) (ADR-0025 §3.5)'; + + static override examples = [ + '$ os plugin keygen --out ./keys/acme', + '$ os plugin keygen --out ./keys/acme --key-id acme-2026', + ]; + + static override flags = { + out: Flags.string({ + char: 'o', + description: 'Path prefix for the pair: writes PATH.key.pem and PATH.pub.pem', + required: true, + }), + 'key-id': Flags.string({ + description: + 'Key id to print for `os plugin sign --key-id` and `os plugin trust add --key-id` (default: the first 16 hex digits of the fingerprint)', + }), + force: Flags.boolean({ + description: 'Overwrite PATH.key.pem / PATH.pub.pem when they already exist', + default: false, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(PluginKeygen); + printHeader('Generate Plugin Signing Key'); + + const prefix = resolvePath(process.cwd(), flags.out); + const privatePath = `${prefix}.key.pem`; + const publicPath = `${prefix}.pub.pem`; + + if (flags['key-id'] !== undefined && !TRUSTED_KEY_ID_PATTERN.test(flags['key-id'])) { + printError(`Key id ${JSON.stringify(flags['key-id'])} is not allowed: use 1-128 letters, digits, '.', '_' or '-'.`); + this.exit(1); + return; + } + if (!existsSync(dirname(prefix))) { + printError(`Directory does not exist: ${dirname(prefix)}`); + this.exit(1); + return; + } + const present = [privatePath, publicPath].filter((p) => existsSync(p)); + if (present.length > 0 && !flags.force) { + printError( + `Refusing to overwrite ${present.join(' and ')}: a private key replaced by accident is a signing identity lost. ` + + 'Choose another --out, or pass --force.', + ); + this.exit(1); + return; + } + + const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair(); + const fingerprint = publicKeyFingerprint(createPublicKey(publicKeyPem)); + const keyId = flags['key-id'] ?? fingerprint.slice('sha256:'.length, 'sha256:'.length + 16); + + // The private key is created owner-only; chmod again in case the file + // already existed (`--force`) with a wider mode, which writeFile keeps. + await writeFile(privatePath, privateKeyPem, { encoding: 'utf-8', mode: 0o600 }); + await chmod(privatePath, 0o600); + await writeFile(publicPath, publicKeyPem, 'utf-8'); + + printSuccess('Key pair written'); + printKV(' Private key', `${privatePath} (keep it; never share or upload it)`); + printKV(' Public key', publicPath); + printKV(' Key ID', keyId); + printKV(' Fingerprint', fingerprint); + printStep(`Sign: os plugin sign ARTIFACT.osplugin --key ${privatePath} --key-id ${keyId}`); + printStep(`Trust: os plugin trust add ${publicPath} --key-id ${keyId}`); + } +} diff --git a/packages/cli/src/commands/plugin/publish.ts b/packages/cli/src/commands/plugin/publish.ts index aebd08272dd..7127d9cc6cb 100644 --- a/packages/cli/src/commands/plugin/publish.ts +++ b/packages/cli/src/commands/plugin/publish.ts @@ -126,9 +126,9 @@ 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). Unpack-time re-verification is owned by the - // future runtime loader (ADR-0025 §3.5 steps 4–7), not by the cloud - // control plane (#11331) — this preflight does not discharge it. + // 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 diff --git a/packages/cli/src/commands/plugin/sign.ts b/packages/cli/src/commands/plugin/sign.ts index 08f0b77c909..b2f840f38de 100644 --- a/packages/cli/src/commands/plugin/sign.ts +++ b/packages/cli/src/commands/plugin/sign.ts @@ -6,8 +6,9 @@ * * The signature is DETACHED and computed over the exact artifact bytes that * will be uploaded — the same bytes the cloud control plane verifies at - * publish time (`verifyPublisherSignature`) and the runtime re-verifies at - * materialize time. It is emitted as `ed25519::` and + * publish time (`verifyPublisherSignature`) and `os plugin install` verifies + * against the host's trusted keys before it materializes anything (ADR-0025 + * §3.5 M1). It is emitted as `ed25519::` and * written to a `.sig` sidecar (and printed), to be passed as the * `signature` field when publishing. The artifact itself is NOT modified, so * signing is idempotent and the signed bytes are exactly the built bytes. diff --git a/packages/cli/src/commands/plugin/trust/add.ts b/packages/cli/src/commands/plugin/trust/add.ts new file mode 100644 index 00000000000..9ec596e46df --- /dev/null +++ b/packages/cli/src/commands/plugin/trust/add.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os plugin trust add PUB.pem --key-id ID` — trust a publisher's Ed25519 + * PUBLIC key in this environment (ADR-0025 §3.5 M1). + * + * Writes one `sys_trusted_key` row (`source: 'local'`, `status: 'active'`) + * through a one-shot boot of the environment's database, the composition + * `os secret rewrap --apply` takes, with the audit CRUD mirror armed so the act + * leaves a `sys_audit_log` row. From then on `os plugin install` accepts an + * artifact whose signature names `ID` and verifies under this key. + * + * Refused, each by name, before anything is written: + * - a PRIVATE-key PEM — never converted to its public half, because a private + * key never enters the platform (ADR-0025 §7); + * - a key that is not Ed25519; + * - a key id already naming a different key, or a key id that was revoked — + * a revoked key id is never trusted again. + * The same key under the same id again changes nothing. + * + * Operator-only: there is no HTTP surface, and the store's API is read-only. + */ + +import { readFile } from 'node:fs/promises'; +import { resolve as resolvePath } from 'node:path'; +import { Args, Command, Flags } from '@oclif/core'; +import { addTrustedKey, readPublisherPublicKey, TRUSTED_KEY_ID_PATTERN } from '@objectstack/runtime'; +import { isExitSignal, printError, printHeader, printInfo, printKV, printStep, printSuccess } from '../../../utils/format.js'; +import { bootSchemaStack } from '../../../utils/schema-migrate.js'; +import { NO_TRUST_STORE_ENGINE, operatorName, trustStoreEngine, trustStorePlugins } from '../../../utils/plugin-trust-store.js'; + +export default class PluginTrustAdd extends Command { + static override description = + "Trust a publisher's Ed25519 public key in this environment, so `os plugin install` accepts artifacts it signed (ADR-0025 §3.5)"; + + static override examples = [ + '$ os plugin trust add ./keys/acme.pub.pem --key-id acme-2026', + ]; + + static override args = { + publicKey: Args.string({ description: 'Path to the publisher public key (SPKI PEM, e.g. PATH.pub.pem from `os plugin keygen`)', required: true }), + }; + + static override flags = { + 'key-id': Flags.string({ + description: 'The key id signatures name (`os plugin sign --key-id`)', + required: true, + }), + 'database-url': Flags.string({ + description: "The environment's database (defaults to $OS_DATABASE_URL / the project DB)", + env: 'OS_DATABASE_URL', + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(PluginTrustAdd); + printHeader('Trust Plugin Publisher Key'); + + const keyId = flags['key-id']; + if (!TRUSTED_KEY_ID_PATTERN.test(keyId)) { + printError(`Key id ${JSON.stringify(keyId)} is not allowed: use 1-128 letters, digits, '.', '_' or '-'.`); + this.exit(1); + return; + } + + let pem: string; + try { + pem = await readFile(resolvePath(process.cwd(), args.publicKey), 'utf-8'); + } catch (error) { + printError(`Cannot read public key: ${(error as Error).message}`); + this.exit(1); + return; + } + const read = readPublisherPublicKey(pem); + if (!read.ok) { + printError(read.message); + this.exit(1); + return; + } + + let stack; + try { + stack = await bootSchemaStack({ + jsonOutput: false, + databaseUrl: flags['database-url'], + extraPlugins: await trustStorePlugins(true), + }); + } catch (error) { + printError(error instanceof Error ? error.message : String(error)); + this.exit(1); + return; + } + + try { + const engine = trustStoreEngine(stack); + if (!engine) { + printError(NO_TRUST_STORE_ENGINE); + this.exit(1); + return; + } + const result = await addTrustedKey(engine, { + keyId, + publicKeyPem: read.publicKeyPem, + fingerprint: read.fingerprint, + addedBy: operatorName(), + }); + if (result.outcome === 'refused') { + printError(result.message); + this.exit(1); + return; + } + if (result.outcome === 'already-trusted') { + printInfo(`Key '${keyId}' is already trusted in this environment with this fingerprint; nothing was written.`); + } else { + printSuccess(`Trusted key '${keyId}'`); + } + printKV(' Key ID', result.row.key_id); + printKV(' Fingerprint', result.row.fingerprint); + printKV(' Source', result.row.source); + printKV(' Database', stack.dbLabel); + printStep(`\`os plugin install\` now accepts artifacts signed with --key-id ${keyId} by this key.`); + } catch (error) { + if (isExitSignal(error)) throw error; + printError(error instanceof Error ? error.message : String(error)); + this.exit(1); + } finally { + await stack.shutdown(); + } + } +} diff --git a/packages/cli/src/commands/plugin/trust/list.ts b/packages/cli/src/commands/plugin/trust/list.ts new file mode 100644 index 00000000000..0ac0c8493a5 --- /dev/null +++ b/packages/cli/src/commands/plugin/trust/list.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os plugin trust list` — the publisher keys this environment trusts, revoked + * ones included (ADR-0025 §3.5 M1). + * + * Reads `sys_trusted_key` through the READ-ONLY one-shot boot (`os migrate + * plan`'s): no row, table or missing database file is written. A database + * whose boot measured the table absent has never trusted a key, and says so. + */ + +import { Command, Flags } from '@oclif/core'; +import chalk from 'chalk'; +import { listTrustedKeys, TRUSTED_KEY_OBJECT, type TrustedKeyRow } from '@objectstack/runtime'; +import { emitJson, errorCodeFields, isExitSignal, printError, printHeader, printInfo } from '../../../utils/format.js'; +import { bootSchemaStack } from '../../../utils/schema-migrate.js'; +import { absentTableReads } from '../../../utils/absent-table-reads.js'; +import { NO_TRUST_STORE_ENGINE, trustStoreEngine, trustStorePlugins } from '../../../utils/plugin-trust-store.js'; + +export default class PluginTrustList extends Command { + static override description = + 'List the publisher keys this environment trusts, revoked ones included (ADR-0025 §3.5)'; + + static override examples = [ + '$ os plugin trust list', + '$ os plugin trust list --json', + ]; + + static override flags = { + 'database-url': Flags.string({ + description: "The environment's database (defaults to $OS_DATABASE_URL / the project DB)", + env: 'OS_DATABASE_URL', + }), + json: Flags.boolean({ description: 'Output as JSON', default: false }), + }; + + async run(): Promise { + const { flags } = await this.parse(PluginTrustList); + const json = flags.json; + if (!json) printHeader('Trusted Plugin Publisher Keys'); + + let stack; + try { + stack = await bootSchemaStack({ + jsonOutput: json, + databaseUrl: flags['database-url'], + extraPlugins: await trustStorePlugins(false), + deferSchemaDdl: true, + readOnlyProbe: true, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (json) { await emitJson({ error: 'boot_failed', message }, 1, { compact: true }); return; } + printError(message); + this.exit(1); + return; + } + + try { + const engine = trustStoreEngine(stack); + if (!engine) { + if (json) { await emitJson({ error: 'no_engine', message: NO_TRUST_STORE_ENGINE }, 1, { compact: true }); return; } + printError(NO_TRUST_STORE_ENGINE); + this.exit(1); + return; + } + const reads = absentTableReads(stack); + const keys: TrustedKeyRow[] = await reads.rows(TRUSTED_KEY_OBJECT, () => listTrustedKeys(engine)); + reads.notice(json); + if (json) { await emitJson({ keys }, 0, { compact: true }); return; } + if (keys.length === 0) { + printInfo('No publisher key is trusted in this environment. Add one with `os plugin trust add PUB.pem --key-id ID`.'); + return; + } + for (const k of keys) { + const status = k.status === 'active' ? chalk.green(k.status) : chalk.red(k.status); + console.log(` ${chalk.bold(k.key_id)} ${status} ${k.source} ${k.fingerprint}`); + console.log(chalk.dim(` added ${k.added_at}${k.added_by ? ` by ${k.added_by}` : ''}`)); + } + } catch (error) { + if (isExitSignal(error)) throw error; + const message = error instanceof Error ? error.message : String(error); + if (json) { await emitJson({ error: 'read_failed', message, ...errorCodeFields(error) }, 1, { compact: true }); return; } + printError(message); + this.exit(1); + } finally { + await stack.shutdown(); + } + } +} diff --git a/packages/cli/src/commands/plugin/trust/revoke.ts b/packages/cli/src/commands/plugin/trust/revoke.ts new file mode 100644 index 00000000000..f60bedae439 --- /dev/null +++ b/packages/cli/src/commands/plugin/trust/revoke.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os plugin trust revoke ID` — revoke a trusted publisher key in this + * environment (ADR-0025 §3.5 M1). + * + * Flips the `sys_trusted_key` row's `status` to `revoked` through a one-shot + * boot of the environment's database, with the audit CRUD mirror armed so the + * act leaves a `sys_audit_log` row. The row is kept: from then on + * `os plugin install` refuses an artifact signed under `ID` by name, and + * `os plugin trust add` refuses to trust any key under `ID` again. + * + * Operator-only: there is no HTTP surface, and the store's API is read-only. + */ + +import { Args, Command, Flags } from '@oclif/core'; +import { revokeTrustedKey } from '@objectstack/runtime'; +import { isExitSignal, printError, printHeader, printInfo, printKV, printSuccess } from '../../../utils/format.js'; +import { bootSchemaStack } from '../../../utils/schema-migrate.js'; +import { NO_TRUST_STORE_ENGINE, trustStoreEngine, trustStorePlugins } from '../../../utils/plugin-trust-store.js'; + +export default class PluginTrustRevoke extends Command { + static override description = + 'Revoke a trusted publisher key in this environment; `os plugin install` refuses what it signed from then on (ADR-0025 §3.5)'; + + static override examples = [ + '$ os plugin trust revoke acme-2026', + ]; + + static override args = { + keyId: Args.string({ description: 'The key id to revoke (`os plugin trust list` shows them)', required: true }), + }; + + static override flags = { + 'database-url': Flags.string({ + description: "The environment's database (defaults to $OS_DATABASE_URL / the project DB)", + env: 'OS_DATABASE_URL', + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(PluginTrustRevoke); + printHeader('Revoke Plugin Publisher Key'); + + let stack; + try { + stack = await bootSchemaStack({ + jsonOutput: false, + databaseUrl: flags['database-url'], + extraPlugins: await trustStorePlugins(true), + }); + } catch (error) { + printError(error instanceof Error ? error.message : String(error)); + this.exit(1); + return; + } + + try { + const engine = trustStoreEngine(stack); + if (!engine) { + printError(NO_TRUST_STORE_ENGINE); + this.exit(1); + return; + } + const result = await revokeTrustedKey(engine, args.keyId); + if (result.outcome === 'refused') { + printError(result.message); + this.exit(1); + return; + } + if (result.outcome === 'already-revoked') { + printInfo(`Key '${args.keyId}' was already revoked in this environment; nothing was written.`); + } else { + printSuccess(`Revoked key '${args.keyId}'`); + } + printKV(' Fingerprint', result.row.fingerprint); + printKV(' Database', stack.dbLabel); + printInfo('`os plugin install` refuses artifacts signed under this key id from now on.'); + } catch (error) { + if (isExitSignal(error)) throw error; + printError(error instanceof Error ? error.message : String(error)); + this.exit(1); + } finally { + await stack.shutdown(); + } + } +} diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts index 354ee2064f7..ba59dacb927 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -743,6 +743,14 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [ `${D7}: every sys_secret read is by id under the system context, never by organization; systemFields.tenant: ` + 'false with the open ceremony (18.x step 6, line fact F2)', }, + { + object: 'sys_trusted_key', + census: PMAIN, + fate: 'column-drop', + citation: + `${CENSUS_MAIN}: systemFields.tenant: false; ${D7}: the environment's trusted plugin-publisher keys, ` + + 'read by key id under the system context, never by organization', + }, { object: 'sys_scim_connection_credential', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] }, { object: 'sys_flow_credential', diff --git a/packages/cli/src/utils/osplugin.ts b/packages/cli/src/utils/osplugin.ts index d988177d8a9..470b0eae525 100644 --- a/packages/cli/src/utils/osplugin.ts +++ b/packages/cli/src/utils/osplugin.ts @@ -14,12 +14,10 @@ * until `os plugin sign`; ADR §3.4) * * The control plane (cloud) stores this blob opaquely. The per-file - * `integrity` map is computed here at build time and self-checked by the - * `os plugin publish` preflight; re-verification at install/load-time - * unpack is not implemented in this repo and is owned by the - * future runtime loader (ADR-0025 §3.5 steps 4–7), not by the cloud - * control plane (#11331). This module owns the two contracts the - * runtime and cloud must agree on byte-for-byte: + * `integrity` map is computed here at build time, self-checked by the + * `os plugin publish` preflight, and re-verified at unpack by + * `os plugin install` (ADR-0025 §3.5 M1). This module owns the two + * contracts the runtime and cloud must agree on byte-for-byte: * * 1. The integrity digest STRING FORMAT — Subresource-Integrity style * `sha256-` (matches ADR-0025 §3.2's example). See @@ -28,7 +26,7 @@ * reader (node-tar, GNU tar) can unpack it. See {@link createTarGz}. * * Everything here is pure (no oclif / filesystem) so it can be unit-tested - * and reused by `os plugin sign` / `publish`. + * and reused by `os plugin sign` / `publish` / `install`. */ import { createHash } from 'node:crypto'; @@ -43,10 +41,9 @@ export interface ArchiveFile { /** * Subresource-Integrity-style digest of `bytes`: `sha256-`. * This is the canonical per-file integrity string written into the - * compiled manifest's `integrity` map and checked back at the - * `os plugin publish` preflight (unpack-time re-verification is owned - * by the future runtime loader — ADR-0025 §3.5 steps 4–7 — not by the - * cloud control plane, #11331). + * compiled manifest's `integrity` map, checked back at the + * `os plugin publish` preflight and again at unpack by `os plugin install` + * (ADR-0025 §3.5 M1). */ export function sriDigest(bytes: Uint8Array): string { return 'sha256-' + createHash('sha256').update(bytes).digest('base64'); diff --git a/packages/cli/src/utils/plugin-install.test.ts b/packages/cli/src/utils/plugin-install.test.ts new file mode 100644 index 00000000000..4bbf30d7e01 --- /dev/null +++ b/packages/cli/src/utils/plugin-install.test.ts @@ -0,0 +1,209 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The file-system half of `os plugin install` (ADR-0025 §3.5 M1): which + * archive entries may be written, and how a verified artifact lands in its + * install directory. + * + * Pinned: every entry that could land outside the install directory, collide + * with another (in any letter case, or as a file and a directory at once), or + * take one of the two reserved names is named (one line per entry); a + * materialized install holds the unpacked files beside the ORIGINAL artifact + * bytes and signature; a second install of one version replaces the first + * whole, leaving no file of the first and no staging directory behind; and a + * swap that fails at either rename puts the first install back. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { sha256Hex } from './osplugin.js'; +import { + INSTALLED_ARTIFACT_FILE, + INSTALLED_SIGNATURE_FILE, + archiveEntryProblems, + installedArtifactSha, + materializePlugin, +} from './plugin-install.js'; + +/** A rename the test makes fail, by its (from, to) pair; every other rename is the real one. */ +const renames = vi.hoisted(() => ({ fail: null as ((from: string, to: string) => boolean) | null })); +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + renameSync: (from: string, to: string) => { + if (renames.fail?.(String(from), String(to))) throw Object.assign(new Error(`EXDEV: rename refused by the test`), { code: 'EXDEV' }); + return actual.renameSync(from, to); + }, + }; +}); + +const bytes = (text: string) => new TextEncoder().encode(text); +const entry = (path: string, text = path) => ({ path, data: bytes(text) }); + +describe('archiveEntryProblems', () => { + it('accepts relative POSIX paths that stay inside the install directory', () => { + expect(archiveEntryProblems([ + entry('objectstack.plugin.json'), + entry('SIGNATURE'), + entry('dist/index.mjs'), + entry('dist/lib/a..b.js'), + ])).toEqual([]); + }); + + it.each([ + ['/etc/passwd', 'not a relative POSIX path'], + ['C:/x.js', 'not a relative POSIX path'], + ['dist\\index.js', 'not a relative POSIX path'], + ['dist/\u0000.js', 'not a relative POSIX path'], + ['', 'not a relative POSIX path'], + ['../escape.txt', "'..' segment"], + ['dist/../../escape.txt', "'..' segment"], + ['./dist/index.mjs', "'..' segment"], + ['dist//index.mjs', "'..' segment"], + ['dist/', "'..' segment"], + [INSTALLED_ARTIFACT_FILE, 'reserves this name'], + [INSTALLED_SIGNATURE_FILE, 'reserves this name'], + ['Artifact.OSPLUGIN', 'reserves this name'], + ['artifact.osplugin.SIG', 'reserves this name'], + ])('refuses %j', (path, reason) => { + const problems = archiveEntryProblems([entry('dist/index.mjs'), entry(path)]); + expect(problems).toHaveLength(1); + expect(problems[0]).toContain(JSON.stringify(path)); + expect(problems[0]).toContain(reason); + }); + + it('refuses a path that appears twice, naming it once per repeat', () => { + const problems = archiveEntryProblems([entry('dist/index.mjs', 'a'), entry('dist/index.mjs', 'b')]); + expect(problems).toEqual(['"dist/index.mjs": appears more than once']); + }); + + it('refuses two paths that differ only in letter case, naming both', () => { + const problems = archiveEntryProblems([entry('dist/Index.mjs'), entry('dist/index.mjs')]); + expect(problems).toEqual(['"dist/index.mjs": differs from "dist/Index.mjs" only in letter case']); + }); + + it.each([ + [['dist', 'dist/index.mjs'], '"dist/index.mjs": sits under "dist"'], + [['dist/lib/a.js', 'dist/lib'], '"dist/lib/a.js": sits under "dist/lib"'], + [['Dist', 'dist/index.mjs'], '"dist/index.mjs": sits under "Dist"'], + ])('refuses a file that is also the directory of another entry: %j', (paths, line) => { + const problems = archiveEntryProblems(paths.map((p) => entry(p))); + expect(problems).toHaveLength(1); + expect(problems[0]).toContain(line); + expect(problems[0]).toContain('which is a file in the same archive'); + }); + + it('control: a directory shared by two files, and a name that only starts like a sibling, are fine', () => { + expect(archiveEntryProblems([entry('dist/a.js'), entry('dist/b.js'), entry('dist.js'), entry('dist-extra/c.js')])).toEqual([]); + }); +}); + +describe('installedArtifactSha', () => { + const dirs: string[] = []; + afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); + }); + + it('is undefined for no directory, null for a directory with no artifact, and the artifact sha otherwise', () => { + const parent = mkdtempSync(join(tmpdir(), 'os-plugin-installed-sha-')); + dirs.push(parent); + const dir = join(parent, '1.0.0'); + expect(installedArtifactSha(dir)).toBeUndefined(); + mkdirSync(dir); + expect(installedArtifactSha(dir)).toBeNull(); + writeFileSync(join(dir, INSTALLED_ARTIFACT_FILE), 'the bytes'); + expect(installedArtifactSha(dir)).toBe(sha256Hex(bytes('the bytes'))); + }); +}); + +describe('materializePlugin', () => { + const dirs: string[] = []; + afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); + }); + + function scratch(): string { + const d = mkdtempSync(join(tmpdir(), 'os-plugin-materialize-')); + dirs.push(d); + return d; + } + + it('writes the unpacked files beside the original artifact bytes and its signature', () => { + const dir = join(scratch(), 'plugins', 'env_local', 'com.acme.a', '1.0.0'); + const artifact = bytes('the signed archive bytes'); + const result = materializePlugin({ + dir, + files: [entry('objectstack.plugin.json', '{}'), entry('dist/index.mjs', 'export {}')], + artifact, + signature: 'ed25519:acme:sig', + }); + expect(result).toEqual({ replaced: false }); + expect(readFileSync(join(dir, 'dist', 'index.mjs'), 'utf8')).toBe('export {}'); + expect(new Uint8Array(readFileSync(join(dir, INSTALLED_ARTIFACT_FILE)))).toEqual(artifact); + expect(readFileSync(join(dir, INSTALLED_SIGNATURE_FILE), 'utf8')).toBe('ed25519:acme:sig\n'); + }); + + it('writes no signature file for an install made under --trust-unsigned', () => { + const dir = join(scratch(), 'com.acme.a', '1.0.0'); + materializePlugin({ dir, files: [entry('a.js')], artifact: bytes('x'), signature: null }); + expect(readdirSync(dir).sort()).toEqual(['a.js', INSTALLED_ARTIFACT_FILE].sort()); + }); + + it('replaces an earlier install of the same version whole, and leaves no staging directory', () => { + const parent = scratch(); + const dir = join(parent, '1.0.0'); + materializePlugin({ dir, files: [entry('old.js'), entry('shared.js', 'old')], artifact: bytes('v1'), signature: 'ed25519:acme:one' }); + const result = materializePlugin({ dir, files: [entry('shared.js', 'new')], artifact: bytes('v2'), signature: null }); + expect(result).toEqual({ replaced: true }); + expect(existsSync(join(dir, 'old.js'))).toBe(false); + expect(existsSync(join(dir, INSTALLED_SIGNATURE_FILE))).toBe(false); + expect(readFileSync(join(dir, 'shared.js'), 'utf8')).toBe('new'); + expect(readdirSync(parent)).toEqual(['1.0.0']); + }); + + describe('a swap that fails', () => { + afterEach(() => { renames.fail = null; }); + + function installed(): { parent: string; dir: string } { + const parent = scratch(); + const dir = join(parent, '1.0.0'); + materializePlugin({ dir, files: [entry('old.js', 'old')], artifact: bytes('v1'), signature: 'ed25519:acme:one' }); + return { parent, dir }; + } + + function expectFirstInstall(parent: string, dir: string): void { + expect(readFileSync(join(dir, 'old.js'), 'utf8')).toBe('old'); + expect(readFileSync(join(dir, INSTALLED_ARTIFACT_FILE), 'utf8')).toBe('v1'); + expect(readFileSync(join(dir, INSTALLED_SIGNATURE_FILE), 'utf8')).toBe('ed25519:acme:one\n'); + expect(readdirSync(parent)).toEqual(['1.0.0']); + } + + it('when the new tree cannot be moved in, puts the earlier install back and removes the new tree', () => { + const { parent, dir } = installed(); + renames.fail = (from, to) => from.includes('.installing-') && to === dir; + expect(() => materializePlugin({ dir, files: [entry('new.js')], artifact: bytes('v2'), signature: null })).toThrow(/rename refused/); + expectFirstInstall(parent, dir); + }); + + it('when the earlier install cannot be moved aside, leaves it in place and removes the new tree', () => { + const { parent, dir } = installed(); + renames.fail = (from) => from === dir; + expect(() => materializePlugin({ dir, files: [entry('new.js')], artifact: bytes('v2'), signature: null })).toThrow(/rename refused/); + expectFirstInstall(parent, dir); + }); + + it('control: a leftover from an earlier failed swap does not block the next one', () => { + const { parent, dir } = installed(); + const leftover = `${dir}.replaced-${process.pid}`; + mkdirSync(leftover); + writeFileSync(join(leftover, 'stale.js'), 'stale'); + materializePlugin({ dir, files: [entry('new.js', 'new')], artifact: bytes('v2'), signature: null }); + expect(readFileSync(join(dir, 'new.js'), 'utf8')).toBe('new'); + expect(readdirSync(parent)).toEqual(['1.0.0']); + }); + }); +}); diff --git a/packages/cli/src/utils/plugin-install.ts b/packages/cli/src/utils/plugin-install.ts new file mode 100644 index 00000000000..75a21a5574e --- /dev/null +++ b/packages/cli/src/utils/plugin-install.ts @@ -0,0 +1,146 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, posix } from 'node:path'; +import { sha256Hex, type ArchiveFile } from './osplugin.js'; +import { makeDirectorySync } from './make-directory.js'; + +/** + * The parts of `os plugin install` (ADR-0025 §3.5 M1) that touch no database: + * which archive entries may be written to disk, and how a verified artifact is + * materialized into its install directory. + */ + +/** + * The names the install directory keeps the ORIGINAL artifact and its detached + * signature under, beside the unpacked files, so a loader can re-verify the + * signed bytes rather than the unpacked copy. No archive entry may take either + * name. + */ +export const INSTALLED_ARTIFACT_FILE = 'artifact.osplugin'; +export const INSTALLED_SIGNATURE_FILE = 'artifact.osplugin.sig'; + +/** + * Every reason the archive's entries cannot be unpacked as they stand, one + * line each, or `[]`. An entry must be a relative POSIX path that stays inside + * the install directory: no absolute path, no `.` or `..` segment, no empty + * segment, no backslash or NUL. Two entries may not share a path, even one + * differing only in letter case (a case-insensitive file system would write one + * over the other), and no entry may be both a file and the directory of + * another. None may take one of the two names the install directory reserves, + * in any letter case. + */ +export function archiveEntryProblems(files: readonly ArchiveFile[]): string[] { + const problems: string[] = []; + /** Case-folded path → the first spelling seen. */ + const seen = new Map(); + for (const { path } of files) { + const shown = JSON.stringify(path); + if (path.length === 0 || path.startsWith('/') || /[\\\u0000]/.test(path) || /^[A-Za-z]:/.test(path)) { + problems.push(`${shown}: not a relative POSIX path`); + continue; + } + const segments = path.split('/'); + if (segments.some((s) => s === '' || s === '.' || s === '..')) { + problems.push(`${shown}: has an empty, '.' or '..' segment, so it could land outside the install directory`); + continue; + } + if (posix.normalize(path) !== path) { + problems.push(`${shown}: not in normal form`); + continue; + } + const folded = path.toLowerCase(); + if (folded === INSTALLED_ARTIFACT_FILE || folded === INSTALLED_SIGNATURE_FILE) { + problems.push(`${shown}: the install directory reserves this name for the original artifact and its signature`); + continue; + } + const first = seen.get(folded); + if (first !== undefined) { + problems.push( + first === path ? `${shown}: appears more than once` : `${shown}: differs from ${JSON.stringify(first)} only in letter case`, + ); + continue; + } + seen.set(folded, path); + } + // A file whose path is a directory of another entry: one of the two cannot be written. + for (const [folded, path] of seen) { + const segments = folded.split('/'); + for (let i = 1; i < segments.length; i++) { + const file = seen.get(segments.slice(0, i).join('/')); + if (file !== undefined) { + problems.push(`${JSON.stringify(path)}: sits under ${JSON.stringify(file)}, which is a file in the same archive`); + break; + } + } + } + return problems; +} + +/** + * The sha256 (hex) of the original artifact an earlier install left in `dir`; + * `null` when `dir` exists but holds no readable artifact, and `undefined` when + * `dir` does not exist. + */ +export function installedArtifactSha(dir: string): string | null | undefined { + if (!existsSync(dir)) return undefined; + try { + return sha256Hex(new Uint8Array(readFileSync(join(dir, INSTALLED_ARTIFACT_FILE)))); + } catch { + return null; + } +} + +/** + * Write `files`, the original artifact bytes and (when signed) the signature + * into `dir`, replacing whatever an earlier install of the same version left + * there. The new tree is written in full beside `dir` first and swapped in by + * rename, so a failed write never leaves a half-written install in `dir`, and + * a failed swap puts the earlier install back and removes the new tree. + * + * The caller has already refused every entry {@link archiveEntryProblems} + * names. + */ +export function materializePlugin(input: { + dir: string; + files: readonly ArchiveFile[]; + artifact: Uint8Array; + signature: string | null; +}): { replaced: boolean } { + const { dir } = input; + makeDirectorySync(dirname(dir)); + const staging = `${dir}.installing-${process.pid}`; + const previous = `${dir}.replaced-${process.pid}`; + rmSync(staging, { recursive: true, force: true }); + try { + makeDirectorySync(staging); + for (const file of input.files) { + const target = join(staging, ...file.path.split('/')); + makeDirectorySync(dirname(target)); + writeFileSync(target, file.data); + } + writeFileSync(join(staging, INSTALLED_ARTIFACT_FILE), input.artifact); + if (input.signature !== null) writeFileSync(join(staging, INSTALLED_SIGNATURE_FILE), `${input.signature}\n`, 'utf-8'); + } catch (error) { + rmSync(staging, { recursive: true, force: true }); + throw error; + } + const replaced = existsSync(dir); + try { + if (replaced) { + rmSync(previous, { recursive: true, force: true }); + renameSync(dir, previous); + } + try { + renameSync(staging, dir); + } catch (error) { + if (replaced) renameSync(previous, dir); + throw error; + } + } catch (error) { + rmSync(staging, { recursive: true, force: true }); + throw error; + } + if (replaced) rmSync(previous, { recursive: true, force: true }); + return { replaced }; +} diff --git a/packages/cli/src/utils/plugin-trust-store.ts b/packages/cli/src/utils/plugin-trust-store.ts new file mode 100644 index 00000000000..946bd75b4bd --- /dev/null +++ b/packages/cli/src/utils/plugin-trust-store.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { userInfo } from 'node:os'; +import type { TrustedKeyEngine } from '@objectstack/runtime'; +import type { SchemaStack } from './schema-migrate.js'; + +/** + * What `os plugin trust add | list | revoke` and `os plugin install` share + * around their one-shot boot of the environment's database (ADR-0025 §3.5 + * M1): which plugins the boot composes, and the engine handle the + * `sys_trusted_key` store is read and written through + * (`@objectstack/runtime`'s `plugin-trusted-keys.ts`). + * + * Each command calls `bootSchemaStack` itself, so each is a declared member of + * the one-shot family (`schema-migrate.one-shot-family.integration.test.ts`). + */ + +/** + * The plugins the boot composes. `PlatformObjectsPlugin` registers + * `sys_trusted_key`. A WRITE boot adds `AuditPlugin`, whose CRUD mirror records + * the insert or the revoke in `sys_audit_log`: adding or revoking a trusted key + * is an operator act on the environment's trust, so it leaves a ledger row. + */ +export async function trustStorePlugins(write: boolean): Promise { + const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin'); + const plugins: unknown[] = [new PlatformObjectsPlugin()]; + if (write) { + const { AuditPlugin } = await import('@objectstack/plugin-audit'); + plugins.push(new AuditPlugin()); + } + return plugins; +} + +/** The booted engine, as the trusted-key store reads it, or `null` when the boot carries none. */ +export function trustStoreEngine(stack: SchemaStack): TrustedKeyEngine | null { + const engine = stack.kernel.getService('objectql') as Partial | undefined; + if (!engine || typeof engine.find !== 'function' || typeof engine.insert !== 'function' || typeof engine.update !== 'function') { + return null; + } + return engine as TrustedKeyEngine; +} + +/** The refusal printed when {@link trustStoreEngine} finds no engine. */ +export const NO_TRUST_STORE_ENGINE = + 'No ObjectQL engine on this boot, so the environment\'s trusted keys (`sys_trusted_key`) cannot be read or written.'; + +/** + * Who is adding a key: the operating-system account running the command. The + * CLI acts at the host with no platform session, so this is the identity the + * act has. `null` when the platform cannot say (no passwd entry). + */ +export function operatorName(): string | null { + try { + const name = userInfo().username; + return name && name.length > 0 ? name : null; + } catch { + return null; + } +} diff --git a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts index 04c9645c020..fa2f784b1de 100644 --- a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts @@ -50,8 +50,10 @@ import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { SqlDriver } from '@objectstack/driver-sql'; +import { generateEd25519KeyPair, signPayload } from '@objectstack/core'; import type { IObjectQLEngine } from '@objectstack/spec/contracts'; import { isExitSignal } from './format.js'; +import { MANIFEST_FILENAME, computeIntegrity, createTarGz, type ArchiveFile } from './osplugin.js'; import { bootSchemaStack } from './schema-migrate.js'; import { buildDataMigrationPlugins } from './data-migration-plugins.js'; import DbOrphans from '../commands/db/orphans.js'; @@ -75,6 +77,10 @@ import MigrateSecurityCatalogOverlays from '../commands/migrate/security-catalog import MigrateSummaryNulls from '../commands/migrate/summary-nulls.js'; import MigrateUnmappedColumns from '../commands/migrate/unmapped-columns.js'; import MigrateValueShapes from '../commands/migrate/value-shapes.js'; +import PluginInstall from '../commands/plugin/install.js'; +import PluginTrustAdd from '../commands/plugin/trust/add.js'; +import PluginTrustList from '../commands/plugin/trust/list.js'; +import PluginTrustRevoke from '../commands/plugin/trust/revoke.js'; import SecretOrphans from '../commands/secret/orphans.js'; import SecretRewrap from '../commands/secret/rewrap.js'; import StorageOrphans from '../commands/storage/orphans.js'; @@ -130,7 +136,11 @@ type Invoke = (argv: string[]) => Promise; interface Mode { /** How the case is named in the report. */ label: string; - /** The argv after the command id. `@DB@` is the case's database URL, `@DIR@` the project directory. */ + /** + * The argv after the command id. `@DB@` is the case's database URL, `@DIR@` + * the case's directory, `@FIXTURE@` the directory holding the plugin + * commands' inputs ({@link writePluginFixture}). + */ argv: string[]; /** * Runs of the same command that must precede this mode on the same case, as @@ -295,6 +305,38 @@ const CALLERS: Record = { noWrite: [{ label: 'migrate value-shapes', argv: ['--database-url', '@DB@', '--json'] }], write: [{ label: 'migrate value-shapes --apply', argv: ['--apply', '--yes', '--database-url', '@DB@', '--json'] }], }, + 'commands/plugin/install.ts': { + run: invoke(PluginInstall), + // The fixture artifact is signed under a key id the database does not + // trust, so the run reads the trusted-key store and refuses by name. The + // boot is what this case is about. + noWrite: [{ + label: 'plugin install (key not trusted)', + argv: ['@FIXTURE@/com.example.osf-plugin-1.0.0.osplugin', '--database-url', '@DB@', '--json'], + }], + write: [], + note: 'it writes no database row in any mode: it only reads the trusted-key store, and an install lands under OS_HOME', + }, + 'commands/plugin/trust/add.ts': { + run: invoke(PluginTrustAdd), + noWrite: [], + write: [{ label: 'plugin trust add', argv: ['@FIXTURE@/publisher.pub.pem', '--key-id', 'osf-publisher', '--database-url', '@DB@'] }], + note: 'every run of it is the write: it has no preview mode', + }, + 'commands/plugin/trust/list.ts': { + run: invoke(PluginTrustList), + noWrite: [{ label: 'plugin trust list', argv: ['--database-url', '@DB@', '--json'] }], + write: [], + note: 'a read-only report: it has no writing mode', + }, + 'commands/plugin/trust/revoke.ts': { + run: invoke(PluginTrustRevoke), + noWrite: [], + // No such key id: the boot happens and the command then refuses the id + // as unknown. The boot is what this case is about. + write: [{ label: 'plugin trust revoke', argv: ['osf-absent-key', '--database-url', '@DB@'] }], + note: 'every run of it is the write: it has no preview mode', + }, 'commands/secret/orphans.ts': { run: invoke(SecretOrphans), noWrite: [{ label: 'secret orphans', argv: ['--database-url', '@DB@', '--json'] }], @@ -480,7 +522,8 @@ function prepareCase(argv: string[], opts: { absent?: boolean } = {}): { dbFile: mkdirSync(caseDir, { recursive: true }); const dbFile = join(caseDir, 'app.db'); if (!opts.absent) copyFileSync(templateDb, dbFile); - const fill = (raw: string[]): string[] => raw.map((a) => a.replace('@DB@', `file:${dbFile}`).replace('@DIR@', caseDir)); + const fill = (raw: string[]): string[] => + raw.map((a) => a.replace('@DB@', `file:${dbFile}`).replace('@DIR@', caseDir).replace('@FIXTURE@', join(dir, 'fixture'))); return { dbFile, argv: fill(argv), fill }; } @@ -492,6 +535,32 @@ async function runBefore(run: Invoke, c: { fill: (argv: string[]) => string[] }, } } +/** + * The `os plugin` commands' inputs, under `/fixture`: a publisher's + * public key, and an artifact signed under its key id (`osf-publisher`) that + * no case's database trusts. + */ +function writePluginFixture(): void { + const fixture = join(dir, 'fixture'); + mkdirSync(fixture, { recursive: true }); + const publisher = generateEd25519KeyPair(); + writeFileSync(join(fixture, 'publisher.pub.pem'), publisher.publicKeyPem); + const files: ArchiveFile[] = [{ path: 'dist/index.mjs', data: new TextEncoder().encode('export default {};\n') }]; + const manifest = { + id: 'com.example.osf-plugin', + version: '1.0.0', + type: 'plugin', + runtime: 'node', + packaging: 'bundled', + integrity: computeIntegrity(files), + }; + files.push({ path: MANIFEST_FILENAME, data: new TextEncoder().encode(JSON.stringify(manifest)) }); + const artifact = new Uint8Array(createTarGz(files)); + const artifactPath = join(fixture, 'com.example.osf-plugin-1.0.0.osplugin'); + writeFileSync(artifactPath, artifact); + writeFileSync(`${artifactPath}.sig`, signPayload(artifact, publisher.privateKeyPem, 'osf-publisher')); +} + beforeAll(async () => { for (const key of [...OVERRIDING_ENV, 'OS_ARTIFACT_PATH', 'NODE_ENV', 'OS_LIFECYCLE_DISABLED', 'OS_SECRET_KEY'] as const) { savedEnv[key] = process.env[key]; @@ -520,6 +589,7 @@ beforeAll(async () => { await bootServedStack(templateDb); // The commands run against the next release of the app. writeFileSync(process.env.OS_ARTIFACT_PATH, JSON.stringify(NEXT_ARTIFACT)); + writePluginFixture(); }, 180_000); afterAll(() => { @@ -558,6 +628,18 @@ describe('[#21391] the bootSchemaStack family is the table above', () => { }); }); +describe('[#27123] the plugin install case reaches its boot, not an earlier refusal', () => { + it('the fixture artifact passes every check before the store read, and the store read refuses its key', async () => { + const caller = CALLERS['commands/plugin/install.ts']; + const c = prepareCase(caller.noWrite[0].argv); + const result = await runJson(caller.run, c.argv); + // An unreadable artifact, a bad manifest or integrity map would refuse + // before the boot and leave the no-write cases above holding vacuously. + expect(result.payload, JSON.stringify(result.payload).slice(0, 400)).toMatchObject({ error: 'signature_key_untrusted' }); + expect(result.exitCode).toBe(1); + }, CASE_TIMEOUT_MS); +}); + const NO_WRITE = Object.entries(CALLERS).flatMap(([file, c]) => c.noWrite.map((m) => ({ file, run: c.run, ...m }))); const WRITE = Object.entries(CALLERS).flatMap(([file, c]) => c.write.map((m) => ({ file, run: c.run, ...m }))); diff --git a/packages/cli/test/plugin-keygen.test.ts b/packages/cli/test/plugin-keygen.test.ts new file mode 100644 index 00000000000..c9d373880af --- /dev/null +++ b/packages/cli/test/plugin-keygen.test.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os plugin keygen` (ADR-0025 §3.5 M1): the pair it writes is one Ed25519 + * key, the private half owner-only, and the fingerprint it prints is the one + * `os plugin trust add` stores for the public half. It never overwrites a key + * file unless `--force` says so. + */ + +import { describe, it, expect, afterAll, afterEach, vi } from 'vitest'; +import { createPublicKey } from 'node:crypto'; +import { existsSync, statSync } from 'node:fs'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parseSignature, signPayload, verifyPayload } from '@objectstack/core'; +import { readPublisherPublicKey } from '@objectstack/runtime'; +import PluginKeygen from '../src/commands/plugin/keygen.js'; + +const dirs: string[] = []; +afterEach(() => vi.restoreAllMocks()); +afterAll(async () => { + for (const d of dirs) await rm(d, { recursive: true, force: true }); +}); + +async function run(argv: string[]): Promise<{ exit: number; printed: string[] }> { + const printed: string[] = []; + vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => void printed.push(a.join(' '))); + vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => void printed.push(a.join(' '))); + try { + await PluginKeygen.run(argv); + return { exit: 0, printed }; + } catch (err) { + const exit = (err as { oclif?: { exit?: number } }).oclif?.exit; + if (typeof exit !== 'number') throw err; + return { exit, printed }; + } +} + +async function scratch(): Promise { + const d = await mkdtemp(join(tmpdir(), 'osplugin-keygen-')); + dirs.push(d); + return d; +} + +describe('os plugin keygen', () => { + it('writes one Ed25519 pair: the private half 0600, and the fingerprint printed is the public half\'s', async () => { + const prefix = join(await scratch(), 'acme'); + const { exit, printed } = await run(['--out', prefix, '--key-id', 'acme-2026']); + expect(exit).toBe(0); + + const privateKeyPem = await readFile(`${prefix}.key.pem`, 'utf-8'); + const publicKeyPem = await readFile(`${prefix}.pub.pem`, 'utf-8'); + expect(privateKeyPem).toContain('BEGIN PRIVATE KEY'); + expect(publicKeyPem).toContain('BEGIN PUBLIC KEY'); + if (process.platform !== 'win32') expect(statSync(`${prefix}.key.pem`).mode & 0o777).toBe(0o600); + + const bytes = new TextEncoder().encode('artifact'); + const signature = signPayload(bytes, privateKeyPem, 'acme-2026'); + expect(parseSignature(signature)?.keyId).toBe('acme-2026'); + expect(verifyPayload(bytes, signature, publicKeyPem)).toBe(true); + + const read = readPublisherPublicKey(publicKeyPem); + expect(read.ok).toBe(true); + if (!read.ok) return; + const output = printed.join('\n'); + expect(output).toContain(read.fingerprint); + expect(output).toContain(`os plugin trust add ${prefix}.pub.pem --key-id acme-2026`); + // The private key's text never reaches the terminal. + expect(output).not.toContain(privateKeyPem.split('\n')[1]); + }); + + it('defaults the key id to the first 16 hex digits of the fingerprint', async () => { + const prefix = join(await scratch(), 'k'); + const { exit, printed } = await run(['--out', prefix]); + expect(exit).toBe(0); + const read = readPublisherPublicKey(await readFile(`${prefix}.pub.pem`, 'utf-8')); + if (!read.ok) throw new Error(read.message); + const keyId = read.fingerprint.slice('sha256:'.length, 'sha256:'.length + 16); + expect(printed.join('\n')).toContain(`--key-id ${keyId}`); + expect(createPublicKey(await readFile(`${prefix}.key.pem`, 'utf-8')).asymmetricKeyType).toBe('ed25519'); + }); + + it('refuses to overwrite an existing key file without --force, and replaces it with --force', async () => { + const prefix = join(await scratch(), 'acme'); + await writeFile(`${prefix}.key.pem`, 'precious', { mode: 0o644 }); + const refused = await run(['--out', prefix]); + expect(refused.exit).toBe(1); + expect(refused.printed.join('\n')).toContain('Refusing to overwrite'); + expect(await readFile(`${prefix}.key.pem`, 'utf-8')).toBe('precious'); + expect(existsSync(`${prefix}.pub.pem`)).toBe(false); + + const forced = await run(['--out', prefix, '--force']); + expect(forced.exit).toBe(0); + expect(await readFile(`${prefix}.key.pem`, 'utf-8')).toContain('BEGIN PRIVATE KEY'); + if (process.platform !== 'win32') expect(statSync(`${prefix}.key.pem`).mode & 0o777).toBe(0o600); + }); + + it('refuses a key id a signature string could not carry, writing nothing', async () => { + const prefix = join(await scratch(), 'acme'); + const { exit } = await run(['--out', prefix, '--key-id', 'acme:2026']); + expect(exit).toBe(1); + expect(existsSync(`${prefix}.key.pem`)).toBe(false); + }); +}); diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 4848de49af4..507268961b5 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -36,9 +36,9 @@ export { // Per-file artifact integrity verification (ADR-0025 §3.2) — pure and // portable like the signature contract above; consumed by the -// `os plugin publish` preflight. Unpack-time re-verification is owned by -// the future runtime loader (ADR-0025 §3.5 steps 4–7), not by the cloud -// control plane (that leg is unbuilt). +// `os plugin publish` preflight and by `os plugin install`, which +// re-verifies at unpack (ADR-0025 §3.5 M1). The cloud control plane stores +// the artifact blob opaquely. export { verifyIntegrity, formatIntegrityViolation, diff --git a/packages/core/src/security/plugin-artifact-integrity.ts b/packages/core/src/security/plugin-artifact-integrity.ts index 4dba5eb6eb8..10798607c11 100644 --- a/packages/core/src/security/plugin-artifact-integrity.ts +++ b/packages/core/src/security/plugin-artifact-integrity.ts @@ -7,12 +7,11 @@ * `integrity` map (artifact-relative path → SRI-style `sha256-` * digest, the format `os plugin build` writes). Like its sibling * `plugin-artifact-signature.ts`, this module is pure and dependency-free - * (node:crypto only) so it stays byte-for-byte portable to whatever runs - * the unpack-time re-verification leg — the future runtime loader - * (ADR-0025 §3.5 steps 4–7), not the cloud control plane (that leg is - * unbuilt, and NOT discharged by this module). The framework caller is the - * `os plugin publish` preflight: the publisher self-checks its own - * artifact before upload. + * (node:crypto only) so it stays byte-for-byte portable. Its two framework + * callers are the `os plugin publish` preflight, where the publisher + * self-checks its own artifact before upload, and `os plugin install`, which + * re-verifies the unpacked files before it materializes them (ADR-0025 §3.5 + * M1). The cloud control plane stores the artifact blob opaquely. * * Verdict semantics: * - absent map (`undefined` / `null`) → ok, `skipped: true` — the field diff --git a/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts b/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts index 379b23a357e..3469020c1fd 100644 --- a/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts +++ b/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts @@ -4,7 +4,14 @@ import { describe, it, expect } from 'vitest'; import { resolveInjectedSystemColumns } from '@objectstack/spec/data'; import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; import { SysJob, SysJobRun, SysJobQueue } from './audit/index.js'; -import { SysMigration, SysMigrationJournal, SysPlatformSettingAudit, SysSecret, SysSettingAudit } from './system/index.js'; +import { + SysMigration, + SysMigrationJournal, + SysPlatformSettingAudit, + SysSecret, + SysSettingAudit, + SysTrustedKey, +} from './system/index.js'; /** * [ADR-0131 D7] The deployment-level plumbing this package declares carries no @@ -39,6 +46,8 @@ const DEPLOYMENT_LEVEL = [ ['sys_migration_journal', SysMigrationJournal], // [ruling record 6257510973, Q4 A] The settings audit trail's global rung. ['sys_platform_setting_audit', SysPlatformSettingAudit], + // [ADR-0025 §3.5 M1] The environment's trusted plugin-publisher keys. + ['sys_trusted_key', SysTrustedKey], ] as const; describe('[ADR-0131 D7] deployment-level plumbing: no tenant column, platform-only reads', () => { diff --git a/packages/platform-objects/src/plugin.test.ts b/packages/platform-objects/src/plugin.test.ts index 22239e33210..59b42e12ee1 100644 --- a/packages/platform-objects/src/plugin.test.ts +++ b/packages/platform-objects/src/plugin.test.ts @@ -10,7 +10,7 @@ import { describe, it, expect } from 'vitest'; // the predicate into a package that depends on neither side. import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { PlatformObjectsPlugin } from './plugin.js'; -import { SysMetadataActivation, SysMigration, SysMigrationJournal, SysSecret } from './system/index.js'; +import { SysMetadataActivation, SysMigration, SysMigrationJournal, SysSecret, SysTrustedKey } from './system/index.js'; /** * Hand-rolled fake PluginContext (mirrors the service-plugin tests) — this @@ -46,7 +46,7 @@ function makeCtx() { } describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4243, #4270)', () => { - it('registers SysMigration, SysMigrationJournal and SysSecret through the manifest service', async () => { + it('registers SysMigration, SysMigrationJournal, SysSecret and SysTrustedKey through the manifest service', async () => { const ctx = makeCtx(); const manifests: any[] = []; ctx.registerService('manifest', { register: (m: any) => manifests.push(m) }); @@ -56,7 +56,7 @@ describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4 const infra = manifests.find((m) => m.id === 'com.objectstack.platform-objects'); expect(infra).toBeDefined(); expect(infra.scope).toBe('system'); - expect(infra.objects).toEqual([SysMigration, SysMigrationJournal, SysSecret]); + expect(infra.objects).toEqual([SysMigration, SysMigrationJournal, SysSecret, SysTrustedKey]); expect(infra.objects.map((o: any) => o.name)).toEqual([ 'sys_migration', // ADR-0119 D2 (#4617). Registered UNCONDITIONALLY, alongside the flag @@ -65,8 +65,12 @@ describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4 // compose and others do not is a journal a boot scanner cannot rely on. 'sys_migration_journal', 'sys_secret', + // [ADR-0025 §3.5 M1, #27123] The environment's trusted plugin-publisher + // keys, read by `os plugin install` from a one-shot boot that composes + // this plugin and nothing that would register it otherwise. + 'sys_trusted_key', ]); - // ⛔ The three above must NOT acquire the ledger's routing triple. They + // ⛔ The four above must NOT acquire the ledger's routing triple. They // ride the project database today; moving them is a different question. expect(infra.defaultDatasource).toBeUndefined(); }); diff --git a/packages/platform-objects/src/plugin.ts b/packages/platform-objects/src/plugin.ts index 9dc317736f4..546392b994a 100644 --- a/packages/platform-objects/src/plugin.ts +++ b/packages/platform-objects/src/plugin.ts @@ -6,6 +6,7 @@ import { SysMetadataActivation } from './system/sys-metadata-activation.object.j import { SysMigration } from './system/sys-migration.object.js'; import { SysMigrationJournal } from './system/sys-migration-journal.object.js'; import { SysSecret } from './system/sys-secret.object.js'; +import { SysTrustedKey } from './system/sys-trusted-key.object.js'; import { attestFreshDatastore } from './system/migration-flag.js'; import type { II18nService, @@ -48,6 +49,12 @@ import { SEED_SETTLEMENT_SERVICE } from '@objectstack/spec/contracts'; * service-settings registered it, a kernel composed without settings * hard-failed every secret-field write with an error that blamed * platform-objects. Registered here, that error message is true. + * - **`sys_trusted_key`** — the environment's trusted plugin-publisher + * PUBLIC keys (ADR-0025 §3.5 M1). Its readers span domains: the CLI's + * `os plugin install` resolves an artifact signature's key id here today, + * and ADR-0025 M1's boot-time plugin loader in the runtime is the second + * reader (not built yet). No capability plugin owns plugin distribution, + * so it rides with the other platform infrastructure (ADR-0029 D1/D2). * - **`sys_metadata_activation`** — the packaged-metadata activation * ledger (ADR-0126 §4). Same story a third time, and the ruling that * moved it here is recorded verbatim on #12359 (maintainer, 2026-08-26, @@ -112,8 +119,8 @@ import { SEED_SETTLEMENT_SERVICE } from '@objectstack/spec/contracts'; /** * [#12359 / ADR-0126 §4] The manifest the activation ledger is registered * under — this plugin's, but SEPARATE from the one carrying `sys_migration` / - * `sys_migration_journal` / `sys_secret`, and carrying the automation - * manifest's `scope` / `namespace` / `defaultDatasource` triple verbatim. + * `sys_migration_journal` / `sys_secret` / `sys_trusted_key`, and carrying the + * automation manifest's `scope` / `namespace` / `defaultDatasource` triple verbatim. * * ## Why a second manifest rather than a fourth entry in the first one * @@ -141,7 +148,7 @@ import { SEED_SETTLEMENT_SERVICE } from '@objectstack/spec/contracts'; * wall this card was given, discharged by preserving the status quo rather * than by asserting it. * - * ⛔ The three siblings are deliberately NOT given this triple. They ride the + * ⛔ The siblings are deliberately NOT given this triple. They ride the * project database today and moving them is a different, larger question. * ⛔ And it is NOT spelled as `datasource: 'cloud'` on the object itself: that * is binding step 1, which answers even when no such driver is registered, so @@ -187,7 +194,7 @@ export class PlatformObjectsPlugin { version: this.version, type: 'plugin', scope: 'system', - objects: [SysMigration, SysMigrationJournal, SysSecret], + objects: [SysMigration, SysMigrationJournal, SysSecret, SysTrustedKey], }); ctx?.getService?.('manifest')?.register?.({ ...ACTIVATION_LEDGER_MANIFEST, diff --git a/packages/platform-objects/src/system/index.ts b/packages/platform-objects/src/system/index.ts index 95839163f25..4926963dc71 100644 --- a/packages/platform-objects/src/system/index.ts +++ b/packages/platform-objects/src/system/index.ts @@ -6,14 +6,17 @@ * Cross-cutting system-level objects that don't belong to identity, * security, audit, or integration. Hosts the generic settings K/V store * backing ADR-0007 (Settings Manifest + K/V Store + Resolver), the - * deployment-level data-migration flags (#3617), and the packaged-metadata - * activation ledger (ADR-0126 §4). + * deployment-level data-migration flags (#3617), the packaged-metadata + * activation ledger (ADR-0126 §4), and the environment's trusted plugin-publisher + * keys (ADR-0025 §3.5 M1). */ export { SysSetting } from './sys-setting.object.js'; // [ADR-0131 D7] The settings cascade's global rung, tenant-less. export { SysPlatformSetting } from './sys-platform-setting.object.js'; export { SysSecret } from './sys-secret.object.js'; +// [ADR-0025 §3.5 M1] Trusted plugin-publisher public keys, tenant-less. +export { SysTrustedKey } from './sys-trusted-key.object.js'; export { SysMetadataActivation } from './sys-metadata-activation.object.js'; export { SysSettingAudit } from './sys-setting-audit.object.js'; // [ADR-0131 D7] The settings audit trail's global rung, tenant-less. diff --git a/packages/platform-objects/src/system/sys-trusted-key.object.ts b/packages/platform-objects/src/system/sys-trusted-key.object.ts new file mode 100644 index 00000000000..cbd1b7b98dd --- /dev/null +++ b/packages/platform-objects/src/system/sys-trusted-key.object.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_trusted_key — the environment's trusted plugin-publisher PUBLIC keys + * (ADR-0025 §3.5 M1 / §3.7). + * + * One row per publisher key this environment accepts a plugin signature from. + * `os plugin install ./x.osplugin` reads the `keyId` out of the artifact's + * `ed25519:KEYID:SIG` sidecar, resolves it here and verifies the artifact bytes + * with that row's `public_key` (`verifyPluginArtifact`, `@objectstack/core`). + * A key id with no row, or whose row is `revoked`, is refused by name. + * + * ## Public keys only + * + * ⛔ A private key never enters this store, nor any other platform surface: + * publisher keys stay with the publisher (ADR-0025 §7). `os plugin keygen` + * writes the pair as two files on the developer's disk, and + * `os plugin trust add` accepts a public-key PEM only — it refuses a + * private-key PEM by name rather than deriving the public half from it. + * + * ## What is not a row + * + * The platform's own ROOT keys (the marketplace counter-signature, §3.7) ship + * in code and rotate by release; they are never rows here. `source` reserves + * `platform` and `cloud` for the keys M2 / M3 cache from that chain; M1 writes + * `local` only. + * + * ## Writers + * + * `managedBy: 'engine-owned'` and a read-only API: rows are written only by the + * CLI's `os plugin trust add` / `os plugin trust revoke`, a one-shot boot + * against this environment's database under the system context — an operator + * act at the host, recorded by the audit CRUD mirror. A revoked row is never + * deleted or re-activated: its key id stays refused, so a revoked key cannot + * come back under the same handle. + * + * Registered by `PlatformObjectsPlugin` (`../plugin.ts`) beside `sys_secret`: + * its readers span domains — the CLI's install command today, and ADR-0025 + * M1's boot-time loader in the runtime once it is built — and no capability + * plugin owns plugin distribution (ADR-0029 D1/D2). + * + * @namespace sys + */ +export const SysTrustedKey = ObjectSchema.create({ + name: 'sys_trusted_key', + label: 'Trusted Key', + pluralLabel: 'Trusted Keys', + icon: 'key-round', + isSystem: true, + managedBy: 'engine-owned', + // [ADR-0131 D7] Environment-scoped, deployment-level: the trust set belongs + // to the environment, never to one organization, and every writer and + // reader acts under the system context. Who may read is object permission: + // the platform-only capability below. + systemFields: { tenant: false }, + requiredPermissions: ['manage_platform_settings'], + description: + "Publisher public keys this environment accepts a plugin artifact's signature from. Public keys only; written by `os plugin trust`.", + nameField: 'key_id', + titleFormat: '{key_id}', + highlightFields: ['key_id', 'status', 'source', 'fingerprint', 'added_at'], + listViews: { + all: { + type: 'grid', + name: 'all', + label: 'All Trusted Keys', + columns: ['key_id', 'status', 'source', 'fingerprint', 'added_by', 'added_at'], + }, + }, + + fields: { + id: Field.text({ + label: 'ID', + readonly: true, + }), + + key_id: Field.text({ + label: 'Key ID', + required: true, + readonly: true, + maxLength: 128, + description: + 'The key id a signature names (`ed25519:KEYID:SIG`, set by `os plugin sign --key-id`). Unique in the environment and never reused, revoked rows included.', + }), + + public_key: Field.textarea({ + label: 'Public Key', + required: true, + readonly: true, + maxLength: 1024, + description: 'The Ed25519 public key, SPKI PEM. Never a private key.', + }), + + fingerprint: Field.text({ + label: 'Fingerprint', + required: true, + readonly: true, + maxLength: 80, + description: 'sha256 of the key\'s SPKI DER bytes, `sha256:` + 64 lowercase hex digits.', + }), + + source: Field.select( + [ + { label: 'Local', value: 'local' }, + { label: 'Platform', value: 'platform' }, + { label: 'Cloud', value: 'cloud' }, + ], + { + label: 'Source', + required: true, + readonly: true, + defaultValue: 'local', + description: + 'Where the key came from. `local`: added on this host by `os plugin trust add`. `platform` and `cloud` are reserved for keys cached from the marketplace signing chain; nothing writes them yet.', + }, + ), + + status: Field.select( + [ + { label: 'Active', value: 'active' }, + { label: 'Revoked', value: 'revoked' }, + ], + { + label: 'Status', + required: true, + defaultValue: 'active', + description: '`active` keys verify plugin signatures; a `revoked` key is refused by name and never re-activated.', + }, + ), + + added_by: Field.text({ + label: 'Added By', + readonly: true, + maxLength: 255, + description: 'Who added the key: for a `local` key, the operating-system account that ran `os plugin trust add`.', + }), + + added_at: Field.datetime({ + label: 'Added At', + required: true, + readonly: true, + description: 'When the key was added to this environment.', + }), + + created_at: Field.datetime({ + label: 'Created At', + readonly: true, + }), + + updated_at: Field.datetime({ + label: 'Updated At', + readonly: true, + }), + }, + + indexes: [ + // One key id names one key, and one key carries one key id, across the + // environment, revoked rows included. The store checks both before it + // writes; these indexes hold them when two adds race. + { fields: ['key_id'], unique: 'global' }, + { fields: ['fingerprint'], unique: 'global' }, + ], + + enable: { + trackHistory: false, + searchable: false, + apiEnabled: true, + // Diagnostic read surface only — writes go through `os plugin trust` + // (system context), so no API caller can add or revive a trusted key. + apiMethods: ['get', 'list'], + }, +}); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 31b907cd78a..20243218c14 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -17,6 +17,41 @@ export type { StandaloneStackConfig, StandaloneStackResult, ResolvedStandaloneDa export { devAutoMigrateConfig } from './dev-auto-migrate.js'; export type { DevAutoMigrateConfig } from './dev-auto-migrate.js'; +// [ADR-0025 §3.5 M1] The local plugin install loop's two stores — public because +// `@objectstack/cli` writes them (`os plugin trust`, `os plugin install`) and +// the runtime's boot-time loader is to read the same ones. +export { + TRUSTED_KEY_OBJECT, + TRUSTED_KEY_ID_PATTERN, + publicKeyFingerprint, + readPublisherPublicKey, + listTrustedKeys, + addTrustedKey, + revokeTrustedKey, + resolveTrustedKey, +} from './plugin-trusted-keys.js'; +export type { + TrustedKeyRow, + TrustedKeyStatus, + TrustedKeySource, + TrustedKeyEngine, + PublisherKeyRead, + PublisherKeyRefusal, + AddTrustedKeyResult, + AddTrustedKeyRefusal, + RevokeTrustedKeyResult, + TrustedKeyResolution, +} from './plugin-trusted-keys.js'; +export { + DEFAULT_PLUGIN_ENVIRONMENT_ID, + pluginsHome, + pluginInstallDir, + pluginInstallationsFile, + loadPluginInstallations, + recordPluginInstallation, +} from './plugin-installation-store.js'; +export type { PluginInstallationRecord } from './plugin-installation-store.js'; + // The ONE libSQL/Turso loader (commit 68f5eccb1). Public because `@objectstack/cli` is a // consumer, not a second implementation: `utils/storage-driver.ts` delegates to // `loadTursoDriverFactory` and RE-EXPORTS `MissingDriverPackageError`, so diff --git a/packages/runtime/src/plugin-installation-store.test.ts b/packages/runtime/src/plugin-installation-store.test.ts new file mode 100644 index 00000000000..5d82cf798c8 --- /dev/null +++ b/packages/runtime/src/plugin-installation-store.test.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `plugin-installation-store` (ADR-0025 §3.5 steps 5–6, M1): where a plugin + * version is materialized, and the record of what was installed. + * + * Pinned: an id or version that is not one safe path segment never names a + * directory; the record round-trips and an update swaps the version for its + * id; the file is keyed by project as well as environment, the sibling + * disable-state store's rule; and a record file that cannot be read is + * refused, never rewritten from empty. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; + +import { + loadPluginInstallations, + pluginInstallDir, + pluginInstallationsFile, + pluginsHome, + recordPluginInstallation, + type PluginInstallationRecord, +} from './plugin-installation-store.js'; +import { packageStateFileName } from './package-state-store.js'; + +let home: string; +const saved = { OS_HOME: process.env.OS_HOME }; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'os-plugin-installs-')); + process.env.OS_HOME = home; +}); + +afterEach(() => { + if (saved.OS_HOME === undefined) delete process.env.OS_HOME; + else process.env.OS_HOME = saved.OS_HOME; + rmSync(home, { recursive: true, force: true }); +}); + +function record(id: string, version: string): PluginInstallationRecord { + return { + id, + version, + versionId: `${id}@${version}`, + runtime: 'node', + status: 'installed', + grantedPermissions: null, + dir: pluginInstallDir('env_local', id, version), + artifact: { file: 'artifact.osplugin', sha256: '0'.repeat(64) }, + signatureFile: 'artifact.osplugin.sig', + signature: { keyId: 'acme', fingerprint: `sha256:${'a'.repeat(64)}` }, + trustUnsigned: false, + installedAt: '2026-10-28T00:00:00.000Z', + }; +} + +describe('pluginInstallDir', () => { + it('is /plugins///', () => { + expect(pluginInstallDir('env_local', 'com.acme.stripe', '1.2.0')) + .toBe(join(home, 'plugins', 'env_local', 'com.acme.stripe', '1.2.0')); + expect(pluginsHome(undefined)).toBe(join(home, 'plugins', 'env_local')); + }); + + it.each([ + ['id', '../escape', '1.0.0'], + ['id', 'com/acme', '1.0.0'], + ['id', '', '1.0.0'], + ['version', 'com.acme', '..'], + ['version', 'com.acme', '1.0.0/../../x'], + ['version', 'com.acme', '.hidden'], + ])('refuses a %s that is not one safe path segment (%s @ %s)', (_what, id, version) => { + expect(() => pluginInstallDir('env_local', id, version)).toThrow(/cannot name an install directory/); + }); +}); + +describe('the installation record', () => { + it('round-trips, and a second version of one id replaces the first', () => { + expect(loadPluginInstallations('env_local')).toEqual([]); + recordPluginInstallation('env_local', record('com.acme.a', '1.0.0')); + recordPluginInstallation('env_local', record('com.acme.b', '1.0.0')); + recordPluginInstallation('env_local', record('com.acme.a', '1.1.0')); + const installs = loadPluginInstallations('env_local'); + expect(installs.map((r) => r.versionId)).toEqual(['com.acme.a@1.1.0', 'com.acme.b@1.0.0']); + expect(installs[0].signature).toEqual({ keyId: 'acme', fingerprint: `sha256:${'a'.repeat(64)}` }); + }); + + it('is keyed by environment AND project, by the disable-state store\'s own file-name rule', () => { + const file = pluginInstallationsFile('env_local'); + expect(basename(file)).toBe(packageStateFileName('env_local', process.cwd())); + expect(dirname(file)).toBe(join(home, 'plugin-installations')); + recordPluginInstallation('env_local', record('com.acme.a', '1.0.0')); + // Another environment on the same host and project sees nothing. + expect(loadPluginInstallations('env_staging')).toEqual([]); + }); + + it('refuses a file it cannot read, and never rewrites it from empty', () => { + const file = pluginInstallationsFile('env_local'); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, '{ not json', 'utf8'); + expect(() => loadPluginInstallations('env_local')).toThrow(/not valid JSON/); + expect(() => recordPluginInstallation('env_local', record('com.acme.a', '1.0.0'))).toThrow(/not valid JSON/); + expect(readFileSync(file, 'utf8')).toBe('{ not json'); + writeFileSync(file, '{"other": []}', 'utf8'); + expect(() => loadPluginInstallations('env_local')).toThrow(/no "installations" array/); + expect(existsSync(`${file}.${process.pid}.tmp`)).toBe(false); + }); +}); diff --git a/packages/runtime/src/plugin-installation-store.ts b/packages/runtime/src/plugin-installation-store.ts new file mode 100644 index 00000000000..ff387a69e6d --- /dev/null +++ b/packages/runtime/src/plugin-installation-store.ts @@ -0,0 +1,173 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Local plugin-installation records (ADR-0025 §3.5 steps 5–6, M1). + * + * `os plugin install ./x.osplugin` materializes a verified artifact under + * + * /plugins//// + * + * and records the installation here, so the install survives a restart and a + * boot-time loader can find what was installed and how it was trusted: + * + * /plugin-installations/..json + * → { "installations": [ PluginInstallationRecord, … ] } + * + * The record is the local stand-in for the control plane's + * `sys_package_installation` row (ADR-0025 v2 §3.8: `env_id`, `version_id`, + * `granted_permissions`, `status`): the environment is the file's name, + * `versionId` is `id@version` because a local install has no + * `sys_package_version` row, and `grantedPermissions` is the declared set — + * there is no partial grant (§3.5 v2). It also keeps what the install was + * trusted on: the signature's key id and the key's fingerprint, or the + * `--trust-unsigned` override. + * + * ## Why the file is keyed by project as well as environment + * + * The same rule as the sibling `package-state-store.ts`, and its file name is + * made by the same function ({@link packageStateFileName}): a boot reads its + * installations, so a record shared between two projects that share one home + * and one environment id would load a plugin into a project that never + * installed it. The materialized bytes are keyed by environment, id and + * version alone (the path ADR-0025 §3.5 names); a reader checks them against + * the record's `sha256` before it trusts them. + * + * ## A record this store cannot read is never overwritten + * + * Unlike the disable-state file, a corrupt installations file is refused, not + * read as empty: rewriting it from an empty start would drop every other + * plugin's record while the write reported success. + * + * Exported from the package index for the CLI, which writes it; the runtime's + * boot-time loader is its reader. + */ +import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { makeDirectorySync } from './make-directory.js'; +import { packageStateFileName } from './package-state-store.js'; +import { resolveObjectStackHome } from './standalone-stack.js'; + +/** The environment id `os serve` stamps when `OS_ENVIRONMENT_ID` is unset. */ +export const DEFAULT_PLUGIN_ENVIRONMENT_ID = 'env_local'; + +/** One installed plugin version, as `os plugin install` recorded it. */ +export interface PluginInstallationRecord { + /** The manifest `id` (reverse-domain). */ + id: string; + version: string; + /** `id@version` — the local stand-in for a `sys_package_version` id. */ + versionId: string; + /** The manifest's declared `runtime` tier, or `null` when it declares none. */ + runtime: string | null; + status: 'installed'; + /** The manifest's declared `permissions` — the granted set equals it. `null` when it declares none. */ + grantedPermissions: Record | null; + /** The materialized directory, absolute. */ + dir: string; + /** The original artifact kept in {@link dir}, and its whole-artifact sha256 (hex). */ + artifact: { file: string; sha256: string }; + /** The detached signature kept in {@link dir}, or `null` for an unsigned install. */ + signatureFile: string | null; + /** The trusted key the signature verified against, or `null` for an unsigned install. */ + signature: { keyId: string; fingerprint: string } | null; + /** `true` when the install was admitted unsigned under `--trust-unsigned`. */ + trustUnsigned: boolean; + /** ISO-8601 time of the install. */ + installedAt: string; +} + +interface InstallationsFile { + installations: PluginInstallationRecord[]; +} + +/** + * A path segment this store will build a directory from: it starts with a + * letter or digit and holds only letters, digits and `.`, `_`, `+`, `-`. So it + * is never empty, `.` or `..`, and never carries a separator. + */ +const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/; + +function requireSafeSegment(what: string, value: string): string { + if (typeof value !== 'string' || !SAFE_SEGMENT.test(value) || value.length > 214) { + throw new Error( + `plugin ${what} ${JSON.stringify(value)} cannot name an install directory: it must start with a letter or digit ` + + 'and hold only letters, digits and . _ + - (no path separator).', + ); + } + return value; +} + +function environmentSegment(environmentId: string | undefined): string { + const raw = (environmentId ?? DEFAULT_PLUGIN_ENVIRONMENT_ID).trim(); + const safe = raw.replace(/[^a-zA-Z0-9._-]/g, '_'); + return safe.length > 0 && SAFE_SEGMENT.test(safe) ? safe : DEFAULT_PLUGIN_ENVIRONMENT_ID; +} + +/** `/plugins/` — the root of one environment's materialized plugins. */ +export function pluginsHome(environmentId?: string): string { + return join(resolveObjectStackHome(), 'plugins', environmentSegment(environmentId)); +} + +/** + * `/plugins///` — where one plugin version + * is materialized. Throws when `id` or `version` could not be a single safe + * path segment. + */ +export function pluginInstallDir(environmentId: string | undefined, id: string, version: string): string { + return join(pluginsHome(environmentId), requireSafeSegment('id', id), requireSafeSegment('version', version)); +} + +/** The installations file for this environment and project (the working directory). */ +export function pluginInstallationsFile(environmentId?: string): string { + return join( + resolveObjectStackHome(), + 'plugin-installations', + packageStateFileName(environmentSegment(environmentId), process.cwd()), + ); +} + +/** + * Every installation recorded for this environment and project, in the order + * they were first installed. An absent file is no installations; a file that + * is not an installations file throws, naming it. + */ +export function loadPluginInstallations(environmentId?: string): PluginInstallationRecord[] { + const file = pluginInstallationsFile(environmentId); + if (!existsSync(file)) return []; + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(file, 'utf8')); + } catch (error) { + throw new Error( + `The plugin installations file ${file} is not valid JSON (${(error as Error).message}). ` + + 'Restore it from a backup or remove it and re-install the plugins it listed.', + ); + } + const list = (parsed as Partial | null)?.installations; + if (!Array.isArray(list)) { + throw new Error( + `The plugin installations file ${file} has no "installations" array. ` + + 'Restore it from a backup or remove it and re-install the plugins it listed.', + ); + } + return list as PluginInstallationRecord[]; +} + +/** + * Record `record` as the installed version of its plugin id, replacing any + * earlier record for the same id (an update swaps the installation pointer, + * ADR-0025 §3.8). The write lands through a temporary file and a rename, so a + * reader never sees half a file. + */ +export function recordPluginInstallation(environmentId: string | undefined, record: PluginInstallationRecord): void { + const current = loadPluginInstallations(environmentId); + const next = current.some((r) => r.id === record.id) + ? current.map((r) => (r.id === record.id ? record : r)) + : [...current, record]; + const file = pluginInstallationsFile(environmentId); + makeDirectorySync(dirname(file)); + const tmp = `${file}.${process.pid}.tmp`; + writeFileSync(tmp, `${JSON.stringify({ installations: next } satisfies InstallationsFile, null, 2)}\n`, 'utf8'); + renameSync(tmp, file); +} diff --git a/packages/runtime/src/plugin-trusted-keys.test.ts b/packages/runtime/src/plugin-trusted-keys.test.ts new file mode 100644 index 00000000000..17838658c95 --- /dev/null +++ b/packages/runtime/src/plugin-trusted-keys.test.ts @@ -0,0 +1,220 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The trusted-key store (ADR-0025 §3.5 M1), driven against a REAL engine over + * a real SQLite file holding the real `sys_trusted_key` definition — never a + * hand-written double, whose looser `find` / `update` would let a refusal the + * engine makes read as green. + * + * Pinned: a private-key or non-Ed25519 PEM never becomes a row; one key id + * names one key and one key carries one key id; a revoked row stays, and + * neither its key id nor its key is trusted again; resolution hands back a key + * only for an active row whose fingerprint is its public key's. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createHash, createPublicKey, generateKeyPairSync, sign } from 'node:crypto'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { generateEd25519KeyPair, signPayload, verifyPayload } from '@objectstack/core'; +import { SysTrustedKey } from '@objectstack/platform-objects/system'; + +import { + TRUSTED_KEY_OBJECT, + addTrustedKey, + listTrustedKeys, + publicKeyFingerprint, + readPublisherPublicKey, + resolveTrustedKey, + revokeTrustedKey, + type TrustedKeyEngine, +} from './plugin-trusted-keys.js'; + +const SYSTEM = { context: { isSystem: true } }; + +let dir: string; +let driver: SqlDriver; +let engine: ObjectQL; + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'os-trusted-keys-')); + driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: join(dir, 'data.sqlite') }, useNullAsDefault: true }); + await driver.initObjects([SysTrustedKey] as any[]); + engine = new ObjectQL(); + engine.registerDriver(driver as any, true); + await engine.init(); + engine.registry.registerObject(SysTrustedKey as any, 'com.objectstack.platform-objects'); +}); + +afterEach(async () => { + await driver.disconnect(); + rmSync(dir, { recursive: true, force: true }); +}); + +const store = (): TrustedKeyEngine => engine as unknown as TrustedKeyEngine; + +function freshKey(): { publicKeyPem: string; privateKeyPem: string; fingerprint: string } { + const pair = generateEd25519KeyPair(); + const read = readPublisherPublicKey(pair.publicKeyPem); + if (!read.ok) throw new Error(read.message); + return { ...pair, fingerprint: read.fingerprint }; +} + +async function rowCount(): Promise { + return (await engine.find(TRUSTED_KEY_OBJECT, {}, SYSTEM)).length; +} + +describe('readPublisherPublicKey', () => { + it('reads an Ed25519 SPKI PEM and fingerprints its DER bytes', () => { + const { publicKeyPem } = generateEd25519KeyPair(); + const read = readPublisherPublicKey(publicKeyPem); + expect(read.ok).toBe(true); + if (!read.ok) return; + const der = createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' }); + expect(read.fingerprint).toBe(`sha256:${createHash('sha256').update(der).digest('hex')}`); + expect(read.fingerprint).toBe(publicKeyFingerprint(createPublicKey(publicKeyPem))); + }); + + it('refuses a PRIVATE-key PEM by name instead of deriving its public half', () => { + const { privateKeyPem } = generateEd25519KeyPair(); + // The control: node itself would happily derive a public key from it. + expect(createPublicKey(privateKeyPem).asymmetricKeyType).toBe('ed25519'); + const read = readPublisherPublicKey(privateKeyPem); + expect(read).toMatchObject({ ok: false, reason: 'private-key' }); + }); + + it('refuses a public key that is not Ed25519, naming its type', () => { + const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const read = readPublisherPublicKey(publicKey.export({ type: 'spki', format: 'pem' }).toString()); + expect(read).toMatchObject({ ok: false, reason: 'wrong-algorithm' }); + if (!read.ok) expect(read.message).toContain("'rsa'"); + }); + + it('refuses text that is not a key', () => { + expect(readPublisherPublicKey('not a pem')).toMatchObject({ ok: false, reason: 'unreadable' }); + }); +}); + +describe('addTrustedKey / revokeTrustedKey', () => { + it('adds an active local row, and the same key under the same id again writes nothing', async () => { + const k = freshKey(); + const first = await addTrustedKey(store(), { keyId: 'acme-2026', publicKeyPem: k.publicKeyPem, fingerprint: k.fingerprint, addedBy: 'dev' }); + expect(first).toMatchObject({ outcome: 'added', row: { key_id: 'acme-2026', status: 'active', source: 'local', added_by: 'dev' } }); + const again = await addTrustedKey(store(), { keyId: 'acme-2026', publicKeyPem: k.publicKeyPem, fingerprint: k.fingerprint, addedBy: 'dev' }); + expect(again.outcome).toBe('already-trusted'); + expect(await rowCount()).toBe(1); + const [row] = await listTrustedKeys(store()); + expect(row).toMatchObject({ key_id: 'acme-2026', fingerprint: k.fingerprint, status: 'active' }); + }); + + it('refuses a key id that a signature string could not carry, before any write', async () => { + const k = freshKey(); + const result = await addTrustedKey(store(), { keyId: 'acme:2026', publicKeyPem: k.publicKeyPem, fingerprint: k.fingerprint, addedBy: null }); + expect(result).toMatchObject({ outcome: 'refused', reason: 'key-id-invalid' }); + expect(await rowCount()).toBe(0); + }); + + it('one key id names one key: a different key under a taken id is refused', async () => { + const a = freshKey(); + const b = freshKey(); + await addTrustedKey(store(), { keyId: 'acme', publicKeyPem: a.publicKeyPem, fingerprint: a.fingerprint, addedBy: null }); + const result = await addTrustedKey(store(), { keyId: 'acme', publicKeyPem: b.publicKeyPem, fingerprint: b.fingerprint, addedBy: null }); + expect(result).toMatchObject({ outcome: 'refused', reason: 'key-id-taken' }); + expect(await rowCount()).toBe(1); + }); + + it('one key carries one key id: the same key under a second id is refused', async () => { + const a = freshKey(); + await addTrustedKey(store(), { keyId: 'acme', publicKeyPem: a.publicKeyPem, fingerprint: a.fingerprint, addedBy: null }); + const result = await addTrustedKey(store(), { keyId: 'acme-two', publicKeyPem: a.publicKeyPem, fingerprint: a.fingerprint, addedBy: null }); + expect(result).toMatchObject({ outcome: 'refused', reason: 'key-trusted-under-another-id' }); + expect(await rowCount()).toBe(1); + }); + + it('revoking keeps the row; neither the key id nor the key is trusted again', async () => { + const a = freshKey(); + await addTrustedKey(store(), { keyId: 'acme', publicKeyPem: a.publicKeyPem, fingerprint: a.fingerprint, addedBy: null }); + expect(await revokeTrustedKey(store(), 'acme')).toMatchObject({ outcome: 'revoked', row: { status: 'revoked' } }); + expect(await revokeTrustedKey(store(), 'acme')).toMatchObject({ outcome: 'already-revoked' }); + const [row] = await listTrustedKeys(store()); + expect(row).toMatchObject({ key_id: 'acme', status: 'revoked', fingerprint: a.fingerprint }); + + const b = freshKey(); + expect(await addTrustedKey(store(), { keyId: 'acme', publicKeyPem: b.publicKeyPem, fingerprint: b.fingerprint, addedBy: null })) + .toMatchObject({ outcome: 'refused', reason: 'key-id-revoked' }); + expect(await addTrustedKey(store(), { keyId: 'acme-new', publicKeyPem: a.publicKeyPem, fingerprint: a.fingerprint, addedBy: null })) + .toMatchObject({ outcome: 'refused', reason: 'key-revoked' }); + expect(await rowCount()).toBe(1); + }); + + it('the table itself holds both rules when a write goes around the store (two adds racing)', async () => { + const a = freshKey(); + const b = freshKey(); + const row = (keyId: string, k: { publicKeyPem: string; fingerprint: string }) => ({ + key_id: keyId, + public_key: k.publicKeyPem, + fingerprint: k.fingerprint, + source: 'local', + status: 'active', + added_by: null, + added_at: new Date().toISOString(), + }); + await engine.insert(TRUSTED_KEY_OBJECT, row('acme', a), SYSTEM); + // The same key under a second id, and a second key under the same id. + await expect(engine.insert(TRUSTED_KEY_OBJECT, row('acme-2', a), SYSTEM)).rejects.toThrow(); + await expect(engine.insert(TRUSTED_KEY_OBJECT, row('acme', b), SYSTEM)).rejects.toThrow(); + // Control: a second key under a second id is a second row. + await engine.insert(TRUSTED_KEY_OBJECT, row('acme-2', b), SYSTEM); + expect(await rowCount()).toBe(2); + }); + + it('refuses to revoke a key id it does not hold', async () => { + expect(await revokeTrustedKey(store(), 'nobody')).toMatchObject({ outcome: 'refused', reason: 'key-id-unknown' }); + }); +}); + +describe('resolveTrustedKey', () => { + it('hands back the key of an active row, and that key verifies what its private half signed', async () => { + const k = freshKey(); + await addTrustedKey(store(), { keyId: 'acme', publicKeyPem: k.publicKeyPem, fingerprint: k.fingerprint, addedBy: null }); + const resolved = await resolveTrustedKey(store(), 'acme'); + expect(resolved.verdict).toBe('active'); + if (resolved.verdict !== 'active') return; + const bytes = new TextEncoder().encode('artifact bytes'); + expect(verifyPayload(bytes, signPayload(bytes, k.privateKeyPem, 'acme'), resolved.publicKey)).toBe(true); + }); + + it('answers unknown for a key id with no row, and revoked for a revoked one', async () => { + expect(await resolveTrustedKey(store(), 'acme')).toEqual({ verdict: 'unknown' }); + const k = freshKey(); + await addTrustedKey(store(), { keyId: 'acme', publicKeyPem: k.publicKeyPem, fingerprint: k.fingerprint, addedBy: null }); + await revokeTrustedKey(store(), 'acme'); + expect(await resolveTrustedKey(store(), 'acme')).toMatchObject({ verdict: 'revoked' }); + }); + + it('refuses a row whose stored fingerprint is not its public key\'s, rather than verifying with it', async () => { + const k = freshKey(); + const other = freshKey(); + // A row written past the store: one key's PEM under another key's fingerprint. + await engine.insert(TRUSTED_KEY_OBJECT, { + key_id: 'acme', + public_key: other.publicKeyPem, + fingerprint: k.fingerprint, + source: 'local', + status: 'active', + added_by: null, + added_at: new Date().toISOString(), + }, SYSTEM); + const resolved = await resolveTrustedKey(store(), 'acme'); + expect(resolved.verdict).toBe('invalid'); + // Anti-vacuity: the PEM itself is a good Ed25519 key, so only the + // fingerprint comparison can have refused it. + expect(readPublisherPublicKey(other.publicKeyPem).ok).toBe(true); + // And a signature by the key the PEM holds would verify, had it been used. + const bytes = new TextEncoder().encode('x'); + expect(verifyPayload(bytes, `ed25519:acme:${sign(null, bytes, other.privateKeyPem).toString('base64url')}`, other.publicKeyPem)).toBe(true); + }); +}); diff --git a/packages/runtime/src/plugin-trusted-keys.ts b/packages/runtime/src/plugin-trusted-keys.ts new file mode 100644 index 00000000000..0e655e7ff97 --- /dev/null +++ b/packages/runtime/src/plugin-trusted-keys.ts @@ -0,0 +1,290 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The environment's trusted plugin-publisher keys — reads and writes of the + * `sys_trusted_key` platform object (ADR-0025 §3.5 M1, ruling B′ on the M1 + * install card). + * + * `os plugin trust add | list | revoke` write and list the store, and + * `os plugin install` resolves an artifact signature's key id in it + * ({@link resolveTrustedKey}). Every call takes an engine-shaped handle so the + * CLI's one-shot boot and the runtime's own boot read one implementation. + * + * Rules the store keeps, whoever calls it: + * + * - **Public keys only.** {@link readPublisherPublicKey} refuses a private-key + * PEM by name instead of deriving its public half: a private key never + * enters the platform (ADR-0025 §7). + * - **Ed25519 only**, the one algorithm plugin artifact signatures use + * (`SIGNATURE_ALG` in `@objectstack/core`). + * - **A key id is never reused, and a revoked key never returns.** Adding a + * different key under a key id the store already holds is refused, revoked + * rows included, so one handle never names two keys; and a key whose + * fingerprint is on a revoked row is refused under any key id, so a revoked + * key cannot come back under a new handle. One key is trusted under one id. + * - **A revoked row stays.** Revoking flips `status`; nothing deletes a row + * or re-activates it. + * - **The row is re-checked on read.** {@link resolveTrustedKey} recomputes + * the fingerprint from `public_key` and refuses a row whose stored + * fingerprint disagrees, rather than verifying with a key the row does not + * describe. + */ +import { createHash, createPublicKey, type KeyObject } from 'node:crypto'; + +import { SIGNATURE_ALG } from '@objectstack/core'; + +/** The platform object the trusted keys live in. */ +export const TRUSTED_KEY_OBJECT = 'sys_trusted_key'; + +/** + * A key id the store accepts: 1–128 letters, digits, `.`, `_` or `-`. A + * signature string is `ed25519:KEYID:SIG`, so a key id can never hold `:` + * (`signPayload` refuses one too). + */ +export const TRUSTED_KEY_ID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/; + +export type TrustedKeyStatus = 'active' | 'revoked'; +export type TrustedKeySource = 'local' | 'platform' | 'cloud'; + +/** One `sys_trusted_key` row, as the store reads it. */ +export interface TrustedKeyRow { + id?: string; + key_id: string; + public_key: string; + fingerprint: string; + source: TrustedKeySource; + status: TrustedKeyStatus; + added_by: string | null; + added_at: string; +} + +/** The engine calls this store makes. `IObjectQLEngine` satisfies it. */ +export interface TrustedKeyEngine { + find(object: string, query?: Record, options?: Record): Promise; + insert(object: string, data: Record, options?: Record): Promise; + update(object: string, data: Record, options?: Record): Promise; +} + +const SYSTEM = { context: { isSystem: true } } as const; + +/** `sha256:` + the lowercase hex sha256 of the key's SPKI DER bytes. */ +export function publicKeyFingerprint(key: KeyObject): string { + const der = key.export({ type: 'spki', format: 'der' }); + return `sha256:${createHash('sha256').update(der).digest('hex')}`; +} + +/** The outcome of reading a PEM handed to `os plugin trust add`. */ +export type PublisherKeyRead = + | { ok: true; publicKeyPem: string; fingerprint: string } + | { ok: false; reason: PublisherKeyRefusal; message: string }; + +/** Why {@link readPublisherPublicKey} refused a PEM, each named in its message. */ +export type PublisherKeyRefusal = 'private-key' | 'unreadable' | 'wrong-algorithm'; + +/** + * Read a publisher PUBLIC key from PEM text: refuse a private key by name, + * refuse anything that is not Ed25519, and normalize the rest to SPKI PEM + * with its fingerprint. + */ +export function readPublisherPublicKey(pem: string): PublisherKeyRead { + // createPublicKey() happily derives a public key from a PRIVATE-key PEM, so + // the label is checked first: that input is refused, never converted. + if (/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/.test(pem)) { + return { + ok: false, + reason: 'private-key', + message: + 'This PEM holds a PRIVATE key. Only a publisher PUBLIC key is ever trusted, and a private key never enters ' + + 'the platform: pass the `.pub.pem` file `os plugin keygen` wrote, and keep the `.key.pem` file to yourself.', + }; + } + let key: KeyObject; + try { + key = createPublicKey(pem); + } catch (error) { + return { + ok: false, + reason: 'unreadable', + message: `This is not a readable public-key PEM (${(error as Error).message}). Pass the SPKI \`.pub.pem\` file \`os plugin keygen\` wrote.`, + }; + } + if (key.asymmetricKeyType !== SIGNATURE_ALG) { + return { + ok: false, + reason: 'wrong-algorithm', + message: + `This public key is of type '${String(key.asymmetricKeyType)}', but plugin artifact signatures are ` + + `'${SIGNATURE_ALG}' only. Generate an Ed25519 pair with \`os plugin keygen\`.`, + }; + } + return { + ok: true, + publicKeyPem: key.export({ type: 'spki', format: 'pem' }).toString(), + fingerprint: publicKeyFingerprint(key), + }; +} + +function toRow(raw: Record): TrustedKeyRow { + return { + id: raw.id == null ? undefined : String(raw.id), + key_id: String(raw.key_id ?? ''), + public_key: String(raw.public_key ?? ''), + fingerprint: String(raw.fingerprint ?? ''), + source: String(raw.source ?? '') as TrustedKeySource, + status: String(raw.status ?? '') as TrustedKeyStatus, + added_by: raw.added_by == null ? null : String(raw.added_by), + added_at: raw.added_at == null ? '' : String(raw.added_at), + }; +} + +/** Every trusted-key row in the environment, revoked rows included, by key id. */ +export async function listTrustedKeys(engine: TrustedKeyEngine): Promise { + const rows = await engine.find(TRUSTED_KEY_OBJECT, { orderBy: [{ field: 'key_id' }] }, SYSTEM); + return rows.map((r) => toRow(r as Record)); +} + +async function findByKeyId(engine: TrustedKeyEngine, keyId: string): Promise { + const rows = await engine.find(TRUSTED_KEY_OBJECT, { where: { key_id: keyId } }, SYSTEM); + const exact = rows.map((r) => toRow(r as Record)).filter((r) => r.key_id === keyId); + return exact[0] ?? null; +} + +async function findByFingerprint(engine: TrustedKeyEngine, fingerprint: string): Promise { + const rows = await engine.find(TRUSTED_KEY_OBJECT, { where: { fingerprint } }, SYSTEM); + return rows.map((r) => toRow(r as Record)).filter((r) => r.fingerprint === fingerprint); +} + +export type AddTrustedKeyResult = + | { outcome: 'added' | 'already-trusted'; row: TrustedKeyRow } + | { outcome: 'refused'; reason: AddTrustedKeyRefusal; message: string; row?: TrustedKeyRow }; + +/** Why {@link addTrustedKey} refused, each named in its message. */ +export type AddTrustedKeyRefusal = + | 'key-id-invalid' + | 'key-id-taken' + | 'key-id-revoked' + | 'key-revoked' + | 'key-trusted-under-another-id'; + +/** + * Add `publicKeyPem` (already read by {@link readPublisherPublicKey}) as an + * active `local` key under `keyId`. The same key under the same id again is + * `already-trusted` and writes nothing. + */ +export async function addTrustedKey( + engine: TrustedKeyEngine, + input: { keyId: string; publicKeyPem: string; fingerprint: string; addedBy: string | null; now?: Date }, +): Promise { + const { keyId } = input; + if (!TRUSTED_KEY_ID_PATTERN.test(keyId)) { + return { + outcome: 'refused', + reason: 'key-id-invalid', + message: `Key id ${JSON.stringify(keyId)} is not allowed: use 1-128 letters, digits, '.', '_' or '-' (the id \`os plugin sign --key-id\` embeds).`, + }; + } + const existing = await findByKeyId(engine, keyId); + if (existing) { + if (existing.status === 'revoked') { + return { + outcome: 'refused', + reason: 'key-id-revoked', + row: existing, + message: + `Key id '${keyId}' was revoked in this environment, and a revoked key id is never trusted again. ` + + 'Generate a new key pair (`os plugin keygen`), sign with it under a new key id and trust that key.', + }; + } + if (existing.fingerprint === input.fingerprint) return { outcome: 'already-trusted', row: existing }; + return { + outcome: 'refused', + reason: 'key-id-taken', + row: existing, + message: + `Key id '${keyId}' already names a different trusted key (${existing.fingerprint}). One key id names one key: ` + + 'choose a new key id for this key, or revoke the old one first (`os plugin trust revoke`).', + }; + } + const sameKey = await findByFingerprint(engine, input.fingerprint); + const revoked = sameKey.find((r) => r.status === 'revoked'); + if (revoked) { + return { + outcome: 'refused', + reason: 'key-revoked', + row: revoked, + message: + `This key (${input.fingerprint}) was revoked in this environment under key id '${revoked.key_id}', and a revoked ` + + 'key is never trusted again, under any key id. Generate a new key pair with `os plugin keygen`.', + }; + } + const active = sameKey.find((r) => r.status === 'active'); + if (active) { + return { + outcome: 'refused', + reason: 'key-trusted-under-another-id', + row: active, + message: + `This key (${input.fingerprint}) is already trusted under key id '${active.key_id}'. One key is trusted under one ` + + `key id: sign with --key-id ${active.key_id}.`, + }; + } + const row: TrustedKeyRow = { + key_id: keyId, + public_key: input.publicKeyPem, + fingerprint: input.fingerprint, + source: 'local', + status: 'active', + added_by: input.addedBy, + added_at: (input.now ?? new Date()).toISOString(), + }; + await engine.insert(TRUSTED_KEY_OBJECT, { ...row }, SYSTEM); + return { outcome: 'added', row }; +} + +export type RevokeTrustedKeyResult = + | { outcome: 'revoked' | 'already-revoked'; row: TrustedKeyRow } + | { outcome: 'refused'; reason: 'key-id-unknown'; message: string }; + +/** Revoke the key under `keyId`. The row stays, with `status: 'revoked'`. */ +export async function revokeTrustedKey(engine: TrustedKeyEngine, keyId: string): Promise { + const existing = await findByKeyId(engine, keyId); + if (!existing) { + return { + outcome: 'refused', + reason: 'key-id-unknown', + message: `No trusted key has key id '${keyId}' in this environment, so there is nothing to revoke. \`os plugin trust list\` shows the ids.`, + }; + } + if (existing.status === 'revoked') return { outcome: 'already-revoked', row: existing }; + await engine.update(TRUSTED_KEY_OBJECT, { id: existing.id, status: 'revoked' }, SYSTEM); + return { outcome: 'revoked', row: { ...existing, status: 'revoked' } }; +} + +export type TrustedKeyResolution = + | { verdict: 'active'; row: TrustedKeyRow; publicKey: KeyObject } + | { verdict: 'revoked'; row: TrustedKeyRow } + | { verdict: 'unknown' } + | { verdict: 'invalid'; row: TrustedKeyRow; message: string }; + +/** + * Resolve the key a signature names. Only an `active` row whose public key is + * an Ed25519 key matching its stored fingerprint yields a key to verify with. + */ +export async function resolveTrustedKey(engine: TrustedKeyEngine, keyId: string): Promise { + const row = await findByKeyId(engine, keyId); + if (!row) return { verdict: 'unknown' }; + if (row.status === 'revoked') return { verdict: 'revoked', row }; + if (row.status !== 'active') { + return { verdict: 'invalid', row, message: `its status is '${row.status}', neither 'active' nor 'revoked'` }; + } + const read = readPublisherPublicKey(row.public_key); + if (!read.ok) return { verdict: 'invalid', row, message: `its public_key cannot be used: ${read.message}` }; + if (read.fingerprint !== row.fingerprint) { + return { + verdict: 'invalid', + row, + message: `its stored fingerprint ${row.fingerprint} is not the fingerprint of its public_key (${read.fingerprint})`, + }; + } + return { verdict: 'active', row, publicKey: createPublicKey(read.publicKeyPem) }; +} diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 721c6311ebd..c2a531ecf66 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -1007,7 +1007,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | validation | seeded 2026-08-01 (#4488). The ADR-0020 carrier: the evaluator honors active/events/priority/severity/type/condition/message (the zod header's "only reads type/condition/…" prose is STALE — trust the ledger). Dead 3 = label/description/tags, declared governance metadata, kept unmarked. Union walk boundary recorded: only base + `script` keys walked; per-variant keys are governed by the evaluator's tests, not ledger rows. **No longer a registered metadata kind** — #4509 retired it under ADR-0088 (a standalone rule had no object-binding key and every variant is `.strict()`, so it bound to nothing and gated no write; a state machine authored that way saved cleanly and did nothing). The rule VOCABULARY is untouched and fully live via `object.validations[]`, so the ledger keeps governing it through the gate's spec-only override, alongside `webhook` and `query`. The contrast with the two bridges in the same batch is the point: enforce-or-remove picked ENFORCE where the feature existed and only the wiring was missing, and REMOVE where the shape itself could not carry the feature | | api | seeded 2026-08-04 (#5271, part of #5206; PR #5312) — **not a metadata type until that same change made it one**, which is the row's point: governance and registration landed together, the treatment `datasource` did not get (#4487) and paid for with six inert keys found by hand. What #5206 measured before the fix: `api` was in neither `DEFAULT_METADATA_TYPE_REGISTRY` nor `BUILTIN_METADATA_TYPE_SCHEMAS`, so `saveMetaItem`'s `resolveOverlaySchema('api', …)` → `getMetadataTypeSchema('api')` returned `undefined` and took its own documented branch — an unregistered type is stored **unvalidated** — while `getMetaTypes()` could not enumerate the type at all, so Studio rendered neither list nor form. That issue names the shape precisely and it is the inverse of this ledger's usual one: **enforced but undeclared** (the matcher was already indexing these entries, #5089), where `dead` is declared-but-unenforced. The seeding pass classified 27 keys — live 25 / planned 2 / dead 0 — each cited `file:line` at the consumer layer that reads it: the MATCHER (`packages/metadata/src/endpoint-matcher.ts`) indexes `name`/`path`/`method`; the EXECUTOR (`packages/runtime/src/endpoint-executor.ts`) dispatches on `type` and reads `target`/`objectParams`; the POLICY chain (`packages/runtime/src/endpoint-policy.ts` + `security/inbound-rate-limit.ts`) enforces `authRequired`/`rateLimit`/`cacheTtl`; the MAPPING layer (`packages/runtime/src/api-mapping.ts`) applies `inputMapping`/`outputMapping`; and OpenAPI enrichment (`packages/rest/src/openapi-endpoints.ts`) emits `summary`/`description`. Timing was the reason it was cheap: #5040's E-series had built every one of those consumers and all of it was on main, so each key had a real evidence path rather than a promise. **Planned 2 = `inputMapping.transform` + `outputMapping.transform`, and `planned` rather than `dead` is load-bearing**: `dead` here means parsed with no consumer — a silent no-op — and these are the opposite, parsed and then LOUDLY REFUSED at publish (`endpoint-publish-gate.ts` mappingGate) and again at runtime, because no transformation-function registry exists anywhere in the platform. An author who writes one is told so and told what to do instead, so there is nothing for enforce-or-remove to chase; they stay in the vocabulary because admitting them needs a function registry **and** a sandbox ruling (#5040 §3.4), which is a design decision, not a key to quietly delete. Zero dead | | capability | seeded 2026-08-08 (#5961; commit ae31a1912) — `CapabilityDeclarationSchema`, the DECLARATION side of ADR-0066 D1's three-way separation: packages DEFINE a capability, permission sets GRANT it via `systemPermissions`, resources REQUIRE it via `requiredPermissions`. **The gate's 12 and the seeding PR's 5 are the same measurement at two granularities** — commit ae31a1912 call-graph-closed **5 authorable properties**, every one to a real reader in `packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts` (the one consumer that turns a declaration into a `sys_capability` row), all `live`, with no `PENDING_GOVERNANCE` debt recorded; the other 7 are the ADR-0010 protection-envelope keys the gate auto-classifies `live` and which carry `null` verdicts in the file, exactly as on `permission`/`position`. The same worked example as `api` above and commit ae31a1912 says so in those words — **enforced but undeclared**, the mirror of the hole #5271 closed. What #5961 measured: absent from `DEFAULT_METADATA_TYPE_REGISTRY`, `BUILTIN_METADATA_TYPE_SCHEMAS` and `HAND_CRAFTED_SCHEMAS`, so `isRuntimeCreateAllowed()` took its no-static-entry fallback (permanently true) and `saveMetaItem` its no-schema branch — `PUT /api/v1/meta/capability/:name` accepted **arbitrary JSON** onto an authorization surface whose names `systemPermissions`/`requiredPermissions` resolve by string, while `/meta/types` synthesised a false `allowRuntimeCreate: true` descriptor Studio drew a raw-JSON create form from. #5870 did not open that path (the write gate reads the registry, not the item store); it only made the type visible in `getMetaTypes()`, and both the issue and this row say so to stop the next reader filing it as a regression. Landed as ruling A on ADR-0066 D1's own authority: `allowRuntimeCreate: false` **and** `allowEnvironmentOverlay: false`, the second self-judged inside the ruling's rationale and flagged for veto — a tenant overlay of a package declaration would lift `scope` from `org` to `platform`, which is the one field on this type that is an escalation rather than display. Its reverse verification is worth copying: deleting the registry entry gave 7 red / 3 green and measured something **sharper than predicted** — a garbage payload turned 422 rather than resolving, i.e. the schema binding is a real second line of defence behind the registry row, not a restatement of it; deleting the schema binding alone gave exactly 3 red. `packageId` is the one key that reads oddly: deliberately a FALLBACK, not the primary, since #5870 added `capabilities` to the ObjectQL stamped-collection list so `_packageId` now reaches a declaration and wins — it stays `live` because the fallback branch still decides materialization for any declaration arriving unstamped. Zero dead | -| manifest | seeded 2026-08-23 (commit 3fc606687) — **not a metadata type and not a stack collection either**, which is the row's whole point. `ManifestSchema` (`packages/spec/src/kernel/manifest.zod.ts:132`) is what an author writes as `objectstack.config.ts` or a packaged manifest; it is parsed at `packages/objectql/src/registry.ts:2950` and by `os plugin build`, and it sat outside the ratchet's universe entirely — `GOVERNED` listed no `plugin`/`manifest`/`package`, `SPEC_ONLY_SCHEMAS` covered only webhook/query/validation/qa, `PENDING_GOVERNANCE` was empty so the gate reported itself **complete**, and `liveness/` had no file for it. A ratchet extended only to unregistered KINDS would not have reached it either: the retired-key entry `17.kernel__Manifest__loading.ts` records that `PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry, so a manifest is never walked as a stack collection member. That blind spot was paid for twice, by hand and after the fact — `loading`'s ten inert keys (#4914, one of them `sandboxing`, which isolated nothing while looking like isolation) and the `contributes` census (recorded in commit be21955ba), which found exactly ONE reader of the 11-member block monorepo-wide. Dead 20 = the ten dead `contributes` members (`kinds` is the sole live one — `engine.ts:4504` → `registerKind`), the five `capabilities.*` and two `configuration.*` keys (all three containers have ZERO reads of the container itself, so no key beneath one can be read), plus `extensions`, `integrity`, and the tombstoned `loading` whose row must stay because `retiredKey()` keeps the key in the walked shape. **`integrity` is security-shaped and not retired here**: it declares per-file digests the future runtime loader is designed to re-verify at unpack (ADR-0025 §3.5 steps 4–7) while nothing re-verifies them today (since #13464 the publisher computes the map at build and self-checks it at the `os plugin publish` preflight, but nothing re-checks it after publish; the leg is the loader's, NOT the cloud control plane's). `runtime` — the ADR-0025 §3.6 trust tier, the other security-shaped key, read locally only by two CLI lines that ECHO the value with no `runtime === 'sandbox'` branch anywhere, while `loading`'s own tombstone used to redirect upgrading authors to it as something "which [is] enforced" — is the family's SPLIT verdict and the ledger's first `live-elsewhere` row (#13483): #12400 measured the cloud leg (cloud @15f55df, 2026-08-29) and found a real consumer — the marketplace publish gate hard-rejects (HTTP 422) an unverified publisher requesting the `node` tier — so the maintainer ruling of 2026-08-30 (executed by commit a9ee98992) took "say it truthfully" over retirement, and retirement is ruled OUT because deleting the key would tear out that gate's input. The tombstone, both `runtime` describes and the D3 entry state the split (publish-gate enforced; load-side NOT implemented); the row's verdict now says it as data rather than as a qualifying note, under the elsewhere criteria (foreign pointer + cross-repo scope + attestation with a 180d expiry), and load-side enforcement is a v18 direction rather than a removal. The `contributes` dispositions have since MOVED: the cloud leg was measured CLEAN 2026-08-24 (recorded in commit be21955ba; cloud `origin/main` @ 5b5925a, zero `manifest.contributes` reads, controls held), and commit be21955ba then executed the retirement — the nine mechanically-dead members are `retiredKey()` tombstones (D3 `plugin-manifest-contributes-dead-members-retired`), their rows staying because a tombstone keeps the key in the walked shape. Commit bc56e1881 then executed the second retirement too (ruled B 2026-08-22; D3 `plugin-manifest-contributes-routes-retired`), tombstoning `routes` and leaving `kinds` the block's sole live member. The remaining non-`contributes` `dead` rows keep their three-repo census verdicts as an enforce-or-remove worklist, not a licence to delete. `permissions` stays `live` on corrected evidence after its legacy flat-list arm was retired (ADR-0049, conversion `manifest-permissions-string-list-removed`): the block is the structured ADR-0025 one alone, its measured consumer is objectui's install-consent disclosure (it refuses nothing at load), and the `collectDeclaredSuggestions` evidence it used to cite was the assembled-stage `PermissionSet[]` reader, which only reports an authoring-stage value as skipped | +| manifest | seeded 2026-08-23 (commit 3fc606687) — **not a metadata type and not a stack collection either**, which is the row's whole point. `ManifestSchema` (`packages/spec/src/kernel/manifest.zod.ts:132`) is what an author writes as `objectstack.config.ts` or a packaged manifest; it is parsed at `packages/objectql/src/registry.ts:2950` and by `os plugin build`, and it sat outside the ratchet's universe entirely — `GOVERNED` listed no `plugin`/`manifest`/`package`, `SPEC_ONLY_SCHEMAS` covered only webhook/query/validation/qa, `PENDING_GOVERNANCE` was empty so the gate reported itself **complete**, and `liveness/` had no file for it. A ratchet extended only to unregistered KINDS would not have reached it either: the retired-key entry `17.kernel__Manifest__loading.ts` records that `PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry, so a manifest is never walked as a stack collection member. That blind spot was paid for twice, by hand and after the fact — `loading`'s ten inert keys (#4914, one of them `sandboxing`, which isolated nothing while looking like isolation) and the `contributes` census (recorded in commit be21955ba), which found exactly ONE reader of the 11-member block monorepo-wide. Dead 19 = the ten dead `contributes` members (`kinds` is the sole live one — `engine.ts:4504` → `registerKind`), the five `capabilities.*` and two `configuration.*` keys (all three containers have ZERO reads of the container itself, so no key beneath one can be read), plus `extensions` and the tombstoned `loading` whose row must stay because `retiredKey()` keeps the key in the walked shape. **`integrity`, the security-shaped key that stood `dead` beside them, is `live` since #27123**: the publisher computes the map at build and self-checks it at the `os plugin publish` preflight (#13464), and `os plugin install` re-verifies it at unpack (ADR-0025 §3.5 step 5), refusing the install by name before it writes anything. `runtime` — the ADR-0025 §3.6 trust tier, the other security-shaped key, read locally only by two CLI lines that ECHO the value with no `runtime === 'sandbox'` branch anywhere, while `loading`'s own tombstone used to redirect upgrading authors to it as something "which [is] enforced" — is the family's SPLIT verdict and the ledger's first `live-elsewhere` row (#13483): #12400 measured the cloud leg (cloud @15f55df, 2026-08-29) and found a real consumer — the marketplace publish gate hard-rejects (HTTP 422) an unverified publisher requesting the `node` tier — so the maintainer ruling of 2026-08-30 (executed by commit a9ee98992) took "say it truthfully" over retirement, and retirement is ruled OUT because deleting the key would tear out that gate's input. The tombstone, both `runtime` describes and the D3 entry state the split (publish-gate enforced; load-side NOT implemented); the row's verdict now says it as data rather than as a qualifying note, under the elsewhere criteria (foreign pointer + cross-repo scope + attestation with a 180d expiry), and load-side enforcement is a v18 direction rather than a removal. The `contributes` dispositions have since MOVED: the cloud leg was measured CLEAN 2026-08-24 (recorded in commit be21955ba; cloud `origin/main` @ 5b5925a, zero `manifest.contributes` reads, controls held), and commit be21955ba then executed the retirement — the nine mechanically-dead members are `retiredKey()` tombstones (D3 `plugin-manifest-contributes-dead-members-retired`), their rows staying because a tombstone keeps the key in the walked shape. Commit bc56e1881 then executed the second retirement too (ruled B 2026-08-22; D3 `plugin-manifest-contributes-routes-retired`), tombstoning `routes` and leaving `kinds` the block's sole live member. The remaining non-`contributes` `dead` rows keep their three-repo census verdicts as an enforce-or-remove worklist, not a licence to delete. `permissions` stays `live` on corrected evidence after its legacy flat-list arm was retired (ADR-0049, conversion `manifest-permissions-string-list-removed`): the block is the structured ADR-0025 one alone, its measured consumer is objectui's install-consent disclosure (it refuses nothing at load), and the `collectDeclaredSuggestions` evidence it used to cite was the assembled-stage `PermissionSet[]` reader, which only reports an authoring-stage value as skipped | | crud_endpoints | seeded 2026-09-02 (commit a3d5724c8) — one of the FOUR `RestServerConfig` sub-objects, and the family that made the `SPEC_ONLY_SCHEMAS` boundary explicit: SERVER CONFIGURATION. An author writes `RestServerConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) as the REST server's construction argument — not a metadata item, not a request body, not a manifest — so no registry has ever held it and no ratchet rooted in one could ask who reads it. Rooted on the four sub-schemas rather than on the whole config on purpose: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with `RestServerConfigSchema` as the root the sub-objects would BE the drilled level and `metadata.endpoints.schema` / `batch.operations.upsertMany` would have no row of their own, their container's blanket `live` silently covering a dead key — #4956's shape, in the ledger written to end it. `RestApiConfigSchema` (the fifth sub-object, `api`) is now enrolled SEPARATELY as `rest_api` (#14640, 2026-09-21): when these four landed its consumption seam was still validate-only, so a census of it would have recorded a half that was about to move — that half has moved (`normalizeConfig` builds the `api` block from its parsed output, released), so the fence expired and the fifth sub-object was measured on the settled seam. ⛔ Its ledger is `rest_api.json`, never `api.json`, which is a different surface entirely. **What #11984 settled and what it did not**: that PR made `RestServer.normalizeConfig` PARSE and CONSUME these four instead of casting them, so an out-of-enum or out-of-range value is now refused at construction — accept/reject. Executing a declared contract does not give a key a consumer, and this family is that distinction's worked example. Dead 5 = `objectParamStyle` (every CRUD route takes the object name as a PATH segment; `'query'` is validated against the enum and mounts exactly what `'path'` mounts) and the four members of `patterns` (custom URL patterns are normalized and never read — every route is mounted from hard-coded method/path pairs in `registerCrudEndpoints`). Live 6 = the five `operations.*` switches, each gating a route mount, and `dataPrefix`, which has five independent consumers and moves the mounted paths and the advertised discovery document together. A different question was open about `patterns` (its `z.record` input type demands all five operations) — a declaration defect, not a liveness one, not re-derived here **Commit b3a63d32c RETIRED the dead set (2026-09-03, ADR-0049 enforce-or-remove)**: `patterns` and `objectParamStyle` are `retiredKey()` tombstones (the schema is a non-strict `z.object`, so the rows STAY `dead` with a REMOVED note — the rls.priority precedent) and the four `patterns.*` child rows collapse into the one `patterns` row because the value def `CrudEndpointPattern` left the shape with its key (RETIRED_DEFS_BY_MAJOR[18]). REMOVE, not ENFORCE, because the mounted CRUD paths are the contract the client SDK, the discovery document and /openapi.json all describe — a per-operation pattern knob could only make them lie — and an endpoint on a custom path is a declarative `api` endpoint. `evidenceScope` widened to `cross-repo` on the cloud reading (#14796 @9b6abe0f2fd5: zero, structural). The `z.partialRecord` question closes with the record | | metadata_endpoints | seeded 2026-09-02 (commit a3d5724c8) — one of the FOUR `RestServerConfig` sub-objects, and the family that made the `SPEC_ONLY_SCHEMAS` boundary explicit: SERVER CONFIGURATION. An author writes `RestServerConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) as the REST server's construction argument — not a metadata item, not a request body, not a manifest — so no registry has ever held it and no ratchet rooted in one could ask who reads it. Rooted on the four sub-schemas rather than on the whole config on purpose: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with `RestServerConfigSchema` as the root the sub-objects would BE the drilled level and `metadata.endpoints.schema` / `batch.operations.upsertMany` would have no row of their own, their container's blanket `live` silently covering a dead key — #4956's shape, in the ledger written to end it. `RestApiConfigSchema` (the fifth sub-object, `api`) is now enrolled SEPARATELY as `rest_api` (#14640, 2026-09-21): when these four landed its consumption seam was still validate-only, so a census of it would have recorded a half that was about to move — that half has moved (`normalizeConfig` builds the `api` block from its parsed output, released), so the fence expired and the fifth sub-object was measured on the settled seam. ⛔ Its ledger is `rest_api.json`, never `api.json`, which is a different surface entirely. **What #11984 settled and what it did not**: that PR made `RestServer.normalizeConfig` PARSE and CONSUME these four instead of casting them, so an out-of-enum or out-of-range value is now refused at construction — accept/reject. Executing a declared contract does not give a key a consumer, and this family is that distinction's worked example. Dead 2 = `cacheTtl` and `endpoints.schema`. `enableCache` is live and `cacheTtl` is not, which is the pair worth reading together: the cached branch delegates to the protocol's `getMetaItemCached`, whose signature takes no TTL, and no cache header anywhere is built from this value. Its negative-bound observation travels in that row by triage ruling rather than as a separate defect — the schema declares `z.number().int()` with no lower bound, so `-1` is accepted, and #11984 pins it as accepted because that is what the contract says. `endpoints.schema` is the sharpest case in the family for per-key rows: its three siblings each gate a route mount and it gates nothing, because `GET /meta/:type/:name/schema` does not exist — `packages/rest/src` mounts no path ending in `/schema` at all **Commit b3a63d32c RETIRED both (2026-09-03, ADR-0049)**: `cacheTtl` and `endpoints.schema` are `retiredKey()` tombstones, rows kept `dead` with a REMOVED note. The negative-bound observation dies with `cacheTtl` (its #11984 acceptance pin is reversed to a refusal pin); `endpoints.schema` had no route to gate, so there was nothing to enforce. `evidenceScope` widened to `cross-repo` (#14796) | | batch_endpoints | seeded 2026-09-02 (commit a3d5724c8) — one of the FOUR `RestServerConfig` sub-objects, and the family that made the `SPEC_ONLY_SCHEMAS` boundary explicit: SERVER CONFIGURATION. An author writes `RestServerConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) as the REST server's construction argument — not a metadata item, not a request body, not a manifest — so no registry has ever held it and no ratchet rooted in one could ask who reads it. Rooted on the four sub-schemas rather than on the whole config on purpose: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with `RestServerConfigSchema` as the root the sub-objects would BE the drilled level and `metadata.endpoints.schema` / `batch.operations.upsertMany` would have no row of their own, their container's blanket `live` silently covering a dead key — #4956's shape, in the ledger written to end it. `RestApiConfigSchema` (the fifth sub-object, `api`) is now enrolled SEPARATELY as `rest_api` (#14640, 2026-09-21): when these four landed its consumption seam was still validate-only, so a census of it would have recorded a half that was about to move — that half has moved (`normalizeConfig` builds the `api` block from its parsed output, released), so the fence expired and the fifth sub-object was measured on the settled seam. ⛔ Its ledger is `rest_api.json`, never `api.json`, which is a different surface entirely. **What #11984 settled and what it did not**: that PR made `RestServer.normalizeConfig` PARSE and CONSUME these four instead of casting them, so an out-of-enum or out-of-range value is now refused at construction — accept/reject. Executing a declared contract does not give a key a consumer, and this family is that distinction's worked example. Dead 2 = `operations.upsertMany` and `defaultAtomic`. `upsertMany` is `endpoints.schema`'s twin — a switch declared for a route that was never built (`this.protocol` carries `createManyData` / `updateManyData` / `deleteManyData` and no upsert counterpart), so `false` disables nothing. `defaultAtomic` promises a transaction default that no batch handler consults. Live 5 = `maxBatchSize` (load-bearing since #11984 gave it a real parse — before that a configured `0` was the live cap, because `0` is not nullish), `enableBatchEndpoint`, and the three `operations.*` switches that do gate a mount **Commit b3a63d32c RETIRED both (2026-09-03, ADR-0049)**: `operations.upsertMany` and `defaultAtomic` are `retiredKey()` tombstones, rows kept `dead` with a REMOVED note. `defaultAtomic` is the family's worked enforce-or-remove call: the per-request `options.atomic` (ADR-0119 D4, opt-in) IS the contract, and a server default that flipped it silently is the move that ADR refused, so the key was removed rather than wired; upsert lives on as an operation type of the generic batch endpoint. `evidenceScope` widened to `cross-repo` (#14796) | diff --git a/packages/spec/liveness/manifest.json b/packages/spec/liveness/manifest.json index 49a66bc0b3c..77bdc0555b9 100644 --- a/packages/spec/liveness/manifest.json +++ b/packages/spec/liveness/manifest.json @@ -303,10 +303,11 @@ "note": "DECLARED at the strictness close of `ManifestSchema` (2026-09-02), not new behaviour: the build door has read this key off the raw `objectstack.plugin.json` since the plugin-distribution CLI landed, while the schema never declared it — so the key was honoured undeclared, and a naked `strictObject` would have refused every plugin that names its entry (three CLI fixtures author it: `osplugin.test.ts`, `plugin-sign.test.ts`, `plugin-publish.test.ts`). Live on the entry choice, not on a print: the value decides which module is bundled into `dist/index.mjs`. `os plugin publish` does not read it back (it reads `id` / `version` / `name` / `runtime` / `integrity` / `description`); cloud unmeasured (see `_note`)." }, "integrity": { - "status": "dead", - "verifiedAt": "2026-08-30", - "evidenceScope": "cross-repo", - "note": "Per-file content digests of the packaged artifact. Status left `dead` per the #13464 mandate: this row is the enforce-or-remove worklist entry for the RULED leg — re-verification at install/load-time unpack (ADR-0025 §3.5 step 5) — which still has zero implementation in this repo and is owned by the future runtime loader (ADR-0025 §3.5 steps 4–7), NOT by the cloud control plane, and still unbuilt (cloud leg unmeasured, see `_note`); #13464 does NOT discharge it. What #13464 DID land, correcting this note's earlier census: the map is computed at build (packages/cli/src/commands/plugin/build.ts#PluginBuild via computeIntegrity) and is now read once in-repo — the `os plugin publish` preflight self-checks the artifact bytes against the manifest's own declared digests and refuses the publish on digest mismatch, missing declared file, or extra undeclared file (packages/cli/src/commands/plugin/publish.ts#PluginPublish, calling packages/core/src/security/plugin-artifact-integrity.ts#verifyIntegrity; absent map = permissive pass — the field is `.optional()`). So the false-compliance shape is narrowed, not closed: a publisher can no longer upload an artifact whose bytes contradict its own map, but nothing at unpack re-checks what a marketplace consumer actually installs. Adjacent machinery that DOES exist and is not this: packages/core/src/security/plugin-artifact-signature.ts verifies an artifact SIGNATURE (and returns `verified=false` rather than throwing when absent) — a different mechanism on a different field. If ledger semantics require the new non-test reader to flip this row to `live`, that flip belongs to whatever builds that load-time re-verification, not to a rider here." + "status": "live", + "evidence": "packages/cli/src/commands/plugin/install.ts#PluginInstall (re-verification at unpack — `verifyIntegrity(files, manifest.integrity, …)` over the archive's entries before anything is written; a digest mismatch, a declared file that is missing or a packaged file the map does not name ⇒ `integrity_mismatch`, a map that is not an object ⇒ `integrity_malformed`, exit 1, nothing installed); packages/cli/src/commands/plugin/publish.ts#PluginPublish (the publish preflight self-check against the manifest's own declared digests, refusing the publish on the same three violations)", + "verifiedAt": "2026-10-28", + "evidenceScope": "in-repo", + "note": "Per-file content digests of the packaged artifact, computed at build (packages/cli/src/commands/plugin/build.ts#PluginBuild via computeIntegrity). 2026-10-28 (#27123): FLIPPED dead → live. The row had stood `dead` per the #13464 mandate as the enforce-or-remove worklist entry for the RULED leg, re-verification at install-time unpack (ADR-0025 §3.5 step 5), and named the flip as belonging to whatever built that leg; `os plugin install` is it, re-hashing every unpacked entry against the map and refusing the install by name before it writes anything. Both readers live in packages/core/src/security/plugin-artifact-integrity.ts#verifyIntegrity. An absent map is still a permissive pass (the field is `.optional()`). Not yet built, and not this key's reader: the boot-time loader (ADR-0025 §3.5 M1's next slice), which is to re-verify the kept original artifact against its detached signature — a different mechanism on a different field (packages/core/src/security/plugin-artifact-signature.ts)." } } } diff --git a/packages/spec/liveness/state-counts/manifest.md b/packages/spec/liveness/state-counts/manifest.md index 0535ecaaaeb..2831343c0a4 100644 --- a/packages/spec/liveness/state-counts/manifest.md +++ b/packages/spec/liveness/state-counts/manifest.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `manifest` | 27 | 0 | 1 | 14 | 0 | 42 | +| `manifest` | 28 | 0 | 1 | 13 | 0 | 42 | diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index 81d2d8e9ed3..8ce1a643040 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -242,18 +242,13 @@ export type PluginPackaging = z.input; * not declare (#13464). An absent map is a permissive pass — the field is * `.optional()`. * - * NOT RE-VERIFIED AT UNPACK. That leg is **not implemented** — nothing in - * this repo unpacks an installed artifact at all: there is no - * `os plugin install`, and the archive reader's only production caller is the - * publish preflight reading back its own output. Re-verification is owned by - * the **future runtime loader** — ADR-0025 §3.5 steps 4–7 (download + verify, - * materialize, register, load + activate), the install flow that ADR's own - * status line records as unimplemented. It is NOT the cloud control plane's - * obligation; the control plane stores the artifact blob opaquely. - * - * So a present `integrity` map is evidence that the publisher checked its own - * bytes, never that anything re-checked them after publish. The enforce leg - * is unbuilt, and belongs to that future runtime loader. + * RE-VERIFIED AT UNPACK BY `os plugin install`. The local install loop + * (ADR-0025 §3.5 M1) re-hashes the unpacked files against the map before it + * materializes anything, and refuses the install by name on the same three + * findings; an absent map is the same permissive pass there. This check sits + * beside the install's signature check over the whole artifact, and neither + * replaces the other. The cloud control plane stores the artifact blob + * opaquely and checks nothing against the map. */ export const PluginIntegritySchema = z .record(z.string(), z.string()) @@ -1060,10 +1055,9 @@ export const ManifestSchema = strictObject({ /** * Per-file content digests of the packaged artifact (ADR-0025 §3.2). - * Computed at build and self-checked at the `os plugin publish` preflight - * (#13464); unpack-time re-verification is NOT implemented and is owned by - * the future runtime loader (ADR-0025 §3.5 steps 4–7), not by the cloud - * control plane. The enforce leg is unbuilt. + * Computed at build, self-checked at the `os plugin publish` preflight + * (#13464), and re-verified at unpack by `os plugin install` (ADR-0025 §3.5 + * M1). The cloud control plane stores the blob opaquely. * See {@link PluginIntegritySchema}. */ integrity: PluginIntegritySchema.optional() diff --git a/packages/spec/src/system/constants/platform-object-names.ts b/packages/spec/src/system/constants/platform-object-names.ts index eb1ab60ebf5..54d525021f9 100644 --- a/packages/spec/src/system/constants/platform-object-names.ts +++ b/packages/spec/src/system/constants/platform-object-names.ts @@ -89,6 +89,7 @@ export const PLATFORM_OBJECTS_BY_PACKAGE: Readonly