feat(plugin-sdk): dev server, preflight, auto-PR submit, signing, wizard

Round out the author experience so the loop is create -> dev -> release/submit
without hand-work or a round-trip through registry review.

- `dev`: run a plugin locally with a real request loop and hot reload — no full
  TREK. Injects a ctx that enforces the manifest's granted permissions (an
  ungranted call throws, so you catch a missing grant), backs db:own with a real
  SQLite file (node:sqlite), serves routes under /api and page/widget UI at /ui,
  and reloads on save. Dependency-free (node:http + built-ins).
- `preflight`: run the registry CI checks locally over the network (tag->commit,
  manifest parity, artifact sha256/size, native scan, README quality gate) so a
  green run predicts a green CI.
- `submit`: fork TREK-Plugins, branch off current main, write/merge the entry,
  push, and open the PR — the last manual publishing step, automated.
- `keygen`/`sign` + `--sign` on entry/release/submit: dependency-free Ed25519
  author signatures over the artifact bytes, verified 1:1 against the server's
  TOFU check. Fills authorPublicKey + signature and guards against a key change.
- `create` gains an interactive wizard (id/type/author/permissions) and flags.
- README + Development/Publishing/Permissions wikis document the new flow.

24 tests pass (sign round-trips through a server-shaped verifier; zip reader;
scaffold options; entry signing + key-change guard).
This commit is contained in:
Maurice
2026-07-04 17:07:15 +02:00
committed by Maurice
parent 39f27ef55d
commit cf9be554ac
14 changed files with 962 additions and 66 deletions
+68 -1
View File
@@ -6,9 +6,10 @@ import zlib from 'node:zlib';
import { definePlugin, PLUGIN_API_VERSION, validateManifest, createMockHost } from '../src/index.js';
import { scaffold } from '../src/cli/create.js';
import { validatePluginDir } from '../src/cli/validate.js';
import { makeZip } from '../src/zip.js';
import { makeZip, listZipNames } from '../src/zip.js';
import { packPluginDir } from '../src/cli/pack.js';
import { buildEntry } from '../src/cli/entry.js';
import { generateKeypair, signArtifact, publicKeyBase64, verifyArtifact, loadPrivateKey } from '../src/cli/sign.js';
/** A central-directory zip reader mirroring the TREK server's, to prove round-trip. */
function readZip(buf: Buffer): Record<string, Buffer> {
@@ -104,6 +105,14 @@ describe('scaffold + validate CLIs', () => {
expect(r.warnings.some((w) => /placeholder|screenshot/.test(w))).toBe(true); // README is the unfilled template
});
it('applies author, description, and permissions from options', () => {
scaffold('opt-plug', 'integration', tmp, { author: 'Jane', description: 'Does X', permissions: ['db:own', 'db:read:trips'] });
const m = JSON.parse(fs.readFileSync(path.join(tmp, 'opt-plug', 'trek-plugin.json'), 'utf8'));
expect(m.author).toBe('Jane');
expect(m.description).toBe('Does X');
expect(m.permissions).toEqual(['db:own', 'db:read:trips']);
});
it('rejects an invalid plugin name', () => {
expect(() => scaffold('Bad Name', 'widget', tmp)).toThrow(/invalid plugin id/);
});
@@ -208,3 +217,61 @@ describe('pack + entry (publishing automation)', () => {
expect(merged.versions.map((v) => v.version)).toEqual(['1.0.0', '0.9.0']);
});
});
describe('listZipNames', () => {
it('lists central-directory entries of a makeZip archive', () => {
const zip = makeZip([{ name: 'a.js', data: Buffer.from('x') }, { name: 'server/b.js', data: Buffer.from('y'.repeat(100)) }]);
expect(listZipNames(zip).sort()).toEqual(['a.js', 'server/b.js']);
});
});
describe('sign + keygen (author signatures, TOFU)', () => {
const koffi = path.resolve(import.meta.dirname, '..', 'examples', 'koffi');
let tmp: string;
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sign-')); });
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
it('keygen writes a private key and returns a schema-length public key', () => {
const keyPath = path.join(tmp, 'k.pem');
const { publicKey } = generateKeypair(keyPath);
expect(fs.existsSync(keyPath)).toBe(true);
expect(publicKey.length).toBeGreaterThanOrEqual(40); // registry schema minLength
expect(() => generateKeypair(keyPath)).toThrow(/already exists/); // never clobbers a key
});
it('a signature round-trips through the server-shaped verifier', () => {
const keyPath = path.join(tmp, 'k.pem');
generateKeypair(keyPath);
const key = loadPrivateKey(keyPath);
const bytes = Buffer.from('the exact plugin.zip bytes ' + 'x'.repeat(60));
const sig = signArtifact(bytes, key);
expect(sig.length).toBeGreaterThanOrEqual(40);
expect(verifyArtifact(bytes, sig, publicKeyBase64(key))).toBe(true);
expect(verifyArtifact(Buffer.concat([bytes, Buffer.from('!')]), sig, publicKeyBase64(key))).toBe(false);
});
it('entry --sign fills signature + authorPublicKey that verify against the artifact', () => {
const out = path.join(tmp, 'plugin.zip');
packPluginDir(koffi, out);
const keyPath = path.join(tmp, 'k.pem');
const { publicKey } = generateKeypair(keyPath);
const entry = buildEntry({ dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.0.0', zipPath: out, commit: 'a'.repeat(40), signKeyPath: keyPath, now: '2026-07-04T00:00:00.000Z' });
expect(entry.authorPublicKey).toBe(publicKey);
const sig = entry.versions[0].signature;
expect(sig).toBeTruthy();
expect(verifyArtifact(fs.readFileSync(out), sig!, entry.authorPublicKey!)).toBe(true);
});
it('refuses to sign an update with a different key than the one already published', () => {
const out = path.join(tmp, 'plugin.zip');
packPluginDir(koffi, out);
const key1 = path.join(tmp, 'k1.pem');
generateKeypair(key1);
const existing = buildEntry({ dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.0.0', zipPath: out, commit: 'a'.repeat(40), signKeyPath: key1, now: '2026-07-04T00:00:00.000Z' });
const existingPath = path.join(tmp, 'koffi.json');
fs.writeFileSync(existingPath, JSON.stringify({ ...existing, versions: existing.versions.map((v) => ({ ...v, version: '0.9.0', gitTag: 'v0.9.0' })) }));
const key2 = path.join(tmp, 'k2.pem');
generateKeypair(key2);
expect(() => buildEntry({ dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.1.0', zipPath: out, commit: 'a'.repeat(40), mergePath: existingPath, signKeyPath: key2, now: '2026-07-04T00:00:00.000Z' })).toThrow(/differs from the one already published/);
});
});