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.
This commit is contained in:
Maurice
2026-07-04 14:05:44 +02:00
parent 6465ea09f1
commit ab0a67aa4c
4 changed files with 562 additions and 149 deletions
+213 -35
View File
@@ -1,7 +1,10 @@
# 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.
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
@@ -17,97 +20,171 @@ 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
README.md # fill this in — the registry requires a screenshot
```
## The three plugin types
- **integration** — extends an existing feature (photo provider, calendar source,
notification target). No UI of its own.
- **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.
- **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 — there is no other way out of the sandbox.
TREK through the `ctx` argument.
```js
const { definePlugin } = require('trek-plugin-sdk')
module.exports = definePlugin({
// Runs once when the plugin is activated.
// 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 cache (k TEXT PRIMARY KEY, v TEXT)')
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 }) }
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.
// 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` / `exec` / `migrate` against your **own** SQLite file | `db:own` |
| `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` (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.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 capability your manifest didn't declare throws `PERMISSION_DENIED`.
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.
`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 sandboxed at an **opaque origin** — it can't read cookies or the
parent page. It talks to TREK only via `postMessage`:
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.theme, m.locale, m.tripId, m.userId */ }
if (m.type === 'trek:response' && m.requestId === '1') { /* … */ }
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):
// 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}`.
**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
to your plugin decrypted through `ctx.config` (instance) — never to the iframe.
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:
`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'
@@ -116,21 +193,122 @@ 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
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) — they're rejected.
- **Vendor your dependencies** — TREK never runs `npm install` on a plugin.
- **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/`.
- Declare every outbound host in `egress[]` when you use `http:outbound`.
`.ts` and `.map` files are stripped by `pack`.
- Declare every outbound host in `egress[]` whenever you use `http:outbound`.
## Validate before publishing
## 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
npx trek-plugin validate .
# 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
```
Runs the same checks the registry CI runs. See [[Publishing a Plugin|Plugin-Publishing]].
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]].
+76 -14
View File
@@ -1,42 +1,104 @@
# 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.
A plugin declares the permissions it needs in `trek-plugin.json`. You review that
list **before you install** — on the plugin's card under Discover — and it only
runs once you turn it on. Because plugins run in an isolated process, **an
ungranted capability is physically unreachable**, not just disallowed. See
[[Plugins]] for the isolation model.
## 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:own` | Read/write the plugin's **own** SQLite file via `ctx.db` — `db.query`, `db.exec`, **and `db.migrate`** | A separate file per plugin — never `trek.db`. `db.migrate` runs a keyed, idempotent migration (schema/table creation, e.g. `CREATE TABLE`) once per id. `ATTACH`/`DETACH`/`VACUUM`/`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. |
| `http:outbound` / `http:outbound:<host>` | Make outbound network requests | **Requires** a non-empty `egress[]`. Only a **per-host** `http:outbound:<host>` actually opens a host at runtime — see below. |
## Outbound network — `http:outbound` vs `http:outbound:<host>`
This is the one permission with a subtlety worth reading twice.
Two independent guards restrict a plugin's network, and **both are built from the
`http:outbound:<host>` permissions you grant — not from the `egress[]` array**:
- the **runtime egress guard** inside the sandboxed child (any connect to a host
that isn't allow-listed is rejected), and
- the plugin iframe's **CSP `connect-src`** (the client can only fetch the same
hosts).
`egress[]` is a **separate declaration** the manifest validator only checks for
*presence*, not contents. The rule it enforces is narrow:
- Only the permissions above are accepted; an unknown string fails validation.
- If **any** `http:outbound` permission (bare or per-host) is declared, `egress[]`
must be **non-empty**.
- `egress[]` may not contain a bare `*`.
Because the validator never cross-checks `egress[]` against the granted hosts:
> [!WARNING]
> **A host you list in `egress[]` but forget to grant as `http:outbound:<host>`
> is silently blocked at runtime.** Validation passes, install passes — then every
> request to that host is refused by the egress guard and the iframe CSP, with no
> manifest error to warn you. **List every host you call as *both* an
> `http:outbound:<host>` permission *and* an `egress[]` entry, and keep the two
> identical.**
**Bare `http:outbound`** (no host) satisfies the "non-empty `egress[]`" rule but
contributes **no host** to either guard — on its own it reaches nothing at
runtime. Use it only alongside the specific `http:outbound:<host>` grants for the
hosts you actually call.
A host may be an exact name (`api.example.com`) or a `*.suffix` wildcard
(`*.example.com`, matching the apex and any sub-domain). Even an allow-listed host
is refused if it resolves to a loopback / private / link-local / metadata address
(the SSRF backstop).
## Declaring them
```jsonc
{
"permissions": ["db:own", "db:read:trips", "http:outbound:api.example.com"],
"egress": ["api.example.com"] // required whenever http:outbound is declared
"egress": ["api.example.com"] // mirror every http:outbound:<host> here
}
```
Rules the manifest validator enforces:
## Publishing — the `trek-plugin` CLI
- 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 `*`.
The `trek-plugin-sdk` package ships a `trek-plugin` CLI that builds the release
artifact and the registry entry for you, so you never hand-compute a sha256,
size, or commit sha. Run it with `npx trek-plugin <command>`. The full submission
flow is in [[Publishing a Plugin|Plugin-Publishing]].
| Command | What it does |
|---|---|
| `trek-plugin validate [dir]` | Manifest + layout checks: parses the manifest with the same rules as install, requires a `README.md` (warns if it has no screenshot or still holds template placeholders) and a built `server/index.js`, and warns if the directory name ≠ the plugin id. This is a **subset** of registry CI — CI additionally verifies the release tag/commit, the artifact's sha256, and the README over the network. A local pass predicts a CI pass. |
| `trek-plugin pack [dir] [--out plugin.zip] [--json]` | Validates, then builds `plugin.zip` in the installer's exact layout (`trek-plugin.json`, `README.md`, `LICENSE`, `package.json` at the root; `server/` and `client/` recursed) and prints its **sha256 + byte size**. Skips `node_modules`, `.git`, `.ts` and `.map` files, and **refuses native binaries** (`.node`, `binding.gyp`, `prebuilds/`) and over-size archives, same as the installer. **`docs/` is intentionally NOT shipped** — the store fetches your screenshot from `docs/screenshot.png` in the repo. |
| `trek-plugin entry --repo <owner/name> --tag <vX.Y.Z> [--zip plugin.zip] [--merge entry.json] [--out file]` | Emits the ready-to-PR registry entry: `commitSha` (resolved from the tag), `downloadUrl`, `sha256` + `size` (from the packed zip), and `minTrekVersion` (derived from the manifest's `trek` range, e.g. `>=3.2.0 <4.0.0` → `3.2.0`). `--merge` prepends this version onto an existing `registry/plugins/<id>.json` for an update, keeping versions newest-first. |
| `trek-plugin release [dir] --repo <owner/name> --tag <vX.Y.Z>` | The one-shot: `pack` → `gh release create` (uploads the zip) → print the registry `entry`. Requires the `gh` CLI authenticated. |
### Registry policy
- **No reserved namespaces.** Any unique lowercase slug id is accepted (3–40
chars, `[a-z][a-z0-9-]*`). The only refused ids are `registry`, `install`, and
`rescan`, which would collide with admin API routes.
- **Owner-binding still holds.** An id is bound to its GitHub owner on first
registration, so nobody can repoint an existing plugin id to a different repo.
- **Optional author signing.** A registry entry may carry an `authorPublicKey`
(stable across versions) and a per-version `signature`. TREK verifies it offline
and pins the key trust-on-first-use. Signing is opt-in — an unsigned entry
installs on `sha256` alone — but once a plugin has shipped signed, an unsigned
update for it is refused. See [[Publishing a Plugin|Plugin-Publishing]].
## 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.
that trip data there. So review the permissions and outbound hosts before you
install — grant only what you'd trust the plugin to do with your data. Prefer
**Reviewed** plugins and authors you trust. To build one, see [[Plugin Development|Plugin-Development]].
+162 -71
View File
@@ -2,75 +2,154 @@
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.
no upload server and no account: you host the code in your own public GitHub repo,
attach a built `plugin.zip` to a release, and list it with a pull request.
## 1. Host your plugin
The `trek-plugin` CLI (shipped in the `trek-plugin-sdk` package) does almost all
the mechanical work — you rarely hand-type a hash, size, commit, or JSON field.
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/`.
## The short version
## 2. Tag a release
```bash
npx trek-plugin validate . # manifest + layout sanity
npx trek-plugin pack . # build plugin.zip, print sha256 + size
gh release create v1.0.0 plugin.zip --repo you/trek-plugin-flight-tracker
npx trek-plugin entry --repo you/trek-plugin-flight-tracker --tag v1.0.0 \
--out registry/plugins/flight-tracker.json # the ready-to-PR entry
```
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.
…then open a PR to **TREK-Plugins** adding that one
`registry/plugins/<id>.json` file. `trek-plugin release` collapses the middle
three steps into one command (see below).
## 3. Validate locally
## 1. Host and build your plugin
Put your plugin in a **public GitHub repo** (convention: `trek-plugin-<id>`).
`create-trek-plugin` scaffolds the layout:
```bash
npx create-trek-plugin flight-tracker --type widget # integration | page | widget
```
A publishable plugin has, at the repo root:
- `trek-plugin.json` — the manifest (see [[Plugin Development|Plugin-Development]])
- `server/index.js` — the built server entry (required)
- `client/` — the built frontend (only for `page`/`widget` plugins)
- `README.md` — filled in, with a real screenshot (the quality gate is strict — see below)
- `docs/screenshot.png` — the store card image, committed to the repo
## 2. Validate
```bash
npx trek-plugin validate .
```
This runs the exact checks the registry CI runs — a local pass predicts a CI
pass.
`validate` runs the manifest checks plus a light layout/README sanity pass:
it fails if `trek-plugin.json` is invalid or `server/index.js` is missing, and
warns if the directory name doesn't match the plugin `id`, the README has no
screenshot, or the README still contains scaffold placeholders. This is a
**subset** of what CI runs — CI additionally verifies the release, the artifact's
SHA-256, and the README over the network — so a clean local run predicts a clean
CI run but doesn't replace it.
## 4. Fork the registry and open a PR
## 3. Pack
You don't have write access to the registry, so **fork
[TREK-Plugins](https://github.com/mauriceboe/TREK-Plugins)** to your own account,
add your entry there, and open a pull request from your fork back to the
registry's `main` branch.
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
}]
}
```bash
npx trek-plugin pack . # writes ./plugin.zip
npx trek-plugin pack . --out dist.zip # custom output path
npx trek-plugin pack . --json # machine-readable result
```
CI validates the entry and the artifact, and the maintainer reviews the source at
the pinned commit before merging.
`pack` validates first, then builds `plugin.zip` in the installer's exact layout
and prints the **sha256** and **size** you'd otherwise compute by hand. It ships
only what the runtime needs — `trek-plugin.json`, `README.md`, `LICENSE`,
`package.json`, and the `server/` and `client/` trees — and drops `node_modules`,
`.git`, source maps and `.ts` sources. It **refuses native binaries**
(`.node`, `binding.gyp`, `prebuilds/`) and enforces the same size limits as the
installer (25 MB per file, 50 MB total, 4000 entries).
> **`docs/` is intentionally not shipped.** The store fetches your
> `docs/screenshot.png` straight from the repo at the pinned commit, so keep it
> committed to GitHub but out of the zip — `pack` handles this for you.
## 4. Create the GitHub release
Tag `vX.Y.Z` where `X.Y.Z` **equals** `version` in your manifest, and attach the
packed `plugin.zip` as a release asset:
```bash
gh release create v1.0.0 plugin.zip --repo you/trek-plugin-flight-tracker
```
Prefer the uploaded `plugin.zip` asset — it's the exact bytes you packed and the
registry pins their hash. Don't rely on GitHub's auto-generated source archives;
they aren't the installer layout and their bytes aren't stable.
## 5. Generate the registry entry
```bash
npx trek-plugin entry \
--repo you/trek-plugin-flight-tracker \
--tag v1.0.0 \
--out registry/plugins/flight-tracker.json
```
`entry` reads your manifest and `plugin.zip` and emits the complete entry —
deriving `commitSha` (from `git rev-parse <tag>^{commit}`), `downloadUrl`,
`sha256`, `size`, `apiVersion`, and `minTrekVersion` (the lower bound of the
manifest's `trek` range, e.g. `">=3.2.0 <4.0.0"` → `3.2.0`). Flags: `--zip`
(default `plugin.zip`), `--commit <sha>` to override commit resolution, `--asset`
to name a differently-named release asset, `--merge` for updates (below), and
`--out` to write a file.
### One-shot: `release`
```bash
npx trek-plugin release . --repo you/trek-plugin-flight-tracker --tag v1.0.0
```
`release` does **pack → `gh release create` → entry** in one go and prints the
entry to stdout. It accepts `--out`, `--notes`, `--commit`, and `--merge`. (It
requires the `gh` CLI, authenticated.)
## 6. Open a registry PR
You don't have write access, so **fork
[TREK-Plugins](https://github.com/mauriceboe/TREK-Plugins)**, add your generated
file as `registry/plugins/<id>.json`, and open a PR back to `main`. Add **only**
that file — `dist/` is generated on merge, and CI rejects manual edits to it.
The entry follows [`schema/plugin-entry.schema.json`](https://github.com/mauriceboe/TREK-Plugins/blob/main/schema/plugin-entry.schema.json);
[`schema/example-entry.json`](https://github.com/mauriceboe/TREK-Plugins/blob/main/schema/example-entry.json)
is the canonical shape. `size` is **required** (a common omission), as are
`commitSha`, `downloadUrl`, `sha256`, `minTrekVersion`, `apiVersion`, and
`nativeModules: false` on every version — all of which `trek-plugin entry` fills
in for you.
## 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.
CI runs `scripts/validate-entry.mjs` and `scripts/check-readme.mjs` on every
changed `registry/plugins/*.json`.
**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.
**Entry** (`validate-entry.mjs`): valid schema · `id` matches the filename and
the slug pattern `^[a-z][a-z0-9-]{2,39}$` · your `id` is bound to your GitHub
owner on first registration, so nobody can repoint it later (owner changes need a
maintainer override) · homoglyph/mixed-script name check · the release tag exists
and resolves to `commitSha` · manifest parity at that commit (`id`, `version`,
`type`, `apiVersion`, and `nativeModules` must not be `true`) · **the downloaded
artifact's SHA-256 matches the pin** and its size is within bounds · **no native
binaries** in the archive · `egress[]` present (and no bare `*`) when
`http:outbound` is declared. Any unique slug is fine — **there are no reserved
namespaces**.
**README** (`check-readme.mjs`, fetched at the pinned commit): must exist at the
repo root, contain the sections **What it does / Screenshots / Permissions /
Setup**, carry **at least one screenshot that resolves to a real image**
(a relative `docs/screenshot.png` is resolved against the commit), have real
prose (**≥ 400 characters** after stripping headings/code/images/tables), contain
no leftover scaffold placeholders, and **explain every permission** your manifest
declares (each permission string must appear in the README).
## Provenance & integrity
@@ -79,34 +158,36 @@ permission.
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.
exact commit — it is **not** an ongoing guarantee. `reviewedAt` and `boundOwner`
are maintained by CI on merge; don't set them yourself.
## Signing your releases (optional, recommended)
`sha256` proves the bytes are the ones the *registry* vouches for. An author
signature additionally proves the bytes were signed by **you** — so a compromised
registry cannot ship attacker code under your name. TREK verifies the signature
offline (Ed25519, no external service), and pins your key on first install
(trust-on-first-use): a later release signed with a different key is refused until
an admin re-trusts it.
signature additionally proves the bytes were signed by **you**, so a compromised
registry can't ship attacker code under your name. The entry schema allows two
optional fields — `authorPublicKey` on the entry and `signature` on each version.
TREK verifies the signature offline (minisign / Ed25519, no external service) and
pins your key on first install (trust-on-first-use): a later release signed with a
different key is refused until an admin re-trusts it.
**One-time — create a key:**
```
minisign -G # writes minisign.key (keep secret) + minisign.pub
```bash
minisign -G # writes minisign.key (keep secret) + minisign.pub
```
Put the public key in your registry entry as `authorPublicKey` (the base64 payload
line from `minisign.pub`). It is stable across versions.
Put the base64 payload line from `minisign.pub` in your entry as
`authorPublicKey` (stable across versions).
**Each release — sign the artifact:**
```
minisign -Sm plugin.zip # writes plugin.zip.minisig
```bash
minisign -Sm plugin.zip # writes plugin.zip.minisig
```
Add the base64 signature line from `plugin.zip.minisig` to that version as
`signature`, next to its `sha256`. Example entry:
Add the base64 line from `plugin.zip.minisig` to that version as `signature`,
next to its `sha256`:
```jsonc
{
@@ -121,12 +202,22 @@ Add the base64 signature line from `plugin.zip.minisig` to that version as
```
Signing is **opt-in**: an entry without `authorPublicKey`/`signature` installs on
`sha256` alone, exactly as before. But once a plugin has shipped signed, an
*unsigned* update for it is refused — don't drop the signature between versions.
`sha256` alone. But once a plugin has shipped signed, an *unsigned* update is
refused — don't drop the signature between versions.
## 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.
Bump `version` in the manifest, re-`pack`, cut a new `vX.Y.Z` release, then fold
the new version onto your existing entry with `--merge`:
```bash
npx trek-plugin entry --repo you/trek-plugin-flight-tracker --tag v1.1.0 \
--merge registry/plugins/flight-tracker.json \
--out registry/plugins/flight-tracker.json
```
`--merge` prepends the new version (keeping the array newest-first) and preserves
the rest of the entry. PR the updated file. Instances see the update on their next
registry poll; applying it is always an explicit admin action, and if a new
version requests **more** permissions the admin must re-approve — see
[[Plugin Permissions|Plugin-Permissions]].
+111 -29
View File
@@ -12,67 +12,149 @@ public registry, and installed at the instance owner's discretion.
> only the access you'd trust it to have with your data, and prefer plugins
> marked **Reviewed**.
## Plugin types
A plugin declares one `type` in its manifest, which decides where it surfaces:
| Type | Where it shows | Typical use |
|---|---|---|
| **widget** | On the dashboard — in the sidebar, or (with `slot: "hero"`) as a boarding-pass style hero overlay | At-a-glance info like flight status or weather |
| **page** | As its own entry in the top navigation | A full self-contained tool |
| **integration** | Nowhere visible — registers into TREK via hooks (e.g. a photo provider or calendar source) | Feed data into existing TREK features |
## Enabling plugins
The plugin runtime is **off by default**. To turn it on, set the environment
variable on your instance and restart:
variable to exactly `true` 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.
When it's off, the **Admin → Plugins** tab shows a "turned off
(TREK_PLUGINS_ENABLED)" banner and nothing runs. Setting it back to `false` is a
kill switch — installed plugins stay on disk, deactivated and harmless.
## 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:
Each active plugin runs as a **separate OS process**, started under Node's
permission model (`--permission`) with filesystem reads scoped to just its own
code:
- 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
secret — those are simply not reachable by its process.
- It **cannot** open `trek.db`, write files, spawn child processes, use worker
threads, or load native addons. 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.
- A page/widget's interface runs in a **sealed browser frame** that can't read
your session cookie or touch the surrounding TREK page.
- If a plugin crashes, hangs, or runs out of memory, only *its* process dies —
TREK keeps running and restarts or disables it.
TREK keeps running and can restart or disable 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.
This means the permission list you approve is a **real boundary**, not a label.
It bounds *what* a plugin can touch, not its intent within a grant: a plugin you
let read trips **and** reach a host could send those trips there. See
[[Plugin Permissions|Plugin-Permissions]] for exactly what each permission grants.
## The Admin → Plugins panel
A single panel with a segmented **Installed / Discover** switch at the top left,
plus a toolbar:
- **Search** — filters the current list by name/description (and author, in Discover).
- **Type** filter — All / Widget / Integration / Page.
- **Status** filter (Installed view only) — All / Active / Off / Update available / Error.
- **Sort** — Name / Recently updated / Updates first.
- **Rescan** — re-reads the on-disk plugins directory (see [Installing](#installing-a-plugin)).
Each installed row shows an icon tile with a **health dot** (green = active,
blue pulse = starting, red = error, amber = disabled/incompatible, faint = inactive),
the name and version, a **Reviewed** shield if applicable, and **capability
chips** derived from its declared permissions — "Reads your trips", "Dashboard
widget", "Real-time updates", "Provides photos", outbound hosts, and so on — so a
plugin's real reach is legible without opening anything.
## Reviewing a plugin before install
In **Discover**, click any card to open its **detail modal**. This is where you
review a plugin *before* it touches your instance. It fetches the plugin's live
manifest (at the reviewed commit) and lays out:
- **What it can access** — the permission-derived capabilities in plain language.
- **Connects to** — the outbound hosts it declared (`egress`).
- **Setup** — configuration fields it will ask for, with scope (instance/user) and
whether each is required.
- **Details** — version, download size, minimum TREK version, review date, plus
links to the source repo and homepage.
A **Reviewed** badge means a TREK maintainer scanned that exact version's source
for malware — **not** that it works well or is harmless. It is not an ongoing
guarantee. Read the access list and outbound hosts, not just the description.
## 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.
1. **From the registry.** In **Discover**, open a plugin and click **Install**
(also available directly on the card). TREK downloads the pinned version, verifies
its SHA-256 against the registry (and an author signature if the plugin ships one),
safely unpacks it, re-validates the manifest, and registers it — **inactive**.
Nothing runs yet.
2. **From disk.** Drop a plugin directory into the plugins code directory
(`server/data/plugins`, or your `TREK_PLUGINS_DIR` 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.
Activation is the **toggle** on the installed row. Flipping it on grants the
permissions you already reviewed and spawns the isolated process; flipping it off
stops that process immediately while keeping the plugin's data. A page plugin then
appears in the top navigation; a widget appears on the dashboard. There is no
separate "Activate" button or second consent screen — you reviewed the permissions
before installing.
## Managing plugins
## Managing a plugin
- **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).
The **⋯** menu on each row:
- **Restart** — stop and re-spawn the process (shown only while active).
- **View error log** — the plugin's own crash/failed-request log.
- **Source repository** — opens the plugin's GitHub repo (registry installs only).
- **Delete** — uninstalls: removes the code and lets you keep or delete its data.
## Updating a plugin
When the registry lists a newer version, an **Update to vX.Y.Z** pill appears on
the row, and an **Update all** bar summarises how many are available.
Updating swaps in the new code and, by default, transparently restarts the plugin
on it. But if the new version declares **more permissions or new outbound hosts**,
TREK installs the new code and **leaves the plugin off**, then shows a
**re-consent dialog** listing exactly the new permissions and hosts. The plugin
only runs again once you approve — an update can never silently widen what a
plugin may do. Choosing "Later" keeps the new code installed but inactive.
## Building your own
See [[Plugin Development|Plugin-Development]] and the `trek-plugin-sdk` package.
Plugins are built with the **plugin SDK** and its `trek-plugin` CLI. The CLI
turns a built plugin directory into a publishable artifact and the ready-to-PR
registry entry, so you never hand-compute a SHA-256 or hand-write registry JSON:
## Kill switch
| Command | What it does |
|---|---|
| `trek-plugin validate [dir]` | Runs the manifest + layout checks locally (a subset of registry CI, which additionally verifies the release, the artifact SHA-256, and the README over the network). |
| `trek-plugin pack [dir] [--out plugin.zip] [--json]` | Builds `plugin.zip` in the installer's exact layout and prints its SHA-256 + byte size. Refuses native binaries; `docs/` is intentionally not shipped (the store fetches the screenshot from your repo). |
| `trek-plugin entry --repo <o/n> --tag <vX> [--zip z] [--merge entry.json] [--out f]` | Emits the registry entry — `commitSha`, `downloadUrl`, `sha256`, `size` and `minTrekVersion` (derived from the manifest `trek` range) all filled in. `--merge` prepends the new version onto an existing entry for updates. |
| `trek-plugin release [dir] --repo <o/n> --tag <vX>` | The one-shot: `pack` → create the GitHub release → print the entry. |
Set `TREK_PLUGINS_ENABLED=false` to disable the entire subsystem instantly —
installed plugins stay on disk, deactivated and harmless.
Run them via `npx trek-plugin …`. See [[Plugin Development|Plugin-Development]]
for the SDK and manifest, and [[Publishing a Plugin|Plugin-Publishing]] for the
registry PR flow. Publishing no longer reserves any namespaces — any unique slug
is fine — but an `id` stays bound to the GitHub owner who first registered it, so
nobody can repoint an existing plugin. Entries may optionally carry an author
signing key (`authorPublicKey` + a per-version `signature`) for offline signature
verification on top of the SHA-256 pin.