diff --git a/wiki/Plugin-Development.md b/wiki/Plugin-Development.md index 3b43c45e4..6437a424f 100644 --- a/wiki/Plugin-Development.md +++ b/wiki/Plugin-Development.md @@ -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/. 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::…`) | `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::`) | `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//…` 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:` | 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/.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]]. diff --git a/wiki/Plugin-Permissions.md b/wiki/Plugin-Permissions.md index b14b62953..3e3af0a8f 100644 --- a/wiki/Plugin-Permissions.md +++ b/wiki/Plugin-Permissions.md @@ -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::` — 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. | +| `http:outbound` / `http:outbound:` | Make outbound network requests | **Requires** a non-empty `egress[]`. Only a **per-host** `http:outbound:` actually opens a host at runtime — see below. | + +## Outbound network — `http:outbound` vs `http:outbound:` + +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:` 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:` +> 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:` 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:` 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: 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 `. 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 --tag [--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/.json` for an update, keeping versions newest-first. | +| `trek-plugin release [dir] --repo --tag ` | 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]]. diff --git a/wiki/Plugin-Publishing.md b/wiki/Plugin-Publishing.md index aa1a57c16..193a06713 100644 --- a/wiki/Plugin-Publishing.md +++ b/wiki/Plugin-Publishing.md @@ -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-`). 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/.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-`). +`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/.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 - }] -} +```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 ^{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 ` 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/.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]]. diff --git a/wiki/Plugins.md b/wiki/Plugins.md index 0077c4dc6..1a20d26bd 100644 --- a/wiki/Plugins.md +++ b/wiki/Plugins.md @@ -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 --tag [--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 --tag ` | 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.