* 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
+34
View File
@@ -3626,6 +3626,40 @@ function runMigrations(db: Database.Database): void {
);
`);
},
// Why a plugin's update was REFUSED by the signature check (#plugins). A refused
// update leaves a working plugin pinned at its old version — previously the reason
// lived only in a transient toast, so the plugin quietly stopped updating and the
// admin had to re-attempt an update to rediscover why. Record it instead.
//
// `update_block_version` is the registry version that was refused: once the registry
// offers something NEWER, the block describes an artifact nobody is being offered
// anymore, so it reads as stale and the admin can just re-attempt (the next install
// re-verifies and either succeeds or re-blocks with fresh values). Deliberately no
// `status = 'error'` — the plugin still runs fine on its old code.
() => {
for (const col of ['update_block_code TEXT', 'update_block_detail TEXT', 'update_block_version TEXT']) {
try {
db.exec(`ALTER TABLE plugins ADD COLUMN ${col};`);
} catch (err) {
console.warn('[migrations] Non-fatal migration step failed:', err);
}
}
},
// The semver RANGE of TREK versions a plugin declares it supports (its manifest's
// `trek`, e.g. ">=3.2.0 <4.0.0"). The existing `min_trek_version` only carries the
// lower bound, so it cannot express "stops working at 4.0" — which is precisely the
// case the activation gate has to catch after a TREK upgrade. Kept nullable: a plugin
// installed before this column existed has no range recorded, and the gate refuses to
// activate it rather than guessing (see TREK_VERSION_UNKNOWN).
() => {
try {
db.exec('ALTER TABLE plugins ADD COLUMN trek_range TEXT;');
} catch (err) {
console.warn('[migrations] Non-fatal migration step failed:', err);
}
},
];
if (currentVersion < migrations.length) {
+38 -3
View File
@@ -9,7 +9,7 @@ import { isAddonEnabled } from '../services/adminService';
import { ADDON_IDS } from '../addons';
import { registerResources } from './resources';
import { registerTools } from './tools';
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient, evictOldestSessionForUser } from './sessionManager';
import { resolveSessionTtlMs, resolveKeepaliveMs } from './config';
import { writeAudit, getClientIp } from '../services/auditLog';
import { getMcpSafeUrl } from '../services/notifications';
@@ -183,6 +183,12 @@ const sessionSweepInterval = setInterval(() => {
// Prevent the interval from keeping the process alive if nothing else is running
sessionSweepInterval.unref();
/** JSON-RPC 2.0 error body. MCP clients parse this and show `message`; a bare `{ error }`
* envelope isn't valid JSON-RPC, so they fall back to a generic "tool execution failed". */
function jsonRpcError(message: string, code = -32000): object {
return { jsonrpc: '2.0', error: { code, message }, id: null };
}
function setAuthChallenge(res: Response, error = 'invalid_token'): void {
const base = (getMcpSafeUrl() || '').replace(/\/+$/, '');
// RFC 9728 §5: resource with path component /mcp → PRM URL must include the path
@@ -304,9 +310,25 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
return;
}
// A client that reuses its session sends the header on every call after initialize, so a
// steady stream of session-less POSTs means the id isn't making it back to the client —
// usually a reverse proxy dropping Mcp-Session-Id, or a client that ignores it. Say so,
// because the visible symptom (sessions piling up to the cap) points nowhere near the cause.
console.warn(
`[MCP] POST without mcp-session-id for user ${user.id} — starting a new session. ` +
'If this repeats on every tool call, the Mcp-Session-Id response header is not reaching ' +
'the client (check that your reverse proxy forwards it).',
);
// At the cap, evict this user's coldest session rather than refusing the request: a client
// stuck re-initializing would otherwise be locked out until the process restarted.
if (countSessionsForUser(user.id) >= MAX_SESSIONS_PER_USER) {
res.status(429).json({ error: 'Session limit reached. Close an existing session before opening a new one.' });
return;
const evicted = evictOldestSessionForUser(user.id);
if (!evicted) {
res.status(429).json(jsonRpcError('Session limit reached. Close an existing session before opening a new one.'));
return;
}
console.log(`[MCP] Session limit (${MAX_SESSIONS_PER_USER}) reached for user ${user.id} — evicted idle session ${evicted}`);
}
// Create a new per-user MCP server and session
@@ -348,6 +370,10 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
sessions.delete(sid);
},
});
// Belt-and-braces: whenever the SDK closes the transport for any reason, drop the map entry.
transport.onclose = () => {
if (transport.sessionId) sessions.delete(transport.sessionId);
};
logToolCallAudit(req, user.id, clientId);
armSseKeepalive(res);
@@ -359,6 +385,15 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
if (!res.headersSent) {
res.status(500).json({ error: 'Internal MCP error' });
}
} finally {
// Only an `initialize` request assigns a sessionId (via onsessioninitialized). Any other
// session-less POST is rejected by the SDK with "Server not initialized" — and this server,
// with its ~200 registered tools, would otherwise be orphaned: never in `sessions`, never
// swept, never closed. Reap it here.
if (!transport.sessionId) {
try { server.close(); } catch { /* ignore */ }
try { transport.close(); } catch { /* ignore */ }
}
}
}
+35 -10
View File
@@ -16,14 +16,18 @@ export interface McpSession {
export const sessions = new Map<string, McpSession>();
/** Close both halves of a session and drop it from the map. Close errors are non-fatal:
* a transport whose socket is already gone must not block eviction of the map entry. */
function closeSession(sid: string, session: McpSession): void {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
sessions.delete(sid);
}
/** Terminate all active MCP sessions for a specific user (e.g. on token revocation). */
export function revokeUserSessions(userId: number): void {
for (const [sid, session] of sessions) {
if (session.userId === userId) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
sessions.delete(sid);
}
if (session.userId === userId) closeSession(sid, session);
}
}
@@ -32,10 +36,31 @@ export function revokeUserSessions(userId: number): void {
* sessions are closed, not sessions from other clients for the same user. */
export function revokeUserSessionsForClient(userId: number, clientId: string): void {
for (const [sid, session] of sessions) {
if (session.userId === userId && session.clientId === clientId) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
sessions.delete(sid);
}
if (session.userId === userId && session.clientId === clientId) closeSession(sid, session);
}
}
/**
* Close the least-recently-active session for a user so a new one can take its slot,
* and return its id (null when the user has no sessions).
*
* This is what keeps the per-user cap from becoming a dead end. A client that cannot
* persist its Mcp-Session-Id — a proxy stripping the header, a non-conformant client —
* re-initializes on every call, and a hard cap would leave the user permanently unable
* to open a session until the process restarted. Evicting the coldest session instead
* means the worst case is a bounded ring of sessions, not a wedged integration.
*/
export function evictOldestSessionForUser(userId: number): string | null {
let oldestSid: string | null = null;
let oldestSession: McpSession | null = null;
for (const [sid, session] of sessions) {
if (session.userId !== userId) continue;
if (!oldestSession || session.lastActivity < oldestSession.lastActivity) {
oldestSid = sid;
oldestSession = session;
}
}
if (!oldestSid || !oldestSession) return null;
closeSession(oldestSid, oldestSession);
return oldestSid;
}
+13 -1
View File
@@ -82,6 +82,14 @@ export function applyGlobalMiddleware(
// answer OPTIONS without Access-Control-Allow-Origin before the SDK's own cors() runs.
// All /.well-known/* paths get open CORS so clients probing openid-configuration or the
// RFC 8414 path-suffixed AS metadata form don't get CORS-blocked (they get 404 JSON instead).
//
// `exposedHeaders` is load-bearing, not cosmetic. Without Access-Control-Expose-Headers the
// Fetch spec forbids a browser-context client (Claude Desktop connectors, Claude.ai, MCP
// Inspector) from *reading* Mcp-Session-Id off the initialize response — so it can never echo
// the header back, every request looks like a fresh initialize, and the server mints a new
// session per tool call until the per-user cap wedges the connection. Same reasoning for
// WWW-Authenticate, which carries the RFC 9728 resource-metadata challenge that drives
// OAuth discovery.
app.use(
(req: Request, _res: Response, next: NextFunction) => {
if (
@@ -91,7 +99,11 @@ export function applyGlobalMiddleware(
req.path === '/oauth/userinfo' ||
req.path === '/mcp'
) {
cors({ origin: '*', credentials: false })(req, _res, next);
cors({
origin: '*',
credentials: false,
exposedHeaders: ['Mcp-Session-Id', 'MCP-Protocol-Version', 'WWW-Authenticate'],
})(req, _res, next);
} else {
next();
}
+4 -1
View File
@@ -303,8 +303,11 @@ export class PluginRpcHost {
this.methods.set('trips.getById', (p, uid) =>
this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM trips WHERE id = ?').get(num(p.tripId, 'tripId'))),
);
// The trip's place POOL — places carry no itinerary position of their own
// (day_id/order_index live on day_assignments), so order by created_at like
// the REST list does. Use trips.getDays for the day-ordered itinerary.
this.methods.set('trips.getPlaces', (p, uid) =>
this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM places WHERE trip_id = ? ORDER BY day_id, position').all(num(p.tripId, 'tripId'))),
this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM places WHERE trip_id = ? ORDER BY created_at DESC').all(num(p.tripId, 'tripId'))),
);
// Hydrated like the REST list (endpoints, day_positions, joins, normalized
// accommodation_id) — a strict superset of the raw row, so older callers
+5 -5
View File
@@ -67,15 +67,15 @@ function upsert(db: BetterSqlite3.Database, m: PluginManifest): void {
if (existing) {
db.prepare(
`UPDATE plugins SET name = ?, description = ?, type = ?, icon = ?, version = ?, api_version = ?,
min_trek_version = ?, permissions = ?, capabilities = ?, dependencies = ?, operator_egress = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0, m.id);
min_trek_version = ?, trek_range = ?, permissions = ?, capabilities = ?, dependencies = ?, operator_egress = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, m.trekRange, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0, m.id);
} else {
db.prepare(
// granted_permissions '' (empty, not '[]') marks "never consented" so the
// first activation is distinguishable from a plugin consented to zero perms.
`INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, permissions, capabilities, dependencies, operator_egress, granted_permissions, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'inactive')`,
).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0);
`INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, trek_range, permissions, capabilities, dependencies, operator_egress, granted_permissions, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'inactive')`,
).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, m.trekRange, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0);
}
// Refresh the settings-page action descriptors from the manifest.
@@ -0,0 +1,106 @@
import semver from 'semver';
/**
* Host-version compatibility for plugins (#plugins). A manifest declares the TREK
* versions it supports as a semver RANGE (`"trek": ">=3.2.0 <4.0.0"`); this module
* is the single place that answers "what TREK is running" and "does that range admit
* it". Every install front door (registry, sideload, dev-link) and the activation
* gate go through here, so a plugin can never run against a host it says it doesn't
* support — and, just as importantly, they all answer the question the same way.
*/
/** The running TREK version (same source as the rest of the app). */
export function hostVersion(): string {
return process.env.APP_VERSION || (require('../../../../package.json') as { version: string }).version;
}
let warnedUnparseable = false;
/**
* The running version as a plain release triple, or null when it can't be parsed at all.
*
* Prereleases are deliberately COERCED to their target release: 3.4.0-rc.1 compares as
* 3.4.0, and 4.0.0-rc.1 as 4.0.0. Both halves of that matter, and semver's own ordering
* gets both wrong for this purpose:
*
* - Plain `satisfies()` excludes a prerelease from any range that doesn't name one, so
* 3.4.0-rc.1 would fail ">=3.2.0 <4.0.0" and EVERY plugin on the instance would go
* incompatible the moment TREK shipped a release candidate.
* - `includePrerelease` fixes that but orders 4.0.0-rc.1 BEFORE 4.0.0, so it satisfies
* "<4.0.0" — letting a plugin that disclaims TREK 4 load on a build that already
* carries TREK 4's breaking changes. That is the precise failure this gate exists to
* prevent.
*
* Coercing to the release says the useful thing instead: for compatibility purposes, an
* rc of 4.0 IS 4.0.
*
* A null host NEVER blocks anything (see {@link hostSatisfies}). This is deliberate:
* APP_VERSION is a Docker build ARG that defaults to the literal string `dev`, and an
* unversioned build must stay fully usable. The warning exists so that a MISCONFIGURED
* APP_VERSION in production — which silently switches this whole gate off — is visible
* in the logs instead of being discovered when an incompatible plugin misbehaves.
*/
export function normalizedHost(): string | null {
const raw = hostVersion();
const coerced = semver.coerce(raw)?.version ?? null;
if (!coerced && !warnedUnparseable) {
warnedUnparseable = true;
console.warn(`[plugins] APP_VERSION "${raw}" is not a semver version — plugin TREK-compatibility checks are disabled`);
}
return coerced;
}
/** Test hook: re-arm the one-time unparseable-host warning. */
export function __resetHostWarningForTests(): void {
warnedUnparseable = false;
}
/**
* Whether `r` is a semver range a plugin may declare.
*
* `semver.validRange` alone is not enough: ">=4.0.0 <3.0.0" is a VALID range that no
* version can ever satisfy, and a plugin that declares it would be uninstallable with
* no way to tell the author why. minVersion() returns null for exactly that case (and
* throws outright on garbage like "latest"), so it is the real satisfiability test.
*/
export function isValidTrekRange(r: unknown): r is string {
if (typeof r !== 'string' || !r.trim()) return false;
if (semver.validRange(r) === null) return false;
try {
return semver.minVersion(r) !== null;
} catch {
return false;
}
}
/**
* The lowest TREK version a range admits — what the UI shows as "Requires TREK x+".
* Undefined for a range with no real lower bound (`*`, `<4.0.0`), because "requires
* TREK 0.0.0+" is noise rather than information.
*/
export function minTrekOf(range: string): string | undefined {
if (!isValidTrekRange(range)) return undefined;
const min = semver.minVersion(range);
if (!min || min.version === '0.0.0') return undefined;
return min.version;
}
/**
* Whether the running TREK satisfies `range`. The host arrives here already coerced to a
* release triple (see {@link normalizedHost}), which is what makes a plain satisfies()
* correct on both prerelease edges.
*
* A null/empty range is not a decision this function can make — the caller knows whether
* an undeclared range means "reject" (an install) or "unknown" (an already-installed
* plugin), and they surface different codes. It returns false so neither can pass by
* accident.
*/
export function hostSatisfies(range: string | null | undefined, host: string | null = normalizedHost()): boolean {
// Coerce again rather than trusting the argument: callers that pass a host explicitly
// (the registry, tests) would otherwise skip the prerelease normalisation and get a
// different answer than the default path for the same TREK.
const release = host === null ? null : (semver.coerce(host)?.version ?? null);
if (release === null) return true; // unversioned build — never block
if (!isValidTrekRange(range)) return false;
return semver.satisfies(release, range);
}
+31 -2
View File
@@ -1,5 +1,6 @@
import semver from 'semver';
import { isKnownPermission } from '../protocol/envelope';
import { isValidTrekRange, minTrekOf } from './host-compat';
import type { NotifEventType } from '../../../services/notificationPreferencesService';
/**
@@ -87,7 +88,11 @@ export interface PluginManifest {
homepage?: string;
icon?: string;
type: 'integration' | 'page' | 'widget' | 'trip-page';
/** The raw semver RANGE of TREK versions this plugin supports (">=3.2.0 <4.0.0"). */
trek?: string;
/** Same range, normalized: a valid+satisfiable one, or null. What the host gates on. */
trekRange: string | null;
/** The range's lower bound, for display ("Requires TREK 3.2.0+"). Derived, never authored. */
minTrekVersion?: string;
nativeModules: boolean;
permissions: string[];
@@ -134,7 +139,19 @@ export function parseJsonText(text: string): unknown {
return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
}
export function parseManifest(raw: unknown): PluginManifest {
/**
* Validate a manifest.
*
* `requireTrek` is the INSTALL front doors (registry, sideload, dev-link): a plugin
* that doesn't say which TREK versions it supports may not be installed at all.
*
* It is deliberately OFF for discovery, which is a reconciler, not a gate. Discovery's
* failure path only logs and skips — it never touches the plugins row — so a plugin it
* rejects keeps its stale `enabled = 1` and gets spawned by the next boot anyway. Making
* this strict there would produce a plugin that is invisible to discovery and still
* running. Activation is where an undeclared range is actually refused.
*/
export function parseManifest(raw: unknown, opts?: { requireTrek?: boolean }): PluginManifest {
if (!raw || typeof raw !== 'object') throw new ManifestError('manifest is not an object');
const m = raw as Record<string, unknown>;
@@ -183,6 +200,14 @@ export function parseManifest(raw: unknown): PluginManifest {
if (badEgress !== undefined) throw new ManifestError(`invalid egress host "${badEgress}"`);
const trek = optStr(m.trek);
const trekRange = isValidTrekRange(trek) ? trek : null;
if (opts?.requireTrek && !trekRange) {
throw new ManifestError(
trek
? `invalid "trek" version range "${trek}" (expected a satisfiable semver range, e.g. ">=3.2.0 <4.0.0")`
: 'missing "trek" version range — declare the TREK versions this plugin supports, e.g. ">=3.2.0 <4.0.0"',
);
}
return {
id,
name: str(m.name, 'name'),
@@ -194,7 +219,11 @@ export function parseManifest(raw: unknown): PluginManifest {
icon: optStr(m.icon) ?? 'Blocks',
type: type as PluginManifest['type'],
trek,
minTrekVersion: trek ? (trek.match(/(\d+\.\d+\.\d+)/)?.[1] ?? undefined) : undefined,
trekRange,
// Derived from the range itself, never from the first number that looks like a
// version in it: a range of "<4.0.0" has a *first* semver of 4.0.0 but a lower
// bound of 0.0.0, so reading it off the text produced the exact inverse of the truth.
minTrekVersion: trekRange ? minTrekOf(trekRange) : undefined,
nativeModules: false,
permissions,
egress,
-1
View File
@@ -119,7 +119,6 @@ export function pluginPermissionArgs(pluginId: string): string[] {
// off by accident.
if (!warnedPermissionsOff) {
warnedPermissionsOff = true;
// eslint-disable-next-line no-console
console.warn('[plugins] TREK_PLUGIN_PERMISSIONS=off — the OS permission jail is DISABLED; installed plugins run with full Node access to this process. Only use this on a machine you fully trust.');
}
return [];
-2
View File
@@ -111,7 +111,6 @@ export function applyStagedPluginTrees(): string[] {
swapContents(live, staged);
applied.push(label);
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[plugins] failed to apply staged ${label} restore:`, err);
}
}
@@ -135,7 +134,6 @@ export async function applyStagedRestoreNow(): Promise<boolean> {
await applier();
return true;
} catch (err) {
// eslint-disable-next-line no-console
console.error('[plugins] immediate staged-restore apply failed; will retry on next boot:', err);
return false;
}
@@ -67,12 +67,20 @@ export class PluginFrameController {
/** Per-plugin, locked-down CSP for the sandboxed frame document. */
private frameCsp(pluginId: string, host: string | undefined): string {
// Defense in depth: the manifest validator already constrains these hosts, but
// never interpolate anything that isn't a clean host/wildcard into connect-src
// (a stray space or `*` would inject an extra CSP source token).
const outbound = this.runtime
.outboundHostsOf(pluginId)
.filter((h) => /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i.test(h));
// The frame must be able to reach exactly what the CHILD may reach. The child's egress
// guard is the union of the manifest's http:outbound:<host> grants AND the hosts an admin
// added post-install for an operatorEgress plugin (plugin-runtime.service: activate()).
// Building connect-src from the grants alone left an operatorEgress plugin WITH A UI able
// to call the operator's host from its server but CSP-blocked in its own iframe. The admin
// consented to these hosts at install and the child already reaches them, so matching the
// frame to the child widens no trust boundary that isn't already crossed.
//
// Defense in depth: both sources are validated upstream (the manifest validator; the
// admin writer's EGRESS_HOST_RE), but never interpolate anything that isn't a clean
// host/wildcard into connect-src — a stray space or `*` would inject an extra CSP source.
const outbound = [
...new Set([...this.runtime.outboundHostsOf(pluginId), ...this.runtime.operatorEgressHosts(pluginId)]),
].filter((h) => /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i.test(h));
// The frame runs at an OPAQUE origin (sandbox without allow-same-origin), so
// 'self' matches nothing and the plugin's own <script src>/<link> files would
// be blocked — a multi-file client build (Vite/React output) only worked
+132 -16
View File
@@ -22,7 +22,10 @@ import { parseJsonText, parseManifest } from './install/manifest';
import { scanForNativeBinaries } from './install/native-scan';
import { devLinkEnabled, DEV_LINK_SOURCE } from './dev-link';
import { pluginCodeDir, pluginDataDir } from './paths';
import { PluginRegistryService } from './registry/registry.service';
import { assertHostCompatible, PluginRegistryService, RegistryError } from './registry/registry.service';
import { hostSatisfies, hostVersion } from './install/host-compat';
import { keyFingerprint } from './signature-status';
import { writeAudit } from '../../services/auditLog';
import { isAddonEnabled } from '../../services/adminService';
import type { PluginDependency } from './install/manifest';
import type { VersionMismatch, PluginDepRow } from './dependencies';
@@ -83,18 +86,31 @@ export class PluginConsentRequired extends Error {
}
}
export type PluginDependencyCode = 'ADDON_DISABLED' | 'DEPENDENCY_MISSING';
export type PluginDependencyCode =
| 'ADDON_DISABLED'
| 'DEPENDENCY_MISSING'
/** The plugin's declared TREK range doesn't admit the running host. */
| 'TREK_VERSION_INCOMPATIBLE'
/** The plugin never declared a range, so we can't know that it does. */
| 'TREK_VERSION_UNKNOWN';
/**
* Thrown when a plugin can't activate because a required addon is disabled or a
* declared plugin dependency is missing / version-mismatched. The controller maps
* it to a 409 carrying `code` + `detail` so the admin UI can offer the right fix.
* Thrown when a plugin can't activate because a required addon is disabled, a declared
* plugin dependency is missing / version-mismatched, or it doesn't support this TREK.
* The controller maps it to a 409 carrying `code` + `detail` so the admin UI can offer
* the right fix.
*
* Host-incompatibility deliberately reuses this class rather than introducing its own.
* The boot loop only reconciles a failed activation's row back to `enabled = 0` for a
* PluginDependencyError / DependencyCycleError; any other error falls through to a branch
* that relies on the supervisor persisting a status — and the supervisor never ran. A
* separate class would therefore leave a plugin that never started still reading "active".
*/
export class PluginDependencyError extends Error {
constructor(
message: string,
readonly code: PluginDependencyCode,
readonly detail: { addons?: string[]; missing?: PluginDependency[]; versionMismatch?: VersionMismatch[] } = {},
readonly detail: { addons?: string[]; missing?: PluginDependency[]; versionMismatch?: VersionMismatch[]; trekRange?: string | null; hostVersion?: string } = {},
) {
super(message);
this.name = 'PluginDependencyError';
@@ -447,15 +463,35 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
/**
* Read-only activation gate for one plugin — throws (without mutating) if it may
* not activate. Checks run most- to least-severe: permission re-consent →
* required addon disabled → missing/mismatched plugin dependency.
* not activate. Checks run most- to least-severe: TREK-version compatibility →
* permission re-consent → required addon disabled → missing/mismatched plugin dependency.
*/
private assertActivatable(id: string, installed: Map<string, PluginDepRow>, consentWiden: boolean): void {
const row = db.prepare('SELECT permissions, granted_permissions, dependencies FROM plugins WHERE id = ?').get(id) as
| { permissions: string; granted_permissions: string; dependencies: string | null }
const row = db.prepare('SELECT permissions, granted_permissions, dependencies, trek_range FROM plugins WHERE id = ?').get(id) as
| { permissions: string; granted_permissions: string; dependencies: string | null; trek_range: string | null }
| undefined;
if (!row) throw new Error(`plugin ${id} not found`);
// This runs FIRST, and before the consent check in particular: a plugin that cannot
// run on this TREK must never be offered a permission dialog, because consenting to
// it would not make the plugin start. It is also the gate that catches the case
// install can't — TREK was upgraded PAST the plugin's declared upper bound, so code
// that was legitimately installed no longer supports the host it's sitting on.
if (!row.trek_range) {
throw new PluginDependencyError(
`plugin ${id} does not declare which TREK versions it supports`,
'TREK_VERSION_UNKNOWN',
{ trekRange: null, hostVersion: hostVersion() },
);
}
if (!hostSatisfies(row.trek_range)) {
throw new PluginDependencyError(
`plugin ${id} requires TREK ${row.trek_range} — this is TREK ${hostVersion()}`,
'TREK_VERSION_INCOMPATIBLE',
{ trekRange: row.trek_range, hostVersion: hostVersion() },
);
}
const declared = parseArray(row.permissions).filter(isKnownPermission);
const granted = parseArray(row.granted_permissions);
const newGrants = declared.filter((p) => !granted.includes(p));
@@ -586,16 +622,24 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
* Install runs first so a failed download/signature/integrity check leaves the
* currently-running child untouched (it keeps serving the old code from memory).
*/
async update(id: string): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }> {
const before = db.prepare('SELECT enabled, granted_permissions FROM plugins WHERE id = ?').get(id) as
| { enabled: number; granted_permissions: string }
async update(id: string, opts?: { version?: string; retrustKey?: string }): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }> {
const before = db.prepare('SELECT enabled, granted_permissions, version FROM plugins WHERE id = ?').get(id) as
| { enabled: number; granted_permissions: string; version: string | null }
| undefined;
if (!before) throw new Error(`plugin ${id} not found`);
if (!this.registry) throw new Error('registry service unavailable');
const wasEnabled = before.enabled === 1;
const granted = new Set(parseArray(before.granted_permissions));
const res = await this.registry.install(id); // swaps code + refreshes declared permissions; keeps granted
// Pick the newest version that this TREK can actually run, not simply the newest one
// published. Taking the latest blindly is how an update BREAKS a working plugin: the
// new release drops support for this host, the swap succeeds, and the activation gate
// then refuses to restart it — leaving the admin worse off than not updating. Refusing
// is the honest outcome; the plugin keeps running on the code it has.
const target = opts?.version ?? (await this.resolveUpdateTarget(id, before.version));
// swaps code + refreshes declared permissions; keeps granted
const res = await this.registry.install(id, { version: target, retrustKey: opts?.retrustKey });
const declared = parseArray(
(db.prepare('SELECT permissions FROM plugins WHERE id = ?').get(id) as { permissions: string }).permissions,
@@ -614,6 +658,72 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
return { version: res.version, activated: false, newPermissions, newEgress };
}
/**
* The version an "update" should install: the newest one compatible with this TREK.
* Refuses when that is not actually newer than what's installed — a compatible-but-older
* artifact is a DOWNGRADE, and silently rolling a plugin back under the word "update"
* would be worse than doing nothing.
*/
private async resolveUpdateTarget(id: string, installedVersion: string | null): Promise<string> {
if (!this.registry) throw new Error('registry service unavailable');
const target = await this.registry.resolveVersion(id); // throws TREK_VERSION_INCOMPATIBLE if nothing fits
if (installedVersion && semver.valid(installedVersion) && !semver.gt(target.version, installedVersion)) {
throw new RegistryError(
`no update available for ${id} on TREK ${hostVersion()} — ${installedVersion} is already the newest compatible version`,
'NO_COMPATIBLE_UPDATE',
);
}
return target.version;
}
/**
* Re-trust a plugin whose author signing key ROTATED, and update it — in ONE call.
*
* The single call is the point. A re-pin that returns and waits for the client to
* then call /update leaves a window where `author_pubkey` is pinned to a key no
* install has ever been verified against; if that second call never arrives (the
* admin closes the tab), the plugin sits pinned to an unverified key. So: either the
* new key verifies the artifact and the plugin lands on the new version with the new
* key pinned, or nothing changes at all.
*
* The safety properties live below this call, not here:
* - assertRetrustable re-derives the condition server-side, so this refuses anything
* other than a genuinely changed key (an invalid signature is NOT overridable),
* and refuses a key that changed again since the admin was shown it.
* - install() still verifies the artifact under the new key before pinning it, and
* only ever pins a key — never clears one to NULL.
*/
async retrust(
id: string,
version: string,
publicKey: string,
actor: { userId: number | null; ip?: string | null },
): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }> {
if (!this.registry) throw new Error('registry service unavailable');
const before = db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string | null } | undefined;
const entry = await this.registry.assertRetrustable(id, publicKey); // throws unless SIGNATURE_KEY_CHANGED
const res = await this.update(id, { version, retrustKey: publicKey });
// Audited because re-trusting a signing key is precisely the event that has to be
// reconstructible after an incident. This goes to the ADMIN audit log, not the
// plugin capability log — that one answers "what have plugins done in my name?"
// and is shown to end users; a lifecycle action by an admin does not belong there.
writeAudit({
userId: actor.userId,
action: 'admin.plugin_retrust',
resource: id,
details: {
plugin: id,
version: res.version,
oldKeyFingerprint: keyFingerprint(before?.author_pubkey),
newKeyFingerprint: keyFingerprint(entry.authorPublicKey),
},
ip: actor.ip ?? null,
});
return res;
}
/**
* Sideload a plugin from an uploaded archive (admin "Upload plugin"). Extracts +
* validates into staging first, stops any running child of the same id (its code
@@ -654,7 +764,8 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
if (!path.isAbsolute(sourceDir)) throw new Error('the dev-link path must be absolute');
const manifestPath = path.join(sourceDir, 'trek-plugin.json');
if (!fs.existsSync(manifestPath)) throw new Error(`no trek-plugin.json at ${sourceDir}`);
const manifest = parseManifest(parseJsonText(fs.readFileSync(manifestPath, 'utf8')));
const manifest = parseManifest(parseJsonText(fs.readFileSync(manifestPath, 'utf8')), { requireTrek: true });
assertHostCompatible(manifest.trekRange, manifest.id);
if (!fs.existsSync(path.join(sourceDir, 'server', 'index.js'))) {
throw new Error('no built server/index.js — build the plugin first (the loader runs the compiled artifact, not TS source)');
}
@@ -675,8 +786,13 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.symlinkSync(sourceDir, dest, 'junction'); // Windows junction (no elevation); POSIX ignores the type -> dir symlink
discoverPlugins(db); // registers/updates the row from the linked manifest, INACTIVE
// Same as a sideload: the plugin has left the registry trust model, so a block that
// described a refused REGISTRY update no longer describes the code that will run.
db.prepare(
`UPDATE plugins SET source_repo = ?, source_commit = NULL, sha256 = NULL, author_pubkey = NULL, status = 'inactive', enabled = 0 WHERE id = ?`,
`UPDATE plugins SET source_repo = ?, source_commit = NULL, sha256 = NULL, author_pubkey = NULL,
update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL,
status = 'inactive', enabled = 0
WHERE id = ?`,
).run(DEV_LINK_SOURCE, id);
this.watchLinked(id, sourceDir);
return { id, version: manifest.version, replaced };
+59 -6
View File
@@ -1,14 +1,37 @@
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, Query, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, Query, Req, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Request } from 'express';
import { PluginsService } from './plugins.service';
import { PluginRuntimeService, PluginConsentRequired, PluginDependencyError } from './plugin-runtime.service';
import { DependencyCycleError } from './dependencies';
import { PluginRegistryService } from './registry/registry.service';
import { PluginRegistryService, RegistryError } from './registry/registry.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/admin.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { getClientIp } from '../../services/auditLog';
import { pluginsEnabled } from './kill-switch';
import { devLinkEnabled } from './dev-link';
/**
* Flatten a registry/install failure into the error envelope — CARRYING THE CODE.
*
* The code is the whole point: the admin UI offers the re-trust override only for
* SIGNATURE_KEY_CHANGED, and it must read that from a field, not by string-matching the
* message. Drop the code here and the client is left guessing from prose, which sooner
* or later means offering "re-trust" on a signature that simply doesn't verify.
* TrekExceptionFilter passes a `{ error, code }` body through verbatim.
*
* A missing plugin is a 404, not a 400 — the request was well-formed, the thing just
* isn't there. Only `assertRetrustable` raises NOT_FOUND, so this is scoped to /retrust:
* install's "not in registry" carries no code and update throws a plain Error, and both
* keep their existing 400.
*/
function registryFailure(e: unknown, fallback: string): HttpException {
const code = e instanceof RegistryError ? e.code : undefined;
const error = e instanceof Error ? e.message : fallback;
return new HttpException(code ? { error, code } : { error }, code === 'NOT_FOUND' ? 404 : 400);
}
/**
* /api/admin/plugins — admin-only plugin control surface (#plugins).
*
@@ -57,7 +80,7 @@ export class PluginsController {
if (body.withDependencies) return await this.registry.installWithDependencies(body.id, body.constraint);
return await this.registry.install(body.id, { version: body.version, constraint: body.constraint });
} catch (e) {
throw new HttpException({ error: e instanceof Error ? e.message : 'install failed' }, 400);
throw registryFailure(e, 'install failed');
}
}
@@ -71,7 +94,7 @@ export class PluginsController {
try {
return await this.runtime.sideload(file.buffer);
} catch (e) {
throw new HttpException({ error: e instanceof Error ? e.message : 'upload failed' }, 400);
throw registryFailure(e, 'upload failed'); // carries TREK_VERSION_INCOMPATIBLE
}
}
@@ -89,7 +112,7 @@ export class PluginsController {
try {
return await this.runtime.link(dir);
} catch (e) {
throw new HttpException({ error: e instanceof Error ? e.message : 'link failed' }, 400);
throw registryFailure(e, 'link failed'); // carries TREK_VERSION_INCOMPATIBLE
}
}
@@ -188,7 +211,37 @@ export class PluginsController {
try {
return await this.runtime.update(id);
} catch (e) {
throw new HttpException({ error: e instanceof Error ? e.message : 'update failed' }, 400);
throw registryFailure(e, 'update failed');
}
}
/**
* Re-trust a plugin whose author signing key ROTATED, and update it in the same call.
*
* This is the ONLY override of a signature refusal that exists, and it exists only for
* a changed key: a rotation has a benign explanation, a signature that doesn't verify
* does not. The scoping is enforced in the service (`assertRetrustable`), not by this
* route and not by the UI — calling this directly on a plugin with an INVALID signature
* is refused.
*
* `publicKey` is the full key the admin was SHOWN, echoed back so the server can refuse
* if the registry entry has been re-keyed again since the dialog rendered.
*/
@Post(':id/retrust')
@HttpCode(200)
async retrust(
@Param('id') id: string,
@Body() body: { version?: string; publicKey?: string },
@CurrentUser() user: { id: number },
@Req() req: Request,
) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
if (!body?.version) throw new HttpException({ error: 'version is required' }, 400);
if (!body?.publicKey) throw new HttpException({ error: 'publicKey is required' }, 400);
try {
return await this.runtime.retrust(id, body.version, body.publicKey, { userId: user?.id ?? null, ip: getClientIp(req) });
} catch (e) {
throw registryFailure(e, 'retrust failed');
}
}
+52 -8
View File
@@ -4,14 +4,16 @@ import { pluginsEnabled } from './kill-switch';
import { devLinkEnabled } from './dev-link';
import { maybe_encrypt_api_key, decrypt_api_key } from '../../services/apiKeyCrypto';
import { readAudit } from './host/plugin-audit';
import { keyFingerprint } from './signature-status';
import { pluginBudgetUsage } from './host/create-rpc-host';
import { isAddonEnabled } from '../../services/adminService';
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, type PluginDepRow, type PluginDependencies, type VersionMismatch } from './dependencies';
import { hostSatisfies, hostVersion } from './install/host-compat';
import type { PluginDependency } from './install/manifest';
const SECRET_MASK = '••••••••';
export type PluginDependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin';
export type PluginDependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin' | 'hostIncompatible';
/**
* Read side of the plugin system (#plugins), M0 scaffold. Lists installed
@@ -35,6 +37,11 @@ interface PluginRawRow {
permissions: string;
capabilities: string;
dependencies: string | null;
trek_range: string | null;
author_pubkey: string | null;
update_block_code: string | null;
update_block_detail: string | null;
update_block_version: string | null;
}
/** Hosts an admin has added for a plugin (0 unless it declared operatorEgress). */
@@ -70,8 +77,24 @@ export interface PluginListItem {
dependencies: PluginDependencies;
/** Whether this plugin can currently activate, and why not if it can't. */
dependencyStatus: PluginDependencyStatus;
/** The TREK range the plugin declares it supports; null if it declared none. */
trekRange: string | null;
/** The running TREK, so the UI can say "needs X, you have Y" without doing semver. */
hostVersion: string;
/** The concrete blockers, so the UI can render chips + the resolve dialog. */
dependencyIssues: { disabledAddons: string[]; missing: PluginDependency[]; versionMismatch: VersionMismatch[] };
/**
* The author's signature was verified and their key TOFU-pinned at install.
* False means the bytes matched the registry's sha256 pin and nothing more — one
* fewer guarantee, not "insecure". Meaningless for a sideloaded/dev-linked plugin,
* which sits outside the registry trust model entirely; the UI badges those instead.
*/
signed: boolean;
/** Short, human-comparable form of the pinned key — for reading out to the author. */
keyFingerprint: string | null;
/** Why an update was refused, if one was. `version` is the registry version that was
* refused, so a caller can treat the block as stale once a newer one is on offer. */
updateBlock: { code: string; detail: string | null; version: string | null } | null;
}
@Injectable()
@@ -80,7 +103,8 @@ export class PluginsService {
const rows = db
.prepare(
`SELECT id, name, description, type, icon, version, status, enabled, last_error, reviewed_at, source_repo,
permissions, capabilities, dependencies, operator_egress
permissions, capabilities, dependencies, operator_egress, trek_range,
author_pubkey, update_block_code, update_block_detail, update_block_version
FROM plugins
ORDER BY sort_order, name`,
)
@@ -92,19 +116,39 @@ export class PluginsService {
const deps = parseDependencies(r.dependencies);
const disabledAddons = disabledRequiredAddons(deps, isAddonEnabled);
const state = resolveDependencyState(deps, installed);
const dependencyStatus: PluginDependencyStatus = disabledAddons.length
? 'addonDisabled'
: state.missing.length || state.versionMismatch.length
? 'missingPlugin'
: 'ok';
const { dependencies: _raw, operator_egress: _oe, ...rest } = r as PluginRawRow & { operator_egress?: number };
// Mirrors the order of assertActivatable's gate, so the card explains the same
// blocker the activate call would hit rather than a second, lesser one.
const dependencyStatus: PluginDependencyStatus = !hostSatisfies(r.trek_range)
? 'hostIncompatible'
: disabledAddons.length
? 'addonDisabled'
: state.missing.length || state.versionMismatch.length
? 'missingPlugin'
: 'ok';
const {
dependencies: _raw,
operator_egress: _oe,
trek_range,
author_pubkey,
update_block_code,
update_block_detail,
update_block_version,
...rest
} = r as PluginRawRow & { operator_egress?: number };
return {
...rest,
operatorEgress: _oe === 1,
egressHostCount: egressHostCount(r.id),
dependencies: deps,
dependencyStatus,
trekRange: trek_range,
hostVersion: hostVersion(),
dependencyIssues: { disabledAddons, missing: state.missing, versionMismatch: state.versionMismatch },
signed: !!author_pubkey,
keyFingerprint: keyFingerprint(author_pubkey),
updateBlock: update_block_code
? { code: update_block_code, detail: update_block_detail, version: update_block_version }
: null,
};
});
return { enabled: pluginsEnabled(), devLink: devLinkEnabled(), plugins };
@@ -6,10 +6,12 @@ import { db } from '../../../db/database';
import type { PluginDependency } from '../install/manifest';
import { pluginCodeDir, pluginsCodeRoot, pluginsDataRoot } from '../paths';
import { safeDownload, sha256Matches } from '../install/safe-fetch';
import { verifyAuthorSignature } from '../install/verify-signature';
import { verifyAuthorSignature, SignatureError } from '../install/verify-signature';
import { clearUpdateBlock, isSignatureCode, setUpdateBlock, RETRUSTABLE_CODE } from '../signature-status';
import { extractArchive } from '../install/safe-extract';
import { scanForNativeBinaries } from '../install/native-scan';
import { parseJsonText, parseManifest } from '../install/manifest';
import { hostSatisfies, hostVersion, normalizedHost } from '../install/host-compat';
import { discoverPlugins } from '../install/discovery';
/**
@@ -35,7 +37,16 @@ interface RegistryVersion {
commitSha: string;
downloadUrl: string;
sha256: string;
minTrekVersion: string;
/**
* The version's declared TREK range, mirroring its manifest's `trek`. Authoritative
* when present. `minTrekVersion`/`maxTrekVersion` are the legacy shape — a lower bound
* plus an optional INCLUSIVE upper bound, which cannot express the exclusive `<4.0.0`
* that a manifest actually declares — and stay only so entries published before this
* field existed still gate.
*/
trek?: string;
/** Nullable: an entry may carry no lower bound at all (and a legacy one carries only this). */
minTrekVersion?: string | null;
maxTrekVersion?: string | null;
size?: number;
apiVersion?: number;
@@ -70,6 +81,17 @@ interface Registry {
plugins: RegistryEntry[];
}
/** A registry entry's standing against the running TREK, resolved server-side. */
export interface HostCompat {
/** The latest version's declared TREK range, or null for a legacy (min/max-only) entry. */
trek: string | null;
hostVersion: string;
/** Whether the LATEST version can be installed on this TREK. */
compatible: boolean;
/** Newest installable version — the latest one, or an older fallback, or null if none fits. */
latestCompatible: string | null;
}
/** Anzeige-only preview of a plugin's live manifest (fetched at the reviewed commit). */
export interface ManifestPreview {
permissions: string[];
@@ -97,7 +119,21 @@ export function __clearRegistryCacheForTests(): void {
_detailCache.clear();
}
export class RegistryError extends Error {}
/**
* A registry/install failure. `code` is machine-readable and MUST survive out to
* the client: the admin UI decides whether to offer a re-trust override from the
* code, never by string-matching the message. A client that matches on prose will
* eventually offer the override on the wrong condition — which is the single worst
* outcome this surface could produce.
*/
export class RegistryError extends Error {
constructor(
message: string,
readonly code?: string,
) {
super(message);
}
}
@Injectable()
export class PluginRegistryService {
@@ -125,8 +161,16 @@ export class PluginRegistryService {
}
}
/** The browse list the admin UI renders (metadata only, no code). */
async browse(force = false): Promise<Array<Omit<RegistryEntry, 'versions'> & { latest: string | null; minTrekVersion: string | null; requiredAddons: string[]; pluginDependencies: PluginDependency[]; screenshotUrl: string | null }>> {
/**
* The browse list the admin UI renders (metadata only, no code).
*
* `signed` describes the LATEST version (browse already treats versions[0] as
* latest), so on a version-constrained install the badge can describe a version
* other than the artifact actually fetched. Accepted for a Discover card.
* `authorPublicKey` is exposed in full — it is a public key, and the re-trust
* round-trip compares it exactly (a truncated fingerprint would be a weak check).
*/
async browse(force = false): Promise<Array<Omit<RegistryEntry, 'versions'> & { latest: string | null; minTrekVersion: string | null; requiredAddons: string[]; pluginDependencies: PluginDependency[]; screenshotUrl: string | null; signed: boolean; authorPublicKey: string | null } & HostCompat>> {
const reg = await this.fetchRegistry(force);
return reg.plugins.map((p) => {
const latest = p.versions[0] ?? null;
@@ -137,10 +181,33 @@ export class PluginRegistryService {
latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null,
requiredAddons: latest?.requiredAddons ?? [], pluginDependencies: latest?.pluginDependencies ?? [],
screenshotUrl: latest ? rawFileUrl(p.repo, latest.commitSha, 'docs/screenshot.png') : null,
signed: !!p.authorPublicKey && !!latest?.signature,
authorPublicKey: p.authorPublicKey ?? null,
...this.hostCompat(p),
};
});
}
/**
* How this entry stands against the running TREK, computed SERVER-side. The client has
* no semver dependency and must never grow a second implementation of range logic — a
* UI that disagreed with the install gate would show an enabled button that 400s.
*
* `latestCompatible` is the useful half: when the newest version has outrun this TREK,
* an older one often still fits, so the UI can offer it instead of a dead grey button.
*/
private hostCompat(entry: RegistryEntry): HostCompat {
const latest = entry.versions[0] ?? null;
const compatible = latest ? hostCompatible(latest, normalizedHost()) : false;
const fallback = compatible ? null : this.latestCompatible(entry);
return {
trek: latest?.trek ?? null,
hostVersion: hostVersion(),
compatible,
latestCompatible: compatible ? (latest?.version ?? null) : (fallback?.version ?? null),
};
}
/**
* Everything the browse detail view shows for one plugin: the registry entry
* plus a preview of its live manifest (permissions, egress, settings) fetched
@@ -176,6 +243,9 @@ export class PluginRegistryService {
size: latest?.size ?? null, publishedAt: latest?.publishedAt ?? null,
requiredAddons: latest?.requiredAddons ?? [], pluginDependencies: latest?.pluginDependencies ?? [],
screenshotUrl: latest ? rawFileUrl(entry.repo, latest.commitSha, 'docs/screenshot.png') : null,
signed: !!entry.authorPublicKey && !!latest?.signature,
authorPublicKey: entry.authorPublicKey ?? null,
...this.hostCompat(entry),
manifest,
};
// Don't negative-cache a transiently failed manifest fetch — the next
@@ -194,32 +264,81 @@ export class PluginRegistryService {
const reg = await this.fetchRegistry();
const entry = reg.plugins.find((p) => p.id === id);
if (!entry) throw new RegistryError(`plugin ${id} not in registry`);
const host = hostVersion();
const v = this.latestCompatible(entry, constraint);
if (!v) {
throw new RegistryError(
constraint
? `no version of ${id} satisfies "${constraint}" and TREK ${hostVersion()}`
: `no version of ${id} is compatible with TREK ${hostVersion()}`,
'TREK_VERSION_INCOMPATIBLE',
);
}
return v;
}
/** The newest version of `entry` that satisfies `constraint` AND admits the running TREK. */
private latestCompatible(entry: RegistryEntry, constraint?: string): RegistryVersion | null {
const host = normalizedHost();
const candidates = entry.versions.filter((v) => {
if (constraint && !semver.satisfies(v.version, constraint, { includePrerelease: true })) return false;
return hostCompatible(v, host);
});
if (!candidates.length) {
throw new RegistryError(
constraint
? `no version of ${id} satisfies "${constraint}" and this TREK version`
: `no compatible version of ${id} for this TREK version`,
);
}
if (!candidates.length) return null;
return [...candidates].sort((a, b) => semver.rcompare(a.version, b.version))[0];
}
/** Install a version from the registry. Returns the installed plugin id + version. */
async install(id: string, opts?: { version?: string; constraint?: string }): Promise<{ id: string; version: string }> {
/**
* Pick the version `install()` will fetch, and refuse an incompatible one — on EVERY
* path, which is the whole point. An explicit `version` and the bare "install latest"
* both used to bypass the compat check entirely (only the `constraint` path went
* through resolveVersion), so the admin UI's own Install button — which sends neither —
* happily installed a plugin that declared it doesn't support this TREK.
*/
private selectVersion(entry: RegistryEntry, opts?: { version?: string; constraint?: string }): RegistryVersion {
if (opts?.version) {
const ver = entry.versions.find((v) => v.version === opts.version);
if (!ver) throw new RegistryError(`version ${opts.version} not found for ${entry.id}`);
if (!hostCompatible(ver, normalizedHost())) {
throw new RegistryError(
`${entry.id} ${ver.version} requires TREK ${trekRequirement(ver)} — this is TREK ${hostVersion()}`,
'TREK_VERSION_INCOMPATIBLE',
);
}
return ver;
}
const ver = this.latestCompatible(entry, opts?.constraint);
if (ver) return ver;
// Nothing compatible. Say WHY against the newest version the admin was actually
// shown, rather than a bare "not found" — the fix is a TREK upgrade, not a retry.
const latest = entry.versions[0];
if (latest && !opts?.constraint) {
throw new RegistryError(
`${entry.id} ${latest.version} requires TREK ${trekRequirement(latest)} — this is TREK ${hostVersion()}`,
'TREK_VERSION_INCOMPATIBLE',
);
}
throw new RegistryError(
opts?.constraint
? `no version of ${entry.id} satisfies "${opts.constraint}" and TREK ${hostVersion()}`
: `no version of ${entry.id} is compatible with TREK ${hostVersion()}`,
'TREK_VERSION_INCOMPATIBLE',
);
}
/**
* Install a version from the registry. Returns the installed plugin id + version.
*
* `retrustKey` is the admin's explicit "I have confirmed this new signing key with
* the author out-of-band" override, and it only lifts the TOFU key-change stop —
* the artifact must STILL verify under that key (see verifySignatureAndTofu), so a
* blessed key that doesn't actually sign the code is refused like any other bad
* signature. It is never a way to install something unverified.
*/
async install(id: string, opts?: { version?: string; constraint?: string; retrustKey?: string }): Promise<{ id: string; version: string }> {
const reg = await this.fetchRegistry();
const entry = reg.plugins.find((p) => p.id === id);
if (!entry) throw new RegistryError(`plugin ${id} not in registry`);
const ver = opts?.version
? entry.versions.find((v) => v.version === opts.version)
: opts?.constraint
? await this.resolveVersion(id, opts.constraint)
: entry.versions[0];
if (!ver) throw new RegistryError(`version ${opts?.version ?? opts?.constraint ?? 'latest'} not found for ${id}`);
const ver = this.selectVersion(entry, opts); // throws TREK_VERSION_INCOMPATIBLE before we fetch a byte
// 1. SSRF-safe download + 2. sha256 verify
const { bytes, sha256 } = await safeDownload(ver.downloadUrl, (ver.size ?? 50 * 1024 * 1024) + 4096);
@@ -228,7 +347,14 @@ export class PluginRegistryService {
// 2b. author signature (opt-in) + TOFU key pin. Unsigned plugins skip this
// and install on sha256 alone (unchanged behaviour); a signed plugin must
// verify, and its author key must match the one pinned on first install.
this.verifySignatureAndTofu(id, bytes, entry, ver);
// A refusal is REMEMBERED (the plugin keeps running on its old code, so the
// reason must outlive the toast) and re-thrown untouched.
try {
this.verifySignatureAndTofu(id, bytes, entry, ver, opts?.retrustKey);
} catch (e) {
if (e instanceof RegistryError && isSignatureCode(e.code)) setUpdateBlock(id, e.code, e.message, ver.version);
throw e;
}
// 3. zip/tar-slip-safe extract to staging
const staging = path.join(pluginsDataRoot(), '.staging', `${id}-${ver.version}-${Date.now()}`);
@@ -238,8 +364,14 @@ export class PluginRegistryService {
if (!pluginRoot) throw new RegistryError('archive contains no trek-plugin.json');
// 4. re-validate the bundled manifest + 5. native re-scan
const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(pluginRoot, 'trek-plugin.json'), 'utf8')));
const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(pluginRoot, 'trek-plugin.json'), 'utf8')), { requireTrek: true });
if (manifest.id !== id) throw new RegistryError(`manifest id "${manifest.id}" != "${id}"`);
// The artifact's OWN range is the authoritative compat check. The index metadata
// gated above is only what the registry says about this version, and it is weaker:
// published entries usually carry a lower bound and no upper one at all, so a
// plugin that declares "<4.0.0" passes the pre-download filter on TREK 4 and is
// caught only here.
assertHostCompatible(manifest.trekRange, id);
if (scanForNativeBinaries(pluginRoot).length) throw new RegistryError('artifact contains native binaries');
// 6. atomic move into place
@@ -253,10 +385,16 @@ export class PluginRegistryService {
db.prepare('UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ? WHERE id = ?').run(
entry.repo, ver.commitSha, ver.sha256, entry.reviewedAt ?? null, id,
);
// Pin the author key on first successful install of a signed plugin (TOFU).
// Pin the author key on first successful install of a signed plugin (TOFU) —
// and, after a re-trust, re-pin to the new key the admin blessed. Only ever set
// to a key the artifact just verified under; NEVER cleared to NULL, because a
// NULL pin re-opens the "was never signed" path that accepts an unsigned update.
if (entry.authorPublicKey) {
db.prepare('UPDATE plugins SET author_pubkey = ? WHERE id = ?').run(entry.authorPublicKey, id);
}
// The plugin is now on new code that passed every check — whatever refusal was
// recorded before no longer describes reality.
clearUpdateBlock(id);
return { id, version: ver.version };
} finally {
fs.rmSync(staging, { recursive: true, force: true });
@@ -281,7 +419,10 @@ export class PluginRegistryService {
if (stack.includes(pid)) throw new RegistryError(`plugin dependency cycle: ${[...stack, pid].join(' -> ')}`);
const ver = await this.resolveVersion(pid, range); // throws if not in registry / unsatisfiable
if (!installedNow.has(pid)) {
await this.install(pid, range ? { constraint: range } : undefined);
// Install the version we just RESOLVED. Passing the range back (or nothing, for
// the root) made install() re-pick on its own and land on entry.versions[0] —
// so the compatible version resolveVersion had chosen was computed and discarded.
await this.install(pid, { version: ver.version });
installed.push(pid);
installedNow.add(pid);
}
@@ -308,7 +449,8 @@ export class PluginRegistryService {
extractArchive(bytes, stagingDir);
const root = locateManifestDir(stagingDir);
if (!root) throw new RegistryError('archive contains no trek-plugin.json');
const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(root, 'trek-plugin.json'), 'utf8')));
const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(root, 'trek-plugin.json'), 'utf8')), { requireTrek: true });
assertHostCompatible(manifest.trekRange, manifest.id);
if (scanForNativeBinaries(root).length) throw new RegistryError('artifact contains native binaries');
return { id: manifest.id, version: manifest.version, root, stagingDir };
} catch (e) {
@@ -334,9 +476,17 @@ export class PluginRegistryService {
// an existing row's status, so replacing a plugin that was active must not
// leave the new code marked active — the admin re-activates (and re-consents
// to permissions) explicitly.
db.prepare("UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ?, author_pubkey = NULL, status = 'inactive', enabled = 0 WHERE id = ?").run(
'local:upload', null, null, null, staged.id,
);
//
// The update-block goes too. It described a REGISTRY update being refused, and this
// plugin has just left the registry trust model entirely — the code is now whatever
// the admin uploaded. Leaving the block would have the row insist an update was
// blocked over a signing key that no longer applies to the code that is running.
db.prepare(
`UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ?, author_pubkey = NULL,
update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL,
status = 'inactive', enabled = 0
WHERE id = ?`,
).run('local:upload', null, null, null, staged.id);
} finally {
fs.rmSync(staged.stagingDir, { recursive: true, force: true });
}
@@ -349,26 +499,88 @@ export class PluginRegistryService {
* - Neither key nor signature → unsigned plugin, skip (opt-in).
* - Key present but no signature (or vice versa) → hard stop; a half-signed
* entry is a misconfiguration we refuse rather than silently downgrade.
* - Signature invalid → hard stop.
* - Signature invalid (or malformed) → hard stop.
* - Registry key differs from the key pinned on a prior install → hard stop
* (author change / rotation / attack; needs an explicit admin re-trust).
*
* Each stop carries a machine-readable code, because only ONE of them
* (SIGNATURE_KEY_CHANGED) may ever be overridden and the UI must be able to tell
* them apart without reading prose.
*
* `retrustKey` lifts the key-change stop for exactly the key the admin confirmed.
* Note what it does NOT do: the signature check below still runs, against the NEW
* key, over the artifact bytes. A key an admin blesses must still sign the code it
* ships — a re-trust moves the pin from one VERIFIED key to another verified key.
*/
private verifySignatureAndTofu(id: string, bytes: Buffer, entry: RegistryEntry, ver: RegistryVersion): void {
private verifySignatureAndTofu(id: string, bytes: Buffer, entry: RegistryEntry, ver: RegistryVersion, retrustKey?: string): void {
const pinned = (db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string } | undefined)?.author_pubkey ?? null;
if (!entry.authorPublicKey && !ver.signature) {
if (pinned) throw new RegistryError('this plugin was signed before but the update is unsigned — refusing');
if (pinned) {
throw new RegistryError('this plugin was signed before but the update is unsigned — refusing', 'SIGNATURE_MISSING');
}
return; // unsigned throughout: sha256 is the only pin
}
if (!entry.authorPublicKey || !ver.signature) {
throw new RegistryError('incomplete signature: an author key and a version signature must both be present');
throw new RegistryError('incomplete signature: an author key and a version signature must both be present', 'SIGNATURE_INCOMPLETE');
}
if (pinned && pinned !== entry.authorPublicKey) {
throw new RegistryError("the plugin's author signing key changed since it was installed — re-trust it explicitly to continue");
if (pinned && pinned !== entry.authorPublicKey && retrustKey !== entry.authorPublicKey) {
throw new RegistryError(
"the plugin's author signing key changed since it was installed — re-trust it explicitly to continue",
RETRUSTABLE_CODE,
);
}
if (!verifyAuthorSignature(bytes, ver.signature, entry.authorPublicKey)) {
throw new RegistryError('author signature verification failed');
// verifyAuthorSignature returns false on a well-formed non-matching signature but
// THROWS SignatureError on a malformed key/signature. Both mean the same thing to
// an admin — the bytes aren't what the author signed — and both are non-overridable.
let ok: boolean;
try {
ok = verifyAuthorSignature(bytes, ver.signature, entry.authorPublicKey);
} catch (e) {
if (e instanceof SignatureError) {
throw new RegistryError(`author signature verification failed: ${e.message}`, 'SIGNATURE_INVALID');
}
throw e;
}
if (!ok) throw new RegistryError('author signature verification failed', 'SIGNATURE_INVALID');
}
/**
* Re-derive, SERVER-SIDE, that `id` is genuinely in the one overridable condition
* (its pinned key no longer matches the registry's) and that `publicKey` is the key
* the registry offers RIGHT NOW.
*
* Both halves matter. The first is the real enforcement of "re-trust is offered only
* for a changed key" — the UI hiding the button is a convenience, not the control, so
* an admin cannot re-trust their way past an invalid signature by calling the endpoint
* directly. The second closes the TOCTOU window: the client sends back the key it
* SHOWED the admin, and if the entry has been re-keyed again since the dialog
* rendered, the admin would be blessing a key they never saw.
*/
async assertRetrustable(id: string, publicKey: string): Promise<RegistryEntry> {
const row = db.prepare('SELECT source_repo, author_pubkey FROM plugins WHERE id = ?').get(id) as
| { source_repo?: string | null; author_pubkey?: string | null }
| undefined;
if (!row) throw new RegistryError(`plugin ${id} not found`, 'NOT_FOUND');
if (!row.source_repo || row.source_repo === 'local:upload' || row.source_repo === 'local:link') {
throw new RegistryError('only a registry-installed plugin can be re-trusted', 'RETRUST_NOT_APPLICABLE');
}
const reg = await this.fetchRegistry();
const entry = reg.plugins.find((p) => p.id === id);
if (!entry) throw new RegistryError(`plugin ${id} not in registry`, 'NOT_FOUND');
const pinned = row.author_pubkey ?? null;
if (!pinned || !entry.authorPublicKey || pinned === entry.authorPublicKey) {
throw new RegistryError("this plugin's signing key has not changed — there is nothing to re-trust", 'RETRUST_NOT_APPLICABLE');
}
if (entry.authorPublicKey !== publicKey) {
throw new RegistryError(
'the signing key changed again since you were shown it — review the new key before re-trusting',
'RETRUST_KEY_MISMATCH',
);
}
return entry;
}
}
@@ -376,18 +588,49 @@ function rawFileUrl(repo: string, commitSha: string, file: string): string {
return `https://raw.githubusercontent.com/${repo}/${commitSha}/${file}`;
}
/** The running TREK version (same source as the rest of the app). */
function hostVersion(): string {
return process.env.APP_VERSION || (require('../../../../package.json') as { version: string }).version;
/**
* Whether a registry version admits the running TREK.
*
* The version's `trek` range is authoritative when the entry carries one. Entries
* published before that field existed only carry min/max bounds, so those remain the
* fallback — but they are a strictly weaker check (maxTrekVersion is inclusive and is
* usually absent entirely, because the SDK never emitted it), which is why install
* re-gates on the artifact's own manifest after extraction. This is only the cheap
* pre-download filter.
*/
function hostCompatible(v: RegistryVersion, host: string | null): boolean {
if (host === null) return true; // unversioned build — don't block on compat
if (v.trek) return hostSatisfies(v.trek, host);
if (v.minTrekVersion && semver.valid(v.minTrekVersion) && semver.lt(host, v.minTrekVersion)) return false;
if (v.maxTrekVersion && semver.valid(v.maxTrekVersion) && semver.gt(host, v.maxTrekVersion)) return false;
return true;
}
/** Whether a registry version's host-version bounds admit the running TREK. */
function hostCompatible(v: RegistryVersion, host: string): boolean {
const h = semver.coerce(host)?.version ?? host;
if (!semver.valid(h)) return true; // unparseable host — don't block on compat
if (v.minTrekVersion && semver.valid(v.minTrekVersion) && semver.lt(h, v.minTrekVersion)) return false;
if (v.maxTrekVersion && semver.valid(v.maxTrekVersion) && semver.gt(h, v.maxTrekVersion)) return false;
return true;
/**
* Refuse an artifact whose declared TREK range doesn't admit the running host. Shared by
* every install front door (registry, sideload, dev-link) so they all fail the same way,
* with a code the admin UI can act on rather than prose it would have to string-match.
*/
export function assertHostCompatible(range: string | null, id: string): void {
if (hostSatisfies(range)) return;
throw new RegistryError(
`${id} requires TREK ${range} — this is TREK ${hostVersion()}`,
'TREK_VERSION_INCOMPATIBLE',
);
}
/**
* How a version's TREK requirement reads in an error/UI string. Each bound is optional —
* an entry may declare only a ceiling, or (once `trek` is absent too) nothing at all — so
* this composes whatever bounds exist rather than interpolating a missing one as "null".
*/
function trekRequirement(v: RegistryVersion): string {
if (v.trek) return v.trek;
const bounds = [
v.minTrekVersion ? `>=${v.minTrekVersion}` : null,
v.maxTrekVersion ? `<=${v.maxTrekVersion}` : null,
].filter(Boolean);
return bounds.length ? bounds.join(' ') : 'any version';
}
/**
+13 -3
View File
@@ -788,9 +788,19 @@ export function createPluginContext(
},
events: {
// Fire-and-forget by contract, but the host CAN reject (undeclared event name,
// rate-limit) — swallow it, otherwise the detached rejection crashes the child
// and terminally disables the plugin over one bad emit.
emit: (name, payload) => { t.rpc('events.emit', { event: name, payload }).catch(() => {}); },
// 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 the manifest.
// Surface it on the plugin's own log stream instead.
emit: (name, payload) => {
t.rpc('events.emit', { event: name, payload }).catch((e: unknown) => {
t.emit('log', {
level: 'warn',
msg: `events.emit("${name}") was rejected by the host: ${e instanceof Error ? e.message : String(e)}`,
meta: { event: name },
});
});
},
},
};
}
@@ -0,0 +1,93 @@
import { db } from '../../db/database';
/**
* Signature/trust status shared between the registry installer, the read-side
* plugin service and the admin controller (#plugins).
*
* TREK verifies an author's Ed25519 signature and TOFU-pins the key on first
* install, but until now none of that was ever surfaced: 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 in a toast.
* These helpers give the four failure conditions machine-readable codes and
* persist the refusal so the admin list can keep showing it.
*/
/**
* Machine-readable reason a signature check refused an install.
*
* Only SIGNATURE_KEY_CHANGED is overridable (an author can legitimately rotate a
* key). The other three mean the bytes are not what the author signed — there is
* no story where waving that through is the right answer, so no override exists
* for them, not even a disabled one.
*/
export type SignatureCode =
| 'SIGNATURE_MISSING'
| 'SIGNATURE_INCOMPLETE'
| 'SIGNATURE_KEY_CHANGED'
| 'SIGNATURE_INVALID';
/** The one code an admin may override, via POST /api/admin/plugins/:id/retrust. */
export const RETRUSTABLE_CODE: SignatureCode = 'SIGNATURE_KEY_CHANGED';
export function isSignatureCode(code: string | undefined): code is SignatureCode {
return (
code === 'SIGNATURE_MISSING' ||
code === 'SIGNATURE_INCOMPLETE' ||
code === 'SIGNATURE_KEY_CHANGED' ||
code === 'SIGNATURE_INVALID'
);
}
/**
* A short, human-comparable form of a minisign/base64 public key — the thing an
* admin reads out to the author over the phone to confirm a rotation ("pinned
* A1b2c3d4…w7x8y9z0, new …"). Display only: the re-trust round-trip carries the
* FULL key, because a truncated fingerprint is a weak equality check. There is
* nothing secret about either — it is a public key.
*/
export function keyFingerprint(pubkey: string | null | undefined): string | null {
if (!pubkey) return null;
const payload = pubkey
.trim()
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('untrusted comment'))
.pop();
if (!payload) return null;
return payload.length <= 20 ? payload : `${payload.slice(0, 8)}…${payload.slice(-8)}`;
}
/**
* Remember WHY an update was refused, so the Installed row can keep saying so
* instead of making the admin re-attempt the update to rediscover the reason.
* `version` is the registry version that was refused — the block is treated as
* stale (and the admin can just re-attempt) once the registry offers a newer one.
*
* Deliberately does NOT touch `status`: the plugin is still running fine on its
* old code. A blocked update is not a broken runtime, and conflating them would
* make the isolation-health dot lie.
*/
export function setUpdateBlock(id: string, code: SignatureCode, detail: string, version: string | null): void {
try {
db.prepare('UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?').run(
code,
detail,
version,
id,
);
} catch {
// Columns absent (a slimmed test app) — the block is a nicety, never a gate.
}
}
/** Clear a recorded block. Called on a successful install/update — NOT on activate:
* activating the plugin at its OLD version resolves nothing, and letting an off/on
* toggle erase the warning is exactly the silent-stops-updating failure this exists
* to prevent. (Uninstall drops the row entirely, so it needs no explicit clear.) */
export function clearUpdateBlock(id: string): void {
try {
db.prepare('UPDATE plugins SET update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL WHERE id = ?').run(id);
} catch {
// See setUpdateBlock.
}
}
+1
View File
@@ -107,6 +107,7 @@ const ACTION_LABELS: Record<string, string> = {
'trip.delete': 'deleted trip',
'admin.user_role_change': 'changed user role',
'admin.user_delete': 'deleted user',
'admin.plugin_retrust': "re-trusted a plugin's author signing key",
'admin.invite_create': 'created invite',
'immich.private_ip_configured': 'configured Immich with private IP',
};