Files
TREK/plugin-sdk/test/sdk.test.ts
T
jubnl ebc98f0a3f v3.4.0 (#1527)
* fix(plugins): unknown column

* fix(mcp): reuse MCP sessions instead of creating one per tool call

The /mcp CORS layer never set exposedHeaders, so Access-Control-Expose-Headers
was absent and browser-context MCP clients (Claude Desktop connectors,
Claude.ai, MCP Inspector) could not read Mcp-Session-Id off the initialize
response. Unable to echo it back, every request looked like a fresh initialize:
one McpServer and one session per tool call, until the per-user cap returned a
429 and the integration died until the container was restarted.

The idle sweep was not at fault — it expires on lastActivity with a 1h default,
so sessions born seconds apart are nowhere near expiry, hence 'cleaned 0'.

- expose Mcp-Session-Id, MCP-Protocol-Version and WWW-Authenticate
- evict a user's least-recently-active session at the cap rather than
  refusing the request, so a client that cannot persist its session id (or a
  proxy that strips the header) can never wedge the server
- close the McpServer/transport orphaned by every session-less non-initialize
  POST, which was leaked: never mapped, never swept, never closed
- return the cap error as JSON-RPC so clients surface the real reason
- warn on session-less POSTs to make a header-stripping proxy diagnosable

* chore(deps): declare @modelcontextprotocol/sdk ^1.29.0

Matches the version already resolved in the lockfile; no dependency-tree change.

* docs(mcp): add the reverse proxy specs for MCP

* fix(plugins): stop the row ⋯ menu being clipped by the sidebar

The menu was an in-flow `absolute` div, and its ancestor (PageSidebar) is
`overflow-hidden` — which clips absolutely-positioned descendants regardless
of z-index. With enough plugins installed a row sits low enough that its menu
runs past the sidebar's bottom edge and gets chopped, taking Delete with it,
so the plugin could no longer be uninstalled from the UI.

Portal the menu to <body> and position it `fixed` against the ⋯ button,
flipping upward when the bottom is tight and re-anchoring on scroll/resize.
A fixed child of <body> has no overflow ancestor, so nothing can clip it.

Closes #1523

* feat(plugins): surface author-signature status and add a scoped re-trust override

TREK has always verified an author's Ed25519 signature and TOFU-pinned the key on
first install, but none of it was ever shown: a successfully-installed UNSIGNED
plugin looked identical to a signed one, and a signature-refused update left the
plugin quietly pinned at its old version with the reason dying in a toast.

Give the four refusal conditions machine-readable codes (SIGNATURE_MISSING /
_INCOMPLETE / _KEY_CHANGED / _INVALID), persist a refusal on the plugin row so the
admin list keeps showing it, and badge Signed/Unsigned in the list and in Discover.

Only SIGNATURE_KEY_CHANGED is overridable — an author can legitimately rotate a key;
a signature that does not verify means the bytes are not what the author signed, and
there is no story where waving that through is right. The override re-pins and
updates in ONE call (POST :id/retrust): a re-pin that waited for a follow-up /update
would leave the plugin pinned to a key no install had ever been verified against if
that second call never came. The artifact must still verify under the new key, so a
re-trust moves the pin from one verified key to another.

assertRetrustable re-derives the condition server-side, so the UI hiding the button
is a convenience, not the control, and it echoes back the full key the admin was
shown so a re-key since the dialog rendered is refused. The rotation is written to
the admin audit log with both fingerprints — after an incident, "which key did we
move from, and to what?" is the question a single key cannot answer.

* fix(plugins): let a plugin's frame reach the hosts an admin added for it

The frame's connect-src was built from the manifest's http:outbound grants alone, but
the child's egress guard is the UNION of those and the hosts an admin added after
install for an operatorEgress plugin (a self-hosted Gotify, an ntfy — hosts the author
cannot know in advance). So such a plugin WITH A UI could call the operator's host from
its server and was CSP-blocked in its own iframe.

Match the frame to the child. The admin consented to these hosts at install and the
child already reaches them, so this widens no trust boundary that isn't already crossed.
Both sources stay validated on the way in, and the interpolation filter is unchanged.

* fix(plugins): report a rejected events.emit on the plugin's own log stream

The host can reject an emit (an undeclared event name, a rate limit). The rejection must
not escape — a detached rejection crashes the child and terminally disables the plugin
over one bad emit — but swallowing it silently left an author with no way to discover
that `emits` was missing from their manifest. Surface it as a warning instead.

* feat(plugin-sdk): expose the raw request body for webhook signature checks

A webhook author must run their HMAC over the exact bytes the sender signed. `body` is
the PARSED value, and re-serializing it will not reproduce those bytes — key order,
whitespace and unicode escaping all differ — so the signature never matches. Document
`rawBodyBase64`, which the host already sets on auth:false routes.

* fix(plugin-sdk): refuse to overwrite a released artifact, and keep the packed zip

A released artifact is IMMUTABLE: the registry pins its sha256, so overwriting the bytes
of a release already in the registry breaks the checksum for everyone who installed that
version — they can no longer install or update it. The old code blanket-caught every
`gh release create` failure (auth, network, a bad repo) and turned it into a --clobber
upload. Probe for the release explicitly and refuse unless --force.

Also stop deleting plugin.zip on the way out. It is the exact bytes the release and the
entry's sha256 pin were computed from; a re-pack on another machine or SDK version can
differ (CRLF, walk order), so anyone re-running `entry`/`sign` afterwards must hash THAT
file, not a rebuild.

* fix(plugin-sdk): don't fail submit when the fork already has an upstream remote

`gh repo clone` of a fork may already have wired `upstream`, in which case a bare
`remote add` exits non-zero and took the whole submit down. Set it either way.

* fix(plugin-sdk): make the dev server behave like the host

Three ways `dev` lied to an author about how their plugin would run in production:

- ctx.db: one try/catch wrapped both the node:sqlite probe AND opening the database, so
  an mkdir/permission failure silently degraded to the in-memory stub — which swallows
  every write while reporting success. A db:own plugin "worked" in dev and persisted
  nothing. Probe separately: fail loudly on a real error, degrade only on old Node, and
  say plainly that the stub discards writes.
- notificationChannel: the host fires it with no acting user and hands the recipient's
  decrypted settings in as a separate `config` argument — send(msg, config, ctx) /
  test(config, ctx). Firing it like an ordinary hook passed `ctx` where `config` belongs,
  so a channel plugin read its settings off ctx and was broken in production.
- /preview pinned tripId 42 while the scaffold seeds trip 1, so the widget's first
  trek:invoke hit assertMember(42) and 500'd. Preview against a trip that exists.

* fix(plugin-sdk): reject unknown flags, wire up create's new flags, and add --help

`parse()` accepts any --x, so a flag a command does not read was silently dropped:
`create --template notification-channel` cheerfully scaffolded a blank plugin. Silently
ignoring an author's explicit instruction is worse than refusing it, so unknown flags are
now an error, and create actually forwards --template/--egress/--required-addons.

A bare --permissions used to split the string "true" into a permission literally named
`true`; listFlag() now treats a valueless flag as absent.

Since an unknown flag is now fatal, `--help` has to exist: it is intercepted before the
flag check and prints usage on stdout with exit 0.

* chore(plugins): drop three unused eslint-disable directives

The no-console rule is not enabled for these files, so the directives were dead and
eslint reports them as unused.

* fix(plugin-sdk): bring preflight back in step with the registry's gates

preflight exists to tell an author what TREK-Plugins' CI will say before they open the
PR. The registry now verifies author signatures, and preflight didn't — so it drifted
into the one failure mode it must never have: a false green. An author trusts a green.

- Verify the signature against the artifact bytes. preflight already downloads them for
  the sha256 check, so this costs one call. Without it, signing with the wrong key (or
  re-packing after signing) sails through and is caught at review.
- Check the signature SHAPE (checkSignatureShape): a key with no signed version, a
  signature with no key, a malformed key or signature. TREK refuses to install a
  half-signed entry, so such an entry is dead on arrival.
- Default apiVersion to 1 before comparing. It is OPTIONAL in the manifest — install/
  manifest.ts and `entry` both default it — so a manifest that legally omits it was
  failing preflight with "manifest apiVersion undefined != entry 1" while the registry
  passed it. A false RED, which teaches authors to ignore the tool.
- Check requiredAddons/pluginDependencies parity, and operatorEgress without an
  http:outbound grant.

The verifier is a port of the host's install/verify-signature.ts (the registry has its
own port); sign.ts's verifyArtifact only understands the bare key/signature pair the SDK
itself emits and cannot judge a minisign key. A test pins all three to the same verdicts.

