Windows editors love to prepend a BOM, and a bare JSON.parse then dies
with an "Unexpected token" pointing at an invisible character - in the
SDK CLIs (dev/validate/entry/submit) and, worse, server-side: a BOM in
an author repo travels through pack into the artifact and fails
discovery and registry install. Strip it at every manifest/JSON read
(readJsonFile in the SDK, parseJsonText in the installer).
A freshly scaffolded plugin could not load anywhere: the npm package is
ESM-only (no require condition in its exports map), so the scaffold's
require('trek-plugin-sdk') threw ERR_PACKAGE_PATH_NOT_EXPORTED under
`trek-plugin dev` - and the runtime injection the wiki promised for the
plugin child never existed, so a packed plugin (node_modules stripped)
crashed with MODULE_NOT_FOUND after a real install.
- plugin child: inject a frozen {definePlugin, PLUGIN_API_VERSION} shim
for require('trek-plugin-sdk'); subpaths fail with a pointed error
- trek-plugin dev: inject the exact same shim, so a fresh scaffold runs
with zero npm install and dev parity with production holds
- npm package: ship a real CommonJS build (dist/cjs + require export
conditions) so the installed package also requires cleanly on Node 18+
- create: scaffold a package.json (type commonjs, SDK as devDependency,
npx scripts); print resolvable `npx trek-plugin-sdk ...` hints
- wiki: package.json in the scaffold tree + publishing checklist, and
document the zero-install dev flow
A second adversarial pass over the first remediation found four findings only
partially closed and five issues the fixes themselves introduced. This closes
them:
Partial → closed
- re-consent gate keyed on `granted.length > 0`, so a plugin first activated with
ZERO permissions (granted '[]') was treated as never-consented and a later
widening was granted silently. Now discovery marks a never-consented plugin with
granted_permissions '' and activate() gates on "ever consented" (any non-empty
string, including '[]').
- db:own DoS: block WITH RECURSIVE outright (the one construct that spins the
synchronous host unboundedly regardless of the size/row caps, via query OR exec).
- dgram: also wrap `new dgram.Socket(...)` (bypassed createSocket) to inject the
IP-vetting lookup, and lock createSocket/Socket.
- frame self-navigation: documented as a bounded best-effort mitigation (inherent
to sandboxed iframes; exposure is the plugin's own routes + already-held context,
never the httpOnly cookie).
Regressions introduced by the first pass → fixed
- proxy: only real redirects (301/302/303/307/308) are gated, to a RELATIVE in-app
Location (supports OAuth-callback bounce, blocks open redirect); 300/304 pass
through; attachment only on non-redirects.
- supervisor: measure RSS via /proc/<pid>/status VmRSS (page-size independent);
activation-timeout awaits kill() before disposing the db handle.
- manifest HOST_RE: allow single-label hosts (self-hoster sibling services) while
keeping wildcards multi-label; mirrored in the SDK + frame CSP filter.
Regression tests added (re-consent incl. the '[]' case, WITH RECURSIVE + row cap,
single-label host). 236 plugin tests + 24 SDK tests green.
Remediates the findings from the adversarial audit (threat model: malicious
plugin author + malicious artifact). Highlights:
Critical
- proxy: force nosniff + Content-Disposition: attachment on every proxied reply
and drop location/content-disposition + non-2xx from the passthrough, so a
plugin can't serve an HTML document at TREK's origin (sandbox-escape → account
takeover) or an open redirect.
High
- db:own runs synchronously in the host: cap the plugin DB (max_page_count) and
row-cap query() via iterate() so a recursive CTE / huge blob can't stall the
event loop, OOM, or exhaust the shared volume.
- supervisor: measure child RSS host-side (/proc/<pid>/statm) instead of trusting
the spoofable heartbeat; add an activation timeout so a stuck onLoad can't hang
activate() or peg a core unreaped.
- safe-extract: enforce entry-count + cumulative-size limits INSIDE readZip
before inflating (decompression-bomb OOM).
- re-consent: activate() never widens granted permissions without explicit
consent (409 CONSENT_REQUIRED); the row toggle + "Update All" route through the
consent dialog, which now queues instead of overwriting.
Medium/low
- egress: gate dgram hostnames through the IP-vetting resolver; block the
low-level socket escape (process.binding) + lock the wrapped prototypes;
canonicalize IPv6 in isBlockedIp (hex-mapped/compressed metadata); reject
degenerate `*.` / whole-TLD / spaced outbound hosts in the manifest + CSP.
- ws:broadcast is membership-gated to the acting user's trips / own connections;
users.getById is scoped to users the acting user can see (no enumeration).
- safe-fetch streams + aborts at the byte cap (chunked codeload OOM); isPrivateIp
reuses the canonicalizing check.
- native-scan throws instead of silently passing past its entry cap.
- SDK: dev serves binary assets as raw buffers + handles EADDRINUSE; manifest
validator gains the reserved-id + outbound-host checks; wikis corrected.
Tests updated for the new membership-gated behaviour + regression tests added
(IPv6 canonicalization, wildcard hardening, outbound-host validation, ws/user
scoping). 234 plugin tests + 24 SDK tests green.
Collapses the release into a single command: pack the artifact, tag + create the
GitHub release, run the registry CI checks locally (preflight), and open the
registry PR — stopping before it submits if preflight would fail, so a broken
entry never becomes a doomed PR. `--sign` signs it; `--no-preflight` skips the
gate. The individual pack/release/preflight/submit commands still exist.
README + the Development/Publishing/Permissions wikis lead with `publish` now.
Round out the author experience so the loop is create -> dev -> release/submit
without hand-work or a round-trip through registry review.
- `dev`: run a plugin locally with a real request loop and hot reload — no full
TREK. Injects a ctx that enforces the manifest's granted permissions (an
ungranted call throws, so you catch a missing grant), backs db:own with a real
SQLite file (node:sqlite), serves routes under /api and page/widget UI at /ui,
and reloads on save. Dependency-free (node:http + built-ins).
- `preflight`: run the registry CI checks locally over the network (tag->commit,
manifest parity, artifact sha256/size, native scan, README quality gate) so a
green run predicts a green CI.
- `submit`: fork TREK-Plugins, branch off current main, write/merge the entry,
push, and open the PR — the last manual publishing step, automated.
- `keygen`/`sign` + `--sign` on entry/release/submit: dependency-free Ed25519
author signatures over the artifact bytes, verified 1:1 against the server's
TOFU check. Fills authorPublicKey + signature and guards against a key change.
- `create` gains an interactive wizard (id/type/author/permissions) and flags.
- README + Development/Publishing/Permissions wikis document the new flow.
24 tests pass (sign round-trips through a server-shaped verifier; zip reader;
scaffold options; entry signing + key-change guard).
The docs told authors to run `npx create-trek-plugin` / `npx trek-plugin`,
but nothing published under those names, so npx couldn't resolve them.
- Ship one package, `trek-plugin-sdk`, with a bin that matches the package
name (`trek-plugin-sdk`) so `npx trek-plugin-sdk <command>` resolves with
zero install. The dispatcher gained a `create` subcommand, so every step
(create/validate/pack/entry/release) runs through that one entry point.
The short `trek-plugin` / `create-trek-plugin` bins still work once
installed.
- Package hardening for publish: repository+directory (monorepo subdir),
homepage/bugs/author/engines, publishConfig public, a prepublishOnly that
builds + tests, and a LICENSE file.
- A publish workflow: pushing a `plugin-sdk-v*` tag builds and publishes with
the NPM_TOKEN repo secret.
- Docs (SDK README + the four wiki pages) now use `npx trek-plugin-sdk <cmd>`,
the invocation that actually resolves.
Publishing a plugin meant hand-building the zip, running shasum + stat,
resolving the tag's commit, and hand-writing the whole registry entry.
The SDK does all of it now:
- `trek-plugin pack` builds plugin.zip in the exact layout the installer
reads (own tiny zip writer, so the SDK stays dependency-free and the
format can't drift from the reader), enforces the same native-binary and
size rules, and prints the sha256 + size. docs/ is left out — the store
fetches the screenshot from the repo, so it doesn't belong in the install
artifact (Koffi's went from 943 KB to 15 KB).
- `trek-plugin entry` emits the ready-to-PR registry entry from the manifest
+ the packed zip + the git tag: commitSha (deref'd), downloadUrl, sha256,
size, and minTrekVersion derived from the manifest's trek range. `--merge`
prepends a new version onto an existing entry for updates.
- `trek-plugin release` chains pack → gh release → entry.
Also: the scaffold now points the README at docs/screenshot.png (the path
the store actually fetches, was screenshot-1.png) with a size hint, and
stops hard-coding an MIT license — a plugin is the author's own code under
their own license. Round-tripped against the real server extractor; 18 tests.
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).
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').
- 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.
- Wiki pages (sync to the GitHub wiki on push to main): Plugins overview + trust
model, Plugin Development (SDK, definePlugin, ctx, routes/jobs, the client
bridge, testing with the mock host), Plugin Permissions reference, and
Publishing (registry PR + CI gates + provenance). Linked from the sidebar.
- Reference plugin plugin-sdk/examples/trip-countdown: a complete, minimal-
permission widget (reads trip data through ctx, renders in the sandboxed
iframe via the bridge, filled-in README). Validated in the SDK test suite so it
passes the exact gate authors face.
The author-facing SDK, a standalone dependency-free package (not wired into the
app workspaces, so it can't affect the app build).
- definePlugin + the full plugin type surface (PluginContext, PluginRoute,
PluginJob, PhotoProvider, CalendarSource) mirroring what the isolated runtime
injects; PLUGIN_API_VERSION.
- createMockHost (trek-plugin-sdk/testing): a PluginContext that enforces the
SAME permission model + membership checks, so authors can unit-test that their
plugin degrades gracefully — no running TREK needed.
- validateManifest: the exact rules the registry CI runs, so a local pass
predicts a CI pass.
- CLIs: create-trek-plugin (scaffolds a working plugin + README + starter iframe)
and trek-plugin validate (manifest + README sanity).
Consolidating the server loader to import this shared validator is a follow-up.