mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
cf9be554ac
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).
278 lines
14 KiB
TypeScript
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\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/);
|
|
});
|
|
});
|