From 9002f613e243ed7fb297b03545339a19922bc114 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Thu, 10 Sep 2026 20:20:00 +0200 Subject: [PATCH 1/7] feat(registry): add signed plugin publisher --- .github/workflows/ci.yml | 5 + .gitignore | 2 + deployment-pulse-plugin/registry.json | 13 + scripts/publish-plugin.test.ts | 566 +++++++++++ scripts/publish-plugin.ts | 1250 +++++++++++++++++++++++++ scripts/tsconfig.json | 10 + 6 files changed, 1846 insertions(+) create mode 100644 deployment-pulse-plugin/registry.json create mode 100644 scripts/publish-plugin.test.ts create mode 100644 scripts/publish-plugin.ts create mode 100644 scripts/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 625d56f..3f3f331 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,11 @@ jobs: working-directory: deployment-pulse-plugin run: bun install --frozen-lockfile + - name: Test registry publisher + run: | + bun test scripts/publish-plugin.test.ts + deployment-pulse-plugin/node_modules/.bin/tsc --project scripts/tsconfig.json + - name: Test and build Deployment Pulse working-directory: deployment-pulse-plugin run: | diff --git a/.gitignore b/.gitignore index f22bd49..421c996 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,8 @@ dist/ .env .env.* !.env.example +.registry-secrets/ +catalog-*.private.pem # Logs *.log diff --git a/deployment-pulse-plugin/registry.json b/deployment-pulse-plugin/registry.json new file mode 100644 index 0000000..3c0da6e --- /dev/null +++ b/deployment-pulse-plugin/registry.json @@ -0,0 +1,13 @@ +{ + "name": "deployment-pulse", + "binary": "temps-deployment-pulse-plugin", + "title": "Deployment Pulse", + "summary": "Monitor deployment health across every project.", + "description": "A searchable, cross-project deployment health dashboard with failures and active deployments shown first.", + "author": "Temps", + "category": "Observability", + "keywords": ["deployments", "health", "projects"], + "repository": "https://github.com/gotempsh/plugins/tree/main/deployment-pulse-plugin", + "docs_url": "https://github.com/gotempsh/plugins/blob/main/deployment-pulse-plugin/README.md", + "logo_url": null +} diff --git a/scripts/publish-plugin.test.ts b/scripts/publish-plugin.test.ts new file mode 100644 index 0000000..c45aad0 --- /dev/null +++ b/scripts/publish-plugin.test.ts @@ -0,0 +1,566 @@ +// SPDX-FileCopyrightText: 2024-2026 Temps Contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +import { afterEach, describe, expect, test } from "bun:test"; +import { + createPublicKey, + generateKeyPairSync, + sign, + verify, + type KeyObject, +} from "node:crypto"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { publishPlugin, type PublishOptions } from "./publish-plugin"; + +const KEYSET_DOMAIN = Buffer.from("temps-plugin-keyset-v1\0"); +const CATALOG_DOMAIN = Buffer.from("temps-plugin-catalog-v1\0"); +const SPKI_PREFIX_BYTES = 12; +const PLATFORMS = [ + "x86_64-linux", + "aarch64-linux", + "x86_64-darwin", + "aarch64-darwin", +]; +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { force: true, recursive: true }); + } +}); + +function rawPublicKey(key: KeyObject): string { + const publicKey = key.type === "public" ? key : createPublicKey(key); + const der = Buffer.from(publicKey.export({ format: "der", type: "spki" })); + return der.subarray(SPKI_PREFIX_BYTES).toString("hex"); +} + +function envelope( + domain: Buffer, + document: unknown, + keyId: string, + privateKey: KeyObject, +) { + const payload = Buffer.from(JSON.stringify(document)); + return { + key_id: keyId, + payload: payload.toString("base64"), + signature: sign( + null, + Buffer.concat([domain, payload]), + privateKey, + ).toString("base64"), + }; +} + +function rootSignedKeyset( + document: unknown, + roots: Array<{ privateKey: KeyObject }>, +) { + const payload = Buffer.from(JSON.stringify(document)); + const message = Buffer.concat([KEYSET_DOMAIN, payload]); + return { + payload: payload.toString("base64"), + signatures: roots.map(({ privateKey }, index) => ({ + key_id: `test-root-${index + 1}`, + signature: sign(null, message, privateKey).toString("base64"), + })), + }; +} + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "temps-registry-publisher-")); + temporaryDirectories.push(root); + const registryDir = join(root, "registry"); + const dataDir = join(registryDir, "src", "registry-data"); + const artifactsDir = join(root, "release"); + mkdirSync(dataDir, { recursive: true }); + mkdirSync(join(registryDir, "public"), { recursive: true }); + mkdirSync(artifactsDir, { recursive: true }); + + const roots = [ + generateKeyPairSync("ed25519"), + generateKeyPairSync("ed25519"), + ]; + const catalog = generateKeyPairSync("ed25519"); + const now = new Date("2026-09-10T12:00:00.000Z"); + const keysetDocument = { + schema_version: 1, + audience: "registry.temps.sh/plugins", + generation: 1, + issued_at: "2026-09-10T11:59:00.000Z", + expires_at: "2026-09-16T12:00:00.000Z", + keys: [ + { + key_id: "catalog-test-1", + algorithm: "ed25519", + public_key: rawPublicKey(catalog.publicKey), + not_before: "2026-09-09T12:00:00.000Z", + not_after: "2027-09-10T12:00:00.000Z", + status: "active", + }, + ], + }; + const keysetEnvelope = rootSignedKeyset(keysetDocument, roots); + writeFileSync(join(dataDir, "keyset.json"), JSON.stringify(keysetEnvelope)); + const catalogEnvelope = envelope( + CATALOG_DOMAIN, + { + schema_version: 1, + revision: 1, + issued_at: "2026-09-10T11:59:00.000Z", + expires_at: "2026-10-09T11:59:00.000Z", + plugins: [], + }, + "catalog-test-1", + catalog.privateKey, + ); + writeFileSync(join(dataDir, "catalog.json"), JSON.stringify(catalogEnvelope)); + + const signingKeyFile = join(root, "catalog.pem"); + writeFileSync( + signingKeyFile, + catalog.privateKey.export({ format: "pem", type: "pkcs8" }), + { mode: 0o600 }, + ); + chmodSync(signingKeyFile, 0o600); + const manifestPath = join(root, "registry.json"); + writeFileSync( + manifestPath, + JSON.stringify({ + name: "deployment-pulse", + binary: "temps-deployment-pulse-plugin", + title: "Deployment Pulse", + summary: "Monitor deployments.", + description: "Monitor deployment health across projects.", + author: "Temps", + category: "Observability", + keywords: ["deployments"], + repository: "https://example.com/plugins/deployment-pulse", + docs_url: null, + logo_url: "/plugin-logos/deployment-pulse.svg", + }), + ); + for (const platform of PLATFORMS) { + writeFileSync( + join(artifactsDir, `temps-deployment-pulse-plugin-${platform}`), + `standalone plugin for ${platform}`, + ); + } + const rootKeys = new Map( + roots.map(({ publicKey }, index) => [ + `test-root-${index + 1}`, + rawPublicKey(publicKey), + ]), + ); + const options: PublishOptions = { + manifestPath, + version: "0.1.0", + artifactsDir, + registryDir, + signingKeyFile, + keyId: "catalog-test-1", + now, + rootKeys, + liveState: { keyset: keysetEnvelope, catalog: catalogEnvelope }, + }; + return { + options, + catalog, + registryDir, + artifactsDir, + dataDir, + keysetEnvelope, + keysetDocument, + roots, + }; +} + +describe("single-plugin registry publisher", () => { + test("copies four immutable artifacts and atomically signs the next catalogue", async () => { + const { options, catalog, registryDir, dataDir } = fixture(); + const result = await publishPlugin(options); + + expect(result).toMatchObject({ + plugin: "deployment-pulse", + version: "0.1.0", + revision: 2, + dryRun: false, + }); + expect(Object.keys(result.artifacts)).toEqual([ + "linux-amd64", + "linux-arm64", + "darwin-amd64", + "darwin-arm64", + ]); + for (const platform of Object.keys(result.artifacts)) { + expect( + readFileSync( + join( + registryDir, + "public", + "artifacts", + "deployment-pulse", + "0.1.0", + platform, + "plugin", + ), + "utf8", + ), + ).toContain("standalone plugin"); + } + + const published = JSON.parse( + readFileSync(join(dataDir, "catalog.json"), "utf8"), + ); + const payload = Buffer.from(published.payload, "base64"); + expect( + verify( + null, + Buffer.concat([CATALOG_DOMAIN, payload]), + catalog.publicKey, + Buffer.from(published.signature, "base64"), + ), + ).toBe(true); + const document = JSON.parse(payload.toString("utf8")); + expect(document.revision).toBe(2); + expect(document.plugins[0]).toMatchObject({ + name: "deployment-pulse", + version: "0.1.0", + platforms: { + "linux-amd64": { + url: "https://registry.temps.sh/artifacts/deployment-pulse/0.1.0/linux-amd64/plugin", + }, + }, + }); + }); + + test("rejects a signing key that is not authorized by the root-signed keyset", async () => { + const { options, dataDir } = fixture(); + const before = readFileSync(join(dataDir, "catalog.json"), "utf8"); + const unrelated = generateKeyPairSync("ed25519"); + writeFileSync( + options.signingKeyFile, + unrelated.privateKey.export({ format: "pem", type: "pkcs8" }), + { mode: 0o600 }, + ); + + await expect(publishPlugin(options)).rejects.toThrow( + "does not match keyset entry catalog-test-1", + ); + expect(readFileSync(join(dataDir, "catalog.json"), "utf8")).toBe(before); + }); + + test("refuses to mutate an already published version", async () => { + const { options, artifactsDir, dataDir } = fixture(); + await publishPlugin(options); + const before = readFileSync(join(dataDir, "catalog.json"), "utf8"); + options.liveState = { + keyset: options.liveState!.keyset, + catalog: JSON.parse(before), + }; + writeFileSync( + join(artifactsDir, "temps-deployment-pulse-plugin-x86_64-linux"), + "tampered replacement", + ); + + await expect(publishPlugin(options)).rejects.toThrow( + "refusing to change immutable release deployment-pulse 0.1.0", + ); + expect(readFileSync(join(dataDir, "catalog.json"), "utf8")).toBe(before); + }); + + test("dry-run validates and signs in memory without changing registry files", async () => { + const { options, registryDir, dataDir } = fixture(); + const before = readFileSync(join(dataDir, "catalog.json"), "utf8"); + + const result = await publishPlugin({ ...options, dryRun: true }); + + expect(result).toMatchObject({ revision: 2, dryRun: true }); + expect(readFileSync(join(dataDir, "catalog.json"), "utf8")).toBe(before); + expect( + existsSync( + join( + registryDir, + "public", + "artifacts", + "deployment-pulse", + "0.1.0", + "linux-amd64", + "plugin", + ), + ), + ).toBe(false); + }); + + test("an idempotent publish restores a missing immutable artifact", async () => { + const { options, registryDir, dataDir } = fixture(); + await publishPlugin(options); + const catalogBefore = readFileSync(join(dataDir, "catalog.json"), "utf8"); + const artifact = join( + registryDir, + "public", + "artifacts", + "deployment-pulse", + "0.1.0", + "linux-amd64", + "plugin", + ); + unlinkSync(artifact); + options.liveState = { + keyset: options.liveState!.keyset, + catalog: JSON.parse(catalogBefore), + }; + + const result = await publishPlugin(options); + + expect(result.revision).toBe(2); + expect(readFileSync(artifact, "utf8")).toContain("standalone plugin"); + expect(readFileSync(join(dataDir, "catalog.json"), "utf8")).toBe( + catalogBefore, + ); + }); + + test("rejects an existing catalogue signed by a verify-only key", async () => { + const { options, dataDir, keysetDocument, roots } = fixture(); + keysetDocument.keys[0].status = "verify_only"; + const replacement = generateKeyPairSync("ed25519"); + keysetDocument.keys.push({ + key_id: "catalog-test-2", + algorithm: "ed25519", + public_key: rawPublicKey(replacement.publicKey), + not_before: "2026-09-09T12:00:00.000Z", + not_after: "2027-09-10T12:00:00.000Z", + status: "active", + }); + const retiredKeyset = rootSignedKeyset(keysetDocument, roots); + writeFileSync(join(dataDir, "keyset.json"), JSON.stringify(retiredKeyset)); + options.liveState = { + keyset: retiredKeyset, + catalog: options.liveState!.catalog, + }; + + await expect(publishPlugin(options)).rejects.toThrow( + "existing catalogue key catalog-test-1 is not active", + ); + }); + + test("rejects an existing catalogue issued outside its key window", async () => { + const { options, catalog, dataDir } = fixture(); + const outOfWindow = envelope( + CATALOG_DOMAIN, + { + schema_version: 1, + revision: 1, + issued_at: "2026-09-08T12:00:00.000Z", + expires_at: "2026-10-07T12:00:00.000Z", + plugins: [], + }, + "catalog-test-1", + catalog.privateKey, + ); + writeFileSync(join(dataDir, "catalog.json"), JSON.stringify(outOfWindow)); + options.liveState = { + keyset: options.liveState!.keyset, + catalog: outOfWindow, + }; + + await expect(publishPlugin(options)).rejects.toThrow( + "existing catalogue issuance is outside key catalog-test-1's validity window", + ); + }); + + test("rejects stale live state without changing the local catalogue", async () => { + const { options, dataDir } = fixture(); + await publishPlugin(options); + const before = readFileSync(join(dataDir, "catalog.json"), "utf8"); + options.version = "0.2.0"; + + await expect(publishPlugin(options)).rejects.toThrow( + "local catalogue does not exactly match the live verified registry catalogue", + ); + expect(readFileSync(join(dataDir, "catalog.json"), "utf8")).toBe(before); + }); + + test("rejects a signing key stored inside the registry checkout", async () => { + const { options, registryDir, dataDir } = fixture(); + const before = readFileSync(join(dataDir, "catalog.json"), "utf8"); + const exposedKey = join(registryDir, "catalog-private.pem"); + writeFileSync(exposedKey, readFileSync(options.signingKeyFile), { + mode: 0o600, + }); + chmodSync(exposedKey, 0o600); + options.signingKeyFile = exposedKey; + + await expect(publishPlugin(options)).rejects.toThrow( + "catalogue signing key must be stored outside the plugins and registry checkouts", + ); + expect(readFileSync(join(dataDir, "catalog.json"), "utf8")).toBe(before); + }); + + test("rejects a root-signed keyset issued too far in the future", async () => { + const { options, dataDir, keysetDocument, roots } = fixture(); + keysetDocument.issued_at = "2026-09-10T12:06:00.000Z"; + keysetDocument.expires_at = "2026-09-16T12:06:00.000Z"; + const futureKeyset = rootSignedKeyset(keysetDocument, roots); + writeFileSync(join(dataDir, "keyset.json"), JSON.stringify(futureKeyset)); + options.liveState = { + keyset: futureKeyset, + catalog: options.liveState!.catalog, + }; + + await expect(publishPlugin(options)).rejects.toThrow( + "registry keyset issued_at is more than five minutes in the future", + ); + }); + + test("uses ASCII SemVer ordering for prerelease identifiers", async () => { + const { options, dataDir } = fixture(); + options.version = "1.0.0-a"; + await publishPlugin(options); + const before = readFileSync(join(dataDir, "catalog.json"), "utf8"); + options.liveState = { + keyset: options.liveState!.keyset, + catalog: JSON.parse(before), + }; + options.version = "1.0.0-A"; + + await expect(publishPlugin(options)).rejects.toThrow( + "refusing to replace deployment-pulse 1.0.0-a with older 1.0.0-A", + ); + expect(readFileSync(join(dataDir, "catalog.json"), "utf8")).toBe(before); + }); + + test("compares large numeric SemVer identifiers without precision loss", async () => { + const { options, dataDir } = fixture(); + options.version = "1.0.0-9007199254740993"; + await publishPlugin(options); + const before = readFileSync(join(dataDir, "catalog.json"), "utf8"); + options.liveState = { + keyset: options.liveState!.keyset, + catalog: JSON.parse(before), + }; + options.version = "1.0.0-9007199254740992"; + + await expect(publishPlugin(options)).rejects.toThrow( + "refusing to replace deployment-pulse 1.0.0-9007199254740993 with older 1.0.0-9007199254740992", + ); + expect(readFileSync(join(dataDir, "catalog.json"), "utf8")).toBe(before); + }); + + test("rejects a symlinked release artifact before staging it", async () => { + const { options, artifactsDir, dataDir } = fixture(); + const before = readFileSync(join(dataDir, "catalog.json"), "utf8"); + const artifact = join( + artifactsDir, + "temps-deployment-pulse-plugin-x86_64-linux", + ); + unlinkSync(artifact); + symlinkSync(options.signingKeyFile, artifact); + + await expect(publishPlugin(options)).rejects.toThrow( + "release artifact for linux-amd64 must be a regular file", + ); + expect(readFileSync(join(dataDir, "catalog.json"), "utf8")).toBe(before); + }); + + test("caps chunked live responses while streaming", async () => { + const { options, dataDir } = fixture(); + const before = readFileSync(join(dataDir, "catalog.json"), "utf8"); + options.liveState = undefined; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(64 * 1024 + 1)); + controller.close(); + }, + }), + { status: 200 }, + )) as unknown as typeof fetch; + + try { + await expect(publishPlugin(options)).rejects.toThrow( + "live registry keyset exceeds the 65536-byte limit", + ); + expect(readFileSync(join(dataDir, "catalog.json"), "utf8")).toBe(before); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("rejects a signed catalogue envelope larger than the client response cap", async () => { + const { options, catalog, dataDir } = fixture(); + const makeDocument = (fillerLength: number) => ({ + schema_version: 1, + revision: 1, + issued_at: "2026-09-10T11:59:00.000Z", + expires_at: "2026-10-09T11:59:00.000Z", + plugins: [ + { + name: "existing-plugin", + version: "1.0.0", + title: "Existing plugin", + summary: "x".repeat(fillerLength), + description: "Existing signed catalogue entry.", + author: "Temps", + category: "Utilities", + keywords: [], + repository: null, + docs_url: null, + logo_url: null, + platforms: {}, + }, + ], + }); + const serializedSize = (fillerLength: number) => { + const candidate = envelope( + CATALOG_DOMAIN, + makeDocument(fillerLength), + "catalog-test-1", + catalog.privateKey, + ); + return Buffer.byteLength(`${JSON.stringify(candidate)}\n`); + }; + let low = 0; + let high = 1024 * 1024; + while (low + 1 < high) { + const middle = Math.floor((low + high) / 2); + if (serializedSize(middle) <= 1024 * 1024) low = middle; + else high = middle; + } + const currentEnvelope = envelope( + CATALOG_DOMAIN, + makeDocument(low), + "catalog-test-1", + catalog.privateKey, + ); + writeFileSync( + join(dataDir, "catalog.json"), + `${JSON.stringify(currentEnvelope)}\n`, + ); + options.liveState = { + keyset: options.liveState!.keyset, + catalog: currentEnvelope, + }; + const before = readFileSync(join(dataDir, "catalog.json"), "utf8"); + + await expect(publishPlugin(options)).rejects.toThrow( + "signed catalogue exceeds the 1048576-byte registry response limit", + ); + expect(readFileSync(join(dataDir, "catalog.json"), "utf8")).toBe(before); + }); +}); diff --git a/scripts/publish-plugin.ts b/scripts/publish-plugin.ts new file mode 100644 index 0000000..2b706e9 --- /dev/null +++ b/scripts/publish-plugin.ts @@ -0,0 +1,1250 @@ +// SPDX-FileCopyrightText: 2024-2026 Temps Contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +import { + closeSync, + chmodSync, + constants, + copyFileSync, + existsSync, + fchmodSync, + fstatSync, + fsyncSync, + lstatSync, + linkSync, + mkdirSync, + mkdtempSync, + openSync, + readSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + unlinkSync, + writeSync, + writeFileSync, +} from "node:fs"; +import { + createHash, + createPrivateKey, + createPublicKey, + randomBytes, + sign, + verify, + type KeyObject, +} from "node:crypto"; +import { dirname, join, resolve, sep } from "node:path"; +import { tmpdir } from "node:os"; + +const KEYSET_SIGNATURE_DOMAIN = Buffer.from("temps-plugin-keyset-v1\0"); +const CATALOG_SIGNATURE_DOMAIN = Buffer.from("temps-plugin-catalog-v1\0"); +const KEYSET_AUDIENCE = "registry.temps.sh/plugins"; +const REGISTRY_ORIGIN = "https://registry.temps.sh"; +const LIVE_KEYSET_URL = `${REGISTRY_ORIGIN}/api/plugins/keys`; +const LIVE_CATALOG_URL = `${REGISTRY_ORIGIN}/api/plugins`; +const MAX_BINARY_BYTES = 256 * 1024 * 1024; +const MAX_CATALOG_BYTES = 1024 * 1024; +const MAX_KEYSET_BYTES = 64 * 1024; +const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000; +const MAX_KEYSET_VALIDITY_MS = 7 * 24 * 60 * 60 * 1000; +const MAX_CATALOG_KEY_VALIDITY_MS = 400 * 24 * 60 * 60 * 1000; +const MAX_CATALOG_VALIDITY_MS = 30 * 24 * 60 * 60 * 1000; +const MAX_CATALOG_KEYS = 16; +const ROOT_THRESHOLD = 2; +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +const OFFICIAL_ROOT_KEYS = new Map([ + [ + "root-2026-1", + "da1494970e3241d5c2f4fc7947d5318faa7b6cd8940cc41a0fa620dfcbe3454c", + ], + [ + "root-2026-2", + "3f2e40fd1b75bce1bdd662d625948ab20a690f4e71ae81c4cc21a5cb8415a36f", + ], + [ + "root-2026-3", + "d6c06311af2737cc509629d8a15b78897ba10d5662539ebf82afa7b77a3b855b", + ], +]); + +const PLATFORM_ARTIFACTS = [ + ["linux-amd64", "x86_64-linux"], + ["linux-arm64", "aarch64-linux"], + ["darwin-amd64", "x86_64-darwin"], + ["darwin-arm64", "aarch64-darwin"], +] as const; + +type JsonRecord = Record; + +export type PluginPublishManifest = { + name: string; + binary: string; + title: string; + summary: string; + description: string; + author: string; + category: string; + keywords?: string[]; + repository?: string | null; + docs_url?: string | null; + logo_url?: string | null; +}; + +type PlatformRelease = { url: string; sha256: string }; + +type RegistryPlugin = Omit & { + version: string; + platforms: Record; +}; + +type CatalogDocument = { + schema_version: number; + revision: number; + issued_at: string; + expires_at: string; + plugins: RegistryPlugin[]; +}; + +type CatalogEnvelope = { + key_id: string; + payload: string; + signature: string; +}; + +type CatalogKey = { + key_id: string; + algorithm: string; + public_key: string; + not_before: string; + not_after: string; + status: "active" | "verify_only" | "revoked"; +}; + +type KeysetDocument = { + schema_version: number; + audience: string; + generation: number; + issued_at: string; + expires_at: string; + keys: CatalogKey[]; +}; + +export type PublishOptions = { + manifestPath: string; + version: string; + artifactsDir: string; + registryDir: string; + signingKeyFile: string; + keyId: string; + dryRun?: boolean; + now?: Date; + /** Test-only trust override. The CLI always uses the official root keys. */ + rootKeys?: ReadonlyMap; + /** Test-only live-state injection. The CLI always fetches registry.temps.sh. */ + liveState?: { keyset: unknown; catalog: unknown }; +}; + +export type PublishResult = { + plugin: string; + version: string; + revision: number; + artifacts: Record; + catalogPath: string; + dryRun: boolean; +}; + +export class PublishError extends Error { + constructor(message: string) { + super(message); + this.name = "PublishError"; + } +} + +function fail(message: string): never { + throw new PublishError(message); +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readJson(path: string, label: string): unknown { + try { + const metadata = lstatSync(path); + if (metadata.isSymbolicLink() || !metadata.isFile()) { + fail(`${label} at ${path} must be a regular file, not a symlink`); + } + return JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + fail(`${label} at ${path} is not readable JSON: ${errorMessage(error)}`); + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function decodeCanonicalBase64(value: unknown, label: string): Buffer { + if (typeof value !== "string" || value.length === 0) { + fail(`${label} must be a non-empty base64 string`); + } + const decoded = Buffer.from(value, "base64"); + if (decoded.toString("base64") !== value) { + fail(`${label} is not canonical base64`); + } + return decoded; +} + +function publicKeyFromRawHex(rawHex: string, label: string): KeyObject { + if (!/^[a-f0-9]{64}$/.test(rawHex)) { + fail(`${label} must be a lowercase 32-byte hexadecimal Ed25519 key`); + } + return createPublicKey({ + key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(rawHex, "hex")]), + format: "der", + type: "spki", + }); +} + +function rawPublicKeyHex(key: KeyObject): string { + const der = createPublicKey(key).export({ format: "der", type: "spki" }); + const encoded = Buffer.from(der); + if ( + encoded.length !== ED25519_SPKI_PREFIX.length + 32 || + !encoded.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX) + ) { + fail("catalogue signing key is not an Ed25519 key"); + } + return encoded.subarray(ED25519_SPKI_PREFIX.length).toString("hex"); +} + +function parseDate(value: unknown, label: string): Date { + if (typeof value !== "string") fail(`${label} must be an ISO-8601 timestamp`); + const date = new Date(value); + if (!Number.isFinite(date.getTime())) + fail(`${label} is not a valid timestamp`); + return date; +} + +function parseKeyset( + value: unknown, + now: Date, + rootKeys: ReadonlyMap, +): KeysetDocument { + if (!isRecord(value) || !Array.isArray(value.signatures)) { + fail("registry keyset envelope is malformed"); + } + const payload = decodeCanonicalBase64(value.payload, "keyset payload"); + const message = Buffer.concat([KEYSET_SIGNATURE_DOMAIN, payload]); + const validRoots = new Set(); + for (const candidate of value.signatures) { + if (!isRecord(candidate) || typeof candidate.key_id !== "string") continue; + const rawKey = rootKeys.get(candidate.key_id); + if (!rawKey || validRoots.has(candidate.key_id)) continue; + try { + const signature = decodeCanonicalBase64( + candidate.signature, + `root signature ${candidate.key_id}`, + ); + if ( + verify( + null, + message, + publicKeyFromRawHex(rawKey, `root key ${candidate.key_id}`), + signature, + ) + ) { + validRoots.add(candidate.key_id); + } + } catch { + // An invalid candidate does not count toward the independent root quorum. + } + } + if (validRoots.size < ROOT_THRESHOLD) { + fail( + `registry keyset has ${validRoots.size} valid root signatures; ${ROOT_THRESHOLD} are required`, + ); + } + + const document = readPayloadJson(payload, "registry keyset payload"); + if ( + document.schema_version !== 1 || + document.audience !== KEYSET_AUDIENCE || + !Number.isSafeInteger(document.generation) || + Number(document.generation) < 1 || + !Array.isArray(document.keys) + ) { + fail("registry keyset document is malformed"); + } + const issuedAt = parseDate(document.issued_at, "keyset issued_at"); + const expiresAt = parseDate(document.expires_at, "keyset expires_at"); + if (issuedAt.getTime() > now.getTime() + MAX_CLOCK_SKEW_MS) { + fail("registry keyset issued_at is more than five minutes in the future"); + } + if ( + expiresAt.getTime() <= issuedAt.getTime() || + expiresAt.getTime() - issuedAt.getTime() > MAX_KEYSET_VALIDITY_MS + ) { + fail( + "registry keyset validity must be positive and no longer than seven days", + ); + } + if (expiresAt.getTime() <= now.getTime()) { + fail(`registry keyset expired at ${expiresAt.toISOString()}`); + } + + if (document.keys.length === 0 || document.keys.length > MAX_CATALOG_KEYS) { + fail( + `registry keyset must contain between 1 and ${MAX_CATALOG_KEYS} catalogue keys`, + ); + } + const keyIds = new Set(); + let activeKeys = 0; + for (const candidate of document.keys) { + if ( + !isRecord(candidate) || + typeof candidate.key_id !== "string" || + !validKeyId(candidate.key_id) || + candidate.algorithm !== "ed25519" || + typeof candidate.public_key !== "string" || + typeof candidate.not_before !== "string" || + typeof candidate.not_after !== "string" || + !["active", "verify_only", "revoked"].includes(String(candidate.status)) + ) { + fail("registry keyset contains a malformed catalogue key"); + } + if (keyIds.has(candidate.key_id)) { + fail( + `registry keyset contains duplicate catalogue key ID ${candidate.key_id}`, + ); + } + keyIds.add(candidate.key_id); + publicKeyFromRawHex( + candidate.public_key, + `catalogue key ${candidate.key_id}`, + ); + const notBefore = parseDate( + candidate.not_before, + `catalogue key ${candidate.key_id} not_before`, + ); + const notAfter = parseDate( + candidate.not_after, + `catalogue key ${candidate.key_id} not_after`, + ); + if ( + notAfter.getTime() <= notBefore.getTime() || + notAfter.getTime() - notBefore.getTime() > MAX_CATALOG_KEY_VALIDITY_MS + ) { + fail(`catalogue key ${candidate.key_id} has an invalid validity window`); + } + if (candidate.status === "active") activeKeys += 1; + } + if (activeKeys === 0) { + fail("registry keyset does not contain an active catalogue key"); + } + return document as unknown as KeysetDocument; +} + +function validKeyId(keyId: string): boolean { + return ( + keyId.length > 0 && keyId.length <= 128 && /^[A-Za-z0-9._-]+$/.test(keyId) + ); +} + +function readPayloadJson(payload: Buffer, label: string): JsonRecord { + try { + const value = JSON.parse(payload.toString("utf8")); + if (!isRecord(value)) fail(`${label} must contain a JSON object`); + return value; + } catch (error) { + if (error instanceof PublishError) throw error; + fail(`${label} is not valid JSON: ${errorMessage(error)}`); + } +} + +function parseCatalog( + value: unknown, + keyset: KeysetDocument, + now: Date, +): CatalogDocument { + if ( + !isRecord(value) || + typeof value.key_id !== "string" || + typeof value.signature !== "string" + ) { + fail("registry catalogue envelope is malformed"); + } + const key = keyset.keys.find( + (candidate) => candidate.key_id === value.key_id, + ); + if (!key || key.status !== "active" || key.algorithm !== "ed25519") { + fail(`existing catalogue key ${value.key_id} is not active in the keyset`); + } + const payload = decodeCanonicalBase64(value.payload, "catalogue payload"); + const signature = decodeCanonicalBase64( + value.signature, + "catalogue signature", + ); + if ( + !verify( + null, + Buffer.concat([CATALOG_SIGNATURE_DOMAIN, payload]), + publicKeyFromRawHex(key.public_key, `catalogue key ${key.key_id}`), + signature, + ) + ) { + fail("existing catalogue signature is invalid"); + } + const document = readPayloadJson(payload, "registry catalogue payload"); + if ( + document.schema_version !== 1 || + !Number.isSafeInteger(document.revision) || + Number(document.revision) < 1 || + !Array.isArray(document.plugins) + ) { + fail("registry catalogue document is malformed"); + } + const issuedAt = parseDate(document.issued_at, "catalogue issued_at"); + const expiresAt = parseDate(document.expires_at, "catalogue expires_at"); + const keyNotBefore = parseDate( + key.not_before, + `catalogue key ${key.key_id} not_before`, + ); + const keyNotAfter = parseDate( + key.not_after, + `catalogue key ${key.key_id} not_after`, + ); + if (issuedAt < keyNotBefore || issuedAt >= keyNotAfter) { + fail( + `existing catalogue issuance is outside key ${key.key_id}'s validity window`, + ); + } + if (issuedAt.getTime() > now.getTime() + MAX_CLOCK_SKEW_MS) { + fail( + "existing catalogue issued_at is more than five minutes in the future", + ); + } + if ( + expiresAt.getTime() <= now.getTime() || + expiresAt.getTime() <= issuedAt.getTime() || + expiresAt.getTime() - issuedAt.getTime() > MAX_CATALOG_VALIDITY_MS + ) { + fail( + "existing catalogue validity must be current and no longer than 30 days", + ); + } + return document as unknown as CatalogDocument; +} + +function parseManifest(value: unknown): PluginPublishManifest { + if (!isRecord(value)) + fail("plugin publishing manifest must be a JSON object"); + for (const field of [ + "name", + "binary", + "title", + "summary", + "description", + "author", + "category", + ]) { + if (typeof value[field] !== "string" || value[field].trim().length === 0) { + fail( + `plugin publishing manifest field ${field} must be a non-empty string`, + ); + } + if ((value[field] as string).length > 4_096) { + fail(`plugin publishing manifest field ${field} is too long`); + } + } + const name = value.name as string; + const binary = value.binary as string; + if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(name)) { + fail( + "plugin name must contain only lowercase ASCII letters, digits, and hyphens", + ); + } + if (!/^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/.test(binary)) { + fail( + "plugin binary name must contain only lowercase ASCII letters, digits, and hyphens", + ); + } + const keywords = value.keywords ?? []; + if ( + !Array.isArray(keywords) || + !keywords.every( + (keyword) => typeof keyword === "string" && keyword.trim().length > 0, + ) + ) { + fail("plugin publishing manifest keywords must be non-empty strings"); + } + for (const field of ["repository", "docs_url", "logo_url"] as const) { + const candidate = value[field] ?? null; + if (candidate !== null && typeof candidate !== "string") { + fail( + `plugin publishing manifest field ${field} must be a string or null`, + ); + } + if ( + typeof candidate === "string" && + field !== "logo_url" && + !isHttpsUrl(candidate) + ) { + fail(`plugin publishing manifest field ${field} must use HTTPS`); + } + if ( + typeof candidate === "string" && + field === "logo_url" && + !isSafeLogoUrl(candidate) + ) { + fail( + "plugin publishing manifest logo_url must be root-relative or use HTTPS", + ); + } + } + return { + name, + binary, + title: value.title as string, + summary: value.summary as string, + description: value.description as string, + author: value.author as string, + category: value.category as string, + keywords: keywords as string[], + repository: (value.repository as string | null | undefined) ?? null, + docs_url: (value.docs_url as string | null | undefined) ?? null, + logo_url: (value.logo_url as string | null | undefined) ?? null, + }; +} + +function isHttpsUrl(value: string): boolean { + try { + const url = new URL(value); + return ( + url.protocol === "https:" && url.username === "" && url.password === "" + ); + } catch { + return false; + } +} + +function isSafeLogoUrl(value: string): boolean { + if (isHttpsUrl(value)) return true; + if ( + !value.startsWith("/") || + value.startsWith("//") || + value.includes("\\") + ) { + return false; + } + return !value + .split("/") + .some((segment) => segment === ".." || segment === "."); +} + +type ParsedVersion = { + core: [bigint, bigint, bigint]; + prerelease: string[] | null; +}; + +function parseVersion(value: string): ParsedVersion { + const match = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec( + value, + ); + if (!match || value.length > 64) + fail(`plugin version ${value} is not valid SemVer`); + const prerelease = match[4]?.split(".") ?? null; + if ( + prerelease?.some( + (identifier) => + /^\d+$/.test(identifier) && + identifier.length > 1 && + identifier.startsWith("0"), + ) + ) { + fail(`plugin version ${value} is not valid SemVer`); + } + return { + core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])], + prerelease, + }; +} + +function compareVersions(left: string, right: string): number { + const a = parseVersion(left); + const b = parseVersion(right); + for (let index = 0; index < 3; index += 1) { + if (a.core[index] !== b.core[index]) + return a.core[index] < b.core[index] ? -1 : 1; + } + if (a.prerelease === null) return b.prerelease === null ? 0 : 1; + if (b.prerelease === null) return -1; + const length = Math.max(a.prerelease.length, b.prerelease.length); + for (let index = 0; index < length; index += 1) { + const leftPart = a.prerelease[index]; + const rightPart = b.prerelease[index]; + if (leftPart === undefined) return -1; + if (rightPart === undefined) return 1; + if (leftPart === rightPart) continue; + const leftNumber = /^\d+$/.test(leftPart) ? BigInt(leftPart) : null; + const rightNumber = /^\d+$/.test(rightPart) ? BigInt(rightPart) : null; + if (leftNumber !== null && rightNumber !== null) { + if (leftNumber === rightNumber) continue; + return leftNumber < rightNumber ? -1 : 1; + } + if (leftNumber !== null) return -1; + if (rightNumber !== null) return 1; + return leftPart < rightPart ? -1 : 1; + } + return 0; +} + +function readSigningKey(path: string): KeyObject { + const metadata = lstatSync(path); + if (metadata.isSymbolicLink() || !metadata.isFile()) { + fail("catalogue signing key must be a regular file, not a symlink"); + } + if (process.platform !== "win32" && (metadata.mode & 0o077) !== 0) { + fail( + "catalogue signing key permissions must not grant group or world access", + ); + } + const noFollow = constants.O_NOFOLLOW ?? 0; + const descriptor = openSync(path, constants.O_RDONLY | noFollow); + try { + const opened = fstatSync(descriptor); + if ( + !opened.isFile() || + opened.dev !== metadata.dev || + opened.ino !== metadata.ino + ) { + fail("catalogue signing key changed while it was being opened"); + } + const key = createPrivateKey(readFileSync(descriptor)); + if (key.asymmetricKeyType !== "ed25519") { + fail( + "catalogue signing key must be an Ed25519 private key in PEM format", + ); + } + return key; + } finally { + closeSync(descriptor); + } +} + +function assertInside(root: string, path: string, label: string): void { + if (path !== root && !path.startsWith(`${root}${sep}`)) { + fail(`${label} resolves outside ${root}`); + } +} + +function isInside(root: string, path: string): boolean { + return path === root || path.startsWith(`${root}${sep}`); +} + +async function fetchJsonCapped( + url: string, + label: string, + limit: number, +): Promise { + const response = await fetch(url, { + headers: { accept: "application/json" }, + redirect: "error", + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) fail(`${label} returned HTTP ${response.status}`); + const contentLength = response.headers.get("content-length"); + const declaredLength = contentLength === null ? null : Number(contentLength); + if ( + declaredLength !== null && + Number.isFinite(declaredLength) && + declaredLength > limit + ) { + fail(`${label} exceeds the ${limit}-byte limit`); + } + if (!response.body) fail(`${label} returned an empty response body`); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + length += value.byteLength; + if (length > limit) { + await reader.cancel(); + fail(`${label} exceeds the ${limit}-byte limit`); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = Buffer.concat(chunks, length); + try { + return JSON.parse(bytes.toString("utf8")); + } catch (error) { + fail(`${label} is not valid JSON: ${errorMessage(error)}`); + } +} + +async function loadLiveState(): Promise<{ keyset: unknown; catalog: unknown }> { + const [keyset, catalog] = await Promise.all([ + fetchJsonCapped(LIVE_KEYSET_URL, "live registry keyset", MAX_KEYSET_BYTES), + fetchJsonCapped( + LIVE_CATALOG_URL, + "live registry catalogue", + MAX_CATALOG_BYTES, + ), + ]); + return { keyset, catalog }; +} + +function envelopePayload(value: unknown, label: string): string { + if (!isRecord(value) || typeof value.payload !== "string") { + fail(`${label} envelope is malformed`); + } + return value.payload; +} + +function fsyncDirectory(path: string): void { + const descriptor = openSync(path, constants.O_RDONLY); + try { + fsyncSync(descriptor); + } finally { + closeSync(descriptor); + } +} + +function ensureDirectoryTree(root: string, segments: string[]): string { + let current = root; + for (const segment of segments) { + current = join(current, segment); + if (existsSync(current)) { + const metadata = lstatSync(current); + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + fail(`artifact directory ${current} must be a real directory`); + } + continue; + } + mkdirSync(current, { mode: 0o755 }); + fsyncDirectory(dirname(current)); + } + assertInside(root, realpathSync(current), "artifact destination"); + return current; +} + +function copyOpenedArtifact( + sourceDescriptor: number, + destination: string, + size: number, +): string { + const hasher = createHash("sha256"); + const destinationDescriptor = openSync( + destination, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, + 0o600, + ); + const buffer = Buffer.allocUnsafe(1024 * 1024); + let position = 0; + try { + while (position < size) { + const bytesRead = readSync( + sourceDescriptor, + buffer, + 0, + Math.min(buffer.length, size - position), + position, + ); + if (bytesRead === 0) + fail("release artifact changed size while being staged"); + hasher.update(buffer.subarray(0, bytesRead)); + let written = 0; + while (written < bytesRead) { + written += writeSync( + destinationDescriptor, + buffer, + written, + bytesRead - written, + ); + } + position += bytesRead; + } + if (readSync(sourceDescriptor, buffer, 0, 1, position) !== 0) { + fail("release artifact grew while being staged"); + } + fsyncSync(destinationDescriptor); + } finally { + closeSync(destinationDescriptor); + } + return hasher.digest("hex"); +} + +function collectArtifacts( + manifest: PluginPublishManifest, + version: string, + artifactsDir: string, +): { + stagingDirectory: string; + artifacts: Array<{ + platform: string; + source: string; + release: PlatformRelease; + }>; +} { + const root = realpathSync(artifactsDir); + const stagingDirectory = mkdtempSync(join(tmpdir(), "temps-plugin-publish-")); + chmodSync(stagingDirectory, 0o700); + const releases = []; + try { + for (const [platform, suffix] of PLATFORM_ARTIFACTS) { + const source = join(root, `${manifest.binary}-${suffix}`); + const metadata = lstatSync(source); + if (metadata.isSymbolicLink() || !metadata.isFile()) { + fail(`release artifact for ${platform} must be a regular file`); + } + const descriptor = openSync( + source, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0), + ); + const staged = join(stagingDirectory, platform); + try { + const opened = fstatSync(descriptor); + if ( + !opened.isFile() || + opened.dev !== metadata.dev || + opened.ino !== metadata.ino + ) { + fail(`release artifact for ${platform} changed while it was opened`); + } + if (opened.size > MAX_BINARY_BYTES) { + fail(`${source} exceeds the ${MAX_BINARY_BYTES}-byte plugin limit`); + } + const sha256 = copyOpenedArtifact(descriptor, staged, opened.size); + chmodSync(staged, 0o400); + releases.push({ + platform, + source: staged, + release: { + url: `${REGISTRY_ORIGIN}/artifacts/${manifest.name}/${version}/${platform}/plugin`, + sha256, + }, + }); + } finally { + closeSync(descriptor); + } + } + return { stagingDirectory, artifacts: releases }; + } catch (error) { + rmSync(stagingDirectory, { recursive: true, force: true }); + throw error; + } +} + +function copyArtifactAtomically( + registryRoot: string, + plugin: string, + version: string, + platform: string, + source: string, + expectedHash: string, +): void { + const directory = ensureDirectoryTree(registryRoot, [ + "public", + "artifacts", + plugin, + version, + platform, + ]); + const destination = join(directory, "plugin"); + if (existsSync(destination)) { + const metadata = lstatSync(destination); + if (metadata.isSymbolicLink() || !metadata.isFile()) { + fail(`artifact destination ${destination} is not a regular file`); + } + const existingHash = createHash("sha256") + .update(readFileSync(destination)) + .digest("hex"); + if (existingHash !== expectedHash) { + fail( + `immutable artifact already exists with different bytes: ${destination}`, + ); + } + return; + } + const temporary = join( + directory, + `.plugin-${randomBytes(8).toString("hex")}.tmp`, + ); + try { + copyFileSync(source, temporary, constants.COPYFILE_EXCL); + chmodSync(temporary, 0o600); + const copiedHash = createHash("sha256") + .update(readFileSync(temporary)) + .digest("hex"); + if (copiedHash !== expectedHash) + fail(`copied artifact hash changed for ${platform}`); + writeFileModeAndSync(temporary, 0o444); + linkSync(temporary, destination); + unlinkSync(temporary); + fsyncDirectory(directory); + } finally { + rmSync(temporary, { force: true }); + } +} + +function writeFileModeAndSync(path: string, mode: number): void { + const descriptor = openSync(path, constants.O_RDWR); + try { + fchmodSync(descriptor, mode); + fsyncSync(descriptor); + } finally { + closeSync(descriptor); + } +} + +function writeCatalogAtomically( + path: string, + serializedEnvelope: string, +): void { + const temporary = join( + dirname(path), + `.catalog-${randomBytes(8).toString("hex")}.tmp`, + ); + let descriptor: number | undefined; + try { + descriptor = openSync( + temporary, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, + 0o644, + ); + writeFileSync(descriptor, serializedEnvelope); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = undefined; + renameSync(temporary, path); + fsyncDirectory(dirname(path)); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + rmSync(temporary, { force: true }); + } +} + +function samePlugin(left: RegistryPlugin, right: RegistryPlugin): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +async function publishPluginLocked( + options: PublishOptions, +): Promise { + const now = options.now ?? new Date(); + if (!Number.isFinite(now.getTime())) fail("publish time is invalid"); + parseVersion(options.version); + + const registryRoot = realpathSync(options.registryDir); + const registryDataDir = join(registryRoot, "src", "registry-data"); + assertInside( + registryRoot, + realpathSync(registryDataDir), + "registry data directory", + ); + const keysetPath = join(registryDataDir, "keyset.json"); + const catalogPath = join(registryDataDir, "catalog.json"); + const manifest = parseManifest( + readJson(resolve(options.manifestPath), "plugin manifest"), + ); + const localKeysetEnvelope = readJson(keysetPath, "registry keyset"); + const keyset = parseKeyset( + localKeysetEnvelope, + now, + options.rootKeys ?? OFFICIAL_ROOT_KEYS, + ); + const localCatalogEnvelope = readJson(catalogPath, "registry catalogue"); + const currentCatalog = parseCatalog(localCatalogEnvelope, keyset, now); + const liveState = options.liveState ?? (await loadLiveState()); + const liveKeyset = parseKeyset( + liveState.keyset, + now, + options.rootKeys ?? OFFICIAL_ROOT_KEYS, + ); + const liveCatalog = parseCatalog(liveState.catalog, liveKeyset, now); + if ( + liveKeyset.generation !== keyset.generation || + envelopePayload(liveState.keyset, "live keyset") !== + envelopePayload(localKeysetEnvelope, "local keyset") + ) { + fail( + "local keyset does not exactly match the live verified registry keyset", + ); + } + if ( + liveCatalog.revision !== currentCatalog.revision || + envelopePayload(liveState.catalog, "live catalogue") !== + envelopePayload(localCatalogEnvelope, "local catalogue") + ) { + fail( + "local catalogue does not exactly match the live verified registry catalogue", + ); + } + + const signingKeyPath = realpathSync(resolve(options.signingKeyFile)); + const publisherRoot = realpathSync(join(import.meta.dir, "..")); + if ( + isInside(registryRoot, signingKeyPath) || + isInside(publisherRoot, signingKeyPath) + ) { + fail( + "catalogue signing key must be stored outside the plugins and registry checkouts", + ); + } + const signingKey = readSigningKey(signingKeyPath); + const catalogKey = keyset.keys.find((key) => key.key_id === options.keyId); + if (!catalogKey || catalogKey.status !== "active") { + fail( + `catalogue key ${options.keyId} is not active in the root-signed keyset`, + ); + } + if (catalogKey.algorithm !== "ed25519") { + fail(`catalogue key ${options.keyId} does not use Ed25519`); + } + if (rawPublicKeyHex(signingKey) !== catalogKey.public_key) { + fail(`catalogue signing key does not match keyset entry ${options.keyId}`); + } + const notBefore = parseDate( + catalogKey.not_before, + "catalogue key not_before", + ); + const notAfter = parseDate(catalogKey.not_after, "catalogue key not_after"); + if (now < notBefore || now >= notAfter) { + fail(`catalogue key ${options.keyId} is outside its validity window`); + } + + const staged = collectArtifacts( + manifest, + options.version, + resolve(options.artifactsDir), + ); + const artifacts = staged.artifacts; + try { + const platforms = Object.fromEntries( + artifacts.map(({ platform, release }) => [platform, release]), + ); + const { binary: _binary, ...publicManifest } = manifest; + const plugin: RegistryPlugin = { + ...publicManifest, + version: options.version, + platforms, + }; + const existing = currentCatalog.plugins.find( + (candidate) => candidate.name === manifest.name, + ); + if (existing && compareVersions(options.version, existing.version) < 0) { + fail( + `refusing to replace ${manifest.name} ${existing.version} with older ${options.version}`, + ); + } + if (existing && compareVersions(options.version, existing.version) === 0) { + if (!samePlugin(existing, plugin)) { + fail( + `refusing to change immutable release ${manifest.name} ${options.version}`, + ); + } + if (!options.dryRun) { + for (const artifact of artifacts) { + copyArtifactAtomically( + registryRoot, + manifest.name, + options.version, + artifact.platform, + artifact.source, + artifact.release.sha256, + ); + } + } + return { + plugin: manifest.name, + version: options.version, + revision: currentCatalog.revision, + artifacts: platforms, + catalogPath, + dryRun: options.dryRun ?? false, + }; + } + + const expiresAt = new Date(now.getTime() + 29 * 24 * 60 * 60 * 1000); + const nextDocument: CatalogDocument = { + schema_version: 1, + revision: currentCatalog.revision + 1, + issued_at: now.toISOString(), + expires_at: expiresAt.toISOString(), + plugins: [ + ...currentCatalog.plugins.filter( + (candidate) => candidate.name !== manifest.name, + ), + plugin, + ].sort((left, right) => + left.name < right.name ? -1 : left.name === right.name ? 0 : 1, + ), + }; + const payload = Buffer.from(JSON.stringify(nextDocument)); + if (payload.length > MAX_CATALOG_BYTES) { + fail(`catalogue payload exceeds the ${MAX_CATALOG_BYTES}-byte limit`); + } + const signature = sign( + null, + Buffer.concat([CATALOG_SIGNATURE_DOMAIN, payload]), + signingKey, + ); + const envelope: CatalogEnvelope = { + key_id: options.keyId, + payload: payload.toString("base64"), + signature: signature.toString("base64"), + }; + const serializedEnvelope = `${JSON.stringify(envelope)}\n`; + if (Buffer.byteLength(serializedEnvelope) > MAX_CATALOG_BYTES) { + fail( + `signed catalogue exceeds the ${MAX_CATALOG_BYTES}-byte registry response limit`, + ); + } + if (options.dryRun) { + return { + plugin: manifest.name, + version: options.version, + revision: nextDocument.revision, + artifacts: platforms, + catalogPath, + dryRun: true, + }; + } + + for (const artifact of artifacts) { + copyArtifactAtomically( + registryRoot, + manifest.name, + options.version, + artifact.platform, + artifact.source, + artifact.release.sha256, + ); + } + writeCatalogAtomically(catalogPath, serializedEnvelope); + + return { + plugin: manifest.name, + version: options.version, + revision: nextDocument.revision, + artifacts: platforms, + catalogPath, + dryRun: false, + }; + } finally { + rmSync(staged.stagingDirectory, { recursive: true, force: true }); + } +} + +export async function publishPlugin( + options: PublishOptions, +): Promise { + if (options.dryRun) return publishPluginLocked(options); + + const registryRoot = realpathSync(options.registryDir); + const registryDataDir = realpathSync( + join(registryRoot, "src", "registry-data"), + ); + assertInside(registryRoot, registryDataDir, "registry data directory"); + const lockPath = join(registryDataDir, ".publish.lock"); + const lock = openSync( + lockPath, + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + (constants.O_NOFOLLOW ?? 0), + 0o600, + ); + try { + return await publishPluginLocked(options); + } finally { + closeSync(lock); + unlinkSync(lockPath); + } +} + +function usage(): string { + return `Publish one signed plugin release into a temps-registry checkout. + +Usage: + bun scripts/publish-plugin.ts \\ + --manifest deployment-pulse-plugin/registry.json \\ + --version 0.1.0 \\ + --artifacts-dir ./dist \\ + --registry-dir ../temps-registry \\ + --key-id catalog-2026-01 \\ + --signing-key-file /secure/catalog-ed25519.pem [--dry-run] + +The signing key must be an Ed25519 PEM file with mode 0600. Its contents are +never printed or copied. The registry checkout must already contain a valid, +root-signed src/registry-data/keyset.json and signed catalog.json.`; +} + +function parseArguments(argv: string[]): PublishOptions | null { + if (argv.includes("--help") || argv.includes("-h")) return null; + const values = new Map(); + let dryRun = false; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--dry-run") { + dryRun = true; + continue; + } + if (!argument.startsWith("--")) fail(`unexpected argument ${argument}`); + const value = argv[index + 1]; + if (!value || value.startsWith("--")) fail(`missing value for ${argument}`); + if (values.has(argument)) fail(`duplicate argument ${argument}`); + values.set(argument, value); + index += 1; + } + const required = [ + "--manifest", + "--version", + "--artifacts-dir", + "--registry-dir", + "--key-id", + "--signing-key-file", + ]; + for (const name of required) { + if (!values.has(name)) fail(`missing required argument ${name}`); + } + return { + manifestPath: values.get("--manifest")!, + version: values.get("--version")!, + artifactsDir: values.get("--artifacts-dir")!, + registryDir: values.get("--registry-dir")!, + keyId: values.get("--key-id")!, + signingKeyFile: values.get("--signing-key-file")!, + dryRun, + }; +} + +if (import.meta.main) { + try { + const options = parseArguments(process.argv.slice(2)); + if (!options) { + process.stdout.write(`${usage()}\n`); + process.exit(0); + } + const result = await publishPlugin(options); + process.stdout.write( + `${result.dryRun ? "Validated" : "Published"} ${result.plugin} ${result.version} ` + + `at catalogue revision ${result.revision}\n`, + ); + for (const [platform, release] of Object.entries(result.artifacts)) { + process.stdout.write(`${platform} ${release.sha256} ${release.url}\n`); + } + process.stdout.write(`Catalogue: ${result.catalogPath}\n`); + } catch (error) { + process.stderr.write(`Plugin publish failed: ${errorMessage(error)}\n`); + process.exit(1); + } +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000..f4e0758 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,10 @@ +{ + // SPDX-FileCopyrightText: 2024-2026 Temps Contributors + // SPDX-License-Identifier: MIT OR Apache-2.0 + "extends": "../deployment-pulse-plugin/tsconfig.json", + "compilerOptions": { + "types": ["bun"], + "typeRoots": ["../deployment-pulse-plugin/node_modules/@types"] + }, + "include": ["./publish-plugin.ts", "./publish-plugin.test.ts"] +} From 15bb875b43597a4f5051092c36a75c3703dd6190 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Thu, 10 Sep 2026 20:29:13 +0200 Subject: [PATCH 2/7] fix(registry): make published artifacts installable --- .github/workflows/release.yml | 24 +++--- deployment-pulse-plugin/registry.json | 1 + deployment-pulse-plugin/src/index.ts | 38 ++++++++-- deployment-pulse-plugin/tsconfig.json | 1 + scripts/publish-plugin.test.ts | 56 ++++++++++++-- scripts/publish-plugin.ts | 101 +++++++++++++++++--------- 6 files changed, 159 insertions(+), 62 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5215ee9..a4822d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ name: Release on: push: tags: - - 'v*' + - "v*" workflow_dispatch: permissions: @@ -20,15 +20,14 @@ jobs: fail-fast: false matrix: include: - - target: x86_64-unknown-linux-gnu - os: ubuntu-latest + - target: x86_64-unknown-linux-musl + os: ubuntu-24.04 suffix: x86_64-linux - bun_target: bun-linux-x64 - - target: aarch64-unknown-linux-gnu - os: ubuntu-latest + bun_target: bun-linux-x64-musl + - target: aarch64-unknown-linux-musl + os: ubuntu-24.04-arm suffix: aarch64-linux - linker: aarch64-linux-gnu-gcc - bun_target: bun-linux-arm64 + bun_target: bun-linux-arm64-musl - target: x86_64-apple-darwin os: macos-latest suffix: x86_64-darwin @@ -55,11 +54,11 @@ jobs: - name: Install Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - - name: Install cross-compilation tools (Linux aarch64) - if: matrix.target == 'aarch64-unknown-linux-gnu' + - name: Install musl toolchain (Linux) + if: contains(matrix.target, 'linux-musl') run: | sudo apt-get update - sudo apt-get install -y gcc-aarch64-linux-gnu + sudo apt-get install -y musl-tools - name: Cache cargo uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 @@ -68,7 +67,8 @@ jobs: - name: Build all plugins env: - CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc run: cargo build --release --workspace --target ${{ matrix.target }} - name: Install Deployment Pulse dependencies diff --git a/deployment-pulse-plugin/registry.json b/deployment-pulse-plugin/registry.json index 3c0da6e..f31800a 100644 --- a/deployment-pulse-plugin/registry.json +++ b/deployment-pulse-plugin/registry.json @@ -1,5 +1,6 @@ { "name": "deployment-pulse", + "version": "0.1.0", "binary": "temps-deployment-pulse-plugin", "title": "Deployment Pulse", "summary": "Monitor deployment health across every project.", diff --git a/deployment-pulse-plugin/src/index.ts b/deployment-pulse-plugin/src/index.ts index af50a3c..8847364 100644 --- a/deployment-pulse-plugin/src/index.ts +++ b/deployment-pulse-plugin/src/index.ts @@ -10,8 +10,13 @@ import { type RequestHandler, type TempsPlugin, } from "@temps-sdk/plugin"; +import pluginMetadata from "../registry.json"; import { embeddedAssets } from "./_embedded_ui.js"; -import { buildOverview, summarizeProject, type ProjectPulse } from "./overview.js"; +import { + buildOverview, + summarizeProject, + type ProjectPulse, +} from "./overview.js"; const MAX_CONCURRENT_PROJECT_QUERIES = 6; @@ -21,12 +26,21 @@ async function loadProjectPulses( ): Promise { const results: ProjectPulse[] = []; - for (let offset = 0; offset < projects.length; offset += MAX_CONCURRENT_PROJECT_QUERIES) { - const batch = projects.slice(offset, offset + MAX_CONCURRENT_PROJECT_QUERIES); + for ( + let offset = 0; + offset < projects.length; + offset += MAX_CONCURRENT_PROJECT_QUERIES + ) { + const batch = projects.slice( + offset, + offset + MAX_CONCURRENT_PROJECT_QUERIES, + ); const pulses = await Promise.all( batch.map(async (project): Promise => { try { - const deployments = await ctx.temps.listDeployments(project.id, { limit: 10 }); + const deployments = await ctx.temps.listDeployments(project.id, { + limit: 10, + }); return summarizeProject(project, deployments); } catch (error) { console.error( @@ -51,7 +65,11 @@ async function loadProjectPulses( return results; } -function json(res: Parameters[1], status: number, body: unknown): void { +function json( + res: Parameters[1], + status: number, + body: unknown, +): void { res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", @@ -61,7 +79,7 @@ function json(res: Parameters[1], status: number, body: unknown) const plugin: TempsPlugin = { manifest() { - return createManifest("deployment-pulse", "0.1.0") + return createManifest("deployment-pulse", pluginMetadata.version) .displayName("Deployment Pulse") .description("See deployment health across every project at a glance") .requiresDb(false) @@ -104,7 +122,9 @@ const plugin: TempsPlugin = { }, onStart() { - console.error(JSON.stringify({ level: "info", message: "Deployment Pulse started" })); + console.error( + JSON.stringify({ level: "info", message: "Deployment Pulse started" }), + ); }, onEvent(_ctx: PluginContext, event: PluginEvent) { @@ -119,7 +139,9 @@ const plugin: TempsPlugin = { }, onShutdown() { - console.error(JSON.stringify({ level: "info", message: "Deployment Pulse stopped" })); + console.error( + JSON.stringify({ level: "info", message: "Deployment Pulse stopped" }), + ); }, }; diff --git a/deployment-pulse-plugin/tsconfig.json b/deployment-pulse-plugin/tsconfig.json index 64f5446..5aba1a3 100644 --- a/deployment-pulse-plugin/tsconfig.json +++ b/deployment-pulse-plugin/tsconfig.json @@ -7,6 +7,7 @@ "skipLibCheck": true, "noEmit": true, "esModuleInterop": true, + "resolveJsonModule": true, "verbatimModuleSyntax": true }, "include": ["src/**/*.ts", "scripts/**/*.ts"] diff --git a/scripts/publish-plugin.test.ts b/scripts/publish-plugin.test.ts index c45aad0..1c1633f 100644 --- a/scripts/publish-plugin.test.ts +++ b/scripts/publish-plugin.test.ts @@ -22,7 +22,11 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { publishPlugin, type PublishOptions } from "./publish-plugin"; +import { + parseArguments, + publishPlugin, + type PublishOptions, +} from "./publish-plugin"; const KEYSET_DOMAIN = Buffer.from("temps-plugin-keyset-v1\0"); const CATALOG_DOMAIN = Buffer.from("temps-plugin-catalog-v1\0"); @@ -141,6 +145,7 @@ function fixture() { manifestPath, JSON.stringify({ name: "deployment-pulse", + version: "0.1.0", binary: "temps-deployment-pulse-plugin", title: "Deployment Pulse", summary: "Monitor deployments.", @@ -167,7 +172,6 @@ function fixture() { ); const options: PublishOptions = { manifestPath, - version: "0.1.0", artifactsDir, registryDir, signingKeyFile, @@ -188,6 +192,12 @@ function fixture() { }; } +function setManifestVersion(options: PublishOptions, version: string): void { + const manifest = JSON.parse(readFileSync(options.manifestPath, "utf8")); + manifest.version = version; + writeFileSync(options.manifestPath, JSON.stringify(manifest)); +} + describe("single-plugin registry publisher", () => { test("copies four immutable artifacts and atomically signs the next catalogue", async () => { const { options, catalog, registryDir, dataDir } = fixture(); @@ -386,8 +396,6 @@ describe("single-plugin registry publisher", () => { const { options, dataDir } = fixture(); await publishPlugin(options); const before = readFileSync(join(dataDir, "catalog.json"), "utf8"); - options.version = "0.2.0"; - await expect(publishPlugin(options)).rejects.toThrow( "local catalogue does not exactly match the live verified registry catalogue", ); @@ -428,14 +436,14 @@ describe("single-plugin registry publisher", () => { test("uses ASCII SemVer ordering for prerelease identifiers", async () => { const { options, dataDir } = fixture(); - options.version = "1.0.0-a"; + setManifestVersion(options, "1.0.0-a"); await publishPlugin(options); const before = readFileSync(join(dataDir, "catalog.json"), "utf8"); options.liveState = { keyset: options.liveState!.keyset, catalog: JSON.parse(before), }; - options.version = "1.0.0-A"; + setManifestVersion(options, "1.0.0-A"); await expect(publishPlugin(options)).rejects.toThrow( "refusing to replace deployment-pulse 1.0.0-a with older 1.0.0-A", @@ -445,14 +453,14 @@ describe("single-plugin registry publisher", () => { test("compares large numeric SemVer identifiers without precision loss", async () => { const { options, dataDir } = fixture(); - options.version = "1.0.0-9007199254740993"; + setManifestVersion(options, "1.0.0-9007199254740993"); await publishPlugin(options); const before = readFileSync(join(dataDir, "catalog.json"), "utf8"); options.liveState = { keyset: options.liveState!.keyset, catalog: JSON.parse(before), }; - options.version = "1.0.0-9007199254740992"; + setManifestVersion(options, "1.0.0-9007199254740992"); await expect(publishPlugin(options)).rejects.toThrow( "refusing to replace deployment-pulse 1.0.0-9007199254740993 with older 1.0.0-9007199254740992", @@ -563,4 +571,36 @@ describe("single-plugin registry publisher", () => { ); expect(readFileSync(join(dataDir, "catalog.json"), "utf8")).toBe(before); }); + + test("rejects unknown CLI flags instead of accidentally publishing", () => { + expect(() => + parseArguments([ + "--manifest", + "deployment-pulse-plugin/registry.json", + "--artifacts-dir", + "dist", + "--registry-dir", + "../temps-registry", + "--key-id", + "catalog-test-1", + "--signing-key-file", + "/secure/catalog.pem", + "--dry-rnu", + "true", + ]), + ).toThrow("unexpected argument --dry-rnu"); + }); + + test("reports a stale publisher lock without removing it", async () => { + const { options, dataDir } = fixture(); + const lockPath = join(dataDir, ".publish.lock"); + writeFileSync(lockPath, '{"pid":123,"created_at":"earlier"}\n', { + mode: 0o600, + }); + + await expect(publishPlugin(options)).rejects.toThrow( + "confirm no publisher is running before removing the lock", + ); + expect(existsSync(lockPath)).toBe(true); + }); }); diff --git a/scripts/publish-plugin.ts b/scripts/publish-plugin.ts index 2b706e9..c82e866 100644 --- a/scripts/publish-plugin.ts +++ b/scripts/publish-plugin.ts @@ -79,6 +79,7 @@ type JsonRecord = Record; export type PluginPublishManifest = { name: string; + version: string; binary: string; title: string; summary: string; @@ -94,7 +95,6 @@ export type PluginPublishManifest = { type PlatformRelease = { url: string; sha256: string }; type RegistryPlugin = Omit & { - version: string; platforms: Record; }; @@ -132,7 +132,6 @@ type KeysetDocument = { export type PublishOptions = { manifestPath: string; - version: string; artifactsDir: string; registryDir: string; signingKeyFile: string; @@ -442,6 +441,7 @@ function parseManifest(value: unknown): PluginPublishManifest { fail("plugin publishing manifest must be a JSON object"); for (const field of [ "name", + "version", "binary", "title", "summary", @@ -459,7 +459,9 @@ function parseManifest(value: unknown): PluginPublishManifest { } } const name = value.name as string; + const version = value.version as string; const binary = value.binary as string; + parseVersion(version); if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(name)) { fail( "plugin name must contain only lowercase ASCII letters, digits, and hyphens", @@ -505,6 +507,7 @@ function parseManifest(value: unknown): PluginPublishManifest { } return { name, + version, binary, title: value.title as string, summary: value.summary as string, @@ -649,11 +652,16 @@ async function fetchJsonCapped( label: string, limit: number, ): Promise { - const response = await fetch(url, { - headers: { accept: "application/json" }, - redirect: "error", - signal: AbortSignal.timeout(10_000), - }); + let response: Response; + try { + response = await fetch(url, { + headers: { accept: "application/json" }, + redirect: "error", + signal: AbortSignal.timeout(10_000), + }); + } catch (error) { + fail(`${label} request to ${url} failed: ${errorMessage(error)}`); + } if (!response.ok) fail(`${label} returned HTTP ${response.status}`); const contentLength = response.headers.get("content-length"); const declaredLength = contentLength === null ? null : Number(contentLength); @@ -679,6 +687,11 @@ async function fetchJsonCapped( } chunks.push(value); } + } catch (error) { + if (error instanceof PublishError) throw error; + fail( + `${label} response from ${url} failed while streaming: ${errorMessage(error)}`, + ); } finally { reader.releaseLock(); } @@ -941,7 +954,6 @@ async function publishPluginLocked( ): Promise { const now = options.now ?? new Date(); if (!Number.isFinite(now.getTime())) fail("publish time is invalid"); - parseVersion(options.version); const registryRoot = realpathSync(options.registryDir); const registryDataDir = join(registryRoot, "src", "registry-data"); @@ -1023,7 +1035,7 @@ async function publishPluginLocked( const staged = collectArtifacts( manifest, - options.version, + manifest.version, resolve(options.artifactsDir), ); const artifacts = staged.artifacts; @@ -1034,21 +1046,20 @@ async function publishPluginLocked( const { binary: _binary, ...publicManifest } = manifest; const plugin: RegistryPlugin = { ...publicManifest, - version: options.version, platforms, }; const existing = currentCatalog.plugins.find( (candidate) => candidate.name === manifest.name, ); - if (existing && compareVersions(options.version, existing.version) < 0) { + if (existing && compareVersions(manifest.version, existing.version) < 0) { fail( - `refusing to replace ${manifest.name} ${existing.version} with older ${options.version}`, + `refusing to replace ${manifest.name} ${existing.version} with older ${manifest.version}`, ); } - if (existing && compareVersions(options.version, existing.version) === 0) { + if (existing && compareVersions(manifest.version, existing.version) === 0) { if (!samePlugin(existing, plugin)) { fail( - `refusing to change immutable release ${manifest.name} ${options.version}`, + `refusing to change immutable release ${manifest.name} ${manifest.version}`, ); } if (!options.dryRun) { @@ -1056,7 +1067,7 @@ async function publishPluginLocked( copyArtifactAtomically( registryRoot, manifest.name, - options.version, + manifest.version, artifact.platform, artifact.source, artifact.release.sha256, @@ -1065,7 +1076,7 @@ async function publishPluginLocked( } return { plugin: manifest.name, - version: options.version, + version: manifest.version, revision: currentCatalog.revision, artifacts: platforms, catalogPath, @@ -1111,7 +1122,7 @@ async function publishPluginLocked( if (options.dryRun) { return { plugin: manifest.name, - version: options.version, + version: manifest.version, revision: nextDocument.revision, artifacts: platforms, catalogPath, @@ -1123,7 +1134,7 @@ async function publishPluginLocked( copyArtifactAtomically( registryRoot, manifest.name, - options.version, + manifest.version, artifact.platform, artifact.source, artifact.release.sha256, @@ -1133,7 +1144,7 @@ async function publishPluginLocked( return { plugin: manifest.name, - version: options.version, + version: manifest.version, revision: nextDocument.revision, artifacts: platforms, catalogPath, @@ -1155,15 +1166,28 @@ export async function publishPlugin( ); assertInside(registryRoot, registryDataDir, "registry data directory"); const lockPath = join(registryDataDir, ".publish.lock"); - const lock = openSync( - lockPath, - constants.O_WRONLY | - constants.O_CREAT | - constants.O_EXCL | - (constants.O_NOFOLLOW ?? 0), - 0o600, - ); + let lock: number; try { + lock = openSync( + lockPath, + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + (constants.O_NOFOLLOW ?? 0), + 0o600, + ); + } catch (error) { + fail( + `could not acquire publisher lock ${lockPath}: ${errorMessage(error)}. ` + + "If a previous publisher crashed, confirm no publisher is running before removing the lock.", + ); + } + try { + writeFileSync( + lock, + `${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`, + ); + fsyncSync(lock); return await publishPluginLocked(options); } finally { closeSync(lock); @@ -1177,23 +1201,34 @@ function usage(): string { Usage: bun scripts/publish-plugin.ts \\ --manifest deployment-pulse-plugin/registry.json \\ - --version 0.1.0 \\ --artifacts-dir ./dist \\ --registry-dir ../temps-registry \\ --key-id catalog-2026-01 \\ --signing-key-file /secure/catalog-ed25519.pem [--dry-run] -The signing key must be an Ed25519 PEM file with mode 0600. Its contents are -never printed or copied. The registry checkout must already contain a valid, -root-signed src/registry-data/keyset.json and signed catalog.json.`; +The version is read from the plugin manifest so the runtime and catalogue use +one source of truth. The signing key must be an Ed25519 PEM file with mode 0600; +its contents are never printed or copied. The registry checkout must already +contain a valid, root-signed keyset.json and signed catalog.json. This command +stages a local checkout only; production publication must use serialized, +protected deployment with compare-and-swap at the live commit boundary.`; } -function parseArguments(argv: string[]): PublishOptions | null { +export function parseArguments(argv: string[]): PublishOptions | null { if (argv.includes("--help") || argv.includes("-h")) return null; const values = new Map(); let dryRun = false; + const allowed = new Set([ + "--manifest", + "--artifacts-dir", + "--registry-dir", + "--key-id", + "--signing-key-file", + "--dry-run", + ]); for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; + if (!allowed.has(argument)) fail(`unexpected argument ${argument}`); if (argument === "--dry-run") { dryRun = true; continue; @@ -1207,7 +1242,6 @@ function parseArguments(argv: string[]): PublishOptions | null { } const required = [ "--manifest", - "--version", "--artifacts-dir", "--registry-dir", "--key-id", @@ -1218,7 +1252,6 @@ function parseArguments(argv: string[]): PublishOptions | null { } return { manifestPath: values.get("--manifest")!, - version: values.get("--version")!, artifactsDir: values.get("--artifacts-dir")!, registryDir: values.get("--registry-dir")!, keyId: values.get("--key-id")!, From f95cd5a1c35918ab57536377a760e79a4c49b042 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Thu, 10 Sep 2026 20:35:21 +0200 Subject: [PATCH 3/7] fix(release): publish libc-specific Linux plugins --- .github/workflows/release.yml | 15 +++++++++++++-- scripts/publish-plugin.test.ts | 28 ++++++++++++++++------------ scripts/publish-plugin.ts | 6 ++++-- 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4822d5..8c2e87f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,13 +20,21 @@ jobs: fail-fast: false matrix: include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-24.04 + suffix: x86_64-linux-gnu + bun_target: bun-linux-x64 - target: x86_64-unknown-linux-musl os: ubuntu-24.04 - suffix: x86_64-linux + suffix: x86_64-linux-musl bun_target: bun-linux-x64-musl + - target: aarch64-unknown-linux-gnu + os: ubuntu-24.04-arm + suffix: aarch64-linux-gnu + bun_target: bun-linux-arm64 - target: aarch64-unknown-linux-musl os: ubuntu-24.04-arm - suffix: aarch64-linux + suffix: aarch64-linux-musl bun_target: bun-linux-arm64-musl - target: x86_64-apple-darwin os: macos-latest @@ -49,10 +57,13 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: + toolchain: 1.98.0 targets: ${{ matrix.target }} - name: Install Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 - name: Install musl toolchain (Linux) if: contains(matrix.target, 'linux-musl') diff --git a/scripts/publish-plugin.test.ts b/scripts/publish-plugin.test.ts index 1c1633f..8f260d5 100644 --- a/scripts/publish-plugin.test.ts +++ b/scripts/publish-plugin.test.ts @@ -32,8 +32,10 @@ const KEYSET_DOMAIN = Buffer.from("temps-plugin-keyset-v1\0"); const CATALOG_DOMAIN = Buffer.from("temps-plugin-catalog-v1\0"); const SPKI_PREFIX_BYTES = 12; const PLATFORMS = [ - "x86_64-linux", - "aarch64-linux", + "x86_64-linux-gnu", + "x86_64-linux-musl", + "aarch64-linux-gnu", + "aarch64-linux-musl", "x86_64-darwin", "aarch64-darwin", ]; @@ -199,7 +201,7 @@ function setManifestVersion(options: PublishOptions, version: string): void { } describe("single-plugin registry publisher", () => { - test("copies four immutable artifacts and atomically signs the next catalogue", async () => { + test("copies six immutable artifacts and atomically signs the next catalogue", async () => { const { options, catalog, registryDir, dataDir } = fixture(); const result = await publishPlugin(options); @@ -210,8 +212,10 @@ describe("single-plugin registry publisher", () => { dryRun: false, }); expect(Object.keys(result.artifacts)).toEqual([ - "linux-amd64", - "linux-arm64", + "linux-amd64-gnu", + "linux-amd64-musl", + "linux-arm64-gnu", + "linux-arm64-musl", "darwin-amd64", "darwin-arm64", ]); @@ -250,8 +254,8 @@ describe("single-plugin registry publisher", () => { name: "deployment-pulse", version: "0.1.0", platforms: { - "linux-amd64": { - url: "https://registry.temps.sh/artifacts/deployment-pulse/0.1.0/linux-amd64/plugin", + "linux-amd64-gnu": { + url: "https://registry.temps.sh/artifacts/deployment-pulse/0.1.0/linux-amd64-gnu/plugin", }, }, }); @@ -282,7 +286,7 @@ describe("single-plugin registry publisher", () => { catalog: JSON.parse(before), }; writeFileSync( - join(artifactsDir, "temps-deployment-pulse-plugin-x86_64-linux"), + join(artifactsDir, "temps-deployment-pulse-plugin-x86_64-linux-gnu"), "tampered replacement", ); @@ -308,7 +312,7 @@ describe("single-plugin registry publisher", () => { "artifacts", "deployment-pulse", "0.1.0", - "linux-amd64", + "linux-amd64-gnu", "plugin", ), ), @@ -325,7 +329,7 @@ describe("single-plugin registry publisher", () => { "artifacts", "deployment-pulse", "0.1.0", - "linux-amd64", + "linux-amd64-gnu", "plugin", ); unlinkSync(artifact); @@ -473,13 +477,13 @@ describe("single-plugin registry publisher", () => { const before = readFileSync(join(dataDir, "catalog.json"), "utf8"); const artifact = join( artifactsDir, - "temps-deployment-pulse-plugin-x86_64-linux", + "temps-deployment-pulse-plugin-x86_64-linux-gnu", ); unlinkSync(artifact); symlinkSync(options.signingKeyFile, artifact); await expect(publishPlugin(options)).rejects.toThrow( - "release artifact for linux-amd64 must be a regular file", + "release artifact for linux-amd64-gnu must be a regular file", ); expect(readFileSync(join(dataDir, "catalog.json"), "utf8")).toBe(before); }); diff --git a/scripts/publish-plugin.ts b/scripts/publish-plugin.ts index c82e866..83ace71 100644 --- a/scripts/publish-plugin.ts +++ b/scripts/publish-plugin.ts @@ -69,8 +69,10 @@ const OFFICIAL_ROOT_KEYS = new Map([ ]); const PLATFORM_ARTIFACTS = [ - ["linux-amd64", "x86_64-linux"], - ["linux-arm64", "aarch64-linux"], + ["linux-amd64-gnu", "x86_64-linux-gnu"], + ["linux-amd64-musl", "x86_64-linux-musl"], + ["linux-arm64-gnu", "aarch64-linux-gnu"], + ["linux-arm64-musl", "aarch64-linux-musl"], ["darwin-amd64", "x86_64-darwin"], ["darwin-arm64", "aarch64-darwin"], ] as const; From 2348c5c619fa2385f4edb1fe5cadd6576560c916 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Thu, 10 Sep 2026 20:37:13 +0200 Subject: [PATCH 4/7] fix(release): preserve glibc compatibility --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c2e87f..11a81f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: matrix: include: - target: x86_64-unknown-linux-gnu - os: ubuntu-24.04 + os: ubuntu-22.04 suffix: x86_64-linux-gnu bun_target: bun-linux-x64 - target: x86_64-unknown-linux-musl @@ -29,7 +29,7 @@ jobs: suffix: x86_64-linux-musl bun_target: bun-linux-x64-musl - target: aarch64-unknown-linux-gnu - os: ubuntu-24.04-arm + os: ubuntu-22.04-arm suffix: aarch64-linux-gnu bun_target: bun-linux-arm64 - target: aarch64-unknown-linux-musl From 6efb0b9e693401e415022449a96639f58ffc81c0 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Thu, 10 Sep 2026 20:38:31 +0200 Subject: [PATCH 5/7] fix(release): verify tag matches plugin versions --- .github/workflows/release.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11a81f1..ec92a2c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,6 +65,16 @@ jobs: with: bun-version: 1.3.14 + - name: Verify tag matches plugin versions + if: startsWith(github.ref, 'refs/tags/v') + shell: bash + run: | + tag_version="${GITHUB_REF_NAME#v}" + manifest_version="$(bun -e 'const value = await Bun.file("deployment-pulse-plugin/registry.json").json(); process.stdout.write(value.version)')" + test "${tag_version}" = "${manifest_version}" + cargo metadata --no-deps --format-version 1 \ + | jq -e --arg version "${tag_version}" 'all(.packages[]; .version == $version)' + - name: Install musl toolchain (Linux) if: contains(matrix.target, 'linux-musl') run: | From 1afa51d7722d88bd76ab727787588762af01da45 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Fri, 11 Sep 2026 14:32:37 +0200 Subject: [PATCH 6/7] fix(deployment-pulse): require effective system admin permission Signed-off-by: David Viejo --- Cargo.toml | 2 +- deployment-pulse-plugin/README.md | 12 +++-- deployment-pulse-plugin/package.json | 2 +- deployment-pulse-plugin/registry.json | 2 +- deployment-pulse-plugin/src/index.test.ts | 57 +++++++++++++++++++++++ deployment-pulse-plugin/src/index.ts | 21 ++++++++- deployment-pulse-plugin/web/src/App.tsx | 3 ++ 7 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 deployment-pulse-plugin/src/index.test.ts diff --git a/Cargo.toml b/Cargo.toml index b09fc45..df84ac8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "0.1.0" +version = "0.1.1" edition = "2021" license = "Apache-2.0" authors = ["Temps Contributors"] diff --git a/deployment-pulse-plugin/README.md b/deployment-pulse-plugin/README.md index 227142a..751dec4 100644 --- a/deployment-pulse-plugin/README.md +++ b/deployment-pulse-plugin/README.md @@ -39,9 +39,15 @@ read-only API at `/api/x/deployment-pulse/overview`. ## Permissions and data -Deployment Pulse declares no raw API capability, database access, or host-data -access. It reads only the caller-scoped `list_projects` and `list_deployments` -methods exposed by the signed protocol-v2 SDK channel. +Deployment Pulse is an installation-wide dashboard for system administrators. +Its API requires an SDK-verified caller with the effective `system:admin` +permission before querying any project data. Restricted API keys do not gain +access merely because their owner has an administrator role. + +It declares no raw API capability, database access, or host-data access. The +legacy `list_projects` and `list_deployments` channel methods span the whole +installation; they are not scoped to the caller. The permission check is +therefore mandatory for this dashboard. Project links use the public `/projects/` route. Internal project IDs are never exposed in dashboard URLs. diff --git a/deployment-pulse-plugin/package.json b/deployment-pulse-plugin/package.json index 4b682ee..2abb0aa 100644 --- a/deployment-pulse-plugin/package.json +++ b/deployment-pulse-plugin/package.json @@ -1,6 +1,6 @@ { "name": "typescript-deployment-pulse-plugin", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "scripts": { diff --git a/deployment-pulse-plugin/registry.json b/deployment-pulse-plugin/registry.json index f31800a..d30448a 100644 --- a/deployment-pulse-plugin/registry.json +++ b/deployment-pulse-plugin/registry.json @@ -1,6 +1,6 @@ { "name": "deployment-pulse", - "version": "0.1.0", + "version": "0.1.1", "binary": "temps-deployment-pulse-plugin", "title": "Deployment Pulse", "summary": "Monitor deployment health across every project.", diff --git a/deployment-pulse-plugin/src/index.test.ts b/deployment-pulse-plugin/src/index.test.ts new file mode 100644 index 0000000..d455a15 --- /dev/null +++ b/deployment-pulse-plugin/src/index.test.ts @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2024-2026 Temps Contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +import { expect, test, mock, spyOn } from "bun:test"; +import { IncomingMessage, ServerResponse } from "node:http"; +import { Socket } from "node:net"; +import { AuthenticatedCaller, PluginContext, type TempsClient } from "@temps-sdk/plugin"; +// Test-only fixture hook: production uses extractAuthContext, never this helper. +import { attachVerifiedCaller } from "../node_modules/@temps-sdk/plugin/dist/auth.js"; +import { plugin } from "./index"; + +async function overview(caller?: AuthenticatedCaller) { + const listProjects = mock(async () => []); + const ctx = new PluginContext({ + pluginName: "deployment-pulse", dataDir: "/tmp/test-pulse", + authSecret: "test-only", client: { listProjects } as unknown as TempsClient, + }); + const req = new IncomingMessage(new Socket()); + req.method = "GET"; + req.url = "/overview"; + // Forging raw identity headers must never authorize a request. + req.headers = { "x-temps-user-role": "admin", "x-temps-user-permissions": "system:admin" }; + if (caller) attachVerifiedCaller(req, caller); + const res = new ServerResponse(req); + const end = spyOn(res, "end").mockImplementation(() => res); + const handler = await plugin.handler(ctx); + await handler(req, res); + end.mockRestore(); + req.destroy(); + return { status: res.statusCode, calls: listProjects.mock.calls.length }; +} + +function caller(role: "admin" | "reader" | "custom", permissions: string[]) { + return new AuthenticatedCaller({ + userId: 1, userEmail: "operator@example.test", role, permissions, requestId: "test", + }); +} + +test("unverified identity headers cannot read any projects", async () => { + expect(await overview()).toEqual({ status: 401, calls: 0 }); +}); + +test("readers cannot read installation-wide deployment data", async () => { + expect(await overview(caller("reader", ["projects:read"]))).toEqual({ status: 403, calls: 0 }); +}); + +test("admin role cannot bypass narrowed effective permissions", async () => { + expect(await overview(caller("admin", ["projects:read"]))).toEqual({ status: 403, calls: 0 }); +}); + +test("verified system administrators can read the overview", async () => { + expect(await overview(caller("admin", ["system:admin"]))).toEqual({ status: 200, calls: 1 }); +}); + +test("authorization follows effective permission, not the role label", async () => { + expect(await overview(caller("custom", ["system:admin"]))).toEqual({ status: 200, calls: 1 }); +}); diff --git a/deployment-pulse-plugin/src/index.ts b/deployment-pulse-plugin/src/index.ts index 8847364..87a41a3 100644 --- a/deployment-pulse-plugin/src/index.ts +++ b/deployment-pulse-plugin/src/index.ts @@ -3,6 +3,7 @@ import { createManifest, + extractAuthContext, runPlugin, type PluginContext, type PluginEvent, @@ -77,7 +78,7 @@ function json( res.end(JSON.stringify(body)); } -const plugin: TempsPlugin = { +export const plugin: TempsPlugin = { manifest() { return createManifest("deployment-pulse", pluginMetadata.version) .displayName("Deployment Pulse") @@ -101,6 +102,20 @@ const plugin: TempsPlugin = { return; } + // Legacy typed channel queries span the installation. Only the SDK's + // verified caller and effective permissions may authorize this view. + const caller = extractAuthContext(req); + if (!caller) { + json(res, 401, { error: "Sign in to view deployment health." }); + return; + } + if (!caller.hasPermission("system:admin")) { + json(res, 403, { + error: "System administrator permission is required to view deployment health across all projects.", + }); + return; + } + try { const projects = await ctx.temps.listProjects(); const pulses = await loadProjectPulses(ctx, projects); @@ -145,4 +160,6 @@ const plugin: TempsPlugin = { }, }; -await runPlugin(plugin); +if (import.meta.main) { + await runPlugin(plugin); +} diff --git a/deployment-pulse-plugin/web/src/App.tsx b/deployment-pulse-plugin/web/src/App.tsx index 51ea91d..e10f2dd 100644 --- a/deployment-pulse-plugin/web/src/App.tsx +++ b/deployment-pulse-plugin/web/src/App.tsx @@ -87,6 +87,9 @@ export function App() { background ? setRefreshing(true) : setLoading(true); try { const response = await fetch(API_URL, { headers: { Accept: "application/json" } }); + if (response.status === 403) { + throw new Error("System administrator permission is required to view deployment health across all projects."); + } if (!response.ok) throw new Error(`Temps returned ${response.status}`); setOverview(await response.json()); setError(""); From 503414cde481f8dda637197bdeff4bb8b4a3fc03 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Fri, 11 Sep 2026 14:36:21 +0200 Subject: [PATCH 7/7] test(deployment-pulse): generate assets before handler tests Signed-off-by: David Viejo --- deployment-pulse-plugin/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deployment-pulse-plugin/package.json b/deployment-pulse-plugin/package.json index 2abb0aa..1996add 100644 --- a/deployment-pulse-plugin/package.json +++ b/deployment-pulse-plugin/package.json @@ -8,7 +8,7 @@ "embed": "bun run scripts/embed-assets.ts", "compile": "bun run embed && bun build src/index.ts --compile --outfile dist/temps-deployment-pulse-plugin", "build": "bun run compile", - "test": "bun test src web/src" + "test": "bun run embed && bun test src web/src" }, "dependencies": { "@temps-sdk/plugin": "0.1.0-beta.1"