Files
TREK/wiki/Plugin-Development.md
T
Maurice 3d714ff04c docs(plugins): rewrite the plugin wiki against the current code + tooling
The plugin wiki had drifted from the app and the SDK. Rewrote all four
pages, verifying every command, permission, field, path and UI behaviour
against source:

- Plugins: activation is a toggle (no separate consent screen); install is
  the Discover tab (no "Browse plugins" button); you review permissions in
  the detail modal before installing; documents update + re-consent, the ⋯
  menu, toolbar filters, capability chips and the health dot.
- Plugin-Development: full manifest reference; ws:broadcast:trip/:user (there
  is no ws:broadcast:*); onLoad + onUnload; the trek:error bridge message and
  full context payload; trips.* only work in a route handler; asUserId is
  accepted-but-ignored; integration hooks are declared but not yet wired.
- Plugin-Permissions: db:own also covers db.migrate; a host must appear as
  both an http:outbound:<host> permission and an egress[] entry or it's
  silently blocked; bare vs per-host outbound.
- Plugin-Publishing: the new one-command flow (validate → pack → release →
  entry), size is a required entry field, signing reconciled with the schema,
  no reserved namespaces, and the --merge update path.
2026-07-04 19:11:23 +02:00

315 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plugin Development
Build a plugin with the `trek-plugin-sdk` package. A plugin is a directory with a
manifest (`trek-plugin.json`), a built server entry, and — for page/widget
plugins — a static client bundle. TREK runs your server code in an **isolated
child process** and reaches it only over RPC; the browser part runs in a
**sandboxed, opaque-origin iframe**. There is no other way in or out.
## 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 a screenshot
```
## The three plugin types
- **integration** — background logic (jobs, routes) with no UI of its own. Photo-
provider / calendar-source hook types exist in the SDK but are **not yet wired
into the host** — see [Integration hooks](#integration-hooks-not-yet-functional).
- **page** — adds a nav entry that opens a full-page sandboxed iframe.
- **widget** — adds a card to the dashboard (`sidebar` slot) or a hero-bar
overlay (`hero` slot).
## The SDK package
`trek-plugin-sdk` is **injected at runtime** — the host makes
`require('trek-plugin-sdk')` resolve inside the child, so **do not vendor it**
into your artifact. Add it as a **devDependency** only, so you get types,
`createMockHost` for tests, and the `trek-plugin` CLI:
```bash
npm i -D trek-plugin-sdk
```
## Writing the server
Your `server/index.js` exports a `definePlugin(...)` object. Everything reaches
TREK through the `ctx` argument.
```js
const { definePlugin } = require('trek-plugin-sdk')
module.exports = definePlugin({
// Runs once when the plugin is activated. NOTE: onLoad has no user context —
// ctx.trips.* is refused here (see the ctx table).
async onLoad(ctx) {
await ctx.db.migrate('001_init', 'CREATE TABLE IF NOT EXISTS cache (k TEXT PRIMARY KEY, v TEXT)')
ctx.log.info('loaded')
},
// Runs once on deactivation/stop. Use it to flush or release resources.
async onUnload(ctx) {
ctx.log.info('unloading')
},
// 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 (no user context).
jobs: [
{ id: 'refresh', schedule: '*/15 * * * *', async handler(ctx) { /* … */ } },
],
})
```
The routes and job ids you declare here are the **authoritative** ones: the host
reads them off your loaded definition (a route's array index is its internal id).
The `routes` block the scaffold writes into `trek-plugin.json` is only a
declaration for readers — the manifest parser does not consume it.
### The `ctx` object
| Area | Methods | Requires |
|---|---|---|
| `ctx.db` | `query(sql, …args)` / `exec(sql, …args)` / `migrate(id, sql)` against your **own** SQLite file | `db:own` |
| `ctx.trips` | `getById` / `getPlaces` / `getReservations` (membership-checked) | `db:read:trips` |
| `ctx.users` | `getById(id)` — public profile only (`id, username, display_name, avatar`) | `db:read:users` |
| `ctx.ws.broadcastToTrip(tripId, event, data)` | broadcast to a trip's members (event forced to `plugin:<id>:<event>`) | `ws:broadcast:trip` |
| `ctx.ws.broadcastToUser(userId, event, data)` | broadcast to one user | `ws:broadcast:user` |
| `ctx.config` | your resolved settings (secrets delivered decrypted) | — |
| `ctx.log` | `info` / `warn` / `error` → your error log | — |
| `ctx.id` | your plugin id (string) | — |
Calling a method your manifest didn't grant returns `PERMISSION_DENIED`; a method
the host doesn't expose at all returns `UNKNOWN_METHOD`.
**`ctx.trips` only works inside a route handler.** The host binds the acting user
from the authenticated request and membership-checks every trip read against it.
`onLoad` and `jobs` have **no user**, so their trip reads are refused with
`RESOURCE_FORBIDDEN`. The SDK's `getById(tripId, asUserId?)` signature keeps an
`asUserId` parameter for source compatibility, but **the host ignores it** — you
cannot read another user's trips by passing an id.
### 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. The
proxy forwards only `{ method, path, query, body, user }` — your code never sees
raw headers or the session cookie.
## Writing the client (page / widget)
The iframe is served same-origin from `/plugin-frame/<id>/…` but sandboxed
**without `allow-same-origin`**, so it runs at an **opaque origin**: it can't read
cookies or the parent DOM. It talks to TREK only via `postMessage` (target origin
must be `'*'` — an opaque frame has no nameable origin).
```js
// Announce readiness — TREK replies with trek:context.
window.parent.postMessage({ type: 'trek:ready' }, '*')
window.addEventListener('message', (e) => {
const m = e.data
if (m.type === 'trek:context') {
// m.tripId, m.userId (string|null), m.theme ('light'|'dark'), m.locale, m.hostOrigin
}
if (m.type === 'trek:response' && m.requestId === '1') { /* m.data */ }
if (m.type === 'trek:error' && m.requestId === '1') { /* m.code, m.message */ }
})
// 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' }, '*')
```
**Messages you send to TREK (inbound bridge):**
| Message | Payload | Effect |
|---|---|---|
| `trek:ready` | — | TREK replies with `trek:context` |
| `trek:context:request` | — | re-request the context |
| `trek:navigate` | `{ to }` | in-app navigation (relative paths only) |
| `trek:notify` | `{ level, message }` | toast; `level` = `info`/`success`/`warning`/`error` |
| `trek:resize` | `{ height }` | set the iframe height (capped at 2000px) |
| `trek:invoke` | `{ requestId, sub, method, body }` | call your own route; resolves as `trek:response` or `trek:error` |
**Messages TREK sends you (host bridge):**
| Message | Payload |
|---|---|
| `trek:context` | `{ tripId, userId, theme, locale, hostOrigin }` — `userId` is a **string** or `null` |
| `trek:response` | `{ requestId, data }` — a successful `trek:invoke` |
| `trek:error` | `{ requestId, code, message }` — a failed `trek:invoke` (`code` is the HTTP status or `"error"`) |
The frame's CSP is locked down per plugin: `default-src 'none'`, own scripts/styles
only, `connect-src` limited to your declared `egress[]` hosts, no popups.
## 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
decrypted through `ctx.config` (server-side only) — never to the iframe. Resolved
values arrive in `ctx.config`.
## Integration hooks (not yet functional)
The SDK exports `PhotoProvider` / `CalendarSource` interfaces and a
`hooks: { photoProvider, calendarSource }` field on the plugin definition, and the
`hook:photo-provider` / `hook:calendar-source` permissions validate. **However the
host runtime does not consume `hooks` yet** — it only invokes `onLoad`, `onUnload`,
`routes` and `jobs`. Treat these as a reserved surface: you can declare them, but
TREK will not call them today. Build integrations with routes + jobs for now.
## Testing without a running TREK
`createMockHost` gives you a `ctx` that enforces the **same** permission model, so
a test can prove your plugin degrades gracefully when a grant is missing:
```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 ctx.trips.getById(1, 42) // ok — member
await expect(ctx.trips.getById(1, 99)).rejects… // RESOURCE_FORBIDDEN
await expect(ctx.db.query('SELECT 1')).rejects… // PERMISSION_DENIED (no db:own)
```
The mock db is a recorder — set `queryResults` for canned rows, or use an
integration test for real SQL.
## Rules
- **No native modules** (`.node`, `binding.gyp`, `prebuilds/`) — rejected at pack
and install time.
- **Don't vendor `trek-plugin-sdk`** — it's injected at runtime (devDependency
only). Vendor any *other* runtime deps: TREK never runs `npm install` on a plugin.
- **Ship built JS** in `server/index.js` and pre-built static files in `client/`.
`.ts` and `.map` files are stripped by `pack`.
- Declare every outbound host in `egress[]` whenever you use `http:outbound`.
## Manifest reference (`trek-plugin.json`)
| Field | Type | Notes |
|---|---|---|
| `id` | string, **required** | lowercase slug, `^[a-z][a-z0-9-]{2,39}$` (3–40 chars). Must match the directory name. |
| `name` | string, **required** | display name; also the page nav label. |
| `version` | string, **required** | semver (`1.2.3`, optional pre-release). |
| `apiVersion` | number | plugin API version (currently `1`; `PLUGIN_API_VERSION`). Defaults to `1`. |
| `type` | string, **required** | `integration` \| `page` \| `widget`. |
| `trek` | string | supported TREK range, e.g. `">=3.2.0 <4.0.0"`. Its lower bound becomes `minTrekVersion` in the registry entry. |
| `author` | string | shown in the store. |
| `description` | string | one-line summary for the store. |
| `icon` | string | lucide-react icon name (default `Blocks`); used for the page nav entry. |
| `homepage` | string | project URL. |
| `license` | string | shown in the store detail (read from the manifest, not enforced). |
| `nativeModules` | boolean | must be `false`/absent — `true` is rejected. |
| `permissions` | string[] | see below. |
| `egress` | string[] | allowed outbound hosts; required (non-empty, no bare `*`) when any `http:outbound` permission is present. |
| `capabilities.widget` | object | `{ title, slot, defaultSize }` — `slot` is `sidebar` (default) or `hero`. |
| `settings` | array | setting fields (below). |
**Permissions** (unknown values are rejected):
| Permission | Grants |
|---|---|
| `db:own` | `ctx.db` — your own SQLite file |
| `db:read:trips` | `ctx.trips.*` (membership-checked, route handlers only) |
| `db:read:users` | `ctx.users.getById` |
| `ws:broadcast:trip` | `ctx.ws.broadcastToTrip` |
| `ws:broadcast:user` | `ctx.ws.broadcastToUser` |
| `http:outbound` or `http:outbound:<host>` | outbound HTTP to `egress[]` hosts |
| `hook:photo-provider` / `hook:calendar-source` | reserved (see [Integration hooks](#integration-hooks-not-yet-functional)) |
> There is **no `ws:broadcast:*`** — use `ws:broadcast:trip` and/or
> `ws:broadcast:user` explicitly.
**Settings field** (`settings[]`):
| Key | Notes |
|---|---|
| `key` | **required** identifier; empty-key entries are dropped. |
| `label` | form label. |
| `input_type` | **snake_case**; e.g. `text` (default), `password`, `number`, `select`. |
| `scope` | `instance` (default) or `user`. |
| `required` | boolean. |
| `secret` | boolean — encrypted at rest, decrypted only into `ctx.config`. |
| `placeholder`, `hint` | form hints. |
| `options` | `[{ value, label }]` for select inputs. |
| `oauth` | `{ initPath, callbackPath }` for OAuth flows. |
**Page nav:** the host builds a page plugin's nav entry from the top-level `name`
and `icon`. `create-trek-plugin` also scaffolds a `capabilities.nav` block, but the
installed-manifest parser only consumes `capabilities.widget` — set `name`/`icon`
to control the nav entry.
See [[Plugin Permissions|Plugin-Permissions]] for the full permission model.
## The `trek-plugin` CLI
Author commands (from `trek-plugin-sdk`):
```bash
# 1. Manifest + layout checks (a subset of the registry CI — CI additionally
# verifies the GitHub release exists, the artifact sha256, and the README
# over the network).
trek-plugin validate [dir]
# 2. Build plugin.zip in the installer's exact layout. Prints sha256 + byte size,
# refuses native binaries, enforces the same size limits (25MB/file, 50MB total).
# Ships trek-plugin.json, README.md, LICENSE(.md), package.json + server/ + client/.
# docs/ is intentionally NOT shipped — the store fetches docs/screenshot.png
# from your repo. --json prints a machine-readable result.
trek-plugin pack [dir] [--out plugin.zip] [--json]
# 3. Emit the ready-to-PR registry entry: commitSha (resolved from the git tag),
# downloadUrl, sha256, size and minTrekVersion (derived from the manifest
# 'trek' range) all computed for you. --merge prepends a new version onto an
# existing entry (the update case, kept newest-first).
trek-plugin entry --repo owner/name --tag vX.Y.Z [--zip plugin.zip] [--merge entry.json] [--out file]
# 4. One shot: pack -> create the GitHub release (via gh) -> print the entry.
trek-plugin release [dir] --repo owner/name --tag vX.Y.Z
```
To publish, open a PR that adds the emitted JSON as
`registry/plugins/<id>.json` in the TREK-Plugins registry.
## Registry & publishing
- **No reserved namespaces** — any unique slug id is accepted. (A tiny set of ids
like `registry`/`install`/`rescan` is blocked only because they'd collide with
admin API routes.)
- **Owner-binding** still prevents anyone but the original author from repointing
an existing id to a different repo.
- **Optional author signing:** an entry may carry `authorPublicKey` (stable,
TOFU-pinned on first install) and each version a `signature` over the artifact
bytes. Unsigned plugins install on sha256 alone; a plugin that was signed can't
later go unsigned or swap its key without an explicit admin re-trust.
Full walkthrough: [[Publishing a Plugin|Plugin-Publishing]]. Overview:
[[Plugins|Plugins]].