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).
canAccessTrip only returns { id, user_id } (it's a membership check), but the
rpc-host's trips.getById returned it verbatim — so plugins saw a trip with no
title/start_date/etc. Fetch the real row after the access check. Also fix the
reference plugin to read t.title (the trips column is 'title', not 'name').
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.