* feat(plugins): enforce compatibility range

* chore: bump sdk version

* test(e2e): repair the trip-creation specs

create-trip and trip-planner have been failing for a while — long enough for
three separate UI changes to drift past them, which nothing caught because CI
runs vitest only and never invokes Playwright.

Each failure was masking the next:

- The release-notice modal greets a freshly seeded user and its backdrop
  swallowed the click on .add-trip-card. Added a shared dismissSystemNotices()
  helper (the X only shows on the notice's last page, so it has to page through
  first).
- .modal-backdrop no longer exists — the class was namespaced to
  .trek-modal-backdrop so content blockers stop hiding it.
- input[type=text].first() is no longer the Title field: the cover-image search
  inputs now sit above it, so the specs were typing the trip name into the photo
  search box and creating an untitled trip that never matched getByText(title).

* fix(planner): gate drag & drop on pointer type, not viewport width (#1432)

The 3.2.1 fix disabled drag on "mobile", but nothing in the client has ever
detected touch — "mobile" was inferred from viewport width, at four independent
breakpoints. A tablet is a coarse-pointer device at a *desktop* width, so an
iPad (820-1366px) fell on the wrong side of all four, which is why iPhone was
fixed and iPad was not:

- useTripPlanner's isMobile (<768px) is what disarmed `draggable`, so on iPad
  rows stayed draggable and a scroll swipe became an HTML5 drag.
- TripPlannerPage hardcoded isMobile={false} on the desktop PlacesSidebar, so
  its drop handlers and the drop-to-import overlay could never be disabled —
  that overlay is the reported symptom.
- The arrow-button reorder fallback was revealed only below 767px, leaving iPad
  with no drag *and* no fallback.
- touchDragPolyfill loaded drag-drop-touch at >=1024px, synthesising drags from
  touchmove — on a landscape iPad that re-armed the very gesture hijack 3.2.1
  removed.

Adds useIsTouch() ((pointer: coarse)) as a signal separate from isMobile: layout
stays width-driven, so the iPad keeps the desktop two-pane planner, while every
drag affordance is gated on isMobile || isTouch. The reorder arrows now show on
coarse pointers, and the polyfill loads only on hybrid laptops
((pointer: fine) and (any-pointer: coarse)), which also removes the #1440
phantom-dblclick map zoom on tablets.

Guarded by unit tests plus an e2e spec on real WebKit in an iPad Pro 11 context
— the engine matters, since every browser on iPadOS is WebKit underneath, which
is why the reporter hit this in all three they tried.

* test(planner): guard the day-plan reorder arrows against phantom clicks

The hover rule that reveals the reorder arrows had been dead since the
TypeScript migration: it targeted `.place-row:hover .reorder-btns`, and neither
class exists — the component renders `.reorder-buttons` inside an unclassed row.
The buttons sat at opacity:0 on desktop with nothing to reveal them.

That was not merely invisible. opacity:0 still hit-tests, so every itinerary row
and day note carried an invisible, fully clickable target that silently
reordered the trip:

  opacity: "0", visibility: "visible", pointerEvents: "auto"
  elementFromPoint(centre) -> BUTTON, inside .reorder-buttons

The repair — pointer-events: none while hidden, plus a working
.dp-row:hover/:focus-within reveal (focus-within so the buttons are also
reachable by keyboard, which the file's JS-hover pattern cannot do) — lives in
index.css and DayPlanSidebar.tsx. Both files also carry the #1432 drag gating,
so they went with that commit rather than being split mid-file; this commit is
the regression guard for them.

E2E rather than unit, because jsdom does not evaluate :hover. Against the old
CSS it fails on exactly the right assertion: "hidden arrows must not swallow
clicks" — expected false, received true.

* fix(pdf): keep the header gap on day-header overflow pages (#1531)

The repeated <thead> day header (#1471) carried no gap before the first
card on overflow pages: the 12px sat in .day-body's block-start padding,
which a fragmented box only paints on its first fragment. Move it to the
thead cell so it repeats with the header; the day's first page renders
pixel-identically.

* fix(budget): keep "no one paid yet" when editing an expense (#1533)

The ExpenseModal payer initializer fell back to the current user whenever the
edited item had no payer, so reopening an expense saved with "no one paid yet"
silently reselected "You" — and re-saving then recorded the current user as the
payer, corrupting the balances. An absent payer on an existing expense is a
deliberate value, so only a brand-new expense defaults to me.
2026-07-13 12:40:00 +02:00

888 lines
47 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 } 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';
/** 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 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);
});
});
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 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('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' })); } } },
});
const host = createMockHost({ grants: ['jobs:run', '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/);
});
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 }); });
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('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
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 mp = path.join(tmp, 'bom-plug', 'trek-plugin.json');
fs.writeFileSync(mp, '\uFEFF' + fs.readFileSync(mp, 'utf8'));
expect(validatePluginDir(path.join(tmp, 'bom-plug')).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 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);
});
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({}).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({}).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);
});
});