docs(plugins): plugin wiki + reference plugin (M7)

- 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.
This commit is contained in:
mauriceboe
2026-07-03 17:55:07 +02:00
parent 7318ee0d42
commit 0a5c8be83d
9 changed files with 511 additions and 0 deletions
@@ -0,0 +1,39 @@
# Trip Countdown
> A dashboard widget that counts down the days to your next trip.
![Trip Countdown widget on the dashboard](./docs/screenshot.png)
## What it does
Trip Countdown adds a small dashboard widget showing how many days remain until
your upcoming trip starts. When a trip is under way it shows "in progress"
instead. It's the reference plugin for the TREK plugin system — a minimal but
complete widget that reads trip data through the sandbox and renders it in an
isolated iframe.
## Screenshots
![The widget counting down to a trip](./docs/screenshot.png)
## Permissions
| Permission | Why this plugin needs it |
|---|---|
| `db:read:trips` | To read the current trip's start date so it can compute the countdown. The read is membership-checked by TREK, so the widget only ever sees a trip you already have access to. |
It requests **no** network access and stores **no** data of its own.
## Setup
None. Enable plugins on your instance (`TREK_PLUGINS_ENABLED=true`), install and
activate Trip Countdown, and the widget appears on your dashboard.
## Building
The widget ships plain JS/HTML — no build step. `server/index.js` is the plugin
entry; `client/index.html` is the widget iframe.
## License
MIT — see the TREK repository.
@@ -0,0 +1,62 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Trip Countdown</title>
<style>
:root { color-scheme: light dark; }
body { margin: 0; font: 14px system-ui, sans-serif; display: grid; place-items: center; height: 100vh;
background: transparent; color: #111827; }
body[data-theme="dark"] { color: #e5e7eb; }
.card { text-align: center; padding: 16px; }
.days { font-size: 40px; font-weight: 800; letter-spacing: -0.02em; }
.label { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.6; margin-top: 2px; }
.name { margin-top: 6px; opacity: 0.85; }
.empty { opacity: 0.6; }
</style>
</head>
<body>
<div class="card" id="root"><span class="empty">Loading…</span></div>
<script>
var ctx = null
var reqSeq = 0
function send(msg) { window.parent.postMessage(msg, '*') }
function render(data) {
var root = document.getElementById('root')
if (!data || !data.trip) { root.innerHTML = '<span class="empty">No upcoming trip</span>'; return }
var d = data.trip.days
root.innerHTML = d == null
? '<div class="name">' + escapeHtml(data.trip.name) + '</div><div class="label">in progress</div>'
: '<div class="days">' + d + '</div><div class="label">days to go</div><div class="name">' + escapeHtml(data.trip.name) + '</div>'
}
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] }) }
function load() {
var id = 'r' + (++reqSeq)
send({ type: 'trek:invoke', requestId: id, sub: '/next?tripId=' + (ctx && ctx.tripId ? ctx.tripId : ''), method: 'GET' })
window._pending = id
}
window.addEventListener('message', function (e) {
var m = e.data
if (!m || typeof m !== 'object') return
if (m.type === 'trek:context') {
ctx = m
document.body.dataset.theme = m.theme
load()
} else if (m.type === 'trek:response' && m.requestId === window._pending) {
render(m.data)
} else if (m.type === 'trek:error' && m.requestId === window._pending) {
document.getElementById('root').innerHTML = '<span class="empty">Unavailable</span>'
}
})
// Ask TREK for context (theme, current trip id) — it replies with trek:context.
send({ type: 'trek:ready' })
</script>
</body>
</html>
@@ -0,0 +1,43 @@
// Trip Countdown — reference widget plugin.
//
// Demonstrates the full author path: definePlugin, a permission-gated route that
// reads trip data through ctx, and a response the client iframe renders. Runs
// isolated; ctx is the only way to reach TREK.
const { definePlugin } = require('trek-plugin-sdk')
/** Days (integer, >= 0) between now and an ISO date, or null if past/absent. */
function daysUntil(iso) {
if (!iso) return null
const then = new Date(iso).getTime()
if (Number.isNaN(then)) return null
const diff = Math.ceil((then - Date.now()) / 86_400_000)
return diff >= 0 ? diff : null
}
module.exports = definePlugin({
routes: [
{
method: 'GET',
path: '/next',
auth: true,
async handler(req, ctx) {
// The client passes the dashboard's current trip id; the host enforces that
// req.user actually has access to it, so a plugin can't read foreign trips.
const tripId = Number(req.query.tripId)
if (!tripId) return { status: 200, headers: json(), body: JSON.stringify({ trip: null }) }
const t = await ctx.trips.getById(tripId, req.user.id)
const days = daysUntil(t && (t.start_date || t.startDate))
return {
status: 200,
headers: json(),
body: JSON.stringify({ trip: t ? { name: t.name, days } : null }),
}
},
},
],
})
function json() {
return { 'content-type': 'application/json' }
}
@@ -0,0 +1,21 @@
{
"id": "trip-countdown",
"name": "Trip Countdown",
"version": "1.0.0",
"apiVersion": 1,
"author": "TREK",
"description": "A dashboard widget counting down the days to your next trip.",
"homepage": "https://github.com/mauriceboe/TREK",
"license": "MIT",
"icon": "CalendarClock",
"type": "widget",
"trek": ">=3.2.0 <4.0.0",
"nativeModules": false,
"permissions": ["db:read:trips"],
"capabilities": {
"widget": { "title": "Trip Countdown", "defaultSize": "small" }
},
"routes": [
{ "method": "GET", "path": "/next", "auth": true }
]
}
+136
View File
@@ -0,0 +1,136 @@
# 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
```bash
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.
```js
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`:
```js
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:
```js
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 install` on a plugin.
- **Ship built JS** in `server/index.js` and pre-built static files in `client/`.
- Declare every outbound host in `egress[]` when you use `http:outbound`.
## Validate before publishing
```bash
npx trek-plugin validate .
```
Runs the same checks the registry CI runs. See [[Publishing a Plugin|Plugin-Publishing]].
+42
View File
@@ -0,0 +1,42 @@
# Plugin Permissions
A plugin declares the permissions it needs in `trek-plugin.json`. At activation
the admin sees the list and approves it — and because plugins run in an isolated
process, **an unapproved capability is physically unreachable**, not just
disallowed.
## Reference
| Permission | Grants | Notes |
|---|---|---|
| `db:own` | Read/write the plugin's **own** SQLite file via `ctx.db` | A separate file per plugin — never `trek.db`. `ATTACH`/`PRAGMA` are refused. |
| `db:read:trips` | Read-only trip data via `ctx.trips` (`getById`, `getPlaces`, `getReservations`) | Every call is **membership-checked** against the acting user — a plugin can't read a trip that user can't see. |
| `db:read:users` | Read-only public profile via `ctx.users.getById` | Returns id, username, display name, avatar only — **never** password hashes, tokens, or secrets. |
| `ws:broadcast:trip` | Push a real-time event to a trip room via `ctx.ws.broadcastToTrip` | Event types are force-namespaced `plugin:<id>:<event>` — a plugin can't forge a core event. |
| `ws:broadcast:user` | Push a real-time event to a user's connections | Same namespacing. |
| `hook:photo-provider` | Register as a photo provider in Memories | Implement the `PhotoProvider` interface. |
| `hook:calendar-source` | Register as a calendar source | Implement the `CalendarSource` interface. |
| `http:outbound` / `http:outbound:<host>` | Make outbound network requests | **Requires** a non-empty `egress[]` list of hosts. The plugin's client CSP and the runtime egress guard are limited to those hosts. |
## Declaring them
```jsonc
{
"permissions": ["db:own", "db:read:trips", "http:outbound:api.example.com"],
"egress": ["api.example.com"] // required whenever http:outbound is declared
}
```
Rules the manifest validator enforces:
- Only the permissions above are accepted; an unknown string fails validation.
- `http:outbound` (with or without a host) requires a non-empty `egress[]`.
- `egress[]` may not contain a bare `*`.
## What is NOT covered
Isolation bounds *what* a plugin can touch, not its intent within a grant. A
plugin you allow to read trip data **and** reach `api.example.com` could send
that trip data there. That is the point of the consent screen — grant only what
you'd trust the plugin to do with your data. Prefer **Reviewed** plugins and
authors you trust.
+84
View File
@@ -0,0 +1,84 @@
# Publishing a Plugin
Plugins are distributed from a static registry — the
[TREK-Plugins](https://github.com/mauriceboe/TREK-Plugins) GitHub repo. There is
no upload server and no account: you host your plugin in your own GitHub repo and
list it with a pull request.
## 1. Host your plugin
Put your plugin in a **public GitHub repo** (convention: `trek-plugin-<id>`). It
must contain a `trek-plugin.json` at the root, a built `server/index.js`, a
filled-in `README.md`, and (page/widget) a built `client/`.
## 2. Tag a release
Tag `vX.Y.Z` where `X.Y.Z` **equals** `version` in your manifest. Either attach a
`plugin.zip` release asset, or rely on the GitHub source archive at that commit.
## 3. Validate locally
```bash
npx trek-plugin validate .
```
This runs the exact checks the registry CI runs — a local pass predicts a CI
pass.
## 4. Open a PR against the registry
Add one file, `registry/plugins/<id>.json`:
```jsonc
{
"id": "flight-tracker",
"name": "Flight Tracker",
"author": "Your Name",
"description": "Live flight status widget.",
"repo": "you/trek-plugin-flight-tracker",
"type": "widget",
"versions": [{
"version": "1.0.0",
"gitTag": "v1.0.0",
"commitSha": "<40-hex commit the tag points at>",
"downloadUrl": "https://github.com/you/trek-plugin-flight-tracker/releases/download/v1.0.0/plugin.zip",
"sha256": "<sha256 of that artifact>",
"minTrekVersion": "3.2.0",
"apiVersion": 1,
"nativeModules": false
}]
}
```
CI validates the entry and the artifact, and the maintainer reviews the source at
the pinned commit before merging.
## What CI enforces
**Entry:** valid schema · `id` matches the filename and is a valid slug ·
reserved namespaces (`trek-`, `official-`) blocked · your `id` is bound to your
GitHub owner on first registration (nobody can repoint it later) · homoglyph
check · the release tag exists and points at the pinned commit · manifest parity
· **the artifact's SHA-256 matches the pin** · **no native binaries** ·
`egress[]` present when `http:outbound` is declared.
**README:** must exist, have the required sections (*What it does / Screenshots /
Permissions / Setup*), contain **at least one screenshot that actually resolves**,
have real content (not the unfilled template), and explain every declared
permission.
## Provenance & integrity
- `commitSha` pins the exact source the maintainer reviewed (git tags are movable).
- `sha256` pins the exact artifact bytes TREK will run (release assets are mutable).
TREK verifies the downloaded bytes against `sha256` and refuses to install on a
mismatch. A `reviewedAt` date on your entry means a maintainer looked at that
exact commit — it is **not** an ongoing guarantee.
## Updating
Add a new version entry (new `version`/`gitTag`/`commitSha`/`sha256`) to your
plugin file and PR it. Instances see the update on their next registry poll;
updating is always an explicit admin action, and if a new version requests **more**
permissions the admin must re-approve.
+78
View File
@@ -0,0 +1,78 @@
# Plugins
Plugins let anyone extend a self-hosted TREK instance with new features — a
dashboard widget, a full page, a photo/calendar integration — **without touching
TREK's source code**. They are developed by third parties, distributed from a
public registry, and installed at the instance owner's discretion.
> [!IMPORTANT]
> **Plugins run arbitrary code, isolated but real.** Every plugin runs in its own
> sandboxed child process with only the permissions you approve. TREK does not
> maintain, audit, or take responsibility for community plugins. Grant a plugin
> only the access you'd trust it to have with your data, and prefer plugins
> marked **Reviewed**.
## Enabling plugins
The plugin runtime is **off by default**. To turn it on, set the environment
variable on your instance and restart:
```yaml
environment:
- TREK_PLUGINS_ENABLED=true
```
When it's off, the **Admin → Plugins** tab shows a "disabled by server
configuration" banner and nothing runs.
## The isolation model — what a plugin can and can't do
Each active plugin runs as a **separate OS process** with a deliberately empty
environment:
- It has **no** access to `JWT_SECRET`, the database connection, or any TREK
secret — those are simply not in its process.
- It **cannot** open `trek.db`. Its own data lives in a separate SQLite file it
reaches only through TREK.
- It talks to TREK exclusively over an internal RPC channel, and TREK only
answers the capabilities the plugin's manifest **declares and you approve**.
An ungranted call is refused, not merely ignored.
- If a plugin crashes, hangs, or runs out of memory, only *its* process dies —
TREK keeps running and restarts or disables it.
This means the permission list you approve at activation is a **real boundary**,
not a label. See [[Plugin Permissions|Plugin-Permissions]] for exactly what each
permission grants.
## Installing a plugin
Two ways, both from **Admin → Plugins**:
1. **From the registry.** Click *Browse plugins*, pick one, and *Install*. TREK
downloads the pinned version from GitHub, verifies its SHA-256 against the
registry, safely unpacks it, and registers it — **inactive**. Nothing runs
yet.
2. **From disk.** Drop a plugin directory into the `/plugins` volume and click
*Rescan* (or restart). TREK discovers it and registers it inactive.
## Activating a plugin
Activation is a separate, deliberate step. You review the plugin's requested
permissions and outbound hosts on a consent screen, then click **Activate** —
which grants those permissions and spawns the isolated process. A page plugin
then appears in the top navigation; a widget appears on the dashboard.
## Managing plugins
- **Disable** stops the process immediately; the plugin keeps its data.
- **Uninstall** removes the code and lets you keep or delete the plugin's data.
- **View errors** shows the plugin's own error log (crashes, failed requests).
## Building your own
See [[Plugin Development|Plugin-Development]] and the `trek-plugin-sdk` package.
## Kill switch
Set `TREK_PLUGINS_ENABLED=false` to disable the entire subsystem instantly —
installed plugins stay on disk, deactivated and harmless.
+6
View File
@@ -66,6 +66,12 @@
- [[Collections]]
- [[Dashboard Widgets|Dashboard-Widgets]]
## Plugins
- [[Plugins Overview|Plugins]]
- [[Plugin Development|Plugin-Development]]
- [[Plugin Permissions|Plugin-Permissions]]
- [[Publishing a Plugin|Plugin-Publishing]]
## AI / MCP
- [[MCP Overview|MCP-Overview]]
- [[MCP Setup|MCP-Setup]]