feat: add plugin packing, signing, pinned trust and tmxplug bundling

This commit is contained in:
LukeGus committed 2026-09-25 09:21:28 -05:00
1 parent c6a44b7ff3
commit 2ee9b3592c
25 files changed
+1764 -56

No files matched your search

+49 -23
View File
@@ -1,39 +1,39 @@
/**
* Builds every plugin workspace, then stages the result in dist/plugins.
* Stages the plugins listed in docker/bundled-plugins.json in dist/plugins.
*
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
* Only what a server needs at runtime is copied: the manifest, the built
* bundles, locales and migrations. Source and tests stay out of the image.
* A workspace plugin is built from plugins/<id>; a tmxplug plugin is
* downloaded (or read from a path), checked against its pinned sha256 and
* unpacked. Either way only what a server needs at runtime lands there.
*/
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const {
loadBundledPlugins,
fetchArtifact,
extractArtifact,
} = require("./lib/bundled-plugins.cjs");
const root = path.resolve(__dirname, "..");
const source = path.join(root, "plugins");
const destination = path.join(root, "dist", "plugins");
const cli = path.join(root, "packages", "plugin-sdk", "cli", "index.mjs");
const SHIPPED = ["manifest.json", "dist", "locales", "migrations", "README.md"];
// Same list as a .tmxplug, see packages/plugin-sdk/cli/lib/tmxplug.mjs.
const SHIPPED = [
"manifest.json",
"dist",
"locales",
"migrations",
"README.md",
"CHANGELOG.md",
"icon.svg",
];
if (!fs.existsSync(source)) {
console.log("No plugins/ directory, nothing to bundle.");
process.exit(0);
}
const pluginIds = fs
.readdirSync(source, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.filter((entry) =>
fs.existsSync(path.join(source, entry.name, "manifest.json")),
)
.map((entry) => entry.name);
fs.rmSync(destination, { recursive: true, force: true });
for (const id of pluginIds) {
function stageWorkspace(id) {
const pluginDir = path.join(source, id);
execFileSync(process.execPath, [cli, "build"], {
@@ -41,8 +41,7 @@ for (const id of pluginIds) {
stdio: "inherit",
});
const built = path.join(pluginDir, "dist", "backend.js");
if (!fs.existsSync(built)) {
if (!fs.existsSync(path.join(pluginDir, "dist", "backend.js"))) {
throw new Error(`${id} produced no dist/backend.js`);
}
@@ -55,4 +54,31 @@ for (const id of pluginIds) {
}
}
console.log(`Bundled ${pluginIds.length} plugin(s): ${pluginIds.join(", ")}`);
async function main() {
const plugins = loadBundledPlugins(root);
fs.rmSync(destination, { recursive: true, force: true });
for (const plugin of plugins) {
if (plugin.source === "workspace") {
stageWorkspace(plugin.id);
} else {
const buffer = await fetchArtifact(plugin, root);
await extractArtifact(
buffer,
path.join(destination, plugin.id),
plugin.id,
);
console.log(`unpacked ${plugin.id}`);
}
}
console.log(
`Bundled ${plugins.length} plugin(s): ${plugins.map((p) => p.id).join(", ")}`,
);
}
main().catch((error) => {
console.error(error?.message ?? error);
process.exit(1);
});
+161
View File
@@ -0,0 +1,161 @@
import { afterEach, describe, expect, it } from "vitest";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import * as tar from "tar";
import {
parseBundledPlugins,
loadBundledPlugins,
fetchArtifact,
extractArtifact,
} from "./lib/bundled-plugins.cjs";
const cleanups: Array<() => void> = [];
function tempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-bundled-"));
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
return dir;
}
afterEach(() => {
while (cleanups.length) cleanups.pop()?.();
});
const SHA = "a".repeat(64);
async function artifactFor(id: string): Promise<Buffer> {
const src = tempDir();
fs.writeFileSync(path.join(src, "manifest.json"), JSON.stringify({ id }));
fs.mkdirSync(path.join(src, "dist"));
fs.writeFileSync(path.join(src, "dist", "backend.js"), "export {}\n");
const file = path.join(tempDir(), `${id}.tmxplug`);
await tar.c({ gzip: true, file, cwd: src }, ["manifest.json", "dist"]);
return fs.readFileSync(file);
}
const sha256 = (buffer: Buffer) =>
crypto.createHash("sha256").update(buffer).digest("hex");
describe("parseBundledPlugins", () => {
it("accepts workspace and tmxplug entries", () => {
const { plugins, problems } = parseBundledPlugins(
{
plugins: [
{ id: "one", source: "workspace" },
{
id: "two",
source: "tmxplug",
url: "https://x/two.tmxplug",
sha256: SHA,
},
{
id: "three",
source: "tmxplug",
path: "vendor/three.tmxplug",
sha256: SHA,
},
],
},
["one"],
);
expect(problems).toEqual([]);
expect(plugins.map((p: { id: string }) => p.id)).toEqual([
"one",
"two",
"three",
]);
});
it("reports a plugins folder nobody listed", () => {
const { problems } = parseBundledPlugins(
{ plugins: [{ id: "one", source: "workspace" }] },
["one", "stray"],
);
expect(problems).toEqual([
"plugins/stray is not listed in docker/bundled-plugins.json",
]);
});
it("reports bad entries", () => {
const { problems } = parseBundledPlugins(
{
plugins: [
{ id: "gone", source: "workspace" },
{ id: "nohash", source: "tmxplug", url: "https://x" },
{ id: "plain", source: "tmxplug", url: "http://x", sha256: SHA },
{ id: "still-here", source: "tmxplug", path: "a", sha256: SHA },
{ id: "gone", source: "workspace" },
{ id: "odd", source: "npm" },
],
},
["still-here"],
);
expect(problems).toEqual([
"gone is a workspace plugin but plugins/gone does not exist",
"nohash needs a lowercase hex sha256",
"plain needs exactly one of an https url or a path",
"still-here comes from a .tmxplug, so delete plugins/still-here",
"gone is listed twice",
'odd has an unknown source "npm"',
]);
});
it("matches the plugins folder in this repo", () => {
const root = path.resolve(__dirname, "..");
expect(() => loadBundledPlugins(root)).not.toThrow();
});
});
describe("tmxplug entries", () => {
it("unpacks an artifact that matches its pinned sha256", async () => {
const root = tempDir();
const buffer = await artifactFor("demo");
fs.writeFileSync(path.join(root, "demo.tmxplug"), buffer);
const fetched = await fetchArtifact(
{ id: "demo", path: "demo.tmxplug", sha256: sha256(buffer) },
root,
);
const out = path.join(root, "dist", "plugins", "demo");
await extractArtifact(fetched, out, "demo");
expect(fs.existsSync(path.join(out, "dist", "backend.js"))).toBe(true);
});
it("refuses an artifact whose sha256 differs", async () => {
const root = tempDir();
fs.writeFileSync(
path.join(root, "demo.tmxplug"),
await artifactFor("demo"),
);
await expect(
fetchArtifact({ id: "demo", path: "demo.tmxplug", sha256: SHA }, root),
).rejects.toThrow(/bundled-plugins.json pins/);
});
it("downloads once and reuses the cache", async () => {
const root = tempDir();
const buffer = await artifactFor("demo");
let calls = 0;
const fakeFetch = async () => {
calls++;
return new Response(buffer);
};
const entry = {
id: "demo",
url: "https://example.test/demo.tmxplug",
sha256: sha256(buffer),
};
await fetchArtifact(entry, root, fakeFetch);
await fetchArtifact(entry, root, fakeFetch);
expect(calls).toBe(1);
});
it("refuses an artifact for a different id", async () => {
const out = path.join(tempDir(), "demo");
await expect(
extractArtifact(await artifactFor("other"), out, "demo"),
).rejects.toThrow(/is for "other"/);
});
});
+196
View File
@@ -0,0 +1,196 @@
/**
* Reads docker/bundled-plugins.json: which plugins ship in the image and
* where each comes from.
*
* { "id": "x", "source": "workspace" } built from plugins/x
* { "id": "x", "source": "tmxplug", "url": "...", "sha256": "..." }
* { "id": "x", "source": "tmxplug", "path": "...", "sha256": "..." }
*
* A tmxplug entry is pinned by sha256 in this file, which is reviewed like
* any other change, so the build does not need the registry signature.
*/
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const ID = /^[a-z0-9][a-z0-9-]*$/;
const SHA256 = /^[0-9a-f]{64}$/;
function parseBundledPlugins(raw, workspaceIds) {
const problems = [];
const list = raw && Array.isArray(raw.plugins) ? raw.plugins : null;
if (!list) {
return {
plugins: [],
problems: ['bundled-plugins.json needs a "plugins" array'],
};
}
const seen = new Set();
const plugins = [];
for (const [index, entry] of list.entries()) {
const where = `plugins[${index}]`;
if (!entry || typeof entry.id !== "string" || !ID.test(entry.id)) {
problems.push(`${where} needs a valid id`);
continue;
}
if (seen.has(entry.id)) {
problems.push(`${entry.id} is listed twice`);
continue;
}
seen.add(entry.id);
if (entry.source === "workspace") {
if (!workspaceIds.includes(entry.id)) {
problems.push(
`${entry.id} is a workspace plugin but plugins/${entry.id} does not exist`,
);
continue;
}
plugins.push({ id: entry.id, source: "workspace" });
} else if (entry.source === "tmxplug") {
const hasUrl =
typeof entry.url === "string" && entry.url.startsWith("https://");
const hasPath = typeof entry.path === "string" && entry.path.length > 0;
if (hasUrl === hasPath) {
problems.push(
`${entry.id} needs exactly one of an https url or a path`,
);
continue;
}
if (typeof entry.sha256 !== "string" || !SHA256.test(entry.sha256)) {
problems.push(`${entry.id} needs a lowercase hex sha256`);
continue;
}
if (workspaceIds.includes(entry.id)) {
problems.push(
`${entry.id} comes from a .tmxplug, so delete plugins/${entry.id}`,
);
continue;
}
plugins.push({
id: entry.id,
source: "tmxplug",
url: hasUrl ? entry.url : null,
path: hasPath ? entry.path : null,
sha256: entry.sha256,
});
} else {
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
}
}
for (const id of workspaceIds) {
if (!seen.has(id)) {
problems.push(
`plugins/${id} is not listed in docker/bundled-plugins.json`,
);
}
}
return { plugins, problems };
}
function loadBundledPlugins(root) {
const configPath = path.join(root, "docker", "bundled-plugins.json");
const pluginsDir = path.join(root, "plugins");
const workspaceIds = fs.existsSync(pluginsDir)
? fs
.readdirSync(pluginsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.filter((entry) =>
fs.existsSync(path.join(pluginsDir, entry.name, "manifest.json")),
)
.map((entry) => entry.name)
.sort()
: [];
const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
const { plugins, problems } = parseBundledPlugins(raw, workspaceIds);
if (problems.length > 0) {
throw new Error(`docker/bundled-plugins.json:\n ${problems.join("\n ")}`);
}
return plugins;
}
/** Refuses an artifact whose bytes are not the ones the config pinned. */
function checkSha256(buffer, expected, id) {
const actual = crypto.createHash("sha256").update(buffer).digest("hex");
if (actual !== expected) {
throw new Error(
`${id}: sha256 is ${actual}, bundled-plugins.json pins ${expected}`,
);
}
}
/**
* The .tmxplug bytes for an entry: a local path, or a download cached by
* sha256 so rebuilds do not fetch it again.
*/
async function fetchArtifact(entry, root, fetchImpl = fetch) {
if (entry.path) {
const buffer = fs.readFileSync(path.resolve(root, entry.path));
checkSha256(buffer, entry.sha256, entry.id);
return buffer;
}
const cacheDir = path.join(
root,
"node_modules",
".cache",
"termix-bundled-plugins",
);
const cached = path.join(cacheDir, `${entry.sha256}.tmxplug`);
if (fs.existsSync(cached)) {
const buffer = fs.readFileSync(cached);
checkSha256(buffer, entry.sha256, entry.id);
return buffer;
}
const response = await fetchImpl(entry.url);
if (!response.ok) {
throw new Error(`${entry.id}: download failed with ${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
checkSha256(buffer, entry.sha256, entry.id);
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(cached, buffer);
return buffer;
}
/** Unpacks a checked .tmxplug into outDir and confirms its manifest id. */
async function extractArtifact(buffer, outDir, id) {
const tar = require("tar");
const { Readable } = require("node:stream");
const { pipeline } = require("node:stream/promises");
fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });
await pipeline(
Readable.from(buffer),
tar.x({
cwd: outDir,
strict: true,
filter: (_p, entry) =>
entry.type === "File" || entry.type === "Directory",
}),
);
const manifestPath = path.join(outDir, "manifest.json");
if (!fs.existsSync(manifestPath)) {
throw new Error(`${id}: the .tmxplug has no manifest.json`);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if (manifest.id !== id) {
throw new Error(`${id}: the .tmxplug is for "${manifest.id}"`);
}
}
module.exports = {
parseBundledPlugins,
loadBundledPlugins,
checkSha256,
fetchArtifact,
extractArtifact,
};
+140
View File
@@ -0,0 +1,140 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import * as tar from "tar";
import { pack } from "../packages/plugin-sdk/cli/commands/pack.mjs";
const cleanups: Array<() => void> = [];
function tempDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
return dir;
}
function writeFile(root: string, rel: string, content: string) {
const full = path.join(root, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
function fixturePlugin(manifest: Record<string, unknown> = {}): string {
const dir = path.join(tempDir("termix-pack-"), "pack-fixture");
writeFile(
dir,
"manifest.json",
JSON.stringify({
id: "pack-fixture",
name: "Pack Fixture",
version: "1.2.3",
description: "Fixture for the pack tests.",
author: { name: "Termix Tests" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: [],
...manifest,
}),
);
writeFile(dir, "dist/backend.js", "export async function activate() {}\n");
writeFile(dir, "dist/frontend.js", "export function activate() {}\n");
writeFile(dir, "locales/en.json", "{}\n");
writeFile(dir, "migrations/sqlite/0001_init.sql", "SELECT 1;\n");
writeFile(dir, "migrations/postgres/0001_init.sql", "SELECT 1;\n");
writeFile(dir, "migrations/mysql/0001_init.sql", "SELECT 1;\n");
writeFile(dir, "README.md", "# Fixture\n");
writeFile(dir, "icon.svg", "<svg/>\n");
writeFile(dir, "src/backend/index.ts", "left out of the archive\n");
return dir;
}
function sha(file: string): string {
return crypto
.createHash("sha256")
.update(fs.readFileSync(file))
.digest("hex");
}
async function entries(file: string): Promise<string[]> {
const names: string[] = [];
await tar.t({ file, onReadEntry: (entry) => names.push(entry.path) });
return names;
}
afterEach(() => {
vi.restoreAllMocks();
while (cleanups.length) cleanups.pop()?.();
});
describe("termix-plugin pack", () => {
it("gives the same sha256 when rebuilt with new mtimes", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const dir = fixturePlugin();
const first = await pack({ cwd: dir, args: ["--out", tempDir("a-")] });
const later = new Date(Date.now() + 86_400_000);
for (const rel of ["manifest.json", "dist/backend.js", "README.md"]) {
fs.utimesSync(path.join(dir, rel), later, later);
}
const second = await pack({ cwd: dir, args: ["--out", tempDir("b-")] });
expect(path.basename(first)).toBe("pack-fixture-1.2.3.tmxplug");
expect(sha(first)).toBe(sha(second));
});
it("holds only the shipped files, sorted, with fixed metadata", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const dir = fixturePlugin();
const file = await pack({ cwd: dir, args: ["--out", tempDir("c-")] });
const names = await entries(file);
expect(names).toEqual([
"README.md",
"dist/backend.js",
"dist/frontend.js",
"icon.svg",
"locales/en.json",
"manifest.json",
"migrations/mysql/0001_init.sql",
"migrations/postgres/0001_init.sql",
"migrations/sqlite/0001_init.sql",
]);
const meta: Array<{ mode: number; mtime: number; uid: number }> = [];
await tar.t({
file,
onReadEntry: (entry) =>
meta.push({
mode: entry.mode ?? -1,
mtime: entry.mtime?.getTime() ?? -1,
uid: entry.uid ?? -1,
}),
});
expect(new Set(meta.map((m) => JSON.stringify(m)))).toEqual(
new Set([JSON.stringify({ mode: 0o644, mtime: 0, uid: 0 })]),
);
const gz = fs.readFileSync(file);
expect(gz.readUInt32LE(4)).toBe(0);
expect(gz[9]).toBe(0x03);
const out = tempDir("x-");
await tar.x({ file, cwd: out });
expect(fs.readFileSync(path.join(out, "dist/backend.js"), "utf8")).toBe(
"export async function activate() {}\n",
);
});
it("refuses a manifest with errors", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
const dir = fixturePlugin({ capabilities: ["not:a-capability"] });
const out = tempDir("d-");
await expect(pack({ cwd: dir, args: ["--out", out] })).rejects.toThrow(
/manifest is not valid/,
);
expect(fs.readdirSync(out)).toEqual([]);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
keygen,
sign,
verify,
} from "../packages/plugin-sdk/cli/commands/sign.mjs";
import {
generateKeyPair,
keyId,
loadPrivateKey,
signArtifact,
verifyArtifact,
} from "../packages/plugin-sdk/cli/lib/signing.mjs";
const cleanups: Array<() => void> = [];
function tempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-sign-"));
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
return dir;
}
afterEach(() => {
vi.restoreAllMocks();
while (cleanups.length) cleanups.pop()?.();
});
describe("signing helpers", () => {
const artifact = Buffer.from("plugin bytes");
it("verifies a signature from the matching key", () => {
const pair = generateKeyPair();
const signature = signArtifact(artifact, loadPrivateKey(pair.privateKey));
expect(verifyArtifact(artifact, signature, [pair.publicKey])).toBe(
pair.keyId,
);
expect(keyId(pair.publicKey)).toMatch(/^[0-9a-f]{16}$/);
});
it("rejects a tampered artifact", () => {
const pair = generateKeyPair();
const signature = signArtifact(artifact, loadPrivateKey(pair.privateKey));
expect(
verifyArtifact(Buffer.from("plugin bytez"), signature, [pair.publicKey]),
).toBeNull();
});
it("rejects the wrong key", () => {
const signer = generateKeyPair();
const other = generateKeyPair();
const signature = signArtifact(artifact, loadPrivateKey(signer.privateKey));
expect(verifyArtifact(artifact, signature, [other.publicKey])).toBeNull();
expect(
verifyArtifact(artifact, signature, [other.publicKey, signer.publicKey]),
).toBe(signer.keyId);
});
it("refuses a missing or non-Ed25519 key", () => {
expect(() => loadPrivateKey(undefined)).toThrow(/not set/);
expect(() => loadPrivateKey("not a key")).toThrow();
});
});
describe("keygen, sign and verify commands", () => {
it("round trips and never prints the private key", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const dir = tempDir();
const file = path.join(dir, "demo-1.0.0.tmxplug");
fs.writeFileSync(file, "archive");
const { keyFile, publicKey } = await keygen({
cwd: dir,
args: ["--out", dir],
});
const privateKey = fs.readFileSync(keyFile, "utf8").trim();
expect(log.mock.calls.flat().join("\n")).not.toContain(privateKey);
await sign({
cwd: dir,
args: [file],
env: { TERMIX_PLUGIN_SIGNING_KEY: privateKey },
});
expect(fs.existsSync(`${file}.sig`)).toBe(true);
await expect(
verify({ cwd: dir, args: [file, "--key", publicKey] }),
).resolves.toMatch(/^[0-9a-f]{16}$/);
fs.writeFileSync(file, "archivE");
await expect(
verify({ cwd: dir, args: [file, "--key", publicKey] }),
).rejects.toThrow(/not valid/);
});
it("refuses to sign without the key env var", async () => {
const dir = tempDir();
const file = path.join(dir, "demo-1.0.0.tmxplug");
fs.writeFileSync(file, "archive");
await expect(sign({ cwd: dir, args: [file], env: {} })).rejects.toThrow(
/TERMIX_PLUGIN_SIGNING_KEY is not set/,
);
});
it("will not overwrite an existing key file", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const dir = tempDir();
await keygen({ cwd: dir, args: ["--out", dir] });
await expect(keygen({ cwd: dir, args: ["--out", dir] })).rejects.toThrow(
/already exists/,
);
});
});