Updating a plugin used to just reinstall and reactivate, which silently
granted whatever the new version declared — so a plugin could quietly
widen its own rights on the next release.
Route updates through a new server-side update() that diffs the new
version's declared permissions against what the admin already granted:
- nothing new -> the plugin is restarted transparently on the new code.
- new permissions or a new outbound host -> the new code is installed
but the plugin is left OFF, and the delta is handed back so the admin
has to approve it before it turns on.
Install runs first, so a failed download/signature check leaves the
running plugin untouched. The client shows the delta in a consent dialog
and only then activates. An update can never widen a plugin behind your back.
Closes the four gaps the security review surfaced:
- OS permission model on the prod plugin child (Node --permission with
fs-read scoped to the compiled server dir + the plugin's own code dir, no
fs-write/child_process/worker/native). A plugin can no longer read trek.db
or the .jwt_secret/.encryption_key files, nor shell out — the direct-fs and
RCE escapes that bypassed the RPC layer. Opt-out via TREK_PLUGIN_PERMISSIONS=off.
- Egress guard extended from fetch to the net.Socket connect choke point, so
node:http/https/net/tls obey the declared-egress allowlist too (no declared
egress = no outbound). Under the permission model there is no clean escape to
an unwrapped runtime. Kernel/network-namespace containment remains the
container step.
- Trip reads are membership-checked against the acting user the HOST binds from
the authenticated invocation, not an asUserId the plugin supplies; a job/onLoad
(no user) can't read user-scoped trips.
- Optional minisign (Ed25519) author signatures verified offline, TOFU-pinned
(migration 158). Unsigned plugins install on sha256 alone; a signed plugin
can't silently drop its signature or swap its author key.
Server suite green (permission-model activation verified against the Koffi
reference plugin on dev1).
Admin list: flush-left header like Addons, type/reviewed badges, runtime
health as a dot on the icon tile (text badge only for problem states),
description + source-repo link on installed cards, manifest icon.
Browse: cards show the plugin screenshot (docs/screenshot.png at the pinned
commit) and open a detail dialog fed by GET /api/admin/plugins/registry/:id —
live-manifest permissions in plain language, egress hosts, setup preview,
repo/homepage links. Manifest fetched server-side through safeDownload at the
reviewed commit, cached per plugin for 30 min and only when a detail opens.
Also fixes the update flow (restart the running child around the install,
keep the admin's enabled intent instead of force-activating disabled
plugins), guards the icon lookup against Object.prototype names, reserves
ids that would shadow static admin routes, stops negative-caching failed
manifest fetches, and makes the version compare prerelease-safe.
Widget plugins can now declare capabilities.widget.slot 'hero' to render as a
transparent, click-through overlay sitting on the boarding-pass bar's top edge
(migration 157 persists capabilities; manifest validation server+SDK, feed
exposes the slot, dashboard mounts hero frames above the pass). Sidebar stays
the default slot.
Replaces the trip-countdown example with Koffi, the TREK mascot: an animated
suitcase with a 14-state behavior engine driven by real trip data — walking,
waving, napping, trolley rolls, passport-stamp stickers, a split-flap luggage-
tag countdown under 7 days, and sunglasses while the trip runs. Validated by
the SDK suite like any author plugin; published as mauriceboe/trek-plugin-koffi
in the registry.
Migrations 156/157 follow the idempotent ALTER pattern (the reconciliation
test re-runs everything from v135, so duplicate-column must stay non-fatal).
The deactivation-on-deploy bug: `status` conflated the admin's ON/OFF intent with
runtime health, so a boot crash flipped status to 'error' and the plugin never
rebooted after the next deploy. Migration 156 adds an `enabled` flag (admin
intent) separate from `status` (runtime health); boot now retries every enabled
plugin regardless of last status, and a crash no longer erases the intent.
Admin → Plugins redesign:
- ON/OFF is a ToggleSwitch bound to `enabled`; runtime health shows separately as
a coloured status dot, with the last error inline when it crashed.
- "Update → vX" badge when the registry has a newer version (one click updates
and reactivates).
- Reviewed/unreviewed trust badges, cleaner cards, nicer empty state, registry
browser marks already-installed plugins. i18n across all locales.
- Plugin frame CSP: the frame runs at an opaque origin (sandbox without
allow-same-origin), so script-src 'self' matches nothing and the widget's own
script never runs (stuck on 'Loading…'). Allow 'unsafe-inline' — the sandbox,
not this directive, is the isolation boundary, and the plugin author controls
the frame code either way.
- Fix a no-constant-binary-expression eslint error in registry.test.ts that was
failing the server lint:check (eslint .) in CI.
- Uninstall with data disposition: POST /api/admin/plugins/:id/uninstall kills the
plugin, removes its code + DB metadata, and (deleteData) drops its data dir,
error log and per-user settings.
- Error log: GET/DELETE /api/admin/plugins/:id/errors — the plugin's own crash /
request-failure log, surfaced in the admin panel.
- Egress guard: the isolated child wraps global fetch and refuses any outbound
host not in the plugin's declared egress[]; with none declared, all outbound is
blocked. Process-level defense in depth (the container runtime enforces it at
the network layer in v2).
- Admin → Plugins is now actionable: activate / deactivate / uninstall, a
registry browser (install), and per-plugin error log. i18n across all locales.
- Dashboard widget slot: active widget plugins render as sandboxed cards.
The trek:request core-data bridge + photo/calendar hook consumers remain follow-ups.
Connects TREK to the static GitHub registry (mauriceboe/TREK-Plugins). The
registry repo + CI gates were already live; this is the server side.
- registry.service: fetches the single aggregated dist/index.json (never
per-plugin GitHub API calls — the HACS rate-limit lesson), caches it 30 min,
soft-fails to a stale/empty registry, and installs a pinned version through
the M4 pipeline: safe download -> sha256 verify -> slip-safe extract ->
manifest re-validate -> native re-scan -> atomic move -> discover (inactive),
recording repo/commit/sha provenance. Handles the codeload {repo}-{sha}/
wrapper directory.
- Admin endpoints: GET /api/admin/plugins/registry (browse metadata) and
POST /api/admin/plugins/install { id, version }. Install never executes code;
activation stays a separate, deliberate step.
Plugins placed on the /plugins volume are now discovered, validated and registered
as inactive, ready to activate.
- manifest.ts: strict trek-plugin.json validation (id/version/type, known
permissions only, egress required with http:outbound, native modules rejected).
- discovery.ts: scans the volume on startup + on demand (POST /api/admin/plugins/
rescan), upserts rows INACTIVE, refreshes settings-field descriptors, and never
downgrades or wipes an already-installed plugin's status / grants / config.
Invalid or native-carrying plugins are skipped and logged.
- Activation now grants the DECLARED permissions (the consent gate) and persists
them before spawning.
- install/ utilities for the registry installer (M5), each independently tested:
- safe-fetch: host allowlist (GitHub only) + private-IP refusal + manual
redirect following + size cap + sha256 (constant-time compare).
- safe-extract: zip/tar-slip-safe extraction with its own minimal tar.gz + zip
readers; rejects traversal, absolute paths, symlinks, oversized/too-many
entries, and unsupported formats.
- native-scan: refuses .node / binding.gyp / prebuilds, never follows symlinks.
Plugins can now render UI. Page plugins appear as a nav entry and open a
full-page sandboxed iframe; the frame talks to TREK only over the postMessage
bridge.
- Server serves plugin client assets at /plugin-frame/:id/* with a strict path
guard and a locked-down, per-plugin CSP (default-src none; connect-src limited
to declared outbound hosts; sandbox WITHOUT allow-same-origin -> opaque origin,
so the frame can't read the session cookie or the parent DOM). Global CSP
frameSrc relaxed from 'none' to 'self' for exactly these frames.
- GET /api/plugins feed lists active plugins for the client.
- Client: pluginStore + PluginFrame (the trekBridge host) authenticates every
inbound message by SENDER WINDOW IDENTITY (event.source), not by a claimed id
or origin; pushes context (theme/locale/tripId/userId), validates navigation,
renders notifications as text, resizes widgets, and proxies trek:invoke to the
plugin's own routes host-side (session cookie stays with the host).
- Page route /plugins/:id + Navbar nav injection for page plugins.
Dashboard widget slot and the trek:request core-data bridge are deferred to a
later slice.
Wires the isolated runtime into TREK. Admins can now activate a plugin from the
panel and its HTTP routes work end to end, still behind the kill switch.
- PluginRuntimeService owns the supervisor: activate spawns the child with its
granted permissions + decrypted instance config, deactivate kills it, status
and errors are persisted to the plugins / plugin_error_log tables, and active
plugins are booted on startup (OnModuleInit).
- Bidirectional RPC: the child now handles host→child invokes (routes/jobs) and
reports its declared routes on load; the supervisor gained invoke()/routesOf().
- /api/plugins/:id/* proxy controller — a single static route that matches the
plugin's declared routes, enforces per-route auth (auth:false routes are public
for OAuth callbacks/webhooks), forwards only a whitelisted request view (never
the session cookie), and strips unsafe response headers.
- Admin endpoints: POST :id/activate, POST :id/deactivate, GET/PUT :id/config —
instance settings with secret fields encrypted (apiKeyCrypto) and masked.
- Kill switch moved to its own module so it never collides with test config mocks.
Photo/calendar hook consumers are deferred to a later slice.
Every plugin now runs in its own forked child process with a scrubbed env
(no JWT_SECRET, no db path, nothing inherited). It talks to TREK only over a
JSON-RPC channel, and the host's capability router registers ONLY the methods a
plugin's granted permissions unlock — so an ungranted call is unreachable, not
merely refused. The plugin's own data lives in a separate sqlite file it can
never open directly; core reads (trips/users) go through membership-checked,
column-projected host methods; ws broadcasts are force-namespaced.
- protocol/envelope: the wire types + method→permission map (pure, shared by
host and the isolated child)
- host/rpc-host: the capability router = the enforcement point (dispatch,
BAD_PARAMS / PERMISSION_DENIED / RESOURCE_FORBIDDEN / UNKNOWN_METHOD)
- host/plugin-data: the per-plugin sqlite file (db:own), guarded against
ATTACH/PRAGMA escape, idempotent migrations
- host/create-rpc-host: wires the router to the real db/websocket (host-only)
- runtime/plugin-sdk + plugin-host-entry: the child bootstrap + definePlugin
ctx; turns each ctx call into an RPC, never imports a privileged module
- supervisor: spawn on activate, heartbeat/reap, crash backoff + auto-disable,
graceful shutdown — a plugin crash/OOM/hang can never reach the Nest loop
- paths: code/data layout, dist-vs-tsx child entry resolution
Nothing is wired into activation yet (that's the next slice); exercised by unit
tests for the router/sdk/data and an integration test that forks a real child.