- 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.
4.7 KiB
Plugin Development
Build a plugin with the trek-plugin-sdk package. A plugin is a directory with a
manifest, a built server entry, and (for page/widget plugins) a client bundle.
Scaffold
npx create-trek-plugin my-plugin --type integration|page|widget
cd my-plugin
This emits:
my-plugin/
trek-plugin.json # manifest
server/index.js # your plugin code (built, plain JS)
client/index.html # page/widget iframe (page/widget only)
README.md # fill this in — the registry requires it
The three plugin types
- integration — extends an existing feature (photo provider, calendar source, notification target). No UI of its own.
- page — adds a nav entry that opens a full-page sandboxed iframe.
- widget — adds a card to the dashboard.
Writing the server
Your server/index.js exports a definePlugin(...) object. Everything reaches
TREK through the ctx argument — there is no other way out of the sandbox.
const { definePlugin } = require('trek-plugin-sdk')
module.exports = definePlugin({
// Runs once when the plugin is activated.
async onLoad(ctx) {
await ctx.db.migrate('001_init', 'CREATE TABLE cache (k TEXT PRIMARY KEY, v TEXT)')
ctx.log.info('loaded')
},
// HTTP routes, mounted at /api/plugins/<id><path>.
routes: [
{ method: 'GET', path: '/status', auth: true, async handler(req, ctx) {
const rows = await ctx.db.query('SELECT COUNT(*) AS n FROM cache')
return { status: 200, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ n: rows[0].n, user: req.user?.username }) }
}},
],
// Scheduled jobs — TREK owns the cron and calls your handler.
jobs: [
{ id: 'refresh', schedule: '*/15 * * * *', async handler(ctx) { /* … */ } },
],
})
The ctx object
| Area | Methods | Requires |
|---|---|---|
ctx.db |
query / exec / migrate against your own SQLite file |
db:own |
ctx.trips |
getById / getPlaces / getReservations (membership-checked) |
db:read:trips |
ctx.users |
getById (public profile only — never secrets) |
db:read:users |
ctx.ws |
broadcastToTrip / broadcastToUser (events namespaced plugin:<id>:…) |
ws:broadcast:* |
ctx.config |
your instance settings (secrets delivered decrypted) | — |
ctx.log |
info / warn / error → your error log |
— |
Calling a capability your manifest didn't declare throws PERMISSION_DENIED.
Route auth
Routes are authenticated by default (req.user is the logged-in user). Set
auth: false for OAuth callbacks or webhooks that can't carry a session.
Writing the client (page / widget)
The iframe is sandboxed at an opaque origin — it can't read cookies or the
parent page. It talks to TREK only via postMessage:
window.parent.postMessage({ type: 'trek:ready' }, '*')
window.addEventListener('message', (e) => {
const m = e.data
if (m.type === 'trek:context') { /* m.theme, m.locale, m.tripId, m.userId */ }
if (m.type === 'trek:response' && m.requestId === '1') { /* … */ }
})
// Call one of your own server routes (TREK proxies it with the user's session):
window.parent.postMessage({ type: 'trek:invoke', requestId: '1', sub: '/status', method: 'GET' }, '*')
Bridge messages you can send: trek:ready, trek:context:request,
trek:navigate {to}, trek:notify {level,message}, trek:resize {height},
trek:invoke {requestId,sub,method,body}.
Settings
Declare settings in the manifest; TREK renders the form (you write no settings
UI). scope: "instance" settings are set once by the admin; scope: "user"
settings are per-user. secret: true fields are stored encrypted and delivered
to your plugin decrypted through ctx.config (instance) — never to the iframe.
Testing without a running TREK
createMockHost gives you a ctx that enforces the same permission model:
import { createMockHost } from 'trek-plugin-sdk/testing'
const { ctx, broadcasts } = createMockHost({
grants: ['db:read:trips'],
trips: { 1: { members: [42], data: { id: 1, name: 'Japan' } } },
})
await plugin.onLoad(ctx) // run your load logic
await expect(ctx.trips.getById(1, 99)).rejects… // proves your access checks
Rules
- No native modules (
.node,binding.gyp, prebuilds) — they're rejected. - Vendor your dependencies — TREK never runs
npm installon a plugin. - Ship built JS in
server/index.jsand pre-built static files inclient/. - Declare every outbound host in
egress[]when you usehttp:outbound.
Validate before publishing
npx trek-plugin validate .
Runs the same checks the registry CI runs. See Publishing a Plugin.