diff --git a/skills/trek-plugin-dev/SKILL.md b/skills/trek-plugin-dev/SKILL.md index 9515e02..44622b9 100644 --- a/skills/trek-plugin-dev/SKILL.md +++ b/skills/trek-plugin-dev/SKILL.md @@ -1,6 +1,6 @@ --- name: trek-plugin-dev -description: Build, test, sign, and publish plugins for TREK, the self-hosted travel planner (github.com/liketrek/TREK). Covers the trek-plugin.json manifest, the definePlugin server API and ctx object, the sandboxed iframe postMessage bridge for widget/page UIs, permissions and egress rules, the enforced `trek` TREK-version range, local development with trek-plugin-sdk, author signing (keygen/--sign, Ed25519 trust-on-first-use), and publishing to the TREK-Plugins community registry including every CI gate. Use when creating or modifying a TREK plugin, working with trek-plugin-sdk or trek-plugin.json, signing a plugin or handling a key rotation or signature problem (rotate-key, allow-key-change, SIGNATURE_KEY_CHANGED), debugging PERMISSION_DENIED / RESOURCE_FORBIDDEN / TREK_VERSION_INCOMPATIBLE / TREK_VERSION_UNKNOWN / API_VERSION_INCOMPATIBLE or a plugin that will not install or activate on a given TREK version, recovering from a failed publish (rollback, unrelease), or preparing a TREK-Plugins registry entry or PR. +description: Build, test, sign, and publish plugins for TREK, the self-hosted travel planner. Covers the trek-plugin.json manifest, the definePlugin server API and ctx object, the sandboxed iframe postMessage bridge for widget/page UIs, permissions and egress rules, plugin MCP tools (capabilities.mcpTools, mcp:tools), the enforced `trek` TREK-version range, local development with trek-plugin-sdk, author signing (keygen/--sign, Ed25519 TOFU), and publishing to the TREK-Plugins registry and its CI gates. Use when creating or modifying a TREK plugin, working with trek-plugin-sdk or trek-plugin.json, signing or handling a key rotation or signature problem (rotate-key, allow-key-change, SIGNATURE_KEY_CHANGED), debugging PERMISSION_DENIED / RESOURCE_FORBIDDEN / TREK_VERSION_INCOMPATIBLE / TREK_VERSION_UNKNOWN / API_VERSION_INCOMPATIBLE or a plugin that will not install or activate on a given TREK version, recovering from a failed publish (rollback, unrelease), or preparing a TREK-Plugins registry entry or PR. --- # TREK Plugin Development @@ -141,8 +141,9 @@ suggestions derived from what the plugin does**: - Either way, open `dev`'s themed **`/preview`** (light/dark/accent toggles) while you iterate, present the image(s), ask *"does this look right?"*, and iterate. The approved shot doubles as the store `docs/screenshot.png` — which is a **hard - registry gate**, and `validate`/`status` now fail if it doesn't resolve to a real - file on disk. + registry gate** on exactly that path (the store card loads it; a README image + under any other name doesn't count), and `validate`/`status` fail when the + file is missing. See [references/testing.md](references/testing.md). @@ -168,7 +169,13 @@ wired** (place-detail / trip-warning / table / map-marker / map-layer / route / day-schedule / day-tint / pdf-section / atlas-layer / journal-entry / trip-card / photo / calendar / notification-channel), plus the GDPR **`hook:user-data`** (`deleteUserData`/`exportUserData`, userless, own-db) — so an `integration` can -inject native UI or honour data-rights with no iframe. See +inject native UI or honour data-rights with no iframe. A plugin of **any type** +can also publish **MCP tools** on TREK's own MCP server — +`capabilities.mcpTools` (≤ 8, declared + consented) + the **`mcp:tools`** +permission + a `hooks.mcpToolProvider` whose `tools` array names the same +tools: each is advertised to connected assistants as `plugin__` +behind the opt-in `plugins:use` OAuth scope, and `callTool` runs **as the +requesting user** (route-like ctx, 15 s timeout). See [references/server-api.md](references/server-api.md). ## Critical rules (violating any of these breaks install or CI) @@ -203,9 +210,14 @@ inject native UI or honour data-rights with no iframe. See it (your `hooks: {}` live in `server/index.js`, which the manifest validator never loads); the *only* automatic check is **`trek-plugin dev`**, which warns at load and 403s if you fire one (SDK ≥ 1.5.0). **Run `dev` once before you publish and read the - banner.** The lone exception is the notification channel — it's declared in the - manifest (`capabilities.notificationChannel`), so `validate` does catch a missing - `hook:notification-channel`. + banner.** Two exceptions live in the manifest and so *are* caught by `validate`: + the notification channel (`capabilities.notificationChannel` without + `hook:notification-channel`) and MCP tools (`capabilities.mcpTools` without + `mcp:tools`). MCP tools add a **second** silent trap all their own: only the + **intersection** of `capabilities.mcpTools[].name` and the code's + `hooks.mcpToolProvider.tools` array is advertised — a name mismatch drops the + tool silently, and **no tool checks it anywhere** (not `validate`, not `dev`, + not the mock host). Keep the two lists identical by eye. 5. **`ctx.trips`, `ctx.users`, `ctx.costs`, `ctx.ws.*` — and `ctx.packing`/`ctx.files`/`ctx.places`/`ctx.days`/`ctx.itinerary`/`ctx.trips.update`/`ctx.meta` — work only inside route handlers** (they need the acting user the host binds from the @@ -399,6 +411,9 @@ inject native UI or honour data-rights with no iframe. See that the plugin won't work there. `"install latest"` resolves to the newest version this TREK can run, and an update that would drag a working plugin *out* of compatibility is refused rather than performed. +- **Instance-scoped settings actions need `trek` `>=4.2.0`.** A host older than that + ignores an action's `scope` and renders the button on every user's settings page instead + (running it as each user) — see [references/manifest.md](references/manifest.md). - Current plugin API: `apiVersion: 1` (`PLUGIN_API_VERSION`) — **enforced**: it must be a positive integer, and a manifest declaring a version newer than the host supports is refused at install and won't activate (`API_VERSION_INCOMPATIBLE`, diff --git a/skills/trek-plugin-dev/references/cli.md b/skills/trek-plugin-dev/references/cli.md index 25b8614..c2fe842 100644 --- a/skills/trek-plugin-dev/references/cli.md +++ b/skills/trek-plugin-dev/references/cli.md @@ -23,11 +23,11 @@ shipping a version. | Command | Needs | What it does | |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `create [name] [--type t] [--icon Name] [--slot s] [--author x] [--description x] [--permissions "a b"] [--template blank\|notification-channel] [--egress host,host] [--required-addons a,b] [--interactive]` | — | Scaffold a plugin. No name (or `--interactive`) → a **Clack wizard** (id, **location**, type, **widget slot**, **icon** — validated against lucide as you type — author, **description**, permissions, and — if `http:outbound` is picked — **egress hosts**), then offers `git init` + `npm install`. Permissions are a **single grouped multiselect** (SDK ≥ 1.5.0): one scrollable screen with all **63** grants under **13 area headings** (storage · trips · itinerary · bookings · budget · packing · files · collab · journal/atlas/vacay/collections · hooks · background · realtime · services) — space toggles a grant, an area header toggles its whole group, `db:own` starts pre-selected; picking `oauth:client` also scaffolds the five instance-scope OAuth-broker settings. (A two-step areas-then-grants flow was considered and rejected: Clack has no "back", so a wrong area pick would be a dead end.) ⚠️ **On SDK ≤ 1.4.x the wizard offered only 18** of the then-58 grants — `jobs:run`, `events:subscribe` and most `hook:*` among the missing — so a plugin needing one had to hand-edit the manifest. Nothing rejected those permissions (the validator always knew the full list); they were simply unofferable. Upgrade, or write them in by hand. With a name it's non-interactive and still requires the name. The page/widget scaffold emits a **design-kit client** (`` marker + a `window.trek` UI); a **notification-channel template** is available (wizard option / `--template notification-channel`). It writes an `icon` (a sensible lucide default per type if you don't pick one), the widget `slot` **explicitly**, `"trek": ">=4.0.0 <5.0.0"`, a `docs/` dir, and a `.gitattributes` (`eol=lf`, so a Windows re-pack hashes the same). ⚠️ A **valueless** flag counts as absent — a bare `--permissions` grants nothing (it does *not* create a permission named `true`). **A fresh scaffold RUNS and PACKS but does not pass `validate`** — the README is a stub and there's no screenshot. That's deliberate; `status` says what's missing. | -| `dev [dir] [--port 4317]` | — | Local dev server (default `http://localhost:4317`, 127.0.0.1 only) with hot reload, SDK injection, permission-enforcing `ctx` (`db:own` backed by a real SQLite file under `.trek-dev/`). Serves a dashboard at `/`, your UI in the themed sandboxed frame at **`/preview`**, the raw unframed UI at `/ui` (expanding the `` marker), your routes at `/api/*`, and `/__dev/fire/` for non-route entry points. **Enforces the permissions TREK gates OUTSIDE `ctx` too** (SDK ≥ 1.5.0): it warns at load about any hook/event/job you implement but never granted (production would **silently never call it**), 403s if you fire one, and runs the **real egress guard** so an undeclared host is refused here rather than after install. Honours `TREK_PLUGIN_ALLOW_PRIVATE_EGRESS=on` like the host. See [testing.md](testing.md#what-dev-enforces-hooks-events-jobs-egress). | +| `create [name] [--type t] [--icon Name] [--slot s] [--author x] [--description x] [--permissions "a b"] [--template blank\|notification-channel] [--egress host,host] [--required-addons a,b] [--interactive]` | — | Scaffold a plugin. No name (or `--interactive`) → a **Clack wizard** (id, **location**, type, **widget slot**, **icon** — validated against lucide as you type — author, **description**, permissions, and — if `http:outbound` is picked — **egress hosts**), then offers `git init` + `npm install`. Permissions are a **single grouped multiselect** (SDK ≥ 1.5.0): one scrollable screen with all **64** grants under **13 area headings** (storage · trips · itinerary · bookings · budget · packing · files · collab · journal/atlas/vacay/collections · hooks · background · realtime · services) — space toggles a grant, an area header toggles its whole group, `db:own` starts pre-selected; picking `oauth:client` also scaffolds the five instance-scope OAuth-broker settings. (A two-step areas-then-grants flow was considered and rejected: Clack has no "back", so a wrong area pick would be a dead end.) ⚠️ **On SDK ≤ 1.4.x the wizard offered only 18** of the then-58 grants — `jobs:run`, `events:subscribe` and most `hook:*` among the missing — so a plugin needing one had to hand-edit the manifest. Nothing rejected those permissions (the validator always knew the full list); they were simply unofferable. Upgrade, or write them in by hand. With a name it's non-interactive and still requires the name. The page/widget scaffold emits a **design-kit client** (`` marker + a `window.trek` UI); a **notification-channel template** is available (wizard option / `--template notification-channel`). It writes an `icon` (a sensible lucide default per type if you don't pick one), the widget `slot` **explicitly**, `"trek": ">=4.0.0 <5.0.0"`, a `docs/` dir, and a `.gitattributes` (`eol=lf`, so a Windows re-pack hashes the same). ⚠️ A **valueless** flag counts as absent — a bare `--permissions` grants nothing (it does *not* create a permission named `true`). **A fresh scaffold RUNS and PACKS but does not pass `validate`** — the README is a stub and there's no screenshot. That's deliberate; `status` says what's missing. | +| `dev [dir] [--port 4317]` | — | Local dev server (default `http://localhost:4317`, 127.0.0.1 only) with hot reload, SDK injection, permission-enforcing `ctx` (`db:own` backed by a real SQLite file under `.trek-dev/`). Serves a dashboard at `/`, your UI in the themed sandboxed frame at **`/preview`**, the raw unframed UI at `/ui` (expanding the `` marker), your routes at `/api/*`, and `/__dev/fire/` for non-route entry points. **Enforces the permissions TREK gates OUTSIDE `ctx` too** (SDK ≥ 1.5.0): it warns at load about any hook/event/job you implement but never granted (production would **silently never call it**), 403s if you fire one, and runs the **real egress guard** so an undeclared host is refused here rather than after install. Honours `TREK_PLUGIN_ALLOW_PRIVATE_EGRESS=on` like the host. See [testing.md](testing.md#what-dev-enforces-hooks-events-jobs-egress). Seeds `ctx.config` / `ctx.settings.get()` from the manifest's settings-field `default`s the way the host does (a `dev-fixtures.json` value still wins), so a plugin that relies on its defaults reads them here too. | | `status [dir]` | — | **"Where am I? What's left?"** Runs every registry gate that can be answered without a network — which is nearly all of them — and prints the journey as a checklist grouped **Manifest / Code / Docs / Release**, plus a **Repo** line (is `vX.Y.Z` tagged locally, is the tree dirty — *the registry grades the commit, not your working tree*), then names **one** next command. **It never exits non-zero**: it is for orientation, not gating (`validate` is the gate), so it's safe to run constantly. Takes no flags. | | `shot [dir] [--port 4317] [--out docs/screenshot.png] [--dark] [--no-serve]` | Playwright | Boots `dev`, renders your plugin in the themed `/preview` frame, writes a **1600×900** `docs/screenshot.png` (2× DPI, waits ~1.2 s so it doesn't photograph a loading skeleton). This is the built-in way to satisfy the registry's screenshot gate. **Playwright is deliberately NOT an SDK dependency** (it ships a ~300 MB browser) — `npm i -D playwright && npx playwright install chromium`; without it the command tells you exactly that instead of throwing a resolution error. `--no-serve` shoots a dev server you're already running (fails if nothing is on the port; and *without* `--no-serve` it refuses if the port is already busy). ⚠️ An **`integration` has no UI**, so `shot` refuses and tells you to screenshot the TREK surface your plugin *changes* instead (the notification it sends, the badge it adds, its settings page). | -| `validate [dir]` | — | **The gate.** Runs the same offline checks as `status` and exits **1** if any would be rejected by the registry — this is the scripts/CI form, `status` is the human form. It is no longer a token subset: **errors** now on an invalid manifest, a missing/unsatisfiable **`trek` range**, a missing `server/index.js`, an **`icon` lucide doesn't have**, `name`/`description`/`author` outside the registry's length limits (2–60 / 5–200 / ≤80 — and a **missing** description, which the registry requires), a **homoglyph** name (Latin mixed with Cyrillic/Greek), an **`egress[]` host with no matching `http:outbound:` permission** (see below), a missing README, a README missing any of the **four required sections**, **template placeholders**, **< 400 chars of prose**, a **screenshot that doesn't resolve to a real file on disk**, any **permission not explained in the README**, a **permission TREK doesn't know** (checked against the same 63-entry list the host and registry CI enforce), and a malformed **`settings[]`** entry (bad `key`, non-array `options`, an option without a non-empty `value`, a non-object `oauth`). **Warns** (never fatal) on dir name ≠ id, an unbounded `trek` (`"*"`), an unknown `requiredAddons` id, **emoji** in name/description (TREK strips them from host-rendered text), a reachable host absent from `egress[]`, and a raw `` without the design kit. A green `validate` means every registry gate that doesn't need the tag/release to exist. It also **warns** (never blocks) on a settings-field attribute the host doesn't know — check `manifest.settings-known-keys`: the host stores only `key, label, input_type, placeholder, hint, required, secret, scope, options, oauth, default` and **silently drops** the rest at install, so a typo'd `defalt` would otherwise do nothing with no diagnostic anywhere. | | `pack [dir] [--out plugin.zip] [--json]` | — | Builds `plugin.zip` in the installer's exact layout; prints **sha256 + byte size**. `--json` for machine-readable output. It refuses only what makes the plugin **unloadable** — a broken manifest, no `server/index.js`, a native binary — and **deliberately does *not* enforce the publish gates** (unwritten README, missing screenshot), because packing is how you install a plugin into a local TREK to try it; blocking the dev loop on a docs gate would be absurd. **So a green `pack` is not a green `validate`.** ⚠️ Zip mod dates are fixed (a same-machine, same-SDK re-pack is byte-identical), but re-packs elsewhere can differ (walk order, per-SDK inlined kit, CRLF) — the registry `sha256`/`size` must come from the **uploaded release asset**, never a re-pack (see [publishing.md](publishing.md)). | | `entry [dir] --repo --tag [--dir d] [--zip plugin.zip] [--commit ] [--asset ] [--merge ] [--out ] [--sign [key]] [--allow-key-change]` | git | Emits the ready-to-PR registry entry: resolves `commitSha` from the tag (`git rev-parse ^{commit}`), fills `downloadUrl`, `sha256`, `size`, `apiVersion`, `icon` (copied from your manifest — it's what the store tile draws; on `--merge` it refreshes from the manifest but won't wipe an icon the entry already has), `operatorEgress`, **`requiredAddons` and `pluginDependencies`** (mirrored from the manifest — they were silently omitted until recently, which failed the registry's parity gate for every plugin with an addon dependency; **nothing to hand-add any more**), and `trek` (your manifest range, verbatim — the one compat field an entry carries, and what TREK gates on). Refuses to build an entry without a usable `trek` range. It no longer emits `minTrekVersion`: that field only restated the range's lower bound, and is deprecated. `--merge` prepends the new version (newest-first — registry CI enforces that ordering) and refuses a key switch / unsigned update to a signed plugin; on the **first signed** update it **retro-signs the older unsigned versions** with your key (each pinned artifact downloaded, verified against its sha256, then signed — a byte mismatch refuses; without `--sign` it errors telling you to pass it). ⚠️ `entry` hashes the **local** zip (`--zip`, default `plugin.zip`) — it never contacts GitHub; `artifact not found: ` means the local zip is missing (run `pack` first). What matters is that the file you hash is **byte-identical to the uploaded release asset**: generate the entry from the same `plugin.zip` you uploaded, then run `preflight`, which *does* download and verify the asset. On `--merge`, a `--sign` key that differs from the entry's published `authorPublicKey` is refused — unless you pass **`--allow-key-change`** (SDK ≥ 1.7.0), which declares a deliberate rotation: the entry adopts the NEW key and the older versions' old-key signatures are **stripped**, so the retro-sign pass re-signs each pinned artifact with the new key. | | `release [dir] --repo --tag [--out] [--notes] [--commit] [--merge] [--sign [key]] [--allow-key-change]` | git + `gh` (authed) | One shot: `pack` → `gh release create` (uploads the zip) → prints the entry, but does **not** open the registry PR. For releasing now and submitting later. `publish` is the whole thing. | diff --git a/skills/trek-plugin-dev/references/manifest.md b/skills/trek-plugin-dev/references/manifest.md index 79fdab1..739f023 100644 --- a/skills/trek-plugin-dev/references/manifest.md +++ b/skills/trek-plugin-dev/references/manifest.md @@ -30,10 +30,11 @@ registry CI, and the TREK install loader all apply the **same** rules | `capabilities.provides` | string[] | no | Callable export names this plugin exposes to its **dependents** via `ctx.plugins.call` (safe identifiers, de-duplicated + name-validated at install). The counterpart to `pluginDependencies` (the consuming side). | | `capabilities.emits` | string[] | no | Event names this plugin publishes to dependents via `ctx.events.emit` (dotted names like `rate.updated` allowed). Drives the host's `subscribersOf` routing. | | `settings` | array | no | Settings fields (below). **Validated by the SDK and the host alike**: `key` must match `^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$` (never `constructor`/`prototype`/`__proto__`), `scope` is `instance`/`user`, an `options` list must be an array whose object entries carry a non-empty `value` (bare strings/numbers are accepted and coerced), and `oauth` must be an object with string paths — a manifest failing any of these is refused at install, and `validate` errors on it first. Plugins write no settings UI (but see `capabilities.settingsUi` below). | -| `actions` | object[] | no | Up to **8 settings-page buttons** ("Test connection"-style). Each renders on the plugin's user-settings page and runs **user-bound** via `POST /api/plugin-settings//action`; the handler's result is normalized to `{ ok, message }` (message emoji-stripped, ≤ 200 chars). Test with the mock host's `declaredActions` + driver `action(key)` — see [testing.md](testing.md). | +| `actions` | object[] | no | Up to **8 settings-page buttons** ("Test connection"-style) — `{ key, label?, hint?, danger?, scope? }`, **8 across both scopes** with keys unique across them (a blank `label` falls back to the key). `scope` is `user` (default) — renders on the user's **Settings → Plugins** page and runs **user-bound** via `POST /api/plugin-settings//actions/` — or `instance`, which renders in the admin's instance-settings dialog (**Admin → Plugins → ⋯ → Instance settings**) and runs **as the clicking admin** (their own `ctx.settings` plus `ctx.config`, still membership-checked — admin-*surfaced*, not admin-privileged) via `POST /api/admin/plugins//actions/`; an instance button is **disabled until the plugin is active**, and the dialog saves an edited form before firing it. Any other `scope` fails install *and* `validate` with `action "".scope must be "user" or "instance"`. The handler's result is normalized to `{ ok, message }` (message emoji-stripped, ≤ 200 chars). ⚠️ **Hosts older than TREK 4.2.0 ignore `scope`** and put the button on every user's settings page, running it as each user — set the manifest `trek` floor to `>=4.2.0` when you use `instance`. Test with the mock host's `declaredActions` (plain keys or `{ key, scope }`) + driver `action(key)` — see [testing.md](testing.md). | | `capabilities.settingsUi` | boolean | no | Ship a **custom settings page**: set `true` and provide a `client/settings.html` — TREK frames it as a card on the user's Settings → Plugins page, in the same opaque-origin sandbox + postMessage bridge as your widget (it can only reach your own routes via `trek:invoke`; persist through a route into `db:own`). For when declared `settings[]` fields aren't enough. Hosts that predate the flag ignore it. | | `capabilities.routeProfiles` | array | no | Up to **3** routing profiles `{ id, label, icon? }` for a plugin implementing the **`routeProvider`** hook — each becomes a selectable mode in the planner's route toggle next to Driving/Walking. `id` is lowercase `[a-z][a-z0-9-]` (≤ 24 chars, unique) and is what `getRoute` receives as `request.profile`; `label` required, ≤ 40 chars; `icon` an optional lucide name (a non-string icon is dropped, not rejected). **Requires the `hook:route-provider` permission** (validation fails without it). See [server-api.md](server-api.md). | | `capabilities.notificationChannel` | object | no | Declares an `integration` as a **notification delivery channel** — `{ title?: string, events: [...] }`. Each event must be one of the **10 plugin-deliverable** events (`trip_invite`, `booking_change`, `trip_reminder`, `todo_due`, `vacay_invite`, `collection_invite`, `photos_shared`, `collab_message`, `packing_tagged`, `plugin_notification`) — admin-scoped/in-app-only events are excluded. **Requires the `hook:notification-channel` permission** (validation fails without it) and a `hooks.notificationChannel` implementation; `create` ships a `notification-channel` template. See [server-api.md](server-api.md). | +| `capabilities.mcpTools` | array | no | Up to **8** MCP tools this plugin publishes on TREK's own MCP server (advertised to connected assistants as **`plugin__`**). Each entry: `{ name, description, title?, inputSchema?, annotations? }` — `name` lowercase `^[a-z0-9_]{1,48}$` (no dash, no dot; unique), `description` **required** (≤ 1024 after sanitising), `title` ≤ 80, `inputSchema` an optional JSON Schema whose root `type` (if present) is `"object"`, `annotations` the four MCP hints (`readOnlyHint`/`destructiveHint`/`idempotentHint`/`openWorldHint` — host-**clamped** against your grants, see [server-api.md](server-api.md)). **Requires the `mcp:tools` permission** (`validate` and install both fail without it) and a `hooks.mcpToolProvider` implementation whose `tools` array names the same tools — **only the intersection is advertised**, silently (see [server-api.md](server-api.md)). ⚠️ **The SDK's `validate` is weaker than the install gate here:** the host additionally enforces a schema **keyword allowlist** (`oneOf`/`anyOf`/`$ref`/`$defs`/unknown keywords are *rejected at install*, not dropped), an 8 KiB schema cap, depth ≤ 5, ≤ 64 properties total, enum ≤ 32 values (≤ 64 chars each), a `format` subset (`email`/`uuid`/`uri`/`date-time`/`date`), plain-identifier property names, and a ReDoS screen on `pattern` — so a green `validate` with an exotic schema can still fail install with `capabilities.mcpTools "": `. Keep schemas simple and flat. | **Declarative-only keys the installed-manifest parser does not consume:** `routes[]` (real routes come from the loaded `definePlugin` object) and @@ -93,12 +94,16 @@ Failures surface as `TREK_VERSION_INCOMPATIBLE` (range excludes this TREK) or ## Permissions catalog (complete) -All **63** grantable permissions. TREK hard-rejects any string not in this list at +All **64** grantable permissions. TREK hard-rejects any string not in this list at install (`unknown permission(s): …`), so this is also the full set `validate` — and, -since the registry vendored the same list, **registry CI** — accepts. +since the registry vendored the same list, **registry CI** — accepts. ⚠️ The +registry's copy is a **manually regenerated snapshot** of TREK's list, so a +freshly added permission can briefly be rejected by registry CI even though +`validate` and TREK itself accept it — if CI says `unknown permission(s)` for a +permission `validate` passes, the registry snapshot is behind, not you. `create`'s wizard offers **every one of them**, grouped into the 13 areas below so you -aren't scrolling a flat list of 63 (SDK ≥ 1.5.0 — **earlier versions offered only 18** +aren't scrolling a flat list of 64 (SDK ≥ 1.5.0 — **earlier versions offered only 18** of the then-58, silently omitting `jobs:run`, `events:subscribe` and most hooks; the validator always accepted them, the wizard just couldn't offer them): @@ -116,7 +121,7 @@ validator always accepted them, the wizard just couldn't offer them): | **hooks** | the 16 `hook:*` (see below) | | **background** | `jobs:run`, `events:subscribe` | | **realtime** | `ws:broadcast:trip`, `ws:broadcast:user` | -| **services** | `http:outbound`, `weather:read`, `rates:read`, `notify:send`, `ai:invoke`, `oauth:client`, `geolocation:read` | +| **services** | `http:outbound`, `weather:read`, `rates:read`, `notify:send`, `ai:invoke`, `oauth:client`, `geolocation:read`, `mcp:tools` | ⚠️ A `hook:*`, `events:subscribe` or `jobs:run` you **forget** is not an error — TREK simply **never calls that entry point**, silently. `trek-plugin dev` warns about it at @@ -154,7 +159,7 @@ load; nothing else will. See [testing.md](testing.md#what-dev-enforces-hooks-eve | `db:write:collab` | `ctx.collab.createNote` / `createPoll` / `votePoll` / `createMessage` | **Route handlers only.** Collab addon **+** trip access **+** **`collab_edit`**. | | `db:write:atlas` | `ctx.atlas.markCountry` / `unmarkCountry` / `markRegion` / `unmarkRegion` / `createBucketItem` / `deleteBucketItem` | Atlas addon. Affects the **acting user's own** atlas data. | | `db:write:vacay` | `ctx.vacay.toggleEntry(date)` / `toggleCompanyHoliday(date, note?)` | Vacay addon. Acting user's own entries. | -| `db:write:journal` | `ctx.journal.createEntry` / `updateEntry` / `deleteEntry` / `createJourney` / `deleteJourney` | Journey addon. Acting-user/contributor gated. | +| `db:write:journal` | `ctx.journal.createEntry` / `updateEntry` / `deleteEntry` / `createJourney` / `deleteJourney` / **`addEntryPhoto`** | Journey addon. Acting-user/contributor gated (entry writes need `editor`/`owner` on the journey; a `viewer` is refused). **Route-only** (userless → refused). `addEntryPhoto(entryId, {name, content_base64, caption?})` attaches a photo to an entry — **images only, no SVG** (`.jpg/.jpeg/.png/.gif/.webp/.avif/.heic/.heif`, plus the operator's allowed-file-types setting), 10 MB decoded / 14 MiB base64, `caption` ≤ 2000; the stored filename is the **host's** (`name` only supplies the extension). | | `db:write:collections` | `ctx.collections.create` / `update` / `savePlace` / `copyToTrip` / `deletePlace` | Collections addon. Acting-user scoped. | | `db:write:daynotes` | `ctx.daynotes.create` / `update` / `delete` | **Route handlers only.** Trip access **+** **`day_edit`** (daynote writes ride on `day_edit`). | | `db:write:tags` | `ctx.tags.create` / `update` / `delete` | The acting user's own tags. | @@ -186,16 +191,18 @@ load; nothing else will. See [testing.md](testing.md#what-dev-enforces-hooks-eve | `weather:read` | `ctx.weather.get` | **Broker.** Weather by coordinates (host-cached). Tenant-free. | | `jobs:run` | Declared cron `jobs[]` **and** `ctx.scheduler.set` / `cancel` | **Opt-in gate** for background execution. Covers both cron-scheduled declared jobs *and* the persistent `ctx.scheduler`. Callbacks run **userless** (trip reads refused) — see [server-api.md](server-api.md). | | `geolocation:read` | `window.trek.geolocation.get()` / `.watch(cb)` in the plugin's sandboxed frames | **Bridge-level** — unlocks no `ctx` RPC method. The HOST reads `navigator.geolocation` (the browser's own site prompt still applies) and posts plain position data into the frame; the sandbox itself never gains the API, and nothing is sent to the server. See [client-bridge.md](client-bridge.md). | +| `mcp:tools` | Dispatch of `hooks.mcpToolProvider.callTool` — publish MCP tools on TREK's MCP server (declared in `capabilities.mcpTools`) | **Hook-gate, not a DB scope** — the only hook permission not named `hook:*`. Unlocks **no** `ctx` method of its own: `callTool` runs **as the requesting MCP user** (route-like ctx — user-scoped namespaces work, membership-checked against *that* user), so what the tool can actually do is set by your *other* grants. Declaring `capabilities.mcpTools` without it fails `validate` and install; implementing the hook without it is silently never called (dev warns). See [server-api.md](server-api.md). | | `http:outbound` | Marker: plugin does outbound HTTP | Satisfies the "egress required" rule but grants **no host** by itself. | | `http:outbound:` | Opens `` in the runtime egress guard **and** the iframe CSP `connect-src` | This is what actually allows a request. | -> **63 grants, two special families.** The **broker +> **64 grants, two special families.** The **broker > permissions** (`ai:invoke` / `notify:send` / `oauth:client` / `rates:read` / > `weather:read` / `jobs:run`) are host services, not DB scopes — `rates`/ > `weather`/`ai` are tenant-free while `notify`/`oauth` are acting-user-scoped; > and the **provider hooks** let an `integration` inject native UI (map > markers, PDF sections, atlas layers, journal rows, trip-card badges, table -> columns) with no iframe of its own. +> columns) with no iframe of its own — or, via `mcp:tools`, publish **MCP +> tools** an assistant connected to TREK's MCP server can call. ### The egress trap (was the most common runtime bug — now a hard local error) @@ -241,13 +248,16 @@ loopback/private/link-local/metadata address (SSRF backstop). | `key` | **Required** identifier; entries with an empty key are dropped. | | `label` | Form label. | | `input_type` | **snake_case**: `text` (default), `password`, `number`, `select`, … Rendered by the host's settings form (Admin → Plugins for `instance` scope; the user's plugin settings for `user` scope). | -| `scope` | `instance` (default — set once by admin) or `user` (per-user). | -| `required` | boolean. | -| `secret` | boolean — encrypted at rest, decrypted only into server-side `ctx.config`, never sent to the iframe. | +| `scope` | `instance` (default — set once by an admin, edited under **Admin → Plugins → ⋯ → Instance settings**, the admin-only dialog since TREK 4.2.0: only fields the manifest declares are stored, secrets stay masked, and saving **re-spawns a running plugin** so `ctx.config` is re-read) or `user` (per-user, edited by each user under **Settings → Plugins**). | +| `required` | Enforced on both ends: the host's form refuses Save while the field is blank (naming it), and the host answers `400 { error: 'Missing required setting ""' }` if a save reaches it anyway. A `checkbox` is exempt (that would be consent, not a setting). A required `scope:'user'` field also gates whether a notification channel dispatches to that user (`isConfiguredFor`). Satisfied by a `default`. | +| `default` | string / number / boolean. The field's value wherever nobody set one: the form pre-fills it **and the runtime resolves it** — `ctx.config.` / `ctx.settings.get(key)` return it until someone saves something else — so a plugin with sensible defaults works before anyone opens the form (hosts that predate the attribute drop it silently — the parsed field simply has no default). Refused on a `secret` (the manifest is public), must be a boolean on a `checkbox`, and one of `options` when declared — `validate` errors on all three, the host drops the offending default at install. `trek-plugin dev` seeds `ctx.config` / `ctx.settings` from these too. | +| `secret` | boolean — encrypted at rest, decrypted only into server-side `ctx.config` / `ctx.settings.get()`, masked to the browser, never sent to the iframe. May not carry a `default`. | | `placeholder`, `hint` | Form hints. | | `options` | `[{ "value": …, "label": … }]` for `select`. | | `oauth` | `{ "initPath": …, "callbackPath": … }` — **descriptive metadata only**; the host doesn't mount or drive it. For a **host-brokered** flow, don't use this: declare the five magic `scope:'instance'` settings instead (`oauth_authorize_url`, `oauth_token_url`, `oauth_scopes`, `oauth_client_id`, `oauth_client_secret`) and read tokens via `ctx.oauth.getAccessToken()` — see [server-api.md](server-api.md). Self-managed flows use your own routes (an `auth:false` callback, relative in-app redirect). | +Any other attribute is **silently dropped at install** (the host stores only the eleven above: `key, label, input_type, placeholder, hint, required, secret, scope, options, oauth, default`). `validate`/`status` **warn** on one — check `manifest.settings-known-keys` — so a typo like `"defalt"` or an attribute from a newer SDK no longer quietly does nothing. + Resolved **instance-scoped** values arrive in `ctx.config` — decrypted and **frozen at activation** (not per-user, not hot-reloaded; a change needs deactivate→activate). `scope: user` settings are **not** surfaced to server @@ -314,7 +324,7 @@ deactivate→activate). `scope: user` settings are **not** surfaced to server "license": "MIT", "icon": "Luggage", "type": "widget", - "trek": ">=3.4.0 <4.0.0", + "trek": ">=3.2.0 <4.0.0", "nativeModules": false, "permissions": [ "db:read:trips" diff --git a/skills/trek-plugin-dev/references/publishing.md b/skills/trek-plugin-dev/references/publishing.md index efff7d5..f7e3fc7 100644 --- a/skills/trek-plugin-dev/references/publishing.md +++ b/skills/trek-plugin-dev/references/publishing.md @@ -29,6 +29,13 @@ with the packed `plugin.zip` attached as a release asset. Use the uploaded asset, never GitHub's auto-generated source archives (wrong layout, unstable bytes). The registry pins the asset's **sha256** — released bytes are immutable in practice; fix things in a new version. +Since TREK 4.1.0 an admin can install or switch to **any published version** of your +plugin straight from the registry entry (**⋯ → Change version**; a deliberate pick that +is not the newest compatible one pauses that instance's updates until they resume them), +so every version you have ever published stays live and its released asset must remain +**byte-identical forever** — never edit or re-upload the asset of a released version; +ship a new version instead. `unrelease` enforces the same rule from the other side and +refuses a version the registry has published (see [cli.md](cli.md)). > **Remote-only tag trap:** if you let `gh release create vX.Y.Z …` create the > tag (instead of tagging locally and pushing), the tag exists **only on @@ -150,7 +157,7 @@ runs schema/format checks only.) | JSON schema | Entry violates `plugin-entry.schema.json` (incl. unknown keys) | Regenerate with `trek-plugin entry` | | id ↔ filename | `id` ≠ filename or not a valid slug — or a **reserved id** (`registry`, `install`, `rescan`; they collide with admin API routes, and TREK's install loader refuses them too) | Rename file / fix id | | Version ordering | `versions[]` is not sorted **newest-first** by semver (`versions[] must be sorted newest-first (found before )`) — the workflow and the gates grade `versions[0]` as the newly published version, so an oldest-first entry would have the wrong commit reviewed | Reorder; `entry --merge` keeps the order for you | -| Permission allowlist | Any manifest permission not on TREK's known-permission list — and not a valid `http:outbound:` — fails with the server's own message, `unknown permission(s): …` (the list is vendored from TREK, so a typo'd permission dies in CI instead of at install on every instance) | Fix the permission string (`validate` catches it offline first) | +| Permission allowlist | Any manifest permission not on TREK's known-permission list — and not a valid `http:outbound:` — fails with the server's own message, `unknown permission(s): …` (the list is a **manually regenerated snapshot** vendored from TREK, so a typo'd permission dies in CI instead of at install on every instance — but a *brand-new* TREK permission can also fail here until a maintainer refreshes the snapshot) | Fix the permission string (`validate` catches a typo offline first); a legitimate new permission needs the registry's snapshot regenerated — ask a maintainer | | Owner binding | Existing id repointed to a different owner (`OWNERS.json`: id → `{ boundOwner, repo }`, stamped on first merge) | Only the bound owner updates it; a genuine transfer needs a maintainer to apply the **`allow-owner-change`** label (see [Maintainer overrides](#maintainer-overrides)) | | Homoglyph / mixed-script | `name` mixes Latin `[A-Za-z]` **with** Cyrillic (U+0400–04FF) or Greek (U+0370–03FF, the full block — Latin+Greek look-alikes like Α/Ο/α **are** caught). Only fires on a *mix*; an all-Cyrillic name is not caught | Use plain ASCII | | Release tag | `gitTag` doesn't exist or doesn't resolve to `commitSha` | Push the tag; re-run `entry` | @@ -181,7 +188,8 @@ applying a **label** to the PR, which re-runs validation: | Label | Lifts | |----------------------|-------------------------------------------------------------------------| | `allow-key-change` | `authorPublicKey` differs from the entry on the PR base — see "Rotating the key" below for the PR the SDK builds | -| `allow-owner-change` | The entry's repo owner differs from the `id`'s binding in `OWNERS.json` | +| `allow-owner-change` | The entry's repo owner differs from the `id`'s binding in `OWNERS.json` (also required for any PR that edits `OWNERS.json` itself) | +| `allow-removal` | The PR **deletes or renames** an entry file under `registry/plugins/` — removals are refused without it, because installed instances still resolve the entry | It is a **label**, not a magic string in a commit message or a file in the branch, **on purpose**: labelling needs triage/write permission on the registry, which a fork @@ -194,17 +202,17 @@ that already has the plugin, so merging one is simply a broken entry. ### README gates (`check-readme.mjs`, fetched from your repo at the pinned commit) -The SDK ports these **line for line** and runs them offline in `status`/`validate` -against your working tree, and again in `preflight` against the README **at the -pinned commit**. The two disagree exactly when you wrote the README and didn't -commit it — a green tree and a red tag — which is the failure the network pass -exists to catch. +The SDK ports these and runs them offline in `status`/`validate` against your +working tree, and again in `preflight` against the README **at the pinned +commit**. The two disagree exactly when you wrote the README and didn't commit +it — a green tree and a red tag — which is the failure the network pass exists +to catch. | Gate | Requirement | |-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Exists | `README.md` at the repo root | | Sections | Tokens **What it does**, **Screenshots**, **Permissions**, **Setup** — each matched **case-insensitively as a substring of any heading, level 1–6** (so `## Setup instructions` or `# Screenshots & demo` count) | -| Screenshot | At least one image whose URL **resolves via a live `GET`** (first 2 KB) with HTTP `Content-Type: image/*`. **`data:` URIs are ignored** (you need a committed file, e.g. `docs/screenshot.png`); `github.com/.../blob/...` links are auto-rewritten to `raw`; relative paths resolve against the pinned commit | +| Screenshot | **Exactly `docs/screenshot.png`** must exist **at the pinned commit** and resolve via a live ranged `GET` with HTTP `Content-Type: image/*` — that precise path is what the store card loads (`raw…//docs/screenshot.png`), so a README that only links *other* image names fails this gate even though it "has screenshots". `trek-plugin shot` writes exactly this file; commit it. The SDK's `validate` (on disk) and `preflight` (at the pinned commit) check the same exact path — **on SDK builds before the 1.7.0 fix they instead accepted *any* resolving README image**, so a green `validate` there could still fail CI; upgrade | | Real prose | ≥ **400 characters** after stripping headings/code/images/tables/links/HTML comments — a template stub fails | | Placeholders | No leftover scaffold placeholders: `{{…}}`, `REPLACE_ME`, template prose starting `Describe what/the …`, or a literal `your-name/trek-plugin` path | | Permission parity | **Every permission string in the manifest appears (case-insensitive substring) in the README** — a plain substring test, not proof of a real explanation, but explain each anyway | @@ -222,9 +230,10 @@ chromium`. An `integration` has no UI to render, so `shot` refuses — screensho TREK surface your plugin *changes* instead. Everything below still applies to what makes a *good* shot. -CI enforces **no dimensions** — `check-readme.mjs` only checks that an image -reference in the README resolves to a real image at the pinned commit. Size it -for how the store renders it. The client's `Screenshot` component +CI enforces **no dimensions** — `check-readme.mjs` checks that +**`docs/screenshot.png` itself** resolves to a real image at the pinned commit +(that exact path is the store cover; other image names don't satisfy the gate). +Size it for how the store renders it. The client's `Screenshot` component (`AdminPluginsPanel.tsx`) uses `object-cover` (scales to fill, **crops**, centres) in two different boxes: diff --git a/skills/trek-plugin-dev/references/server-api.md b/skills/trek-plugin-dev/references/server-api.md index b11160e..0b86e70 100644 --- a/skills/trek-plugin-dev/references/server-api.md +++ b/skills/trek-plugin-dev/references/server-api.md @@ -94,8 +94,13 @@ export interface PluginDefinition { journalEntryProvider?: JournalEntryProvider; // hook:journal-entry-provider tripCardProvider?: TripCardProvider; // hook:trip-card-provider notificationChannel?: { send(msg, config, ctx); test?(config, ctx) }; // hook:notification-channel — USERLESS; config = recipient's settings + mcpToolProvider?: { // mcp:tools — publish MCP tools (see "MCP tools" below) + tools: string[]; // plugin-local names; only ∩ capabilities.mcpTools is advertised + callTool(call: { name: string; args: unknown }, ctx): Promise | unknown; // runs AS the requesting MCP user + }; }; - actions?: Record Promise<{ ok: boolean; message?: string }>>; // manifest `actions` buttons — user-bound + actions?: Record Promise<{ ok: boolean; message?: string }>>; // manifest `actions` — bound to whoever CLICKED + // a user for scope:'user', the clicking admin for scope:'instance'; ctx.config + their own ctx.settings either way } // core-event subscription — see "Event subscriptions" below @@ -138,14 +143,14 @@ export interface PluginResponse { export interface PluginContext { readonly id: string; - readonly config: Readonly>; // instance-scoped, frozen at activation + readonly config: Readonly>; // instance-scoped, frozen at activation; an unset field = its manifest `default` db: { query(sql: string, ...args: unknown[]): Promise; exec(sql: string, ...args: unknown[]): Promise<{ changes: number }>; migrate(id: string, sql: string): Promise<{ applied: boolean }>; tx(ops: { sql: string; args?: unknown[] }[]): Promise<({ rows: unknown[] } | { changes: number })[]>; // atomic batch on your OWN db, ≤100 ops }; - settings: { get(key: string): Promise }; // acting user's own scope:'user' value; undefined if unset/userless + settings: { get(key: string): Promise }; // acting user's own scope:'user' value, else its manifest `default`; undefined if neither / userless // reads return typed entities (Trip/Place/Day/Reservation/PackingItem/ // TripFile/BudgetItem/Assignment/User) — but only `id` is guaranteed; every shape // keeps an index signature and mirrors the raw DB row, so treat other fields as optional. @@ -199,7 +204,11 @@ export interface PluginContext { }; // db:write:collab + collab_edit; Collab addon journal: { listMine(); getEntries(journeyId); // db:read:journal - createEntry(journeyId, input); updateEntry(id, input); deleteEntry(id); createJourney(input); deleteJourney(id) + createEntry(journeyId, input); updateEntry(id, input); deleteEntry(id); createJourney(input); deleteJourney(id); + // attach a photo to an entry — images only (no SVG), 10 MB decoded / 14 MiB base64, + // `name` supplies only the EXTENSION (stored filename is the host's), caption ≤ 2000. + // Needs an acting user with edit rights on the journey (viewer → RESOURCE_FORBIDDEN). + addEntryPhoto(entryId: number, input: { name: string; content_base64: string; caption?: string }): Promise }; // db:write:journal; Journey addon atlas: { visited(); @@ -291,7 +300,7 @@ export interface PluginContext { | `ctx.places.*` / `ctx.days.*` / `ctx.itinerary.*` | **Route handlers only.** Create/update/delete planner places & days; assign/unassign places to days. Trip access **+** the matching edit permission (`place_edit` / `day_edit` / `day_edit`); the day & place must belong to the trip. zod-validated (→ `BAD_PARAMS`); each broadcasts the app's real event (`place:*` / `day:*` / `assignment:*`) and is audited. | `db:write:places` / `db:write:days` / `db:write:itinerary` | | `ctx.meta.*` | **Route handlers only.** The plugin's **own** namespaced KV store on a `trip`/`place`/`day` — **and `reservation`/`accommodation`** (`get`/`set`/`list`/`delete`). Reads need trip access; writes need the entity's edit permission. Per-plugin namespace; quotas key ≤ 256 chars / value ≤ 64 KB JSON / ≤ 100 keys per entity (over → `BAD_PARAMS`). Enrich core entities without forking the schema. ⚠️ **Can be `undefined` on real hosts too** — never hard-depend; see the optional-namespaces note below. | `db:meta` | | `ctx.db.tx(ops)` | Run **≤ 100** statements atomically on your **own** db (all commit or roll back). Each op `{sql, args?}`; reads see the batch's earlier writes; a read op returns `{rows}`, a write `{changes}`. Same refused-SQL/length caps as `ctx.db`. | `db:own` | -| `ctx.settings.get(key)` | The **acting user's own** decrypted value for a `scope:'user'` field. `undefined` for unset or a **userless** (`onLoad`/job/scheduler) context — fall back to `ctx.config`. Wired unconditionally (no grant). | — | +| `ctx.settings.get(key)` | The **acting user's own** decrypted value for a `scope:'user'` field, or the field's manifest `default` when they never set one. `undefined` for a field with neither, and in a **userless** (`onLoad`/job/scheduler) context — fall back to `ctx.config`. Wired unconditionally (no grant). | — | | `ctx.reservations.*` / `ctx.accommodations.*` | **Route handlers only.** `reservations.listMine()` (read, `db:read:trips`); create/update/delete need `reservation_edit` and persist `endpoints` (omit=keep, `[]`=delete all, array=replace). `accommodations.*` need `day_edit`; creating one **auto-creates the partner hotel reservation**. | `db:write:reservations` / `db:write:accommodations` | | `ctx.files.getContent` / writes | **Route handlers only.** `getContent(tripId, fileId)` → `{name, mimetype, size, content_base64}` (10 MB cap, trashed refused) needs the **distinct** `db:read:files:content` (listing ≠ reading bytes). `create`/`createLink`/`update`/`softDelete` need `db:write:files` **+** `file_upload`/`file_edit`/`file_delete`; blocked extensions refused. | `db:read:files:content` / `db:write:files` | | `ctx.packing` writes + bags | **Route handlers only.** `create`/`update`/`delete` and **all bag methods** (`listBags`/`createBag`/`updateBag`/`deleteBag`/`setBagMembers`) need `packing_edit`. ⚠️ **`db:read:packing` unlocks only `list`; every bag method — incl. `listBags` — needs `db:write:packing`.** `create` shape: `{name, category?, checked?, is_private?, visibility?, recipient_ids?}`. | `db:write:packing` | @@ -302,7 +311,7 @@ export interface PluginContext { | `ctx.plugins.call` / `ctx.events.emit` | Inter-plugin: `plugins.call(pluginId, fn, args)` invokes a **dependency's** `capabilities.provides` export (runs as the current user); `events.emit(name, payload)` publishes to dependents that subscribed (`name` must be in `capabilities.emits`). Authorized via declared dependency edges. | — (declared deps) | | `ctx.ws.broadcastToTrip` | **Route handlers only.** The acting user must be a member of the target trip. Event to the **core TREK app's** trip-room clients as `plugin::`. | `ws:broadcast:trip` | | `ctx.ws.broadcastToUser` | **Route handlers only.** Target **must equal the acting user** (`userId === req.user.id`) — you can only push to the acting user's **own** connections. Event to core clients as `{ type: 'plugin:', event, ...data }`. | `ws:broadcast:user` | -| `ctx.config` | **Instance-scoped** settings, decrypted and **frozen at activation** (`secret:true` arrive decrypted, server-side only). Not per-user; not hot-reloaded — change requires deactivate→activate. `scope:user` settings are **not** surfaced here — read them via `ctx.settings.get(key)` (row above). | — | +| `ctx.config` | **Instance-scoped** settings, decrypted and **frozen at activation** (`secret:true` arrive decrypted, server-side only); a field nobody set resolves to its manifest `default`, so only a field with neither is absent. Not per-user; not hot-reloaded — an admin save re-spawns the plugin. `scope:user` settings are **not** surfaced here — read them via `ctx.settings.get(key)` (row above). | — | | `ctx.log` | `info`/`warn`/`error` → the plugin's error log in Admin → Plugins. | — | | `ctx.id` | Your plugin id (also in `process.env.TREK_PLUGIN_ID`). | — | @@ -408,7 +417,10 @@ permission and called with the plugin `ctx`: panel. Additive & **fail-safe** — a throw/timeout is skipped, never fatal. - **`warningProvider`** (`hook:trip-warning-provider`) — `getWarnings(tripId, ctx): Promise<{ level: 'info'|'warning'|'error'; message: string; dayId?: number; placeId?: number }[]>`. - TREK surfaces the returned warnings in the trip planner. + TREK surfaces the returned warnings in the trip planner — **and to MCP clients** + via TREK's own `get_trip_warnings` tool (needs no `mcp:tools`; messages + emoji-stripped, ≤ 300 chars, ≤ 20 warnings per provider, 5 s per-provider + budget, a slow/throwing provider contributes `[]`). **Ten more declarative contribution hooks** (each `hook:*`-gated, its own consuming controller, host-sanitized — emoji stripped from any text — and @@ -469,12 +481,95 @@ Implement it if your plugin stores personal data (GDPR erasure/portability). The `hook:*` grant is **enforced at dispatch**: core only wires a provider that is active, implements the hook in code, **and** holds the matching `hook:*` -permission. If a provider "never fires", check the manifest `permissions` first. +permission (for `mcpToolProvider` the grant is **`mcp:tools`** — the one hook +permission not named `hook:*`). If a provider "never fires", check the manifest +`permissions` first. Hooks feed **core UI** without your own iframe; the scoped **widget slots** (`place-detail`/`day-detail`/`reservation-detail`) are the other route (your own sandboxed panel — see [client-bridge.md](client-bridge.md)). +## MCP tools — `mcpToolProvider` (`mcp:tools`) + +A plugin can publish tools on **TREK's own MCP server**, so an assistant a user +connected to TREK can call into the plugin. Three parts, all required: + +1. **`capabilities.mcpTools`** in the manifest — the signed, consented + declaration (name/description/schema/annotations, ≤ 8 tools; full shape and + limits in [manifest.md](manifest.md)). +2. **The `mcp:tools` permission** — the only hook grant not named `hook:*`. + Declaring the capability without it fails `validate` *and* install. +3. **`hooks.mcpToolProvider`** on the definition: + +```js +hooks: { + mcpToolProvider: { + tools: ['pin_note', 'list_notes'], // plugin-local names + async callTool({ name, args }, ctx) { // ONE function for all tools + if (name === 'pin_note') { /* … */ } + return { pinned: true } // any JSON value; host builds the MCP envelope + }, + }, +}, +``` + +Semantics — each of these bites: + +- **Only the intersection of `capabilities.mcpTools[].name` and the code's + `tools` array is advertised — silently.** Declared-but-unimplemented is + dropped without a warning; implemented-but-undeclared is never advertised + (the manifest is what the admin consented to, the `tools` array is not). So + **a tool that vanishes from `tools/list` is almost always a name mismatch + between the two lists** — nothing anywhere will tell you. Keep them + identical. +- **Advertised names are prefixed: `plugin__`.** `callTool` receives + the **local** name, without the prefix. A local name colliding with a TREK + built-in tool is skipped with a host-side warn. Global cap: **32 plugin tools + across all plugins** (over → dropped with a host log). +- **`callTool` runs as the requesting MCP user — route-like, not userless.** + The host binds the acting user from the MCP session (the plugin can never + name one); user-scoped `ctx.*` namespaces work exactly as in a route handler, + membership-checked against *that* user. `mcp:tools` itself unlocks **no** ctx + method — the tool can only do what your other grants allow. Timeout: **15 s** + (the longest of any hook). Demo users are refused before your code runs. +- **Arguments are validated against your declared `inputSchema` before + `callTool` runs** — the host builds a real validator from the schema it + advertises. `required` is enforced; `additionalProperties: false` **rejects** + extras (otherwise they pass through untouched); `enum`/`const`/ranges/ + `pattern`/`format` are enforced. **`default` is advertisement only — the host + does NOT inject it; apply defaults yourself.** +- **A throw is a tool error, not a crash:** the assistant sees + `Plugin "" could not run "": ` (sanitised, ≤ 300 chars). +- **Results are capped:** 64 KiB across the whole result, ≤ 32 content blocks — + over-budget output is truncated with a visible `[truncated: …]` block. Return + a plain JSON value (the host serialises it, bounded) or a pre-shaped + `{ content: [{type:'text',text}], isError? }`. +- **Every string the assistant reads is sanitised** (tool titles/descriptions, + schema descriptions, enum members, your error text): control characters and + bidi overrides stripped, **newlines collapsed to spaces** (so plugin text + can't fake a markdown heading or system prompt), emoji stripped, length-capped. +- **Annotations are clamped against your grants:** `readOnlyHint: true` is + *lowered* if the plugin holds any write-ish grant (anything beyond + `db:read:*`/`*:read`/`db:own`/`hook:*`/`mcp:tools`/`events:subscribe`); + `openWorldHint` is forced **true** if you hold any `http:outbound*`; + `destructiveHint` defaults **true** unless read-only or explicitly `false`. +- **Caller side:** the MCP client's token needs the **`plugins:use`** OAuth + scope — deliberately coarse (not per-plugin/per-tool) and **opt-in only** + (never in the default scope set; static `trek_` tokens and web-session JWTs + have full access). The scope grants no data access of its own — the plugin + acts with the grants the **admin** consented to, which is the real boundary. +- **The tool surface is frozen per MCP session** (at `initialize`). Any admin + lifecycle change — activate/deactivate/update/retrust/uninstall, or an addon + flip that affects a plugin — **closes every live MCP session**; clients pick + up the new surface on re-initialize. The dev-link **Reload does *not*** + invalidate sessions — reconnect your MCP client yourself after changing + `tools`. + +Admin → Plugins shows the tools a plugin will advertise, and `trek-plugin dev` +warns at load about a `mcpToolProvider` without the `mcp:tools` grant (and 403s +`/__dev/fire/hook/mcpToolProvider/callTool`), like any other hook — see +[testing.md](testing.md). + ## Event subscriptions This is the **working reactive mechanism** — the one `jobs` never became. Declare diff --git a/skills/trek-plugin-dev/references/testing.md b/skills/trek-plugin-dev/references/testing.md index bef1ee2..1733cd0 100644 --- a/skills/trek-plugin-dev/references/testing.md +++ b/skills/trek-plugin-dev/references/testing.md @@ -71,7 +71,10 @@ Fidelity details: `can`/`canEdit*` rights, addon-enable toggles, …) — see the `createMockHost` options list below. It also honours **`actingUserId`**, which dev **defaults to `1`** when omitted, so the documented one-arg user-bound calls work on a fresh - scaffold. + scaffold. `dev` also seeds `config` and `userSettings` with the manifest's + settings-field **`default`s** (a fixture value wins), mirroring the host's + `stored ?? default` rule — so a plugin that relies on its defaults reads them in + dev exactly as it will in production. ```json { @@ -118,7 +121,7 @@ Dev refuses the same four, **loudly** (SDK ≥ 1.5.0): | Permission | Production when it's MISSING | Dev | |---------------------------------------------------|-----------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------| -| `hook:` (e.g. `hook:trip-warning-provider`) | The supervisor **silently skips you** — the hook is never invoked | ⚠️ warns at load; `/__dev/fire/hook//` → **403** | +| `hook:` (e.g. `hook:trip-warning-provider`) — for `mcpToolProvider` the grant is **`mcp:tools`** | The supervisor **silently skips you** — the hook is never invoked | ⚠️ warns at load; `/__dev/fire/hook//` → **403** | | `hook:user-data` | `deleteUserData` / `exportUserData` are never called | ⚠️ warns at load; firing them → **403** | | `events:subscribe` | Core events are **never delivered** to you | ⚠️ warns at load; `/__dev/fire/event/` → **403** | | `jobs:run` | **No job is scheduled at all**; `ctx.scheduler.set` is denied, so `scheduled` never fires either | ⚠️ warns at load; firing a job/scheduled → **403** | @@ -368,10 +371,11 @@ when a grant is missing. ```ts export interface MockHostOptions { grants?: string[]; // permissions to grant the ctx - config?: Record; // becomes ctx.config (frozen) + config?: Record; // becomes ctx.config (frozen; NOT defaulted — see settingDefaults below) actingUserId?: number; // host-bound user — required for any costs.* budgetAddonEnabled?: boolean; // default true; false → RESOURCE_FORBIDDEN - declaredActions?; channelEvents?; // manifest `actions` / notificationChannel events for the driver + declaredActions?: Array; // manifest `actions` for the driver + channelEvents?; // notificationChannel events for the driver // addon-enable flags (each defaults true; false → RESOURCE_FORBIDDEN): journeyAddonEnabled?; atlasAddonEnabled?; vacayAddonEnabled?; collectionsAddonEnabled?; collabAddonEnabled?: boolean; // inter-plugin + per-user + broker fixtures: @@ -439,6 +443,7 @@ await drv.deleteUserData(42); await drv.exportUserData(42) // userless GDPR await drv.hook('placeDetailProvider', 'getDetails', placeId) // user-bound await drv.action('test_connection') // manifest `actions` button, user-bound +await drv.action('purge_cache') // scope:'instance' in the manifest — admin-bound in the host; the mock gives both scopes the acting-user ctx await drv.channel.send(payload); await drv.channel.test() // notification channel ``` @@ -450,7 +455,7 @@ the host do — so `grants` must include them or the driver throws `PermissionDe |------------------------------------------------|---------------------------------| | `job()` · `scheduled()` | `jobs:run` | | `event()` | `events:subscribe` | -| `hook(name, …)` · `channel.send()` / `.test()` | that hook's `hook:*` permission | +| `hook(name, …)` · `channel.send()` / `.test()` | that hook's `hook:*` permission (`mcpToolProvider` → `mcp:tools`) | | `deleteUserData()` · `exportUserData()` | `hook:user-data` | This is deliberate: a unit test that fires a hook you never declared would pass while the @@ -541,12 +546,31 @@ Notes: the owner. Set `canEditPlaces`/`canEditDays`/`canEditTrip` or a `can` entry (`reservation_edit`/`packing_edit`/`collab_edit`/`file_*`) to `false` to test the permission-denied write paths. +- **MCP tools:** `run(def).hook('mcpToolProvider', 'callTool', { name: 'x', args: {…} })` + fires your tool handler user-bound and throws `PermissionDenied` without the + `mcp:tools` grant — but the mock does **not** check the call against + `capabilities.mcpTools`, validate the args against your `inputSchema`, or model + the host's manifest∩implementation intersection. So the classic failure — a + name mismatch between `capabilities.mcpTools[].name` and the code's `tools` + array, which makes the tool **silently unadvertised** in production — has **no + local check anywhere**; eyeball the two lists, or verify on a real TREK + (see [server-api.md](server-api.md)). +- **`journal.addEntryPhoto` is modelled** like the host: needs + `db:write:journal` + an acting user + the Journey addon; unknown entry → + `RESOURCE_FORBIDDEN`; the image extension allowlist (no SVG) and the 14 MiB + base64 cap are enforced. Attached photos aren't exposed on the mock — + assert on the call's return value. - **addon-off pattern generalises:** each of `budgetAddonEnabled` / `journeyAddonEnabled` / `atlasAddonEnabled` / `vacayAddonEnabled` / `collectionsAddonEnabled` / `collabAddonEnabled` (all default true) → set `false` to prove your plugin degrades when the addon is disabled (`RESOURCE_FORBIDDEN`). - Mock ctx id is `mock-plugin`; `config` is frozen like the real one. +- **`createMockHost` applies no manifest `default`s** — only `dev` does that for + you. To mirror the host's `stored ?? default` rule in a unit test, spread the + package-root export `settingDefaults(manifest, scope)` under your own fixtures: + `config: { ...settingDefaults(manifest, 'instance'), ...yours }` and + `userSettings: { ...settingDefaults(manifest, 'user'), ...yours }`. - Differences vs the real host worth knowing: the mock's `trips.getById` honors the `asUserId` argument for membership checks (that's the point of the fixture `members`), while the **real host ignores `asUserId`** and binds