TREK's four channels (in-app, email, webhook, ntfy) were a closed set: notificationService.send() dispatched with four copy-pasted `if` blocks and no provider abstraction, so a fifth channel meant editing eight files by hand. A plugin could produce a notification via ctx.notify.send(), but never deliver one. A plugin now registers a channel with `hooks.notificationChannel` + `hook:notification-channel` on a plain `type: 'integration'` — not a new manifest type, so the TREK-Plugins registry schema and both its CI gates are untouched. Core refactor - New channel registry (services/notifications/): email/webhook/ntfy become ExternalChannel providers wrapping the EXISTING send functions — no delivery logic is rewritten, only relocated. In-app deliberately stays out: it writes typed rows with scope/target/callbacks, not a rendered title+body, the same line shared/ already draws with i18n/externalNotifications. - The event text is now rendered once per recipient instead of once per channel. - The channel set is open: NotifChannel becomes a string, the matrix is registry-derived, and the UI columns are server-driven. The DB column was already bare TEXT and the Zod contract already a string record — only the TypeScript and the two UIs were ever closed. The hook runs USERLESS. Every other hook is user-initiated, so actingUserId falls out of the request; a notification is host-initiated for an ARBITRARY recipient, so ctx.settings.get() would return undefined. The host resolves the recipient's decrypted scope:'user' settings itself and passes them as an argument. That is what lets a channel plugin be handed someone's push token WITHOUT being handed the right to read their trips as them. Enabling the plugin is the opt-in: a plugin channel is not gated on the admin's `notification_channels` list. A built-in always exists in code and needs an explicit switch; a plugin channel only exists because an admin enabled that plugin. (Nothing could write a `plugin:` id into that CSV anyway, and the admin toggle rebuilt it from three booleans, silently dropping anything else — so requiring a second opt-in meant the channel could never be turned on at all.) Also fixed, found while building this: - Plugin settings keys were unvalidated, so a field named `__proto__` or `constructor` resolved off Object.prototype: a REQUIRED field with such a name reported as configured for every user who had configured nothing — enough, for a channel, to be dispatched to everyone with no credentials. Keys are now constrained at install and the config blob is parsed null-prototype, so it is impossible even for an already-installed plugin. - A `select` field's options were cast straight through, so the obvious `["1","5"]` form rendered every dropdown entry BLANK (the client reads value/label). Now coerced, and malformed options are rejected. Also adds: operator-supplied egress hosts (a plugin talking to a self-hosted service can't name the operator's host at publish time, so an admin adds it post-install and the runtime re-spawns the child with the widened allow-list — only for a plugin that DECLARED operatorEgress, and only an admin, never a user); settings-page actions (a "Test connection" button, user-initiated so ctx.settings.get() returns the clicking user's own value); and a Gotify-shaped notification-channel template in the SDK. Verified end to end against a real Gotify container, not just in tests.
trek-plugin-sdk
The SDK for building TREK plugins.
Scaffold a plugin
npx trek-plugin-sdk # no command? a guided menu of everything below
npx trek-plugin-sdk create # interactive wizard (id, location, type, permissions)
npx trek-plugin-sdk create my-plugin --type widget # or non-interactive
cd my-plugin
The wizard also offers to initialize a git repo and install dependencies for you.
In a non-interactive shell (CI, pipes) every command stays flag-driven with plain
output — no prompts, and machine output (entry JSON, pack --json, PR URLs) stays
on stdout.
Develop with a live reload loop
dev runs your plugin locally — no full TREK needed. It injects a ctx that
enforces exactly the permissions your manifest grants (an ungranted call throws
PERMISSION_DENIED, so you catch missing grants), backs db:own with a real
SQLite file, serves your routes and your page/widget UI, and reloads on save.
npx trek-plugin-sdk dev # http://localhost:4317 — dashboard, routes, UI
Open /preview to see a page/widget rendered in a real sandboxed frame with a
theme/accent/appearance toggle (trek.invoke() is proxied to your routes). Hit a route
as an unauthenticated request with ?_anon=1. Drop a dev-fixtures.json (trips, users,
config) next to your manifest to feed ctx.trips / ctx.users.
Build a native UI (page / widget)
The UI is a sandboxed, opaque-origin iframe that can't load TREK's stylesheet — so the
SDK ships it. Put one line in your client/index.html <head>:
<!-- trek:ui -->
dev and pack expand it into the inlined design kit: token-driven styles that
follow the host's theme and accent (glass, cards, .trek-btn, .trek-input,
.trek-chip, .trek-row, hover), plus a window.trek bridge:
trek.onContext((ctx) => { /* ctx.theme, ctx.tokens, ctx.appearance, ctx.user, ctx.tripId */ })
const data = await trek.invoke('/status') // calls your own route, host-proxied
trek.notify('success', 'Saved')
The kit applies the theme, mirrors the appearance flags (reduced-motion,
no-transparency) and auto-reports your height. It also upgrades any native
<select> into a host-styled, keyboard-accessible dropdown that matches TREK —
the OS-drawn popup never could. Write a plain <select> and it just works; add
data-trek-native to opt a field out. See the
Plugin Development wiki
for the full component + token reference.
Write a plugin
const { definePlugin } = require('trek-plugin-sdk')
module.exports = definePlugin({
async onLoad(ctx) {
await ctx.db.migrate('001', 'CREATE TABLE cache (k TEXT PRIMARY KEY, v TEXT)')
},
routes: [
{ method: 'GET', path: '/status', auth: true, async handler(req, ctx) {
return { status: 200, headers: { 'content-type': 'application/json' }, body: '{"ok":true}' }
}},
],
})
Your plugin runs in an isolated child process. ctx is the only way to reach
TREK, and it grants exactly the permissions your trek-plugin.json declares — an
ungranted call throws PERMISSION_DENIED.
Test without a running TREK
import { createMockHost } from 'trek-plugin-sdk/testing'
const { ctx, broadcasts } = createMockHost({
grants: ['db:read:trips', 'ws:broadcast:trip'],
trips: { 1: { members: [42], data: { id: 1, name: 'Japan' } } },
})
// the mock enforces the SAME permission model, so you can prove your plugin
// degrades gracefully when a permission is missing.
Publish — one command
Commit and push your plugin to its public GitHub repo, then:
npx trek-plugin-sdk publish --repo you/repo --tag v1.0.0
publish does the whole release in one go: pack → tag + GitHub release
→ preflight (runs the registry CI checks locally) → open the registry PR.
If preflight finds a problem it stops before submitting, so a broken entry
never becomes a doomed PR. It prints the PR URL at the end.
Updating a listed plugin: bump version in the manifest, commit, and run
publish again with the new tag — it detects the existing entry and prepends the
new version, newest-first.
Prefer to drive the steps yourself? They still exist individually — pack,
release (pack → GitHub release → entry), preflight, submit (opens the PR),
and entry (just prints the JSON).
Sign your releases (optional, recommended)
Give your plugin a stable identity. TREK pins your key on first install (trust-on-first-use); afterwards an unsigned or wrong-key update is refused.
npx trek-plugin-sdk keygen # once — writes ~/.trek-plugin/signing.key
npx trek-plugin-sdk publish --repo you/repo --tag v1.1.0 --sign
Signing is dependency-free Ed25519 over the artifact bytes. Back up the key — losing it means you can't ship signed updates.
Exports
definePlugin(def)+ all the plugin types (PluginContext,PluginRoute,PluginJob,PhotoProvider,CalendarSource).PLUGIN_API_VERSION— embed asapiVersionin your manifest.validateManifest(json)— the manifest rules the server loader uses.createMockHost(opts)(fromtrek-plugin-sdk/testing).TREK_UI_CSS,TREK_THEME_JS,TREK_UI_MARKER,injectTrekUi(html)— the design kit, for authors who inline it themselves (a bundler, a custom build). Most plugins just use the<!-- trek:ui -->marker instead.
Commands
Run any of these with npx trek-plugin-sdk <command> (or the short trek-plugin
bin if you install the package):
create [name] [--type t] [--interactive]— scaffold a plugin; a wizard if you omit the name.dev [dir] [--port 4317]— run locally with a real request loop, SQLitedb:own, and hot reload.validate [dir]— manifest + layout checks (a subset of registry CI, offline).pack [dir] [--out plugin.zip] [--json]— build the artifact, printsha256+size.keygen [--key file]— create an Ed25519 signing key.sign [zip] [--key file]— print a signature + public key for an artifact.entry --repo o/n --tag vX [--merge f] [--sign [key]] [--out f]— emit the registry entry JSON.preflight --repo o/n --tag vX(or--entry f) — run the registry CI checks locally, over the network.submit --repo o/n --tag vX [--sign [key]] [--draft]— open the registry PR for you.release [dir] --repo o/n --tag vX [--sign [key]] [--merge f]— pack → GitHub release → entry, in one go.publish [dir] --repo o/n --tag vX [--sign [key]] [--no-preflight]— the lot: pack → tag + release → preflight → open the PR.
The SDK tooling in this repo is MIT. Your plugin is your own code under your own license.