Files
TREK/plugin-sdk/test/sdk.test.ts
T
Maurice cf9be554ac 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).
2026-07-04 19:11:23 +02:00

278 lines
14 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
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, 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> {
let e = -1;
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { e = i; break; }
const count = buf.readUInt16LE(e + 8);
let p = buf.readUInt32LE(e + 16);
const out: Record<string, Buffer> = {};
for (let i = 0; i < count; i++) {
const method = buf.readUInt16LE(p + 10);
const compSize = buf.readUInt32LE(p + 20);
const nameLen = buf.readUInt16LE(p + 28);
const extraLen = buf.readUInt16LE(p + 30);
const commentLen = buf.readUInt16LE(p + 32);
const lho = buf.readUInt32LE(p + 42);
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
const dataStart = lho + 30 + buf.readUInt16LE(lho + 26) + buf.readUInt16LE(lho + 28);
const comp = buf.subarray(dataStart, dataStart + compSize);
out[name] = method === 0 ? Buffer.from(comp) : zlib.inflateRawSync(comp);
p += 46 + nameLen + extraLen + commentLen;
}
return out;
}
describe('definePlugin + api version', () => {
it('returns the definition and exposes the api version', () => {
const def = { onLoad: async () => {} };
expect(definePlugin(def)).toBe(def);
expect(PLUGIN_API_VERSION).toBe(1);
});
});
describe('validateManifest', () => {
const base = { id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', apiVersion: 1 };
it('accepts a valid manifest', () => {
expect(validateManifest(base).ok).toBe(true);
});
it('collects every problem', () => {
const r = validateManifest({ id: 'Bad', version: '1.x', type: 'nope', permissions: ['fs:read'] });
expect(r.ok).toBe(false);
expect(r.errors.length).toBeGreaterThan(2);
});
it('requires egress when http:outbound is declared', () => {
expect(validateManifest({ ...base, permissions: ['http:outbound'] }).ok).toBe(false);
expect(validateManifest({ ...base, permissions: ['http:outbound'], egress: ['api.x.com'] }).ok).toBe(true);
});
it('rejects native modules and non-objects', () => {
expect(validateManifest({ ...base, nativeModules: true }).ok).toBe(false);
expect(validateManifest('nope').ok).toBe(false);
});
});
describe('createMockHost', () => {
it('enforces the granted permission set', async () => {
const { ctx } = createMockHost({ grants: ['db:own'] });
await expect(ctx.db.migrate('1', 'CREATE TABLE t (x)')).resolves.toEqual({ applied: true });
await expect(ctx.trips.getById(1, 1)).rejects.toThrow(/PERMISSION_DENIED/);
});
it('membership-checks trip reads and records broadcasts', async () => {
const { ctx, broadcasts } = createMockHost({
grants: ['db:read:trips', 'ws:broadcast:trip'],
trips: { 1: { members: [42], data: { id: 1, name: 'Japan' } } },
});
await expect(ctx.trips.getById(1, 99)).rejects.toThrow(/RESOURCE_FORBIDDEN/);
expect(await ctx.trips.getById(1, 42)).toEqual({ id: 1, name: 'Japan' });
await ctx.ws.broadcastToTrip(1, 'ping', { a: 1 });
expect(broadcasts).toEqual([{ kind: 'trip', target: 1, event: 'ping', data: { a: 1 } }]);
});
it('returns canned db.query results + records logs', async () => {
const { ctx, logs } = createMockHost({ grants: ['db:own'], queryResults: { 'SELECT 1': [{ n: 1 }] } });
expect(await ctx.db.query('SELECT 1')).toEqual([{ n: 1 }]);
ctx.log.info('hi');
expect(logs).toEqual([{ level: 'info', msg: 'hi' }]);
});
});
describe('scaffold + validate CLIs', () => {
let tmp: string;
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-')); });
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
it('scaffolds a widget plugin that then validates (with README warnings)', () => {
scaffold('my-widget', 'widget', tmp);
const dir = path.join(tmp, 'my-widget');
expect(fs.existsSync(path.join(dir, 'trek-plugin.json'))).toBe(true);
expect(fs.existsSync(path.join(dir, 'server', 'index.js'))).toBe(true);
expect(fs.existsSync(path.join(dir, 'client', 'index.html'))).toBe(true);
const r = validatePluginDir(dir);
expect(r.ok).toBe(true); // manifest + files valid
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/);
});
it('validatePluginDir flags a missing manifest', () => {
expect(validatePluginDir(tmp).ok).toBe(false);
});
});
describe('reference plugin (examples/koffi)', () => {
const dir = path.resolve(import.meta.dirname, '..', 'examples', 'koffi');
it('passes the same validation authors run', () => {
const r = validatePluginDir(dir);
expect(r.errors).toEqual([]);
expect(r.ok).toBe(true);
});
it('has a valid, minimal-permission hero-widget manifest', () => {
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'trek-plugin.json'), 'utf8')) as {
capabilities?: { widget?: { slot?: string } };
};
const res = validateManifest(manifest);
expect(res.ok).toBe(true);
expect(res.manifest?.permissions).toEqual(['db:read:trips']);
expect(manifest.capabilities?.widget?.slot).toBe('hero');
});
});
describe('makeZip', () => {
it('round-trips through a central-directory reader (installer-compatible)', () => {
const files = [
{ name: 'trek-plugin.json', data: Buffer.from('{"id":"x"}') },
{ name: 'server/index.js', data: Buffer.from('module.exports={}\n'.repeat(200)) },
];
const zip = makeZip(files);
expect(zip.subarray(0, 2).toString()).toBe('PK'); // local file header magic
const back = readZip(zip);
expect(Object.keys(back).sort()).toEqual(['server/index.js', 'trek-plugin.json']);
expect(back['trek-plugin.json'].toString()).toBe('{"id":"x"}');
expect(back['server/index.js'].length).toBe(files[1].data.length);
});
});
describe('pack + entry (publishing automation)', () => {
const koffi = path.resolve(import.meta.dirname, '..', 'examples', 'koffi');
let tmp: string;
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-')); });
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
it('packs the canonical layout, excludes docs/, and reports sha256 + size', () => {
const out = path.join(tmp, 'plugin.zip');
const r = packPluginDir(koffi, out);
expect(r.files).toEqual(['README.md', 'client/index.html', 'server/index.js', 'trek-plugin.json']);
expect(r.sha256).toMatch(/^[0-9a-f]{64}$/);
expect(r.size).toBeGreaterThan(0);
const back = readZip(fs.readFileSync(out));
expect(back['trek-plugin.json']).toBeTruthy();
expect(Object.keys(back).some((n) => n.startsWith('docs/'))).toBe(false); // screenshot served from repo, not shipped
});
it('refuses a plugin that ships a native binary', () => {
const bad = path.join(tmp, 'bad');
fs.mkdirSync(path.join(bad, 'server'), { recursive: true });
fs.writeFileSync(path.join(bad, 'trek-plugin.json'), JSON.stringify({ id: 'bad-plug', name: 'Bad', version: '1.0.0', type: 'integration', permissions: [], egress: [] }));
fs.writeFileSync(path.join(bad, 'server', 'index.js'), 'module.exports={}');
fs.writeFileSync(path.join(bad, 'server', 'thing.node'), Buffer.from([1, 2, 3]));
fs.writeFileSync(path.join(bad, 'README.md'), '# Bad\n![x](x.png)\ncontent');
expect(() => packPluginDir(bad, path.join(tmp, 'x.zip'))).toThrow(/native binaries/);
});
it('builds a registry entry with sha256/size/commit/minTrekVersion filled in', () => {
const out = path.join(tmp, 'plugin.zip');
const packed = packPluginDir(koffi, out);
const entry = buildEntry({
dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.0.0', zipPath: out,
commit: 'a'.repeat(40), now: '2026-07-04T00:00:00.000Z',
});
expect(entry.id).toBe('koffi');
expect(entry.type).toBe('widget');
const v = entry.versions[0];
expect(v.sha256).toBe(packed.sha256);
expect(v.size).toBe(packed.size);
expect(v.commitSha).toBe('a'.repeat(40));
expect(v.minTrekVersion).toBe('3.2.0');
expect(v.downloadUrl).toBe('https://github.com/mauriceboe/trek-plugin-koffi/releases/download/v1.0.0/plugin.zip');
expect(v.nativeModules).toBe(false);
});
it('--merge prepends the new version onto an existing entry, newest-first', () => {
const out = path.join(tmp, 'plugin.zip');
packPluginDir(koffi, out);
const existingPath = path.join(tmp, 'koffi.json');
fs.writeFileSync(existingPath, JSON.stringify({
id: 'koffi', name: 'Koffi', author: 'TREK', description: 'x', repo: 'mauriceboe/trek-plugin-koffi', type: 'widget',
versions: [{ version: '0.9.0', gitTag: 'v0.9.0', commitSha: 'b'.repeat(40), downloadUrl: 'https://github.com/x/y/releases/download/v0.9.0/plugin.zip', sha256: 'c'.repeat(64), minTrekVersion: '3.2.0', size: 10, apiVersion: 1, nativeModules: false, publishedAt: '2026-01-01T00:00:00.000Z' }],
}));
const merged = buildEntry({
dir: koffi, repo: 'mauriceboe/trek-plugin-koffi', tag: 'v1.0.0', zipPath: out,
commit: 'a'.repeat(40), mergePath: existingPath, now: '2026-07-04T00:00:00.000Z',
});
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/);
});
});