* 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.
This commit is contained in:
jubnl
2026-07-13 12:40:00 +02:00
committed by GitHub
parent e60fbe7dd6
commit ebc98f0a3f
114 changed files with 5148 additions and 425 deletions
+28 -1
View File
@@ -27,7 +27,7 @@ beforeEach(() => {
db = new Database(':memory:');
db.exec(`
CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
api_version INTEGER, min_trek_version TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT, status TEXT, config TEXT, updated_at TEXT);
api_version INTEGER, min_trek_version TEXT, trek_range TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT, status TEXT, config TEXT, updated_at TEXT);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT,
required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER);
CREATE TABLE plugin_actions (plugin_id TEXT, action_key TEXT, label TEXT, hint TEXT, danger INTEGER, sort_order INTEGER,
@@ -125,4 +125,31 @@ describe('discoverPlugins', () => {
process.env.TREK_PLUGINS_DIR = path.join(codeRoot, 'does-not-exist');
expect(discoverPlugins(db)).toEqual({ discovered: [], skipped: [] });
});
describe('the TREK range', () => {
it('persists the range and its lower bound', () => {
writePlugin('ranged', { trek: '>=3.2.0 <4.0.0' });
discoverPlugins(db);
expect(db.prepare("SELECT trek_range, min_trek_version FROM plugins WHERE id='ranged'").get())
.toMatchObject({ trek_range: '>=3.2.0 <4.0.0', min_trek_version: '3.2.0' });
});
it('still registers a plugin that declares NO range — it must not vanish', () => {
// Discovery is a reconciler, not a gate: it only logs and skips on a throw and never
// touches the plugins row, so refusing here would leave an existing plugin's stale
// enabled=1 row to be spawned by the next boot — invisible AND running. The row is
// registered with a null range and the activation gate refuses it (TREK_VERSION_UNKNOWN).
writePlugin('rangeless', {});
expect(discoverPlugins(db).discovered).toEqual(['rangeless']);
expect(db.prepare("SELECT trek_range FROM plugins WHERE id='rangeless'").get()).toMatchObject({ trek_range: null });
});
it('refreshes the range on re-discovery, so a plugin that narrowed its support is caught', () => {
writePlugin('shrink', { trek: '>=3.0.0' });
discoverPlugins(db);
writePlugin('shrink', { trek: '>=3.0.0 <3.1.0' });
discoverPlugins(db);
expect(db.prepare("SELECT trek_range FROM plugins WHERE id='shrink'").get()).toMatchObject({ trek_range: '>=3.0.0 <3.1.0' });
});
});
});
@@ -0,0 +1,116 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import {
hostVersion,
normalizedHost,
isValidTrekRange,
minTrekOf,
hostSatisfies,
__resetHostWarningForTests,
} from '../../../src/nest/plugins/install/host-compat';
/**
* The TREK-version gate (#plugins). Every install front door and the activation gate
* answer "can this plugin run here?" through these functions, so their edges are the
* edges of the whole feature.
*/
const APP_VERSION = process.env.APP_VERSION;
afterEach(() => {
if (APP_VERSION === undefined) delete process.env.APP_VERSION;
else process.env.APP_VERSION = APP_VERSION;
__resetHostWarningForTests();
});
describe('isValidTrekRange', () => {
it('accepts the ranges a plugin actually declares', () => {
for (const r of ['>=3.2.0 <4.0.0', '^3.2.0', '~3.2', '>=3', '<4.0.0', '3.x', '*']) {
expect(isValidTrekRange(r), r).toBe(true);
}
});
it('rejects a range no version can ever satisfy — validRange() alone would accept it', () => {
// Syntactically fine, semantically empty. A plugin declaring this would be
// uninstallable everywhere with nothing to tell its author why.
expect(isValidTrekRange('>=4.0.0 <3.0.0')).toBe(false);
});
it('rejects junk without letting semver.minVersion throw out of it', () => {
for (const r of ['latest', 'v3-ish', '3.2+', '', ' ', undefined, null, 42, {}]) {
expect(isValidTrekRange(r), String(r)).toBe(false);
}
});
});
describe('minTrekOf', () => {
it('reads the lower bound off the RANGE, not off the first version-shaped substring', () => {
// The regression this replaces: the old regex took the first X.Y.Z in the string, so
// "<4.0.0" reported a *minimum* of 4.0.0 — the exact inverse of what the plugin says.
expect(minTrekOf('<4.0.0')).toBeUndefined(); // lower bound is 0.0.0 → nothing to show
expect(minTrekOf('>=3.2.0 <4.0.0')).toBe('3.2.0');
expect(minTrekOf('^3.2.0')).toBe('3.2.0');
expect(minTrekOf('~3.2')).toBe('3.2.0');
expect(minTrekOf('>=3')).toBe('3.0.0');
expect(minTrekOf('>=3.2.0-beta.1')).toBe('3.2.0-beta.1');
});
it('has no lower bound to show for an unbounded range', () => {
expect(minTrekOf('*')).toBeUndefined();
});
});
describe('hostSatisfies', () => {
it('admits a host inside the range and refuses one outside it', () => {
expect(hostSatisfies('>=3.2.0 <4.0.0', '3.3.0')).toBe(true);
expect(hostSatisfies('>=3.2.0 <4.0.0', '3.2.0')).toBe(true);
expect(hostSatisfies('>=3.2.0 <4.0.0', '3.1.9')).toBe(false);
expect(hostSatisfies('>=3.2.0 <4.0.0', '4.0.0')).toBe(false); // the exclusive upper bound
});
it('treats a prerelease host as its target release — both edges matter', () => {
// Plain semver would fail 3.4.0-rc.1 against a range that names no prerelease, which
// would make EVERY plugin incompatible the moment TREK shipped an rc.
expect(hostSatisfies('>=3.2.0 <4.0.0', '3.4.0-rc.1')).toBe(true);
// And includePrerelease would order 4.0.0-rc.1 BEFORE 4.0.0, so it would satisfy
// "<4.0.0" — loading a plugin that disclaims TREK 4 onto a build that already carries
// TREK 4's breaking changes. An rc of 4.0 IS 4.0 for compatibility purposes.
expect(hostSatisfies('>=3.2.0 <4.0.0', '4.0.0-rc.1')).toBe(false);
expect(hostSatisfies('>=4.0.0', '4.0.0-rc.1')).toBe(true);
});
it('never blocks when the host version is not semver at all (APP_VERSION=dev)', () => {
// The Docker build ARG defaults to the literal "dev". An unversioned build must stay
// fully usable rather than refusing every plugin it has.
process.env.APP_VERSION = 'dev';
expect(normalizedHost()).toBeNull();
expect(hostSatisfies('>=3.2.0 <4.0.0')).toBe(true);
expect(hostSatisfies(null)).toBe(true);
});
it('warns once — and only once — about an unparseable host, so a misconfig is visible', () => {
process.env.APP_VERSION = 'nonsense';
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
normalizedHost();
normalizedHost();
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toMatch(/not a semver version/);
warn.mockRestore();
});
it('refuses an undeclared or unsatisfiable range rather than waving it through', () => {
// The caller decides what an absent range MEANS (reject at install, "unknown" at
// activation) — it must not pass by accident here.
expect(hostSatisfies(null, '3.3.0')).toBe(false);
expect(hostSatisfies('', '3.3.0')).toBe(false);
expect(hostSatisfies('>=4.0.0 <3.0.0', '3.3.0')).toBe(false);
});
it('reads the running version from APP_VERSION, falling back to the package version', () => {
process.env.APP_VERSION = '3.9.1';
expect(hostVersion()).toBe('3.9.1');
expect(hostSatisfies('>=3.2.0 <4.0.0')).toBe(true);
expect(hostSatisfies('>=4.0.0')).toBe(false);
delete process.env.APP_VERSION;
expect(hostVersion()).toMatch(/^\d+\.\d+\.\d+/); // package.json
});
});
@@ -20,6 +20,41 @@ describe('parseManifest', () => {
expect(parseManifest({ ...base, trek: '>=3.2.0 <4.0.0' }).minTrekVersion).toBe('3.2.0');
});
describe('the trek range', () => {
it('normalizes a valid range onto trekRange — what the host actually gates on', () => {
const m = parseManifest({ ...base, trek: '>=3.2.0 <4.0.0' });
expect(m.trekRange).toBe('>=3.2.0 <4.0.0');
});
it('reads the lower bound off the range, not off its first version-shaped substring', () => {
// "<4.0.0" has a first X.Y.Z of 4.0.0 but a lower bound of 0.0.0. The old regex
// published 4.0.0 as the MINIMUM — the exact inverse of what the plugin declared.
expect(parseManifest({ ...base, trek: '<4.0.0' }).minTrekVersion).toBeUndefined();
expect(parseManifest({ ...base, trek: '>=3' }).minTrekVersion).toBe('3.0.0');
});
it('stays lenient by default — discovery must still register a plugin with no range', () => {
// Discovery only logs and skips on a throw; it never touches the plugins row, so a
// strict parse there would leave a stale enabled=1 row that the next boot spawns
// anyway. The activation gate refuses an undeclared range instead.
const m = parseManifest({ ...base });
expect(m.trekRange).toBeNull();
expect(m.minTrekVersion).toBeUndefined();
});
it('drops an unparseable range to null rather than throwing, in lenient mode', () => {
expect(parseManifest({ ...base, trek: '3.2+' }).trekRange).toBeNull();
});
it('requires a satisfiable range at the install front doors', () => {
expect(() => parseManifest({ ...base }, { requireTrek: true })).toThrow(/missing "trek"/);
expect(() => parseManifest({ ...base, trek: '3.2+' }, { requireTrek: true })).toThrow(ManifestError);
// Syntactically valid, semantically empty — no TREK could ever run it.
expect(() => parseManifest({ ...base, trek: '>=4.0.0 <3.0.0' }, { requireTrek: true })).toThrow(/satisfiable/);
expect(parseManifest({ ...base, trek: '^3.2.0' }, { requireTrek: true }).trekRange).toBe('^3.2.0');
});
});
it('accepts the trip-page type (mounts as a tab inside the trip planner)', () => {
expect(parseManifest({ ...base, type: 'trip-page' }).type).toBe('trip-page');
});
+35 -2
View File
@@ -42,8 +42,12 @@ function fakeRes() {
}
const req = (p: string, host?: string) => ({ params: { path: p }, get: (h: string) => (h.toLowerCase() === 'host' ? host : undefined) }) as never;
function runtime(active = true, hosts: string[] = []): PluginRuntimeService {
return { isActive: vi.fn(() => active), outboundHostsOf: vi.fn(() => hosts) } as unknown as PluginRuntimeService;
function runtime(active = true, hosts: string[] = [], operatorHosts: string[] = []): PluginRuntimeService {
return {
isActive: vi.fn(() => active),
outboundHostsOf: vi.fn(() => hosts),
operatorEgressHosts: vi.fn(() => operatorHosts),
} as unknown as PluginRuntimeService;
}
describe('PluginFrameController', () => {
@@ -60,6 +64,35 @@ describe('PluginFrameController', () => {
expect(res.headers['X-Content-Type-Options']).toBe('nosniff');
});
// An operatorEgress plugin's hosts are supplied by the ADMIN after install, so they are not
// in the manifest grants. activate() unions them into the child's egress guard; the frame
// must match, or a plugin with a UI can call the host from its server but not its iframe.
it('includes admin-supplied operatorEgress hosts in connect-src', () => {
const res = fakeRes();
new PluginFrameController(runtime(true, ['api.weather.com'], ['gotify.home.lan'])).serve('widget', req(''), res as never);
const csp = res.headers['Content-Security-Policy'];
expect(csp).toContain('https://api.weather.com');
expect(csp).toContain('https://gotify.home.lan');
});
it('does not duplicate a host granted in the manifest AND added by the admin', () => {
const res = fakeRes();
new PluginFrameController(runtime(true, ['ntfy.sh'], ['ntfy.sh'])).serve('widget', req(''), res as never);
const csp = res.headers['Content-Security-Policy'];
expect(csp.match(/https:\/\/ntfy\.sh/g)).toHaveLength(1);
});
it('refuses to interpolate a malformed operator host into the CSP', () => {
const res = fakeRes();
// The admin writer validates hosts, but connect-src is the last line of defence: a token
// with a space or a bare * would widen the whole policy.
new PluginFrameController(runtime(true, [], ['evil.com https://*', '*'])).serve('widget', req(''), res as never);
const csp = res.headers['Content-Security-Policy'];
expect(csp).toContain("connect-src 'self'");
expect(csp).not.toContain('evil.com');
expect(csp).not.toContain('*');
});
it('allows the plugin its own static assets when the Host header is clean', () => {
const res = fakeRes();
new PluginFrameController(runtime(true)).serve('widget', req('', 'trek.example.com:8443'), res as never);
@@ -0,0 +1,176 @@
/**
* The admin plugin controller's signature surface (#plugins): the machine-readable
* refusal code reaching the WIRE, and the re-trust endpoint.
*
* The code is tested through the real TrekExceptionFilter rather than on the thrown
* object, because that filter is the one thing standing between the code and the client
* — and the whole design rests on the client being able to distinguish "the author
* rotated their key" (overridable) from "these bytes are not what the author signed"
* (never overridable) WITHOUT string-matching prose. If the code silently died in the
* envelope, every test on the thrown object would still pass and the UI would be
* guessing.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { HttpException, type ArgumentsHost } from '@nestjs/common';
vi.mock('../../../src/db/database', () => ({ db: {}, canAccessTrip: () => undefined }));
import { PluginsController } from '../../../src/nest/plugins/plugins.controller';
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import { PluginRegistryService, RegistryError } from '../../../src/nest/plugins/registry/registry.service';
import { PluginsService } from '../../../src/nest/plugins/plugins.service';
import { TrekExceptionFilter } from '../../../src/nest/common/trek-exception.filter';
const ADMIN = { id: 1 } as { id: number };
const REQ = { headers: {}, socket: {} } as never;
/** Run an exception through the real global filter and report what the client would see. */
function throughFilter(e: unknown): { status: number; body: unknown } {
let status = 0;
let body: unknown;
const res = { status: (s: number) => ((status = s), res), json: (b: unknown) => ((body = b), res) };
new TrekExceptionFilter().catch(e, { switchToHttp: () => ({ getResponse: () => res }) } as unknown as ArgumentsHost);
return { status, body };
}
/** Drive a controller call and return the wire response of its failure. */
async function wireFailure(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
try {
await fn();
throw new Error('expected the call to fail');
} catch (e) {
expect(e).toBeInstanceOf(HttpException);
return throughFilter(e);
}
}
let runtime: PluginRuntimeService;
let registry: PluginRegistryService;
let controller: PluginsController;
beforeEach(() => {
process.env.TREK_PLUGINS_ENABLED = 'true';
runtime = { update: vi.fn(), retrust: vi.fn() } as unknown as PluginRuntimeService;
registry = { install: vi.fn() } as unknown as PluginRegistryService;
controller = new PluginsController({} as PluginsService, runtime, registry);
});
describe('signature refusal codes survive to the client', () => {
it('POST :id/update carries { error, code } through the exception filter', async () => {
vi.mocked(runtime.update).mockRejectedValue(
new RegistryError("the plugin's author signing key changed since it was installed", 'SIGNATURE_KEY_CHANGED'),
);
const { status, body } = await wireFailure(() => controller.update('flight-tracker'));
expect(status).toBe(400);
expect(body).toEqual({
error: "the plugin's author signing key changed since it was installed",
code: 'SIGNATURE_KEY_CHANGED',
});
});
it('POST install carries { error, code } through the exception filter', async () => {
vi.mocked(registry.install).mockRejectedValue(new RegistryError('author signature verification failed', 'SIGNATURE_INVALID'));
const { status, body } = await wireFailure(() => controller.install({ id: 'flight-tracker' }));
expect(status).toBe(400);
expect(body).toEqual({ error: 'author signature verification failed', code: 'SIGNATURE_INVALID' });
});
// A SIGNATURE_INVALID reaching the client as a bare { error } would leave the UI unable
// to tell it apart from a rotation — and a UI that guesses will eventually offer
// "re-trust" on bytes the author never signed.
it('an INVALID signature arrives with its own code, not the re-trustable one', async () => {
vi.mocked(runtime.update).mockRejectedValue(new RegistryError('author signature verification failed', 'SIGNATURE_INVALID'));
const { body } = await wireFailure(() => controller.update('flight-tracker'));
expect((body as { code: string }).code).toBe('SIGNATURE_INVALID');
expect((body as { code: string }).code).not.toBe('SIGNATURE_KEY_CHANGED');
});
it('an ordinary failure with no code still emits the plain { error } envelope', async () => {
vi.mocked(runtime.update).mockRejectedValue(new Error('network unreachable'));
const { status, body } = await wireFailure(() => controller.update('flight-tracker'));
expect(status).toBe(400);
expect(body).toEqual({ error: 'network unreachable' });
});
});
describe('POST :id/retrust', () => {
it('re-pins AND installs in one call, returning the update result', async () => {
vi.mocked(runtime.retrust).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
const res = await controller.retrust('flight-tracker', { version: '2.0.0', publicKey: 'NEWKEY' }, ADMIN, REQ);
expect(res).toEqual({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
// The version + the full key the admin was SHOWN are both forwarded: the version
// because the artifact bytes must be fetched to verify them under the new key, the
// full key (not a fingerprint) because the server's equality check must be exact.
expect(runtime.retrust).toHaveBeenCalledWith('flight-tracker', '2.0.0', 'NEWKEY', expect.objectContaining({ userId: 1 }));
});
it('requires the version and the public key', async () => {
await expect(controller.retrust('flight-tracker', { publicKey: 'K' }, ADMIN, REQ)).rejects.toThrow(HttpException);
await expect(controller.retrust('flight-tracker', { version: '2.0.0' }, ADMIN, REQ)).rejects.toThrow(HttpException);
expect(runtime.retrust).not.toHaveBeenCalled();
});
// D2 at the HTTP boundary: the service refuses anything but a changed key, and the
// refusal reaches the client with its code intact. Hiding the button in the UI is a
// convenience — THIS is the control.
it('surfaces the service refusal (with its code) when the condition is not a changed key', async () => {
vi.mocked(runtime.retrust).mockRejectedValue(
new RegistryError("this plugin's signing key has not changed — there is nothing to re-trust", 'RETRUST_NOT_APPLICABLE'),
);
const { status, body } = await wireFailure(() =>
controller.retrust('flight-tracker', { version: '2.0.0', publicKey: 'K' }, ADMIN, REQ),
);
expect(status).toBe(400);
expect(body).toMatchObject({ code: 'RETRUST_NOT_APPLICABLE' });
});
it('surfaces a TOCTOU key mismatch (the entry was re-keyed since the dialog rendered)', async () => {
vi.mocked(runtime.retrust).mockRejectedValue(new RegistryError('the signing key changed again', 'RETRUST_KEY_MISMATCH'));
const { body } = await wireFailure(() => controller.retrust('flight-tracker', { version: '2.0.0', publicKey: 'STALE' }, ADMIN, REQ));
expect(body).toMatchObject({ code: 'RETRUST_KEY_MISMATCH' });
});
// A plugin that isn't there is a 404, not a 400 — the request was well-formed. The code
// still rides along, so a client that keys off it keeps working either way.
it('answers 404 (not 400) for a plugin that does not exist', async () => {
vi.mocked(runtime.retrust).mockRejectedValue(new RegistryError('plugin ghost not found', 'NOT_FOUND'));
const { status, body } = await wireFailure(() => controller.retrust('ghost', { version: '2.0.0', publicKey: 'K' }, ADMIN, REQ));
expect(status).toBe(404);
expect(body).toEqual({ error: 'plugin ghost not found', code: 'NOT_FOUND' });
});
// The 404 is scoped to NOT_FOUND alone. Every other refusal on this surface — and every
// install/update failure, which never carries that code — stays a 400.
it('leaves every other refusal on 400', async () => {
vi.mocked(runtime.retrust).mockRejectedValue(new RegistryError('nothing to re-trust', 'RETRUST_NOT_APPLICABLE'));
expect((await wireFailure(() => controller.retrust('p', { version: '1', publicKey: 'K' }, ADMIN, REQ))).status).toBe(400);
vi.mocked(registry.install).mockRejectedValue(new RegistryError('plugin ghost not in registry'));
expect((await wireFailure(() => controller.install({ id: 'ghost' }))).status).toBe(400);
});
it('is refused when the plugin runtime is disabled by server configuration', async () => {
process.env.TREK_PLUGINS_ENABLED = 'false';
const { status } = await wireFailure(() => controller.retrust('flight-tracker', { version: '2.0.0', publicKey: 'K' }, ADMIN, REQ));
expect(status).toBe(503);
expect(runtime.retrust).not.toHaveBeenCalled();
});
// Re-trusting a signing key is an admin act, and there is no non-admin route to it:
// the guards sit on the CLASS, so every route on this controller — including the one
// added here — is behind them. Asserted on the metadata rather than on one handler, so
// it keeps holding for routes added later.
it('is admin-only (the guards are class-level, so retrust inherits them)', () => {
const guards = (Reflect.getMetadata('__guards__', PluginsController) ?? []) as Array<{ name: string }>;
expect(guards.map((g) => g.name)).toEqual(['JwtAuthGuard', 'AdminGuard']);
});
});
@@ -10,7 +10,8 @@ const { testDb } = vi.hoisted(() => {
db.exec(`CREATE TABLE plugins (
id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, updated_at TEXT,
sort_order INTEGER DEFAULT 0);
author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT,
trek_range TEXT, sort_order INTEGER DEFAULT 0);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT DEFAULT '2026-01-01');`);
return { testDb: db };
@@ -60,6 +61,65 @@ describe('PluginsService.list', () => {
expect(out.plugins).toEqual([]);
});
// The four trust states an admin can be in. `signed` derives from the TOFU-pinned
// author key; sideloaded/dev-linked derive from source_repo — so they are NOT
// mutually exclusive in the data, and a sideloaded plugin legitimately reports
// signed:false. The UI's precedence rule (source badge wins) depends on that being
// reported honestly rather than papered over here.
describe('signature status', () => {
const insert = (id: string, sourceRepo: string | null, pubkey: string | null) =>
testDb
.prepare('INSERT INTO plugins (id, name, type, status, version, source_repo, author_pubkey) VALUES (?,?,?,?,?,?,?)')
.run(id, id, 'widget', 'inactive', '1.0.0', sourceRepo, pubkey);
it('reports signed + a display fingerprint for a registry plugin with a pinned key', () => {
const key = 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd';
insert('signed-one', 'acme/signed-one', key);
const p = new PluginsService().list().plugins[0];
expect(p.signed).toBe(true);
// Short head…tail, for eyeballing against what the author reads out over the
// phone. NOT a confidentiality measure — the key is public, and the re-trust
// round-trip deliberately carries it in full.
expect(p.keyFingerprint).toBe(`${key.slice(0, 8)}…${key.slice(-8)}`);
});
it('reports unsigned for a registry plugin with no pinned key', () => {
insert('plain', 'acme/plain', null);
const p = new PluginsService().list().plugins[0];
expect(p.signed).toBe(false);
expect(p.keyFingerprint).toBeNull();
});
it('reports unsigned for a sideloaded and a dev-linked plugin (they carry no key)', () => {
insert('uploaded', 'local:upload', null);
insert('linked', 'local:link', null);
const plugins = new PluginsService().list().plugins;
expect(plugins.map((p) => [p.id, p.signed, p.source_repo])).toEqual([
['linked', false, 'local:link'],
['uploaded', false, 'local:upload'],
]);
});
it('surfaces a recorded update block, and reports none when there is none', () => {
insert('blocked', 'acme/blocked', 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd');
insert('fine', 'acme/fine', null);
testDb
.prepare('UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?')
.run('SIGNATURE_KEY_CHANGED', 'the key changed', '2.0.0', 'blocked');
const byId = Object.fromEntries(new PluginsService().list().plugins.map((p) => [p.id, p]));
expect(byId.blocked.updateBlock).toEqual({ code: 'SIGNATURE_KEY_CHANGED', detail: 'the key changed', version: '2.0.0' });
expect(byId.fine.updateBlock).toBeNull();
});
it('never leaks the raw pinned key into the list response (only the fingerprint)', () => {
insert('signed-one', 'acme/signed-one', 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd');
const p = new PluginsService().list().plugins[0] as Record<string, unknown>;
expect(p.author_pubkey).toBeUndefined();
});
});
it('controller delegates to the service', () => {
const svc = { list: vi.fn(() => ({ enabled: false, plugins: [] })) } as unknown as PluginsService;
const runtime = {} as unknown as import('../../../src/nest/plugins/plugin-runtime.service').PluginRuntimeService;
@@ -6,6 +6,8 @@
* child (the router runs in the host).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import Database from 'better-sqlite3';
import { createTables } from '../../../src/db/schema';
import { PluginRpcHost, type HostDeps } from '../../../src/nest/plugins/host/rpc-host';
import type { RpcRequest, RpcResponse, RpcError } from '../../../src/nest/plugins/protocol/envelope';
@@ -1030,6 +1032,33 @@ describe('PluginRpcHost — capability enforcement', () => {
expect((noUser as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
});
// Regression (#getPlaces): the mocked `db.prepare` above never parses the SQL, so an
// invalid column sails through. Drive the trip reads against a REAL schema-loaded
// SQLite instead — `places` has no day_id/position (those live on day_assignments),
// so the old `ORDER BY day_id, position` threw `no such column` on every call.
it('trips.getPlaces runs against the real places schema and returns the trip pool', async () => {
const realDb = new Database(':memory:');
createTables(realDb);
realDb.exec(`
INSERT INTO users (id, username, email, password_hash) VALUES (42, 'ada', 'ada@example.com', 'x');
INSERT INTO trips (id, user_id, title) VALUES (1, 42, 'Japan'), (2, 42, 'Peru');
INSERT INTO places (id, trip_id, name, created_at) VALUES
(1, 1, 'Older', '2027-01-01 10:00:00'),
(2, 1, 'Newer', '2027-01-02 10:00:00'),
(3, 2, 'Other trip', '2027-01-03 10:00:00');
`);
deps.db = realDb as unknown as HostDeps['db'];
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
const res = await host.dispatch(req('trips.getPlaces', { tripId: 1 }), 42);
expect(ok(res)).toBe(true);
const rows = (res as RpcResponse).result as Array<{ id: number; name: string }>;
// only trip 1's places, newest first (mirrors the REST list's created_at DESC)
expect(rows.map(r => r.name)).toEqual(['Newer', 'Older']);
realDb.close();
});
it('meta WRITES need the entity edit permission — a read-only member is RESOURCE_FORBIDDEN', async () => {
// Member can access the trip but not edit it (viewer role).
(deps.canEditTrip as ReturnType<typeof vi.fn>).mockReturnValue(false);