From 0a5c8be83da4951a47f311dfe85cfd56d047b5c1 Mon Sep 17 00:00:00 2001 From: mauriceboe Date: Fri, 3 Jul 2026 17:55:07 +0200 Subject: [PATCH] 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. --- plugin-sdk/examples/trip-countdown/README.md | 39 +++++ .../examples/trip-countdown/client/index.html | 62 ++++++++ .../examples/trip-countdown/server/index.js | 43 ++++++ .../examples/trip-countdown/trek-plugin.json | 21 +++ wiki/Plugin-Development.md | 136 ++++++++++++++++++ wiki/Plugin-Permissions.md | 42 ++++++ wiki/Plugin-Publishing.md | 84 +++++++++++ wiki/Plugins.md | 78 ++++++++++ wiki/_Sidebar.md | 6 + 9 files changed, 511 insertions(+) create mode 100644 plugin-sdk/examples/trip-countdown/README.md create mode 100644 plugin-sdk/examples/trip-countdown/client/index.html create mode 100644 plugin-sdk/examples/trip-countdown/server/index.js create mode 100644 plugin-sdk/examples/trip-countdown/trek-plugin.json create mode 100644 wiki/Plugin-Development.md create mode 100644 wiki/Plugin-Permissions.md create mode 100644 wiki/Plugin-Publishing.md create mode 100644 wiki/Plugins.md diff --git a/plugin-sdk/examples/trip-countdown/README.md b/plugin-sdk/examples/trip-countdown/README.md new file mode 100644 index 000000000..049c98d13 --- /dev/null +++ b/plugin-sdk/examples/trip-countdown/README.md @@ -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. diff --git a/plugin-sdk/examples/trip-countdown/client/index.html b/plugin-sdk/examples/trip-countdown/client/index.html new file mode 100644 index 000000000..b35d45dea --- /dev/null +++ b/plugin-sdk/examples/trip-countdown/client/index.html @@ -0,0 +1,62 @@ + + + + + Trip Countdown + + + +
Loading…
+ + + diff --git a/plugin-sdk/examples/trip-countdown/server/index.js b/plugin-sdk/examples/trip-countdown/server/index.js new file mode 100644 index 000000000..a15335c70 --- /dev/null +++ b/plugin-sdk/examples/trip-countdown/server/index.js @@ -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' } +} diff --git a/plugin-sdk/examples/trip-countdown/trek-plugin.json b/plugin-sdk/examples/trip-countdown/trek-plugin.json new file mode 100644 index 000000000..f84fa5e46 --- /dev/null +++ b/plugin-sdk/examples/trip-countdown/trek-plugin.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 } + ] +} diff --git a/wiki/Plugin-Development.md b/wiki/Plugin-Development.md new file mode 100644 index 000000000..3b43c45e4 --- /dev/null +++ b/wiki/Plugin-Development.md @@ -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/. + 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::…`) | `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]]. diff --git a/wiki/Plugin-Permissions.md b/wiki/Plugin-Permissions.md new file mode 100644 index 000000000..b14b62953 --- /dev/null +++ b/wiki/Plugin-Permissions.md @@ -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::` — 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:` | 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. diff --git a/wiki/Plugin-Publishing.md b/wiki/Plugin-Publishing.md new file mode 100644 index 000000000..8f625f378 --- /dev/null +++ b/wiki/Plugin-Publishing.md @@ -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-`). 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/.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": "", + "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. diff --git a/wiki/Plugins.md b/wiki/Plugins.md new file mode 100644 index 000000000..0077c4dc6 --- /dev/null +++ b/wiki/Plugins.md @@ -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. diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md index 9dd5a4f3a..02335181e 100644 --- a/wiki/_Sidebar.md +++ b/wiki/_Sidebar.md @@ -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]]