Files
TREK/plugin-sdk/test/sdk.test.ts
T
Maurice 6c7e257c17 test(plugins): cover the mock double and the child proxy for addEntryPhoto
The handler had eight cases, its two doubles had none, which Sonar counted as
new code without coverage. The mock is the more useful of the two to pin
anyway: an importer developing against it should hit the same refusals it will
hit in production, so the SVG, the missing extension, the empty payload and the
size cap are all asserted against the double rather than only against the host.
2026-08-28 18:04:25 +02:00

1154 lines
62 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import zlib from 'node:zlib';
import { createRequire } from 'node:module';
import { definePlugin, PLUGIN_API_VERSION, validateManifest, createMockHost, PermissionDenied } from '../src/index.js';
import { isUnboundedRange } from '../src/manifest.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';
import { makePublishable } from './helpers.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, trek: '>=3.2.0 <4.0.0' };
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('waives the egress requirement for an operatorEgress plugin', () => {
// A self-hosted target (Gotify, ntfy) has no host the author can name at publish
// time — the admin supplies it after install, and TREK blocks all outbound until then.
expect(validateManifest({ ...base, permissions: ['http:outbound'], operatorEgress: true }).ok).toBe(true);
// The waiver is tied to the flag: no flag, still an error.
expect(validateManifest({ ...base, permissions: ['http:outbound'], operatorEgress: false }).ok).toBe(false);
// …and the flag alone never grants reach: it still needs an http:outbound permission.
expect(validateManifest({ ...base, permissions: ['db:own'], operatorEgress: true }).ok).toBe(false);
});
it('rejects native modules and non-objects', () => {
expect(validateManifest({ ...base, nativeModules: true }).ok).toBe(false);
expect(validateManifest('nope').ok).toBe(false);
});
it('accepts the newer host permissions (writes, notify, map-marker hook)', () => {
const permissions = ['db:write:reservations', 'db:write:accommodations', 'notify:send', 'hook:map-marker-provider'];
const r = validateManifest({ ...base, permissions });
expect(r.errors).toEqual([]);
expect(r.ok).toBe(true);
expect(r.manifest?.permissions).toEqual(permissions);
});
it('accepts hook:trip-card-provider and hook:user-data (server accepts them)', () => {
const permissions = ['hook:trip-card-provider', 'hook:user-data'];
const r = validateManifest({ ...base, permissions });
expect(r.ok).toBe(true);
expect(r.manifest?.permissions).toEqual(permissions);
});
it('accepts hook:map-layer-provider, hook:day-schedule-provider, hook:day-tint-provider and geolocation:read', () => {
const r = validateManifest({ ...base, permissions: ['hook:map-layer-provider', 'hook:day-schedule-provider', 'hook:day-tint-provider', 'geolocation:read'] });
expect(r.errors).toEqual([]);
expect(r.ok).toBe(true);
});
it('validates capabilities.routeProfiles and ties it to hook:route-provider', () => {
const profiles = [{ id: 'ev', label: 'EV' }];
const ok = validateManifest({ ...base, permissions: ['hook:route-provider'], capabilities: { routeProfiles: profiles } });
expect(ok.errors).toEqual([]);
expect(ok.ok).toBe(true);
// profiles without the grant are dead buttons — rejected at validate time
expect(validateManifest({ ...base, capabilities: { routeProfiles: profiles } }).ok).toBe(false);
// id shape, label cap, count cap, duplicates
const bad = (routeProfiles: unknown) => validateManifest({ ...base, permissions: ['hook:route-provider'], capabilities: { routeProfiles } }).ok;
expect(bad([{ id: 'EV', label: 'x' }])).toBe(false);
expect(bad([{ id: 'ev' }])).toBe(false);
expect(bad([{ id: 'ev', label: 'L'.repeat(41) }])).toBe(false);
expect(bad([{ id: 'ev', label: 'a' }, { id: 'ev', label: 'b' }])).toBe(false);
expect(bad([1, 2, 3, 4].map(i => ({ id: `p${i}`, label: 'x' })))).toBe(false);
expect(bad('ev')).toBe(false);
});
it('accepts the read-symmetry + broker permissions (collab, file content, trip create, rates)', () => {
const permissions = ['db:read:collab', 'db:create:trips', 'rates:read', 'db:read:files:content'];
const r = validateManifest({ ...base, permissions });
expect(r.errors).toEqual([]);
expect(r.ok).toBe(true);
expect(r.manifest?.permissions).toEqual(permissions);
});
it('validates tripPage: replaceable tabs only (never plan), position 0-50', () => {
const page = { ...base, type: 'trip-page' };
expect(validateManifest({ ...page, capabilities: { tripPage: { replaces: ['transports', 'buchungen'], position: 1 } } }).ok).toBe(true);
expect(validateManifest({ ...page, capabilities: { tripPage: { replaces: ['plan'] } } }).ok).toBe(false);
expect(validateManifest({ ...page, capabilities: { tripPage: { replaces: ['nope'] } } }).ok).toBe(false);
expect(validateManifest({ ...page, capabilities: { tripPage: { position: -1 } } }).ok).toBe(false);
});
it('validates settingsUi as a boolean', () => {
expect(validateManifest({ ...base, capabilities: { settingsUi: true } }).ok).toBe(true);
expect(validateManifest({ ...base, capabilities: { settingsUi: false } }).ok).toBe(true);
expect(validateManifest({ ...base, capabilities: { settingsUi: 'yes' } }).ok).toBe(false);
});
});
describe('createMockHost', () => {
it('mocks plugin UI session storage with scope isolation and production limits', async () => {
const host = createMockHost({ sessionTripId: 42 });
await host.session.set('filters', ['hotel']);
await host.session.set('filters', ['flight'], { scope: 'trip' });
await expect(host.session.get('filters')).resolves.toEqual(['hotel']);
await expect(host.session.get('filters', { scope: 'trip' })).resolves.toEqual(['flight']);
await expect(host.session.set('x'.repeat(65), true)).rejects.toThrow(/SESSION_INVALID_KEY/);
await expect(host.session.set('large', 'x'.repeat(1024))).rejects.toThrow(/SESSION_VALUE_TOO_LARGE/);
await expect(host.session.set('x'.repeat(64), 'x'.repeat(1022))).resolves.toBeUndefined();
await host.session.clear();
await host.session.clear({ scope: 'trip' });
for (let index = 0; index < 32; index += 1) await host.session.set(`key-${index}`, index);
await expect(host.session.set('key-32', 32)).rejects.toThrow(/SESSION_KEY_LIMIT/);
await expect(host.session.set('trip-key', true, { scope: 'trip' })).resolves.toBeUndefined();
});
it('requires a trip context for trip-scoped mock session storage', async () => {
const { session } = createMockHost();
await expect(session.get('filters', { scope: 'trip' })).rejects.toThrow(/NO_TRIP_CONTEXT/);
});
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 against the ACTING user (asUserId is ignored, like the real host) and records broadcasts', async () => {
const member = createMockHost({
grants: ['db:read:trips', 'ws:broadcast:trip'],
actingUserId: 42,
trips: { 1: { members: [42], data: { id: 1, name: 'Japan' } } },
});
expect(await member.ctx.trips.getById(1)).toEqual({ id: 1, name: 'Japan' });
await member.ctx.ws.broadcastToTrip(1, 'ping', { a: 1 });
expect(member.broadcasts).toEqual([{ kind: 'trip', target: 1, event: 'ping', data: { a: 1 } }]);
// asUserId is accepted for source-compat but IGNORED: a non-member acting user is
// refused even when it passes a member id as asUserId (the #13 divergence, now fixed).
const outsider = createMockHost({
grants: ['db:read:trips'],
actingUserId: 99,
trips: { 1: { members: [42], data: { id: 1, name: 'Japan' } } },
});
await expect(outsider.ctx.trips.getById(1, 42)).rejects.toThrow(/RESOURCE_FORBIDDEN/);
});
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' }]);
});
it('gates costs reads/writes on the grant, addon, membership and edit permission', async () => {
const { ctx } = createMockHost({
grants: ['db:read:costs', 'db:write:costs'],
actingUserId: 42,
trips: {
1: { members: [42], costs: [{ id: 5, name: 'Hotel' }] },
2: { members: [42], costs: [{ id: 6, name: 'Food' }], canEditCosts: false },
},
});
// read: trip-scoped + cross-trip aggregate
expect(await ctx.costs.getByTrip(1)).toEqual([{ id: 5, name: 'Hotel' }]);
expect(await ctx.costs.listMine()).toEqual([{ id: 5, name: 'Hotel' }, { id: 6, name: 'Food' }]);
// write: allowed where the user may edit, refused where they may not
expect(await ctx.costs.create(1, { name: 'Taxi' })).toMatchObject({ trip_id: 1, name: 'Taxi' });
await expect(ctx.costs.create(2, { name: 'Nope' })).rejects.toThrow(/RESOURCE_FORBIDDEN/);
// update: edits an existing item where allowed, refused without edit permission
expect(await ctx.costs.update(1, 5, { name: 'Hostel' })).toMatchObject({ id: 5, name: 'Hostel' });
await expect(ctx.costs.update(2, 6, { name: 'Nope' })).rejects.toThrow(/RESOURCE_FORBIDDEN/);
// update: a missing item is refused
await expect(ctx.costs.update(1, 999, { name: 'X' })).rejects.toThrow(/RESOURCE_FORBIDDEN/);
// delete: removes where allowed, refused without edit permission / when missing
expect(await ctx.costs.delete(1, 5)).toEqual({ deleted: true });
await expect(ctx.costs.delete(2, 6)).rejects.toThrow(/RESOURCE_FORBIDDEN/);
await expect(ctx.costs.delete(1, 999)).rejects.toThrow(/RESOURCE_FORBIDDEN/);
});
it('gates planner writes (places/days/itinerary/trips) on grant, membership and edit permission', async () => {
const { ctx } = createMockHost({
grants: ['db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips'],
actingUserId: 42,
trips: {
1: { members: [42], data: { id: 1, title: 'Japan' } },
2: { members: [42], canEditPlaces: false },
},
});
// create + assign on a trip the user may edit
const place = await ctx.places.create(1, { name: 'Fushimi Inari' });
expect(place).toMatchObject({ trip_id: 1, name: 'Fushimi Inari' });
const day = await ctx.days.create(1, { notes: 'Day 1' });
expect(await ctx.itinerary.assign(1, (day as { id: number }).id, (place as { id: number }).id))
.toMatchObject({ day_id: (day as { id: number }).id });
expect(await ctx.trips.update(1, { title: 'Renamed' })).toMatchObject({ title: 'Renamed' });
// refused where the user may not edit
await expect(ctx.places.create(2, { name: 'Nope' })).rejects.toThrow(/RESOURCE_FORBIDDEN/);
});
it('refuses a planner write without the matching write scope', async () => {
const { ctx } = createMockHost({ grants: ['db:read:trips'], actingUserId: 42, trips: { 1: { members: [42] } } });
await expect(ctx.places.create(1, { name: 'X' })).rejects.toThrow(/PERMISSION_DENIED/);
});
it('ctx.meta stores/reads/lists/deletes namespaced metadata, gated on db:meta', async () => {
const { ctx } = createMockHost({ grants: ['db:meta'], actingUserId: 42, trips: { 1: { members: [42] } } });
await ctx.meta.set('trip', 1, 'rating', 5);
expect(await ctx.meta.get('trip', 1, 'rating')).toBe(5);
expect(await ctx.meta.list('trip', 1)).toEqual({ rating: 5 });
expect(await ctx.meta.delete('trip', 1, 'rating')).toEqual({ deleted: true });
expect(await ctx.meta.get('trip', 1, 'rating')).toBe(null);
const ungranted = createMockHost({ grants: [], actingUserId: 42, trips: { 1: { members: [42] } } });
await expect(ungranted.ctx.meta.get('trip', 1, 'x')).rejects.toThrow(/PERMISSION_DENIED/);
});
it('serves days + accommodations from the trip fixture and creates lodging blocks', async () => {
const { ctx } = createMockHost({
grants: ['db:read:trips', 'db:write:accommodations'],
actingUserId: 42,
trips: {
1: {
members: [42],
days: [{ id: 1, date: '2026-07-01' }, { id: 2, date: '2026-07-02' }],
accommodations: [{ id: 9, place_id: 3, start_day_id: 1, end_day_id: 2 }],
},
},
});
expect(await ctx.trips.getDays(1)).toEqual([{ id: 1, date: '2026-07-01' }, { id: 2, date: '2026-07-02' }]);
expect(await ctx.trips.getAccommodations(1)).toHaveLength(1);
const created = await ctx.accommodations.create(1, { place_id: 7, start_day_id: 1, end_day_id: 2 });
expect(created).toMatchObject({ trip_id: 1, place_id: 7 });
expect(await ctx.trips.getAccommodations(1)).toHaveLength(2);
// ungranted read stays refused
const ungranted = createMockHost({ grants: [], actingUserId: 42, trips: { 1: { members: [42] } } });
await expect(ungranted.ctx.trips.getDays(1)).rejects.toThrow(/PERMISSION_DENIED/);
});
it('removeMember + journey create/delete round out the write symmetry, grant-gated', async () => {
const host = createMockHost({
grants: ['db:write:members', 'db:write:journal'],
actingUserId: 42,
trips: { 1: { members: [42, 7] } },
});
expect(await host.ctx.trips.removeMember(1, 7)).toEqual({ removed: true });
expect(await host.ctx.trips.removeMember(1, 999)).toEqual({ removed: true }); // prod's DELETE reports removed:true either way
const j = await host.ctx.journal.createJourney({ title: 'Imported', trip_ids: [1] });
expect(j).toMatchObject({ title: 'Imported' });
expect(await host.ctx.journal.deleteJourney(1)).toEqual({ deleted: true });
// grants enforced identically to production
const ungranted = createMockHost({ grants: [], actingUserId: 42, trips: { 1: { members: [42, 7] } } });
await expect(ungranted.ctx.trips.removeMember(1, 7)).rejects.toThrow(/PERMISSION_DENIED/);
await expect(ungranted.ctx.journal.createJourney({ title: 'x' })).rejects.toThrow(/PERMISSION_DENIED/);
});
it('journal.addEntryPhoto refuses in the mock what the host refuses in production', async () => {
const png = Buffer.from('89504e470d0a1a0a', 'hex').toString('base64');
const host = createMockHost({
grants: ['db:write:journal'],
actingUserId: 42,
journals: [{ id: 1, title: 'Japan' }],
journalEntries: [{ id: 5, journey_id: 1, entry_date: '2027-04-01' }],
});
const photo = await host.ctx.journal.addEntryPhoto(5, { name: 'tokyo.jpg', content_base64: png, caption: 'Arrival' });
expect(photo).toMatchObject({ entry_id: 5, caption: 'Arrival' });
// An importer that hits one of these in the mock would hit it in production too,
// which is the whole point of the double refusing the same things.
await expect(host.ctx.journal.addEntryPhoto(999, { name: 'a.jpg', content_base64: png }))
.rejects.toThrow(/RESOURCE_FORBIDDEN/);
await expect(host.ctx.journal.addEntryPhoto(5, { name: 'map.svg', content_base64: png }))
.rejects.toThrow(/not an allowed image type/);
await expect(host.ctx.journal.addEntryPhoto(5, { name: 'noext', content_base64: png }))
.rejects.toThrow(/not an allowed image type/);
await expect(host.ctx.journal.addEntryPhoto(5, { name: '', content_base64: png }))
.rejects.toThrow(/name is required/);
await expect(host.ctx.journal.addEntryPhoto(5, { name: 'a.jpg', content_base64: '' }))
.rejects.toThrow(/content_base64 is required/);
await expect(host.ctx.journal.addEntryPhoto(5, { name: 'a.jpg', content_base64: 'A'.repeat(15 * 1024 * 1024) }))
.rejects.toThrow(/10MB plugin upload cap/);
const ungranted = createMockHost({ grants: [], actingUserId: 42, journalEntries: [{ id: 5, journey_id: 1 }] });
await expect(ungranted.ctx.journal.addEntryPhoto(5, { name: 'a.jpg', content_base64: png }))
.rejects.toThrow(/PERMISSION_DENIED/);
});
it('creates a trip for the acting user and serves rates + collab reads against the grants', async () => {
const { ctx } = createMockHost({
grants: ['db:create:trips', 'rates:read', 'db:read:collab'],
actingUserId: 42,
trips: { 1: { members: [42], notes: [{ id: 1, title: 'Museum tips' }] } },
ratesResult: { USD: 1.08, GBP: 0.85 },
});
expect(await ctx.trips.create({ title: 'Japan 2027', currency: 'JPY' })).toMatchObject({ title: 'Japan 2027', user_id: 42 });
expect(await ctx.collab.listNotes(1)).toEqual([{ id: 1, title: 'Museum tips' }]);
expect(await ctx.rates.get('EUR')).toEqual({ USD: 1.08, GBP: 0.85 });
// ungranted capability (file content) stays refused
await expect(ctx.files.getContent(1, 1)).rejects.toThrow(/PERMISSION_DENIED/);
});
it('serves file bytes via files.getContent under db:read:files:content', async () => {
const content = Buffer.from('voucher bytes').toString('base64');
const { ctx } = createMockHost({
grants: ['db:read:files:content'],
actingUserId: 42,
trips: { 1: { members: [42], files: [{ id: 3, name: 'voucher.pdf', mimetype: 'application/pdf', content_base64: content }] } },
});
expect(await ctx.files.getContent(1, 3)).toEqual({ name: 'voucher.pdf', mimetype: 'application/pdf', size: 13, content_base64: content });
await expect(ctx.files.getContent(1, 99)).rejects.toThrow(/RESOURCE_FORBIDDEN/);
});
it('refuses costs when the permission is missing or the addon is disabled', async () => {
const ungranted = createMockHost({ grants: [], actingUserId: 42, trips: { 1: { members: [42] } } });
await expect(ungranted.ctx.costs.getByTrip(1)).rejects.toThrow(/PERMISSION_DENIED/);
const addonOff = createMockHost({
grants: ['db:read:costs'],
actingUserId: 42,
budgetAddonEnabled: false,
trips: { 1: { members: [42], costs: [] } },
});
await expect(addonOff.ctx.costs.getByTrip(1)).rejects.toThrow(/RESOURCE_FORBIDDEN/);
});
it('host.run drives the plugin\'s own handlers (route, job, scheduled, event, GDPR, hook)', async () => {
const seen: string[] = [];
const def = definePlugin({
routes: [{ method: 'GET', path: '/ping', async handler(req) { return { status: 200, body: { user: req.user?.id } }; } }],
jobs: [{ id: 'refresh', schedule: '* * * * *', async handler() { seen.push('job'); } }],
async scheduled({ name, payload }) { seen.push(`sched:${name}:${JSON.stringify(payload)}`); },
events: [{ on: 'place:created', handler(p) { seen.push(`evt:${p.event}:${p.entityId}`); } }],
async deleteUserData({ userId }) { seen.push(`del:${userId}`); },
async exportUserData({ userId }) { return { userId, rows: 2 }; },
hooks: { tripCardProvider: { async getCards(tripIds) { return tripIds.map((id) => ({ tripId: id, id: 'b', label: 'X' })); } } },
});
// Every entry point needs the grant TREK would gate it on — events and the GDPR
// handlers included, or the driver refuses them exactly as the host would.
const host = createMockHost({
grants: ['jobs:run', 'events:subscribe', 'hook:user-data', 'hook:trip-card-provider'],
actingUserId: 7,
});
const d = host.run(def);
const res = await d.route({ method: 'GET', path: '/ping' });
expect(res).toEqual({ status: 200, body: { user: 7 } });
await d.job('refresh');
await d.scheduled('daily', { x: 1 });
await d.event('place:created', { tripId: 1, entity: 'place', entityId: 9 });
await d.deleteUserData(42);
expect(await d.exportUserData(42)).toEqual({ userId: 42, rows: 2 });
expect(await d.hook('tripCardProvider', 'getCards', [1, 2])).toEqual([
{ tripId: 1, id: 'b', label: 'X' }, { tripId: 2, id: 'b', label: 'X' },
]);
expect(seen).toEqual(['job', 'sched:daily:{"x":1}', 'evt:place:created:9', 'del:42']);
// missing handlers / unknown ids throw a clear error, not a silent no-op
await expect(d.job('nope')).rejects.toThrow(/no job/);
await expect(host.run(definePlugin({})).scheduled('x')).rejects.toThrow(/no scheduled handler/);
});
// TREK gates hooks, events, jobs and the GDPR handlers on a permission BEFORE the plugin
// is reached, and skips an ungranted plugin silently — no error, no log, it just never
// runs. The driver must refuse them, or a green unit test still means a dead plugin.
it('refuses every entry point the manifest did not grant, the way TREK would', async () => {
const ran: string[] = [];
const def = definePlugin({
jobs: [{ id: 'refresh', schedule: '* * * * *', async handler() { ran.push('job'); } }],
async scheduled() { ran.push('scheduled'); },
events: [{ on: 'place:created', handler() { ran.push('event'); } }],
async deleteUserData() { ran.push('delete'); },
async exportUserData() { ran.push('export'); return {}; },
hooks: {
warningProvider: { async getWarnings() { ran.push('warn'); return []; } },
notificationChannel: { async send() { ran.push('send'); }, async test() { ran.push('test'); } },
},
});
const d = createMockHost({ grants: [] }).run(def); // implements everything, granted nothing
await expect(d.job('refresh')).rejects.toThrow(PermissionDenied);
await expect(d.job('refresh')).rejects.toThrow(/requires jobs:run/);
await expect(d.scheduled('daily')).rejects.toThrow(/requires jobs:run/);
await expect(d.event('place:created')).rejects.toThrow(/requires events:subscribe/);
await expect(d.deleteUserData(1)).rejects.toThrow(/requires hook:user-data/);
await expect(d.exportUserData(1)).rejects.toThrow(/requires hook:user-data/);
await expect(d.hook('warningProvider', 'getWarnings', 1)).rejects.toThrow(/requires hook:trip-warning-provider/);
await expect(d.channel.send({ event: 'todo_due', title: 'T', body: 'B' })).rejects.toThrow(/requires hook:notification-channel/);
await expect(d.channel.test()).rejects.toThrow(/requires hook:notification-channel/);
// Not one handler ran. That is the production behaviour this mirrors.
expect(ran).toEqual([]);
});
it('runs the same entry points once the grants are there', async () => {
const ran: string[] = [];
const def = definePlugin({
jobs: [{ id: 'refresh', schedule: '* * * * *', async handler() { ran.push('job'); } }],
events: [{ on: 'place:created', handler() { ran.push('event'); } }],
hooks: { warningProvider: { async getWarnings() { ran.push('warn'); return []; } } },
});
const d = createMockHost({ grants: ['jobs:run', 'events:subscribe', 'hook:trip-warning-provider'] }).run(def);
await d.job('refresh');
await d.event('place:created');
await d.hook('warningProvider', 'getWarnings', 1);
expect(ran).toEqual(['job', 'event', 'warn']);
});
it('exposes the scheduler timers a plugin armed via ctx.scheduler', async () => {
const def = definePlugin({ async onLoad(ctx) { await ctx.scheduler.every(3_600_000, 'sync', { n: 1 }); } });
const host = createMockHost({ grants: ['jobs:run'] });
await host.run(def).load();
expect(host.scheduled.get('sync')).toMatchObject({ everyMs: 3_600_000, payload: { n: 1 } });
});
it('runs jobs USERLESS like production — a membership read refuses, the same read from a route works', async () => {
const seen: string[] = [];
const def = definePlugin({
jobs: [{ id: 'sync', schedule: '* * * * *', async handler(ctx) {
try { await ctx.trips.getById(1); seen.push('read-ok'); } catch (e) { seen.push((e as Error).message.split(':')[0]); }
} }],
routes: [{ method: 'GET', path: '/t', async handler(_req, ctx) { return { status: 200, body: await ctx.trips.getById(1) }; } }],
});
const host = createMockHost({ grants: ['db:read:trips', 'jobs:run'], actingUserId: 42, trips: { 1: { members: [42], data: { id: 1 } } } });
const d = host.run(def);
await d.job('sync');
expect(seen).toEqual(['RESOURCE_FORBIDDEN']);
expect(await d.route(0)).toEqual({ status: 200, body: { id: 1 } });
});
it('notify.send strips emojis and enforces the host caps + in-app link rule', async () => {
const host = createMockHost({ grants: ['notify:send'], actingUserId: 7, trips: { 1: { members: [7] } } });
await expect(host.ctx.notify.send({ title: '🎉🎉', body: 'x', scope: 'user', targetId: 7 })).rejects.toThrow(/title is required/);
await expect(host.ctx.notify.send({ title: 't', body: 'x'.repeat(1001), scope: 'user', targetId: 7 })).rejects.toThrow(/max 1000/);
await expect(host.ctx.notify.send({ title: 't', body: 'b', scope: 'user', targetId: 7, link: 'https://evil.example' })).rejects.toThrow(/in-app path/);
await host.ctx.notify.send({ title: 'Trip 🎉 ready', body: 'b', scope: 'trip', targetId: 1, link: '/trips/1' });
expect(host.notifications).toEqual([{ title: 'Trip ready', body: 'b', link: '/trips/1', scope: 'trip', targetId: 1 }]);
});
it('db guards match the host: forbidden statements, tx control, op cap', async () => {
const { ctx } = createMockHost({ grants: ['db:own'] });
await expect(ctx.db.query('PRAGMA user_version')).rejects.toThrow(/not allowed/);
await expect(ctx.db.tx([{ sql: ' COMMIT' }])).rejects.toThrow(/transaction-control/);
await expect(ctx.db.tx([{ sql: '/* x */ROLLBACK' }])).rejects.toThrow(/transaction-control/);
await expect(ctx.db.tx(Array.from({ length: 101 }, () => ({ sql: 'SELECT 1' })))).rejects.toThrow(/at most 100/);
});
it('member writes need member_manage + protect the owner; users.getById serves only public fields', async () => {
const host = createMockHost({
grants: ['db:write:members', 'db:read:users'],
actingUserId: 42,
users: { 7: { id: 7, username: 'ada', email: 'secret@example.com' }, 42: { id: 42, username: 'me' } },
trips: { 1: { members: [42, 7], data: { id: 1, user_id: 42 } }, 2: { members: [42], can: { member_manage: false } } },
});
await expect(host.ctx.trips.removeMember(1, 42)).rejects.toThrow(/trip owner/);
await expect(host.ctx.trips.addMember(2, 7)).rejects.toThrow(/RESOURCE_FORBIDDEN/);
await expect(host.ctx.trips.addMember(1, 999)).rejects.toThrow(/no user 999/);
const u = await host.ctx.users.getById(7) as Record<string, unknown>;
expect(u).toMatchObject({ id: 7, username: 'ada' });
expect('email' in u).toBe(false);
await expect(host.ctx.users.getById(999)).rejects.toThrow(/no access to user 999/); // no shared trip
});
it('addon toggles gate journal/vacay/collections like production; atlas normalizes codes', async () => {
const off = createMockHost({
grants: ['db:read:journal', 'db:read:atlas', 'db:write:atlas', 'db:read:vacay', 'db:read:collections'],
actingUserId: 1,
journeyAddonEnabled: false, vacayAddonEnabled: false, collectionsAddonEnabled: false,
});
await expect(off.ctx.journal.listMine()).rejects.toThrow(/journey addon is disabled/);
await expect(off.ctx.vacay.mine()).rejects.toThrow(/vacay addon is disabled/);
await expect(off.ctx.collections.listMine()).rejects.toThrow(/collections addon is disabled/);
expect(await off.ctx.atlas.markCountry('de')).toEqual({ visited: true }); // atlas stays enabled
expect(await off.ctx.atlas.visited()).toMatchObject({ countries: ['DE'] });
await expect(off.ctx.atlas.markCountry('not-a-code!')).rejects.toThrow(/short code/);
});
it('scheduler refuses a non-finite or >1-year dueAt, like the host', async () => {
const { ctx } = createMockHost({ grants: ['jobs:run'] });
await expect(ctx.scheduler.at(Number.NaN, 'x')).rejects.toThrow(/dueAt out of range/);
await expect(ctx.scheduler.at(Date.now() + 400 * 24 * 3600 * 1000, 'x')).rejects.toThrow(/dueAt out of range/);
await expect(ctx.scheduler.every(1000, 'x')).rejects.toThrow(/>= 60000/);
});
it('declaredEmits drops an undeclared emit with a warning — production refuses it', () => {
const host = createMockHost({ declaredEmits: ['rate.updated'] });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
host.ctx.events.emit('rate.updated', { a: 1 });
host.ctx.events.emit('undeclared.event', {});
expect(warn).toHaveBeenCalledOnce();
} finally { warn.mockRestore(); }
expect(host.emitted).toEqual([{ name: 'rate.updated', payload: { a: 1 } }]);
});
});
describe('scaffold + validate CLIs', () => {
let tmp: string;
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-')); });
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
/**
* A scaffold is HONESTLY INCOMPLETE: it builds and runs, but it is not publishable until it is
* documented. These two facts are the contract, and they are deliberately different:
*
* pack/dev work immediately — you can install it into a local TREK and try it.
* validate fails until the README is written and a screenshot exists.
*
* It used to be the other way round: a fresh scaffold passed `validate` (which only checked a
* fifth of the registry's rules and demoted the README to a warning), and the author learned
* the truth from CI — after cutting an immutable release. A green that means nothing is worse
* than a red that tells you what to do.
*/
it('scaffolds a widget that builds immediately but is not yet publishable', () => {
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);
// It packs — the dev loop is not blocked by the docs gates.
expect(() => packPluginDir(dir, path.join(tmp, 'w.zip'))).not.toThrow();
// But it does not publish, and it says exactly why. Both of these used to be WARNINGS, which
// is how a scaffold could sail through `validate` and then be rejected by the registry.
const r = validatePluginDir(dir);
expect(r.ok).toBe(false);
expect(r.errors.some((e) => /placeholder/.test(e))).toBe(true);
// The scaffold's README references ./docs/screenshot.png and never creates it. The old check
// regexed for an image LINK and passed; this one resolves the path.
expect(r.errors.some((e) => /screenshot/.test(e))).toBe(true);
expect(fs.existsSync(path.join(dir, 'docs', 'screenshot.png'))).toBe(false);
});
it('a documented scaffold passes validate clean — the scaffold is not born unfixable', () => {
scaffold('done-plug', 'widget', tmp);
const dir = path.join(tmp, 'done-plug');
makePublishable(dir);
const r = validatePluginDir(dir);
expect(r.errors).toEqual([]);
expect(r.ok).toBe(true);
});
/**
* The registry REJECTS an icon lucide does not have (validate-entry.mjs: "is not a lucide icon
* name"), so warning about it locally was a false green: the author saw a pass, cut the release,
* and CI failed them once the artifact was immutable. Local severity now matches the registry's.
*/
it('rejects an icon lucide does not know — the registry does, so we must too', () => {
scaffold('icon-plug', 'widget', tmp);
const dir = path.join(tmp, 'icon-plug');
makePublishable(dir);
const file = path.join(dir, 'trek-plugin.json');
const m = JSON.parse(fs.readFileSync(file, 'utf8'));
fs.writeFileSync(file, JSON.stringify({ ...m, icon: 'Stethscope' }, null, 2));
const r = validatePluginDir(dir);
expect(r.ok).toBe(false);
expect(r.errors.some((e) => /Stethscope.*not a lucide icon/.test(e))).toBe(true);
});
it('accepts any real lucide icon name, not just a curated few', () => {
scaffold('icon-plug', 'widget', tmp);
const dir = path.join(tmp, 'icon-plug');
makePublishable(dir);
const file = path.join(dir, 'trek-plugin.json');
const m = JSON.parse(fs.readFileSync(file, 'utf8'));
fs.writeFileSync(file, JSON.stringify({ ...m, icon: 'Stethoscope' }, null, 2));
const r = validatePluginDir(dir);
expect(r.ok).toBe(true);
expect(r.errors.some((e) => /lucide icon/.test(e))).toBe(false);
});
it('scaffolds a client that opts into the design kit via the marker (source stays one line)', () => {
scaffold('kit-plug', 'widget', tmp);
const html = fs.readFileSync(path.join(tmp, 'kit-plug', 'client', 'index.html'), 'utf8');
expect(html).toContain('<!-- trek:ui -->'); // the one-line opt-in
expect(html).toContain('trek.onContext'); // uses the bridge the marker installs
expect(html).not.toContain('--glass-bg'); // kit is NOT pre-inlined in the source
});
it('scaffolds a trip-page plugin (a tab inside the trip) with a client UI', () => {
scaffold('trip-diary', 'trip-page', tmp);
const dir = path.join(tmp, 'trip-diary');
const m = JSON.parse(fs.readFileSync(path.join(dir, 'trek-plugin.json'), 'utf8'));
expect(m.type).toBe('trip-page');
expect(fs.existsSync(path.join(dir, 'client', 'index.html'))).toBe(true); // non-integration → gets a UI
makePublishable(dir);
expect(validatePluginDir(dir).ok).toBe(true);
});
it('pack expands the design-kit marker into the archived client HTML', () => {
scaffold('kit-plug', 'widget', tmp);
const out = path.join(tmp, 'kit-plug.zip');
packPluginDir(path.join(tmp, 'kit-plug'), out);
const html = readZip(fs.readFileSync(out))['client/index.html'].toString('utf8');
expect(html).not.toContain('<!-- trek:ui -->'); // expanded away
expect(html).toContain('.trek-glass'); // kit CSS inlined
expect(html).toContain('window.trek'); // bridge inlined
});
it('scaffolds a CommonJS package.json with the SDK as a devDependency only', () => {
scaffold('my-widget', 'widget', tmp);
const pkg = JSON.parse(fs.readFileSync(path.join(tmp, 'my-widget', 'package.json'), 'utf8'));
expect(pkg.type).toBe('commonjs');
expect(pkg.private).toBe(true);
expect(pkg.devDependencies['trek-plugin-sdk']).toMatch(/^\^\d/);
expect(pkg.dependencies).toBeUndefined(); // runtime deps are the author's call; the SDK never is one
});
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('tolerates a UTF-8 BOM in trek-plugin.json (Windows editors add one)', () => {
scaffold('bom-plug', 'integration', tmp);
const dir = path.join(tmp, 'bom-plug');
makePublishable(dir);
const mp = path.join(dir, 'trek-plugin.json');
fs.writeFileSync(mp, '\uFEFF' + fs.readFileSync(mp, 'utf8'));
// A bare JSON.parse chokes on the BOM and reports "Unexpected token" against an invisible
// character \u2014 the manifest must still parse, and the plugin must still be publishable.
const r = validatePluginDir(dir);
expect(r.errors.some((e) => /not valid JSON/.test(e))).toBe(false);
expect(r.ok).toBe(true);
});
it('validatePluginDir flags a missing manifest', () => {
expect(validatePluginDir(tmp).ok).toBe(false);
});
});
describe('dev-server SDK injection', () => {
let tmp: string;
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'inject-')); });
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
it('makes require(trek-plugin-sdk) resolve from the package itself — no npm install', async () => {
const { installSdkInjection } = await import('../src/cli/dev.js');
const { createRequire } = await import('node:module');
installSdkInjection();
// A scaffold-shaped CJS entry that requires the SDK without any node_modules.
// The injected surface must MATCH the prod child shim: definePlugin +
// PLUGIN_API_VERSION only, subpaths throw the same pointed error.
fs.writeFileSync(path.join(tmp, 'entry.cjs'),
"const sdkShim = require('trek-plugin-sdk');\n" +
"let testingError = '';\n" +
"try { require('trek-plugin-sdk/testing'); } catch (e) { testingError = e.message; }\n" +
'module.exports = { def: sdkShim.definePlugin({ routes: [] }), api: sdkShim.PLUGIN_API_VERSION, keys: Object.keys(sdkShim).sort(), testingError };\n');
const req = createRequire(path.join(tmp, 'entry.cjs'));
const mod = req(path.join(tmp, 'entry.cjs')) as { def: unknown; api: number; keys: string[]; testingError: string };
expect(mod.def).toEqual({ routes: [] });
expect(mod.api).toBe(1);
expect(mod.keys).toEqual(['PLUGIN_API_VERSION', 'definePlugin']); // prod parity — no dev-only extras
expect(mod.testingError).toMatch(/build\/test-time/);
});
});
describe('dev-server preview', () => {
// The preview plays the host, so it has to refuse what the host refuses. A preview
// that answered trek:geolocation without the grant green-lights a plugin production
// then breaks — the one drift direction this package cannot afford.
it('gates the geolocation bridge on geolocation:read, like PluginFrame', async () => {
const { preview } = await import('../src/cli/dev.js');
const without = preview('demo', 'widget', 1, {});
expect(without).toContain('var GEO_ALLOWED=false;');
expect(without).toContain('if(!GEO_ALLOWED){');
expect(without).toContain('error:"forbidden"');
expect(preview('demo', 'widget', 1, { geoAllowed: true })).toContain('var GEO_ALLOWED=true;');
});
});
describe('dev db bind shapes', () => {
let tmp: string;
beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'devdb-')); });
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
// Without node:sqlite (Node < 22.5) createDevDb falls back to the [] stub and
// there is no bind behaviour to test.
const hasNodeSqlite = (() => {
try { createRequire(import.meta.url)('node:sqlite'); return true; } catch { return false; }
})();
it.runIf(hasNodeSqlite)('accepts spread args AND a single array of them, like the real host', async () => {
const { createDevDb } = await import('../src/cli/dev.js');
const { db, close } = createDevDb(path.join(tmp, 'db.sqlite'));
try {
await db.migrate('001', 'CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT)');
await db.exec('INSERT INTO kv (k, v) VALUES (?, ?)', ['a', '1']); // array form (better-sqlite3 style)
await db.exec('INSERT INTO kv (k, v) VALUES (?, ?)', 'b', '2'); // spread form
expect(await db.query('SELECT v FROM kv WHERE k = ?', ['a'])).toEqual([{ v: '1' }]);
expect(await db.query('SELECT v FROM kv WHERE k = ?', 'b')).toEqual([{ v: '2' }]);
} finally {
close();
}
});
it.runIf(hasNodeSqlite)('refuses the statements the real host forbids for plugin databases', async () => {
const { createDevDb } = await import('../src/cli/dev.js');
const { db, close } = createDevDb(path.join(tmp, 'db.sqlite'));
try {
await expect(db.query('PRAGMA user_version')).rejects.toThrow(/not allowed/);
await expect(db.exec("ATTACH DATABASE 'x' AS y")).rejects.toThrow(/not allowed/);
await expect(db.migrate('001', 'PRAGMA journal_mode = OFF')).rejects.toThrow(/not allowed/);
} finally {
close();
}
});
});
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: [], trek: '>=3.2.0 <4.0.0' }));
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/trek 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));
// The RAW range is the one compatibility field an entry carries. minTrekVersion is gone:
// it restated the range's lower bound in a weaker form (it cannot express the exclusive
// upper bound), so the entry used to drop "<4.0.0" silently and a TREK 4 server read
// "requires 3.2.0+" and considered the plugin compatible.
expect(v.trek).toBe('>=3.2.0 <4.0.0');
expect(v).not.toHaveProperty('minTrekVersion');
expect(v.downloadUrl).toBe('https://github.com/mauriceboe/TREK-plugin-koffi/releases/download/v1.0.0/plugin.zip');
expect(v.nativeModules).toBe(false);
});
// The store tile reads `icon` off the registry ENTRY (the index has no manifest to consult),
// so if the entry generator drops it, every plugin in the store renders as a generic Blocks.
it('carries the manifest icon onto the registry entry', () => {
const out = path.join(tmp, 'plugin.zip');
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.icon).toBe('Luggage'); // koffi's trek-plugin.json declares it
});
it('--merge refreshes the icon from the manifest, but never wipes one the entry already carries', () => {
const out = path.join(tmp, 'plugin.zip');
packPluginDir(koffi, out);
const prior = (icon: string) => {
const p = path.join(tmp, `prior-${icon}.json`);
fs.writeFileSync(p, JSON.stringify({
id: 'koffi', name: 'Koffi', author: 'TREK', description: 'x',
repo: 'mauriceboe/TREK-plugin-koffi', type: 'widget', icon,
versions: [{ version: '0.9.0', gitTag: 'v0.9.0', commitSha: 'b'.repeat(40), downloadUrl: 'https://x/y.zip', sha256: 'c'.repeat(64), size: 10, apiVersion: 1, nativeModules: false, publishedAt: '2026-01-01T00:00:00.000Z' }],
}));
return p;
};
// koffi's manifest declares Luggage → an author who rebrands sees it in the store
const rebranded = 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', mergePath: prior('Coffee'),
});
expect(rebranded.icon).toBe('Luggage');
// a manifest that declares NO icon leaves the entry's existing one alone
const noIconOpts = entryOptsFor('no-icon', '>=3.2.0 <4.0.0');
const keepPath = path.join(tmp, 'keep.json');
fs.writeFileSync(keepPath, JSON.stringify({
id: 'no-icon', name: 'No Icon', author: 'TREK', description: 'x', repo: 'a/b',
type: 'integration', icon: 'Coffee', versions: [],
}));
const kept = buildEntry({ ...noIconOpts, now: '2026-07-04T00:00:00.000Z', mergePath: keepPath });
expect(kept.icon).toBe('Coffee');
});
it('publishes an upper-bound-only range verbatim, with no floor to get wrong', () => {
// This is why the derived floor is gone. "<4.0.0" has a first X.Y.Z of 4.0.0 but a lower
// bound of 0.0.0, and the old regex published 4.0.0 as the MINIMUM — advertising a plugin
// that supports everything BELOW 4 as requiring 4+, the precise inverse of what its author
// wrote. A field that only ever restates the range cannot be wrong if it does not exist.
const entry = buildEntry({ ...entryOptsFor('upper-only', '<4.0.0'), now: '2026-07-04T00:00:00.000Z' });
expect(entry.versions[0].trek).toBe('<4.0.0');
expect(entry.versions[0]).not.toHaveProperty('minTrekVersion');
});
it('refuses to publish a plugin with no usable trek range', () => {
expect(() => buildEntry({ ...entryOptsFor('no-trek', undefined), now: '2026-07-04T00:00:00.000Z' }))
.toThrow(/no valid "trek" version range/);
expect(() => buildEntry({ ...entryOptsFor('bad-trek', '>=4.0.0 <3.0.0'), now: '2026-07-04T00:00:00.000Z' }))
.toThrow(/no valid "trek" version range/);
});
/** A minimal plugin dir + artifact for buildEntry (which only needs the zip to exist). */
function entryOptsFor(id: string, trek: string | undefined) {
const dir = path.join(tmp, id);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'trek-plugin.json'), JSON.stringify({ id, name: id, version: '1.0.0', type: 'integration', ...(trek === undefined ? {} : { trek }) }));
const zipPath = path.join(tmp, `${id}.zip`);
fs.writeFileSync(zipPath, makeZip([{ name: 'trek-plugin.json', data: Buffer.from('{}') }]));
return { dir, repo: 'a/b', tag: 'v1.0.0', zipPath, commit: 'a'.repeat(40) };
}
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/);
});
});
describe('mock-host inter-plugin (plugins.call + events.emit)', () => {
it('calls a configured dependency export and records the call', async () => {
const { ctx, calls } = createMockHost({
pluginExports: { 'dep-lib': { greet: (args: any) => ({ hi: args?.who }) } },
});
const r = await ctx.plugins.call('dep-lib', 'greet', { who: 'ada' });
expect(r).toEqual({ hi: 'ada' });
expect(calls.some((c) => c.method === 'plugins.call')).toBe(true);
});
it('throws RESOURCE_FORBIDDEN calling an export that is not configured', async () => {
const { ctx } = createMockHost({ pluginExports: { 'dep-lib': {} } });
await expect(ctx.plugins.call('dep-lib', 'nope', {})).rejects.toThrow(/RESOURCE_FORBIDDEN/);
await expect(ctx.plugins.call('other', 'greet', {})).rejects.toThrow(/RESOURCE_FORBIDDEN/);
});
it('records emitted events', () => {
const { ctx, emitted } = createMockHost({});
ctx.events.emit('rate.updated', { pair: 'USD/EUR' });
expect(emitted).toEqual([{ name: 'rate.updated', payload: { pair: 'USD/EUR' } }]);
});
it('drives a notificationChannel USERLESS, with the recipient config passed in', async () => {
const sent: unknown[] = [];
const def = definePlugin({
hooks: {
notificationChannel: {
async send(msg, config, ctx) {
// The whole point of the userless dispatch: the credentials arrive as `config`,
// and the channel canNOT read anything as the recipient.
sent.push({ msg, token: config.token, settings: await ctx.settings.get('token') });
},
async test(config) { if (!config.token) throw new Error('no token'); },
},
},
});
const host = createMockHost({
grants: ['hook:notification-channel', 'db:read:trips'],
actingUserId: 7,
userSettings: { token: 'abc' },
trips: { 1: { members: [7] } },
});
const d = host.run(def);
await d.channel.send({ event: 'trip_invite', title: 'Hi', body: 'Japan' });
expect(sent).toEqual([{
msg: { event: 'trip_invite', title: 'Hi', body: 'Japan' },
token: 'abc',
// Userless: ctx.settings.get() resolves against the ACTING user, and there is none.
settings: undefined,
}]);
await expect(d.channel.test({ token: 'x' })).resolves.toBeUndefined();
await expect(d.channel.test({})).rejects.toThrow(/no token/);
});
it('refuses a channel event the host would never dispatch, and a manifest may narrow the set', async () => {
const def = definePlugin({ hooks: { notificationChannel: { async send() { /* delivered */ } } } });
const d = createMockHost({ grants: ['hook:notification-channel'] }).run(def);
// Admin-scoped / in-app-only events are not in CHANNEL_EVENTS.
await expect(d.channel.send({ event: 'admin_alert', title: 'T', body: 'B' })).rejects.toThrow(/never dispatches/);
await expect(d.channel.send({ event: 'trip_invite', title: 'T', body: 'B' })).resolves.toBeUndefined();
const narrowed = createMockHost({ grants: ['hook:notification-channel'], channelEvents: ['todo_due'] }).run(def);
await expect(narrowed.channel.send({ event: 'trip_invite', title: 'T', body: 'B' })).rejects.toThrow(/never dispatches/);
await expect(narrowed.channel.send({ event: 'todo_due', title: 'T', body: 'B' })).resolves.toBeUndefined();
await expect(createMockHost({ grants: ['hook:notification-channel'] }).run(definePlugin({}))
.channel.send({ event: 'todo_due', title: 'T', body: 'B' }))
.rejects.toThrow(/no notificationChannel hook/);
});
it('a plugin with no notificationChannel.test is a clear error, not a silent pass', async () => {
const def = definePlugin({ hooks: { notificationChannel: { async send() {} } } });
await expect(createMockHost({ grants: ['hook:notification-channel'] }).run(def).channel.test())
.rejects.toThrow(/no notificationChannel\.test/);
});
it('runs a settings action as the CLICKING user and shapes the result like the host', async () => {
const def = definePlugin({
actions: {
test: async (ctx) => ({ ok: true, message: `token=${await ctx.settings.get('token')} ✅` }),
quiet: () => {},
boom: () => { throw new Error('credentials rejected'); },
loud: () => ({ ok: false, message: 'x'.repeat(500) }),
},
});
const host = createMockHost({ actingUserId: 7, userSettings: { token: 'abc' } });
const d = host.run(def);
// User-initiated: ctx.settings.get() returns the clicker's own value. The message is
// emoji-stripped and bounded, exactly as the host does before showing it.
expect(await d.action('test')).toEqual({ ok: true, message: 'token=abc' });
// A handler that returns nothing succeeded.
expect(await d.action('quiet')).toEqual({ ok: true, message: undefined });
// Throwing is the documented "action failed" path, not a rejected promise.
expect(await d.action('boom')).toEqual({ ok: false, message: 'credentials rejected' });
expect((await d.action('loud')).message).toHaveLength(200);
await expect(d.action('nope')).rejects.toThrow(/no action/);
});
it('refuses an action key the manifest does not declare', async () => {
const def = definePlugin({ actions: { sync: () => ({ ok: true }) } });
const d = createMockHost({ actingUserId: 7, declaredActions: ['test'] }).run(def);
await expect(d.action('sync')).rejects.toThrow(/RESOURCE_FORBIDDEN/);
});
it('never resolves a settings key off Object.prototype', async () => {
// The bug this models: `__proto__`/`constructor` used to resolve to a truthy object,
// reporting a REQUIRED field as configured for a user who had configured nothing.
const { ctx } = createMockHost({ actingUserId: 7, userSettings: { token: 'abc' } });
expect(await ctx.settings.get('__proto__')).toBeUndefined();
expect(await ctx.settings.get('constructor')).toBeUndefined();
expect(await ctx.settings.get('token')).toBe('abc');
// And a fixture the host would have refused at install fails the test up front. Note a
// `__proto__` OWN key only exists via JSON.parse (in a literal it sets the prototype) —
// which is exactly the shape a stored config blob arrives in.
expect(() => createMockHost({ userSettings: JSON.parse('{"__proto__":{"evil":1}}') })).toThrow(/settings key/);
expect(() => createMockHost({ userSettings: { '9lives': 1 } })).toThrow(/settings key/);
});
});
describe('validateManifest capabilities.provides/emits', () => {
const base = { id: 'my-plug', name: 'My Plug', version: '1.0.0', type: 'integration', permissions: ['db:own'], trek: '>=3.2.0 <4.0.0' };
it('accepts well-formed provides + emits', () => {
const r = validateManifest({ ...base, capabilities: { provides: ['computeRate'], emits: ['rate.updated'] } });
expect(r.ok).toBe(true);
});
it('rejects a malformed export/event name and a non-array', () => {
expect(validateManifest({ ...base, capabilities: { provides: ['bad name!'] } }).ok).toBe(false);
expect(validateManifest({ ...base, capabilities: { emits: 'nope' } }).ok).toBe(false);
});
});
/**
* The `trek` range is what TREK gates installs on, so it has to be caught HERE — the same
* validateManifest the registry CI runs — rather than by a rejected install.
*/
describe('validateManifest: the trek range', () => {
const base = { id: 'my-plug', name: 'My Plug', version: '1.0.0', type: 'integration', permissions: ['db:own'] };
it('requires a range', () => {
const r = validateManifest({ ...base });
expect(r.ok).toBe(false);
expect(r.errors.join()).toMatch(/missing "trek"/);
});
it('rejects a range that is not semver, and one nothing can satisfy', () => {
expect(validateManifest({ ...base, trek: '3.2+' }).ok).toBe(false);
// Syntactically valid, semantically empty: no TREK could ever run it.
expect(validateManifest({ ...base, trek: '>=4.0.0 <3.0.0' }).ok).toBe(false);
});
it('accepts the ranges authors really write, and carries the range through', () => {
for (const trek of ['>=3.2.0 <4.0.0', '^3.2.0', '>=3']) {
const r = validateManifest({ ...base, trek });
expect(r.ok, trek).toBe(true);
expect(r.manifest?.trek).toBe(trek);
}
});
it('allows "*" (it is a legal claim) but validate warns — it is nearly always a lie', () => {
expect(validateManifest({ ...base, trek: '*' }).ok).toBe(true);
expect(isUnboundedRange('*')).toBe(true);
expect(isUnboundedRange('>=3.2.0 <4.0.0')).toBe(false);
});
});