Files
TREK/wiki/Plugin-Development.md
T
Renzo Beux c89d2bf3a1 Add a dayTintProvider plugin hook
Lets a plugin colour-code days in the Plan sidebar. trip-segments can
already announce "Kanazawa begins" in the day plan, but it can't make
day 12 look like it belongs to Kanazawa.

dayScheduleProvider can't cover this: it renders a fixed row, and it's
capped at 60 items per provider, so one item per day would tint the
first 60 days of a six-month trip and silently stop. This hook is
bounded by day count instead.

GET /api/day-tints/:tripId returns per-day tones for three regions of a
day card - the number badge (also the mobile day chip), the header row,
and the expanded activity list. `tone` is a shorthand filling every
region a contribution doesn't name; unnamed regions render as they do
today. Tones only, never hex, so the host can pick the alpha per theme
and per region.

One contribution per day, resolved whole: first entry wins within a
provider, first granted provider wins across plugins.
2026-08-05 15:28:15 +02:00

1308 lines
88 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plugin Development
Build a plugin with the `trek-plugin-sdk` package. A plugin is a directory with a
manifest (`trek-plugin.json`), a built server entry, and — for page/widget
plugins — a static client bundle. TREK runs your server code in an **isolated
child process** and reaches it only over RPC; the browser part runs in a
**sandboxed, opaque-origin iframe**. There is no other way in or out.
## Before you start
Two resources sit alongside this page:
- **[Plugin-Skill](https://github.com/liketrek/Plugin-Skill)** — an agent skill
that teaches Claude Code and other SKILL.md-compatible coding agents how to
build, test and publish a TREK plugin. It covers the same ground as this page,
but the agent reads it instead of you. Add it to the repo you build your plugin
in and it loads automatically whenever the task touches TREK plugins.
- **[TREK-Plugins](https://github.com/liketrek/TREK-Plugins)** — the community
registry. It is a static index with no server and no account: you list a plugin
by opening a pull request that adds one JSON file. Your plugin's code stays in
your own repository; the registry only points at it. See
[Plugin-Publishing](Plugin-Publishing) for the submission flow and the CI gates.
Neither is required. You can build, install and run a plugin without touching
either — the registry only matters when you want other people's TREK instances
to find it.
## Scaffold
```bash
npx trek-plugin-sdk create # interactive wizard
npx trek-plugin-sdk create my-plugin --type integration|page|widget|trip-page # or direct
cd my-plugin
```
The wizard (run `create` with no name) asks for the id, type, author, icon (checked
against lucide — a typo there is invisible locally but rejected by the registry),
permissions, egress hosts and required addons; the direct form takes them as flags.
This emits:
```
my-plugin/
trek-plugin.json # manifest — including `icon`, and a widget's `slot`
package.json # CommonJS marker + the SDK as a devDependency
server/index.js # your plugin code (built, plain JS)
client/index.html # native UI via the design kit (page / widget / trip-page only)
README.md # fill this in — the registry requires four sections and a screenshot
docs/ # where docs/screenshot.png goes; `trek-plugin shot` writes it
.gitattributes # LF everywhere, so the packed artifact's sha256 is reproducible
```
The scaffold declares `"trek": ">=3.4.0 <4.0.0"` — the `ctx` namespaces it is written
against only exist from 3.4.0, and claiming an older floor would let the plugin install
on a host where they are `undefined`.
What you get **runs and packs immediately**, but it does **not** pass `validate` yet:
the README is still a template and there is no screenshot. That is deliberate — those
are yours to write — and `trek-plugin status` says exactly what is missing.
## Run it locally with hot reload
```bash
npx trek-plugin-sdk dev # http://localhost:4317
```
`dev` works straight after `create` — no `npm install` needed, because it
injects `require('trek-plugin-sdk')` from the CLI itself, exactly like TREK
injects it in production. It loads your `server/index.js` through the same
`definePlugin` contract the host uses and gives you a **real request loop
without a full TREK**: a dashboard
listing your routes, the routes served under `/api/<path>`, your page/widget UI
at `/ui`, a **themed host preview at `/preview`** (a real sandboxed frame with a
theme/accent/appearance toggle, `trek.invoke()` proxied to your routes), and a reload
on every save. The injected `ctx` is the **full surface** — every capability (`costs`, `packing`,
`files`, `notify`, `ai`, `settings`, `scheduler`, `meta`, `oauth`, `weather`, `rates`,
`journal`, `db.tx`, …), not just routes + `db:own` — and **enforces exactly the
permissions your manifest grants**: an ungranted call throws `PERMISSION_DENIED`, so
you catch a missing grant here rather than after install. `db:own` is backed by a real
SQLite file (`.trek-dev/db.sqlite`) when the runtime has `node:sqlite`; every other
capability is served from your fixtures with the same rules as production.
- Hit a route as an unauthenticated request with `?_anon=1` (an `auth: true`
route then returns 401, mirroring the host).
- Feed the fixtures by dropping a `dev-fixtures.json` next to the manifest — it takes
the **same shape as `createMockHost` options**, so you can seed trips, users, costs,
settings, weather, canned AI results, etc.: `{ "actingUserId": 1, "trips": { "1": {
"members": [1], "data": { … }, "costs": [ … ] } }, "users": {} }`.
- Fire a **non-route** entry point with `GET /__dev/fire/<kind>[/<name>][/<fn>]` (a JSON
body or query params become the payload/args): `/__dev/fire/job/refresh`,
`/__dev/fire/scheduled/daily`, `/__dev/fire/event/place:created`,
`/__dev/fire/hook/tripCardProvider/getCards`, `/__dev/fire/deleteUserData?userId=1` —
so jobs, scheduled timers, event subscriptions and provider hooks are all testable in
the same hot-reload loop as your routes.
### Test against a real instance's data (dev-link)
`dev` above is fast but its host data is **synthetic** (fixtures / a scratch
`db:own`). When you need your plugin to run against **real** trips, places,
reservations, costs and real membership/permissions, link it into a running TREK
instance instead of packing + uploading it every time.
On the **server** (a local `trek-dev` or a dev instance — **never production**), set:
```bash
TREK_PLUGINS_DEV_LINK=1
```
With the flag set, the **Admin → Plugins** tab shows a **“Link a local plugin”**
field. Paste the absolute path to your **built** plugin directory (the one holding
`trek-plugin.json` + `server/index.js` — build first, the loader runs the compiled
artifact, not TS source) and hit **Link**. The plugin is symlinked into the plugins
volume and registered INACTIVE, and shows a **Dev-link** badge in the list.
Prefer scripting? The same thing over HTTP:
```bash
curl -XPOST /api/admin/plugins/link -H 'content-type: application/json' \
-d '{ "path": "/abs/path/to/your/plugin" }'
```
Activate it in the admin UI and consent to its permissions as usual. It now runs
through the **same capability RPC host** as any installed plugin: real,
membership-gated data, the acting user resolved host-side, no impersonation — code
origin never touches the security gate.
**Hot-reload:** rebuild your plugin (e.g. `tsc --watch` emitting `server/index.js`)
and TREK re-forks it automatically (a file-watch on the linked dir). To force it,
`POST /api/admin/plugins/:id/reload`, or just hit **Restart** in the admin UI —
both re-fork the child, picking up the new code (a rebuilt manifest that widened
permissions still requires explicit re-consent).
> Dev-link is gated behind `TREK_PLUGINS_DEV_LINK` on top of admin + the plugin
> kill-switch, and is **off by default**. It loads unsigned local code that mutates
> live between restarts, and under `npm run dev` the OS permission jail is off — so
> only ever enable it on a machine you control, pointed at a dev instance.
## The plugin types
- **integration** — background logic (jobs, routes) with no UI of its own. Most
provider hooks are **live** (placeDetailProvider, warningProvider,
tableContributor, mapMarkerProvider, mapLayerProvider, pdfSectionProvider,
atlasLayerProvider, journalEntryProvider); only `photoProvider` / `calendarSource` are declared in the
SDK but **not yet wired into the host** — see [Provider hooks](#provider-hooks).
- **page** — adds a nav entry that opens a full-page sandboxed iframe.
- **widget** — adds a card to the dashboard (`sidebar` slot), a hero-bar overlay
(`hero` slot), a panel inside the trip planner's **place-detail** view
(`place-detail` slot — the frame also receives the open `placeId` in
`trek:context`, so it can show place-specific info like reviews or ratings), a
panel inside the **day-detail** view (`day-detail` slot — receives the open `dayId`;
the home for per-day content like outfit planning, live flight status or logistics),
or a panel at the foot of a booking card in the **reservation-detail** view
(`reservation-detail` slot — receives the open `reservationId`, for things like
live check-in status or a seat map). Set the slot in `capabilities.widget.slot`.
- **trip-page** — adds a tab **inside every trip planner**, so your UI lives in the
trip alongside Plan / Transports / Files. The frame is the same sandboxed iframe as
a `page`, but it receives the current `tripId` in `trek:context` (so you can scope
data to the open trip) and it has no dashboard nav entry. The tab shows on desktop
and mobile. Via `capabilities.tripPage` the plugin can also **take over core tabs**:
`replaces: ['transports', 'buchungen', …]` hides the named tabs while the plugin is
active (they return the moment it's deactivated; `plan` is never replaceable), and
`position` picks the 0-based index of your tab in the bar instead of appending it.
A plugin that replaces tabs gets a "Replaces planner tabs" chip in the admin list,
so the takeover is visible before activation.
## The SDK package
`trek-plugin-sdk` is **injected at runtime** — the host makes
`require('trek-plugin-sdk')` resolve inside the child, so **do not vendor it**
into your artifact. Add it as a **devDependency** only, so you get types,
`createMockHost` for tests, and the `trek-plugin` CLI:
```bash
npm i -D trek-plugin-sdk
```
## Writing the server
Your `server/index.js` exports a `definePlugin(...)` object. Everything reaches
TREK through the `ctx` argument.
```js
const { definePlugin } = require('trek-plugin-sdk')
module.exports = definePlugin({
// Runs once when the plugin is activated. NOTE: onLoad has no user context —
// ctx.trips.* is refused here (see the ctx table).
async onLoad(ctx) {
await ctx.db.migrate('001_init', 'CREATE TABLE IF NOT EXISTS cache (k TEXT PRIMARY KEY, v TEXT)')
ctx.log.info('loaded')
},
// Runs once on deactivation/stop. Use it to flush or release resources.
async onUnload(ctx) {
ctx.log.info('unloading')
},
// HTTP routes, mounted at /api/plugins/<id><path>.
routes: [
{ method: 'GET', path: '/status', auth: true, async handler(req, ctx) {
const rows = await ctx.db.query('SELECT COUNT(*) AS n FROM cache')
return {
status: 200,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ n: rows[0].n, user: req.user?.username }),
}
}},
],
// Scheduled jobs — TREK owns the cron and calls your handler on the schedule.
// Requires the `jobs:run` permission (opt-in: scheduled work runs with NO user,
// so its trip reads are refused — a job can only use ctx.db and declared egress).
// An invalid cron expression is skipped; jobs stop when the plugin is deactivated.
jobs: [
{ id: 'refresh', schedule: '*/15 * * * *', async handler(ctx) { /* … */ } },
],
// Persistent, userless timers you arm at runtime with ctx.scheduler — they
// survive restarts and call this handler when due. Also needs `jobs:run`.
// await ctx.scheduler.in(3_600_000, 'remind', { tripId }) // once, in 1h
// await ctx.scheduler.every(86_400_000, 'digest') // daily
async scheduled({ name, payload }, ctx) {
if (name === 'remind') { /* … one-shot fired; re-arm if you want another */ }
},
// GDPR data-subject rights (needs `hook:user-data`). Userless — you only get the
// userId and act on your OWN db. deleteUserData is delivered DURABLY (queued and
// retried until it succeeds, even across restarts), so make it idempotent.
async deleteUserData({ userId }, ctx) {
await ctx.db.exec('DELETE FROM my_prefs WHERE user_id = ?', userId)
},
async exportUserData({ userId }, ctx) {
return await ctx.db.query('SELECT * FROM my_prefs WHERE user_id = ?', userId)
},
})
```
The routes and job ids you declare here are the **authoritative** ones: the host
reads them off your loaded definition (a route's array index is its internal id).
The `routes` block the scaffold writes into `trek-plugin.json` is only a
declaration for readers — the manifest parser does not consume it.
### The `ctx` object
| Area | Methods | Requires |
|---|---|---|
| `ctx.db` | `query(sql, …args)` / `exec(sql, …args)` / `migrate(id, sql)` / `tx(ops)` against your **own** SQLite file. `tx([{sql, args?}, …])` runs up to 100 statements in one transaction (all commit or all roll back; reads see the batch's own earlier writes) → `{ results: [{changes?}|{rows?}, …] }` | `db:own` |
| `ctx.trips` | `getById` / `getPlaces` / `getReservations` / `getDays` / `getAccommodations` / `listMine()` — enumerate every trip the acting user can access (membership-checked). `getDays` includes each day's `assignments` + `notes_items`; `getReservations` includes `endpoints` + `day_positions` | `db:read:trips` |
| `ctx.trips.update(tripId, fields)` | update trip fields (title/dates/currency/reminder_days/…) | `db:write:trips` |
| `ctx.trips.create(input)` | create a **new trip owned by the acting user** (importers) — `title` required, plus `description?`/`start_date?`/`end_date?`/`currency?`/`reminder_days?`/`day_count?` | `db:create:trips` (+ `trip_create`) |
| `ctx.places` | `create(tripId, fields)` / `update(tripId, placeId, fields)` / `delete(tripId, placeId)` | `db:write:places` |
| `ctx.days` | `create(tripId, {date?, notes?})` / `update(tripId, dayId, {notes?, title?})` / `delete(tripId, dayId)` | `db:write:days` |
| `ctx.itinerary` | `assign(tripId, dayId, placeId, notes?)` / `unassign(tripId, assignmentId)` — place↔day | `db:write:itinerary` |
| `ctx.meta` | `get` / `set` / `list` / `delete` your **own** namespaced data on a `trip`/`place`/`day` (enrich core entities without forking the schema) | `db:meta` |
| `ctx.packing` | `list(tripId)` — a trip's packing items (membership-checked, respects private-item visibility) | `db:read:packing` |
| `ctx.files` | `list(tripId)` — a trip's files, trash excluded (membership-checked) | `db:read:files` |
| `ctx.files.getContent` | `getContent(tripId, fileId)` → `{ name, mimetype, size, content_base64 }` — a file's bytes as base64 (10MB cap; trashed files refused) | `db:read:files:content` |
| `ctx.journal` | `listMine()` — the acting user's own travel journals; `getEntries(journeyId)` — one journey's entries (photos/story/checkins, access-checked) | `db:read:journal` (+ Journey addon) |
| `ctx.atlas` | `visited()` — the acting user's visited countries + regions; `bucketList()` — their bucket-list items | `db:read:atlas` (+ Atlas addon) |
| `ctx.vacay` | `mine()` — the acting user's vacation plan | `db:read:vacay` (+ Vacay addon) |
| `ctx.collections` | `listMine()` / `get(id)` — the acting user's saved-place collections | `db:read:collections` (+ Collections addon) |
| `ctx.collections` (write) | `create(input)` / `update(id, input)` / `savePlace(input)` / `copyToTrip(input)` / `deletePlace(placeId)` — per-collection role enforced by the service | `db:write:collections` (+ Collections addon) |
| `ctx.atlas` (write) | `markCountry(code)` / `unmarkCountry` / `markRegion(regionCode, countryCode, regionName?)` / `unmarkRegion` / `createBucketItem(input)` / `deleteBucketItem(itemId)` — the acting user's own rows | `db:write:atlas` (+ Atlas addon) |
| `ctx.vacay` (write) | `toggleEntry(date)` — the acting user's own PTO day on their active plan; `toggleCompanyHoliday(date, note?)` | `db:write:vacay` (+ Vacay addon) |
| `ctx.journal` (write) | `createEntry(journeyId, {entry_date, ...})` / `updateEntry(entryId, fields)` / `deleteEntry(entryId)` — owner/contributor-gated; `createJourney({title, subtitle?, trip_ids?})` / `deleteJourney(journeyId)` — a new journal owned by the acting user (importers bootstrap the journal they then fill) | `db:write:journal` (+ Journey addon) |
| `ctx.files` (write) | `create(tripId, {name, content_base64, mimetype?, ...})` (10MB cap, blocked extensions refused) / `createLink(tripId, fileId, opts)` / `update` / `softDelete` — broadcasts `file:*` | `db:write:files` (+ the acting user's `file_upload`/`file_edit`/`file_delete`) |
| `ctx.collab` | `listNotes(tripId)` / `listPolls(tripId)` / `listMessages(tripId, before?)` — a trip's notes, polls (with options + voters) and chat (newest 100, oldest first; `before` = a message id to page back), membership-checked | `db:read:collab` (+ Collab addon) |
| `ctx.collab` (write) | `createNote(tripId, {title, ...})` / `createPoll(tripId, {question, options})` / `votePoll(tripId, pollId, optionIndex)` / `createMessage(tripId, text, replyTo?)` — broadcasts `collab:*` | `db:write:collab` (+ `collab_edit`, Collab addon) |
| `ctx.trips.addMember` / `.removeMember` | `addMember(tripId, userId)` — **grants trip access**; `removeMember(tripId, userId)` — revokes it (never the owner), so a directory-sync integration can reconcile departures too | `db:write:members` (+ `member_manage`) |
| `ctx.notify` | `send({title, body, link?, scope, targetId})` — bell inbox + email/ntfy fan-out; recipient forced to the acting user (`scope:'user'`) or a trip they belong to (`scope:'trip'`) | `notify:send` |
| `ctx.ai` | `complete(prompt, system?)` → `{ text }`; `extract(text, jsonSchema, prompt?)` → `{ results }` — the admin/user-configured provider; host holds the key; output is DATA (no auto-writes) | `ai:invoke` |
| `ctx.oauth` | `getAccessToken()` → a **short-lived access token** for the acting user of a third-party service the host connected on their behalf (Settings → Plugins → Connect); `null` if not connected / userless. Host holds the refresh token + client secret | `oauth:client` |
| `ctx.scheduler` | `at(whenMs, name, payload?)` / `in(ms, name, payload?)` / `every(ms, name, payload?)` / `cancel(name)` — **persistent, userless** timers that survive restarts and fire your `scheduled(input, ctx)` handler. `set` is an upsert by `name`; caps: ≤100 tasks, 8 KB payload, recurring interval ≥ 60 s, ≤ ~1 year out. Same risk class as `jobs` (no acting user → trip reads refused) | `jobs:run` |
| `ctx.settings` | `get(key)` — the **acting user's** own value for one of your `scope:'user'` settings fields (decrypted host-side). Returns `undefined` for an unset value or a userless context (job/onLoad) — fall back to `ctx.config` there. Users fill these in under **Settings → Plugins**; secrets are stored encrypted and never echoed back | none (your own settings) |
| `ctx.daynotes` | `list(tripId, dayId)` — a day's notes (membership-checked) | `db:read:daynotes` |
| `ctx.daynotes` (write) | `create(tripId, dayId, {text, time?, icon?, sort_order?})` / `update(tripId, dayId, noteId, fields)` / `delete(tripId, dayId, noteId)` — broadcasts `dayNote:*` | `db:write:daynotes` |
| `ctx.packing` (write) | `create(tripId, {name, category?, checked?, is_private?, visibility?, recipient_ids?})` / `update(tripId, itemId, fields)` / `delete(tripId, itemId)` — broadcasts `packing:*`, private items (#858) stay owner-scoped | `db:write:packing` |
| `ctx.packing` (bags) | `listBags(tripId)` / `createBag(tripId, {name, color?})` / `updateBag` / `deleteBag` / `setBagMembers(tripId, bagId, userIds)` — bags carry no privacy | `db:write:packing` |
| `ctx.weather` | `get(lat, lng, date?)` — the host's cached forecast (tenant-free) | `weather:read` |
| `ctx.rates` | `get(base)` → a map of quote → rate relative to `base` (e.g. `'EUR'` → `{ USD: 1.08, … }`; cached upstream, tenant-free); `null` on an upstream failure | `rates:read` |
| `ctx.categories` | `list()` — the global place-category list | `db:read:categories` |
| `ctx.tags` | `list()` / `create({name, color?})` / `update(tagId, fields)` / `delete(tagId)` — the acting user's own tags | `db:read:tags` / `db:write:tags` |
| `ctx.trips.members` | `members(tripId)` — the trip roster (id + display fields), membership-checked | `db:read:trips` |
| `ctx.todos` | `list(tripId)` / `create(tripId, {name, ...})` / `update(tripId, todoId, fields)` / `delete(tripId, todoId)` — broadcasts `todo:*` | `db:read:todos` / `db:write:todos` (+ `packing_edit`) |
| `ctx.costs` | `getByTrip(tripId)` / `listMine()` — read budget items (membership-checked) | `db:read:costs` |
| `ctx.costs` (write) | `create(tripId, input)` / `update(tripId, itemId, input)` / `delete(tripId, itemId)` — broadcasts `budget:*` | `db:write:costs` |
| `ctx.reservations` | `listMine()` — every booking across the acting user's accessible trips (membership-checked) | `db:read:trips` |
| `ctx.reservations` (write) | `create(tripId, input)` / `update(tripId, reservationId, input)` / `delete(tripId, reservationId)` — full parity with the app (accommodation, budget-sync, notification, broadcasts `reservation:*`). `input.endpoints` = array of `{ role: 'from'\|'to'\|'stop', name, lat, lng, code?, sequence?, timezone?, local_time?, local_date? }`; update semantics: omitted = keep, `[]` = delete all, array = replace (endpoint ids are not stable) | `db:write:reservations` |
| `ctx.accommodations` | `create(tripId, { place_id, start_day_id, end_day_id, check_in?, check_in_end?, check_out?, confirmation?, notes? })` / `update(tripId, accommodationId, fields)` / `delete(tripId, accommodationId)` — lodging blocks; create auto-creates the partner hotel reservation, delete cascades it (broadcasts like the app) | `db:write:accommodations` (+ `day_edit`) |
| `ctx.users` | `getById(id)` — public profile only (`id, username, display_name, avatar`) | `db:read:users` |
| `ctx.ws.broadcastToTrip(tripId, event, data)` | broadcast to a trip's members (event forced to `plugin:<id>:<event>`) | `ws:broadcast:trip` |
| `ctx.ws.broadcastToUser(userId, event, data)` | broadcast to one user | `ws:broadcast:user` |
| `ctx.plugins.call(id, fn, args?)` | call a function another plugin **exposes** and get its result — `id` must be a declared, satisfied `pluginDependency` that lists `fn` in its `capabilities.provides` | a plugin dependency (no permission) |
| `ctx.events.emit(name, payload?)` | publish an event to dependents that subscribed — `name` must be in your `capabilities.emits` | — (no permission) |
| `ctx.config` | your resolved settings (secrets delivered decrypted) | — |
| `ctx.log` | `info` / `warn` / `error` → your error log | — |
| `ctx.id` | your plugin id (string) | — |
Calling a method your manifest didn't grant returns `PERMISSION_DENIED`; a method
the host doesn't expose at all returns `UNKNOWN_METHOD`.
**`ctx.trips` only works inside a route handler.** The host binds the acting user
from the authenticated request and membership-checks every trip read against it.
`onLoad` and `jobs` have **no user**, so their trip reads are refused with
`RESOURCE_FORBIDDEN`. The SDK's `getById(tripId, asUserId?)` signature keeps an
`asUserId` parameter for source compatibility, but **the host ignores it** — you
cannot read another user's trips by passing an id.
**Writes (`ctx.trips.update` / `ctx.places` / `ctx.days` / `ctx.itinerary` /
`ctx.costs.create`) are route-context only too, and doubly gated:** the host checks
the acting user can **access** the trip AND holds the app's edit permission for that
entity (`place_edit` / `day_edit` / `trip_edit`), exactly like the web UI. They run
through the same services and broadcast the same events, so open sessions update
live. Input is validated against TREK's own schemas (a bad payload is `BAD_PARAMS`),
and every write is recorded in the tamper-evident capability audit log against the
acting user. A plugin can only change what its user could change by hand.
**`ctx.costs` ("costs" = budget items)** behaves exactly like `ctx.trips`: reads are
membership-checked against the request's user and only work **inside a route handler**
(`onLoad`/`jobs` have no user → `RESOURCE_FORBIDDEN`). `getByTrip(tripId)` returns one
trip's budget items (hydrated with members/payers); `listMine()` aggregates budget items
across every trip the acting user can access. `create/update/delete(tripId, …)` mutate a
trip's budget items — gated exactly like a normal budget write (the same model the planner
write scopes `db:write:places`/`days`/`itinerary`/`trips` use): the acting user needs the
**`budget_edit`** permission on that trip, the input is
validated against TREK's budget schema, and a successful create broadcasts the same
`budget:created` event the app emits. **Every `ctx.costs.*` call also requires the Costs
(budget) addon to be enabled** — if the admin has turned it off, the call is refused with
`RESOURCE_FORBIDDEN`.
### Route auth
Routes are authenticated by default (`req.user` is the logged-in user). Set
`auth: false` for OAuth callbacks or webhooks that can't carry a session.
The proxy forwards `{ method, path, query, body, headers, user }`. **`req.headers`
is populated ONLY on `auth: false` routes** (an authenticated route gets `{}`) and
only an explicit, credential-free **allowlist** — the common provider signature +
event headers (`stripe-signature`, `x-hub-signature-256`, `svix-signature`,
`x-gitlab-event`, `content-type`, `user-agent`, …). **`Cookie`, `Authorization`,
`X-Socket-Id` and every session/forwarded-auth header are stripped** and never reach
your code, so a forwarded header can't leak a TREK session. To trust a webhook,
verify the provider's signature over the raw body against a secret you hold in
`ctx.config` (admin-set instance setting) or `ctx.settings` (per-user).
## Writing the client (page / widget)
The iframe is served same-origin from `/plugin-frame/<id>/…` but sandboxed
**without `allow-same-origin`**, so it runs at an **opaque origin**: it can't read
cookies or the parent DOM. It talks to TREK only via `postMessage` (target origin
must be `'*'` — an opaque frame has no nameable origin).
Your whole `client/` directory is served (and shipped by `pack`) — not just
`index.html`. The frame CSP allows the plugin its **own** static files (and nothing
from any other host), so a **multi-file build works as-is**: point a bundler at
`client/` and use **relative asset paths** (Vite: `base: './'`), drop the output in,
done. That includes React/Vue/Svelte builds — no more inlining the bundle into
`index.html` (inline still works, and is what the design-kit marker does).
### The design kit (recommended)
Because the frame can't load TREK's stylesheet, we ship it. Drop **one line** in your
`client/index.html` `<head>`:
```html
<!-- trek:ui -->
```
`dev` and `pack` expand that marker into the inlined **TREK design kit** — a
token-driven stylesheet plus a `window.trek` bridge. It costs nothing to keep the
source a one-liner, and a rebuild always ships the current kit. The kit:
- gives you native components — **glass panels, cards, buttons, inputs, chips, list
rows, hover** — that swap correctly between light and dark;
- follows the user's live **accent scheme, custom accent and high-contrast** (it
applies the tokens TREK sends);
- mirrors the host's **appearance flags** (reduced-motion, no-transparency, density);
- **auto-reports your height** (widgets/pages self-size — no manual `trek:resize`);
- installs `window.trek` so you never hand-roll `postMessage`.
`window.trek` also carries **`trek.ui`** — tiny DOM builders that emit kit-styled
elements, so you can build UI with no bundler and no CSS:
```js
const { ui } = trek
ui.mount(ui.card([
ui.el('div', { class: 'trek-title', text: 'Nearby' }),
ui.button('Refresh', { variant: 'primary', onClick: refresh }),
ui.chip('open now', 'success'),
]))
// ui.el(tag, props, children) is the general builder; props take class/text/html/on:{event}.
```
The scaffold seeds a working example. A minimal client:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- trek:ui -->
</head>
<body>
<div class="trek-glass trek-stack" style="margin:16px">
<div class="trek-title">Your plugin</div>
<p class="trek-muted" id="hello">…</p>
<button class="trek-btn trek-btn--primary" id="go">Say hello</button>
</div>
<script>
trek.onContext((ctx) => { document.getElementById('hello').textContent = 'theme: ' + ctx.theme })
document.getElementById('go').addEventListener('click', async () => {
try { const data = await trek.invoke('/hello'); document.getElementById('hello').textContent = 'Hello ' + data.hello }
catch (e) { trek.notify('error', e.message) }
})
</script>
</body>
</html>
```
**Component classes** (the bootstrap adds `trek-ui` to `<body>`):
| Class | What |
|---|---|
| `.trek-glass` | the signature frosted-glass surface |
| `.trek-card` | a solid card |
| `.trek-interactive` | add to a glass/card for the native hover-lift |
| `.trek-btn` + `--primary` / `--secondary` / `--ghost` / `--danger` | buttons |
| `.trek-input` / `.trek-textarea` / `.trek-select` / `.trek-label` | form controls |
| `.trek-chip` + `--accent` / `--success` / `--danger` / `--warning` / `--info` | chips / badges |
| `.trek-row` | a hover-highlight list row |
| `.trek-title` / `.trek-muted` / `.trek-faint` | text helpers |
| `.trek-stack` / `.trek-cluster` | vertical / horizontal flex with gap |
| `.trek-menu-enter` / `.trek-menu-enter-left` | dropdown enter (scales from its trigger corner) |
| `.trek-popover-enter` / `.trek-modal-enter` / `.trek-backdrop-enter` / `.trek-toast-enter` | the host's enter animations (`trek-modal-enter` becomes a bottom drawer below 640px) |
| `.trek-page-enter` | subtle fade-up on mount |
| `.trek-stagger` | children fade up with a 40ms cascade |
| `.trek-skeleton` | shimmer loading placeholder |
| `.trek-pie-reveal` / `.trek-bar-fill` | chart reveal animations (plus the `trek-progress-fill` keyframe, driven by `--trek-progress-to`) |
All animations honour reduced motion: under `[data-reduce-motion]` or
`prefers-reduced-motion` they degrade to a gentle 120ms fade, and the skeleton
stops shimmering.
**Selects are auto-upgraded.** With the kit inlined, every native `<select>` becomes
a host-styled, keyboard-accessible dropdown that matches TREK — the OS-drawn popup
can't be themed, so the kit replaces it while keeping the real `<select>` as the
value/form source (it still fires `change`). Write a plain `<select>` and it just
works. Add `data-trek-native` to a field to keep the browser default; `multiple` and
`size` selects are always left native.
**The `window.trek` bridge:**
| Call | Does |
|---|---|
| `trek.onContext(cb)` | run `cb(context)` now (if already received) and on every update; returns an unsubscribe fn |
| `trek.context` | the last context (or `null`) |
| `trek.invoke(sub, { method, body })` | call your own route; returns a `Promise` (rejects with an `Error`, `.code` = HTTP status) |
| `trek.session.get(key, options?)`, `.set(key, value, options?)`, `.remove(key, options?)`, `.clear(options?)` | Host-managed JSON session state for this browser tab. `scope: 'plugin'` is the default; `scope: 'trip'` additionally partitions it by the trip in view and rejects with `NO_TRIP_CONTEXT` outside a trip. Missing `get()` values are `undefined`. Each plugin or individual trip scope is limited to 32 keys, 64 characters/key, and 1 KiB/value. Do not use it to store secrets. |
| `trek.notify(level, message, duration?)` | toast (`info`/`success`/`warning`/`error`); optional duration in ms (clamped 1.5–15s) |
| `trek.confirm({ title?, message, confirmLabel?, cancelLabel?, danger? })` | host-rendered native confirm dialog; resolves `true`/`false` (one at a time — a second concurrent request resolves `false`) |
| `trek.navigate(to)` | in-app navigation (relative paths only) |
| `trek.openExternal(url)` | open an `http(s)` URL in a new tab (the sandbox itself can't) |
| `trek.onEvent(cb)` | `cb(event, tripId)` for core events on the trip in view — names only, no payloads; refetch via `invoke()`; returns an unsubscribe fn |
| `trek.resize(px)` | override the auto height (ignored on full pages — see `trek:resize` below) |
| `trek.geolocation.get()` | one browser position as `{ lat, lng, accuracy, heading, speed, timestamp }` — **needs the `geolocation:read` permission**; rejects with `.code` `forbidden`/`denied`/`timeout`/`unavailable`/`unsupported`. The HOST reads the position (the sandbox never gains the API) and the browser's own site prompt still applies |
| `trek.geolocation.watch(cb)` | stream positions — `cb(position, error)`; returns an unsubscribe fn. The host keeps ONE GPS watch per frame and force-stops it when the frame closes |
| `trek.ready()` / `trek.requestContext()` | re-handshake / re-request the context |
**Preview it:** `npx trek-plugin-sdk dev`, then open **`/preview`** — it renders your UI
in a real sandboxed frame with a theme/accent/appearance toggle and proxies
`trek.invoke()` to your routes.
### The raw bridge (without the kit)
If you'd rather not use the kit, talk to the frame yourself. Announce readiness and
handle messages:
```js
window.parent.postMessage({ type: 'trek:ready' }, '*') // TREK replies with trek:context
window.addEventListener('message', (e) => {
if (e.source !== window.parent) return // opaque frame: trust the parent window
const m = e.data
if (m.type === 'trek:context') { /* m.theme, m.tokens, m.appearance, … (below) */ }
if (m.type === 'trek:response') { /* m.requestId, m.data */ }
if (m.type === 'trek:error') { /* m.requestId, m.code, m.message */ }
})
window.parent.postMessage({ type: 'trek:invoke', requestId: '1', sub: '/status', method: 'GET' }, '*')
```
**Messages you send to TREK:**
| 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, duration? }` | toast; `level` = `info`/`success`/`warning`/`error`; `duration` in ms, clamped 1500–15000 |
| `trek:resize` | `{ height }` | set the iframe height (capped at 2000px); **ignored for full pages** (`page` / `trip-page`), which always fill their host container |
| `trek:invoke` | `{ requestId, sub, method, body }` | call your own route; resolves as `trek:response` or `trek:error` |
| `trek:confirm` | `{ requestId, title?, message?, confirmLabel?, cancelLabel?, danger? }` | host-rendered ConfirmDialog; answered as `trek:confirm:result` |
| `trek:openExternal` | `{ url }` | open an `http(s)` URL in a new `noopener` tab; anything else is dropped |
| `trek:geolocation` | `{ requestId, action?: 'get'\|'watch'\|'clear' }` | host-brokered browser position (needs `geolocation:read`); answered as `trek:geolocation:result`, watch updates stream as `trek:geolocation:update` |
| `trek:session:get` | `{ requestId, key, scope?: 'plugin'\|'trip' }` | read tab-scoped state; answered as `trek:response` with `data` (`undefined` when unset) |
| `trek:session:set` | `{ requestId, key, value, scope? }` | store a JSON-serialisable value; answered as `trek:response` |
| `trek:session:remove` | `{ requestId, key, scope? }` | drop one key; answered as `trek:response` |
| `trek:session:clear` | `{ requestId, scope? }` | drop every key in that scope; answered as `trek:response` |
The session messages are rejected as `trek:error` with `code` set to `NO_TRIP_CONTEXT`
(trip scope outside a trip), `SESSION_INVALID_KEY` (empty or over 64 characters),
`SESSION_INVALID_VALUE` (not JSON-serialisable), `SESSION_VALUE_TOO_LARGE` (over 1 KiB
serialised), `SESSION_KEY_LIMIT` (over 32 keys in that scope) or `SESSION_STORAGE_ERROR`
(the browser refused the write). The storage key itself is host-owned and includes the
signed-in user and your plugin id, so scopes never collide.
**Messages TREK sends you:**
| Message | Payload |
|---|---|
| `trek:context` | `{ tripId, placeId, dayId, reservationId, userId, theme, locale, dir, hostOrigin, user, formats, tokens, appearance }` (see below) — re-sent whenever the theme, appearance, **locale or formats** change |
| `trek:response` | `{ requestId, data }` — a successful `trek:invoke` or `trek:session:*` |
| `trek:error` | `{ requestId, code, message }` — a failed request; for `trek:invoke` `code` is the HTTP status or `"error"`, for `trek:session:*` one of the `SESSION_*` / `NO_TRIP_CONTEXT` codes above |
| `trek:confirm:result` | `{ requestId, confirmed }` — the user's answer to your `trek:confirm` |
| `trek:event` | `{ event, tripId }` — a core event fired on the trip in view; **names only, never payloads** — refetch what you need via `trek:invoke` |
| `trek:geolocation:result` | `{ requestId, position? \| watching? \| cleared? \| error? }` — the answer to a `trek:geolocation` request (`error` ∈ `forbidden`/`denied`/`timeout`/`unavailable`/`unsupported`) |
| `trek:geolocation:update` | `{ position? , error? }` — a streamed watch fix (`position` = `{ lat, lng, accuracy, heading, speed, timestamp }`) |
The frame's CSP is locked down per plugin: `default-src 'none'`, own inline
scripts/styles + the plugin's **own** `/plugin-frame/<id>/` files only (no other
host may serve it script/style/img), `connect-src` limited to the hosts you were
**granted** via `http:outbound:<host>` permissions (not merely the `egress[]` you
declared), no popups.
### The context payload
| Field | Type |
|---|---|
| `tripId` | `string \| null` — the trip in view (a `trip-page` tab, or a widget on a trip), else `null`. **IDs arrive as strings** — compare with `String(id)` |
| `placeId` | `string \| null` — the place in view (a `place-detail` slot), else `null` |
| `dayId` | `string \| null` — the day in view (a `day-detail` slot), else `null` |
| `reservationId` | `string \| null` — the reservation in view (a `reservation-detail` slot), else `null` |
| `dir` | `'ltr' \| 'rtl'` — the host's text direction; the kit mirrors it onto your `<html>` so RTL hosts get RTL plugin UIs |
| `userId` | `string \| null` |
| `theme` | `'light' \| 'dark'` |
| `locale` | e.g. `'en'` |
| `hostOrigin` | the app origin |
| `user` | `{ name, avatar, isAdmin } \| null` — **never** an email; role only as a boolean |
| `formats` | `{ locale, currency, timeFormat, distanceUnit, temperatureUnit, timezone, blurBookingCodes }` — `blurBookingCodes` mirrors the user's "blur booking codes" preference so your UI can hide confirmation numbers until they're revealed. It is a **display hint, not redaction**: the codes still reach your plugin in full over `trek:invoke`. |
| `tokens` | TREK's resolved CSS design tokens for the current theme (see below) |
| `appearance` | `{ scheme, density: 'comfortable'\|'compact', reducedMotion, noTransparency }` |
### Matching the TREK look by hand (`m.tokens`)
`tokens` is the whole global palette resolved for the **current** theme — surfaces
(`--bg-card`, `--bg-hover`, …), text (`--text-primary`/`-secondary`/`-muted`/`-faint`),
borders, the **accent family** (`--accent`, `--accent-text`, `--accent-hover`,
`--accent-subtle`), semantic + soft fills (`--success`/`--danger`/`--warning`/`--info`
`-soft`), shadows (`--shadow-*`), radii (`--radius-*`) and fonts (`--font-system`).
Apply them as CSS variables and your UI matches the host exactly — in both themes and
under a custom accent or high-contrast — instead of hard-coding a palette that drifts:
```js
function applyContext(m) {
document.documentElement.dataset.theme = m.theme // for your dark rules
for (const k in m.tokens) document.documentElement.style.setProperty(k, m.tokens[k])
const a = m.appearance || {}
document.documentElement.toggleAttribute('data-reduce-motion', !!a.reducedMotion)
document.documentElement.toggleAttribute('data-no-transparency', !!a.noTransparency)
}
// in your trek:context handler: applyContext(m)
```
`tokens`/`appearance` are non-secret display values only, re-sent on every theme or
appearance change so plugins feel native rather than bolted-on. (The glassy tokens the
dashboard uses — `--glass-*`, `--r-*`, `--sh-*` — aren't in `tokens`; the design kit
bakes those, since they only change with light/dark, not the accent.) Honour
`appearance.reducedMotion` / `noTransparency`, and the frame also inherits the OS
`prefers-reduced-motion`. Dashboard widgets are wrapped in the native glassy tool card
and auto-size to the height you report via `trek:resize`, so render flush and
transparent — the design kit reports your height for you.
## Settings
Declare settings in the manifest; TREK renders the form (you write no settings
UI). `scope: "instance"` settings are set once by the admin; `scope: "user"`
settings are per-user. `secret: true` fields are stored encrypted and delivered
decrypted through `ctx.config` (server-side only) — never to the iframe. Resolved
values arrive in `ctx.config`.
### A custom settings page (`capabilities.settingsUi`)
When declared fields aren't enough — a picker with previews, anything visual —
set `"capabilities": { "settingsUi": true }` and ship a `client/settings.html`.
TREK frames it as a card on the user's **Settings → Plugins** page, in the same
opaque-origin sandbox and postMessage bridge as your widget: it can only reach
your own declared routes (`trek:invoke`), receives `trek:context`, and should
report its height via `trek:resize`. Persist whatever it edits through one of
your own routes (e.g. into your `db:own` database keyed by `req.user.id`) — the
settings page has no special storage powers. Hosts older than this capability
simply ignore the flag, so declaring it never breaks an install.
## Host-brokered OAuth (`ctx.oauth`)
A plugin can act as an OAuth *client* of a third-party service **without ever handling the secrets**. The **host** runs the entire flow — authorize → callback → token exchange → refresh — with PKCE (S256) and a `state` check, and holds the tokens. The plugin only triggers "connect" and reads a **short-lived access token** at runtime.
**Setup.** Declare the provider as `scope: "instance"` settings the admin fills in: `oauth_authorize_url`, `oauth_token_url`, `oauth_scopes` (optional), plus the two `secret: true` fields `oauth_client_id` and `oauth_client_secret`. (A settings field may also carry an `oauth: { initPath, callbackPath }` block.)
**Connecting.** A user connects under **Settings → Plugins → Connect**, which sends them to the provider's authorize page. The callback returns to `…/api/plugin-oauth/<id>/callback`; the host verifies the `state` (single-use, 10-minute TTL, bound to that user — CSRF defence), exchanges the code and stores the tokens **per user, encrypted at rest**.
**Using it.** Read the access token in a route handler:
```js
const token = await ctx.oauth.getAccessToken() // needs the `oauth:client` permission
if (!token) return { status: 401, body: 'connect this plugin first' }
// call the third-party API with `Authorization: Bearer ${token}`
```
- Returns `null` when the acting user hasn't connected, or in a **userless** context (a job / `onLoad`).
- The host **auto-refreshes** a token that is expiring (60-second skew) using the refresh token it holds.
- The plugin **never** sees the refresh token or the client secret.
- The authorize/token URLs must be `https` to a non-private host, and the host-side token exchange goes through the SSRF guard (blocks the link-local / cloud-metadata range and pins the resolved IP against DNS-rebind; a self-hosted internal IdP on loopback/LAN still works).
## GDPR data-subject hooks
Grant `hook:user-data` and implement either handler to honour data-subject rights. Both are **userless** — the plugin only receives the `userId` and acts on its **own** `ctx.db`:
```js
module.exports = definePlugin({
permissions: ['db:own', 'hook:user-data'],
async deleteUserData({ userId }, ctx) { // erasure
await ctx.db.exec('DELETE FROM my_prefs WHERE user_id = ?', userId)
},
async exportUserData({ userId }, ctx) { // portability / access
return await ctx.db.query('SELECT * FROM my_prefs WHERE user_id = ?', userId)
},
})
```
- **Erasure is durable.** When a user is deleted, `deleteUserData` is queued and **retried until the plugin ACKs**, across restarts and even after the plugin is later reactivated — so make it **idempotent**.
- **Export is complete-or-flagged.** A data-access request aggregates `exportUserData` across every granted plugin. A plugin that is currently **inactive** but holds `hook:user-data`, or one whose export **errored/timed out**, is flagged `pending` (never silently omitted), so the admin knows to reactivate it to finish the request.
- Uninstalling a plugin also **purges its stored OAuth tokens and state**.
## Provider hooks
A hook is core calling **into** your plugin for data (host→plugin). Declare it on
the plugin definition and grant the matching `hook:*` permission:
```js
module.exports = definePlugin({
hooks: {
placeDetailProvider: {
// Return extra rows TREK renders natively on a place. Runs with the current
// user bound, on a short timeout — a slow/failing call is skipped, never fatal.
async getDetails(placeId, ctx) {
return [{ label: 'Crowd', value: 'Quiet now' }, { label: 'Guide', url: 'https://…' }]
},
},
},
})
```
| Hook | Permission | Status |
|---|---|---|
| `placeDetailProvider.getDetails(placeId, ctx)` → `{ label, value?, url? }[]` | `hook:place-detail-provider` | **live** — shown in the place-detail panel; also `GET /api/place-details/:placeId` |
| `warningProvider.getWarnings(tripId, ctx)` → `{ level, message, dayId?, placeId? }[]` | `hook:trip-warning-provider` | **live** — validation warnings shown as a non-blocking banner in the trip planner; also `GET /api/trip-warnings/:tripId` |
| `tableContributor.getContributions(view, tripId, ctx)` → `TableContribution[]` | `hook:table-contributor` | **live** — host-rendered **columns/actions** keyed by `entityId` in the reservations, transports, places, day, costs, packing, files and todos views. A `column` is `{kind:'column', entityId, id, label, value?, url?, icon?, tone?}` (url is http/https/mailto only); an `action` is `{kind:'action', entityId, id, label, icon?, target}` where `target` opens your sandboxed frame (`{kind:'frame', sub}`) or calls a route (`{kind:'route', method, sub}`). All fields are bounded + normalized host-side; also `GET /api/view-contributions/:view/:tripId` |
| `mapMarkerProvider.getMarkers(tripId, ctx)` → `MapMarkerContribution[]` | `hook:map-marker-provider` | **live** — bounded markers overlaid on the trip map (#587). Each is `{id, lat, lng, label?, popupText?, url?, icon?, tone?}`; coordinates are range-checked (−90..90 / −180..180), text length-capped, url http/https/mailto-only, count capped (≤200/plugin). Declarative only — plugin JS never runs on the map canvas. Also `GET /api/map-markers/:tripId` |
| `mapLayerProvider.getLayers(tripId, ctx)` → `MapLayerContribution[]` | `hook:map-layer-provider` | **live** — bounded vector overlays on the trip map: a computed route, a reachable-range corridor, a zone. Each layer is `{id, name?, features}` with features `{type: 'polyline'\|'polygon'\|'circle', points?/center?+radiusM?, tone?, width?, dash?, opacity?, fill?, label?}`. Styling stays in the tone palette; width (1–8), opacity (0.05–1) and radius (≤2000 km) are clamped, `dash` is an enum. Budgets per plugin: ≤4 layers, ≤150 features, ≤8000 vertices, ≤2000 vertices/shape — an oversized or partly-invalid shape is dropped whole, never truncated. Declarative only; drawn beneath TREK's own day route on both the Leaflet and GL renderers. Also `GET /api/map-layers/:tripId` |
| `routeProvider.getRoute(request, ctx)` → `RouteProviderResult` | `hook:route-provider` | **live** — routes a day's stops under one of the plugin's declared `capabilities.routeProfiles` (an EV profile with charging stops, a scenic profile…). `request` is `{tripId, dayId, profile, waypoints}` (2–30 located stops in visit order); the result is `{coordinates, distance, duration, legs, viaPoints?}` and is validated **whole**: ≤10000 vertices, `legs` must be exactly `waypoints−1` entries (each `{distance, duration, note?}` — `note` ≤120 chars shows on the sidebar connector), ≤40 via points (`{lat, lng, label?, tone?, dwellSeconds?}` drawn as stops on the route line). A malformed result is discarded and the planner falls back to straight lines. **Targeted, not a fan-out**: TREK calls exactly the provider whose profile the user picked in the route toggle, with a 20 s timeout (room to call an external solver through your declared egress). `POST /api/plugin-routes/:pluginId/:profileId` |
| `dayScheduleProvider.getSchedule(tripId, ctx)` → `DayScheduleContribution[]` | `hook:day-schedule-provider` | **live** — time contributions rendered into the day plan on desktop and mobile: "35 min charging at this stop", "45 min security before this flight". Each is `{id, dayId, assignmentId?/reservationId?/position?, minutes?, label, tone?}` — anchored under a place/booking row or at a day's start/end (default end). `dayId` must belong to the trip (checked server-side), `minutes` (1–1440) is shown on the row **and folded into the day's route-footer total**, `label` ≤120 chars, ≤60 items/plugin. Also `GET /api/day-schedule/:tripId` |
| `dayTintProvider.getDayTints(tripId, ctx)` → `DayTintContribution[]` | `hook:day-tint-provider` | **live** — colours painted into a day card in the Plan sidebar (and into that day's mobile chip), so a trip split into legs shows its leg membership while you scroll. Each is `{dayId, tone?, badgeTone?, headerTone?, activityTone?, label?}`. The card has **three separately tintable regions** — the day-number badge (also the mobile chip), the header row, and the expanded activity list — so a plugin can mark the leg boldly on the badge while leaving the dense activity list plain; `tone` is the shorthand that fills every region you do not name, and an unnamed region renders exactly as it does with no plugin. Tone only, never a hex: the host picks the alpha per theme **and** per region, so the tint stays subtle in light and dark and a tinted header hovers to a deeper mix of its own tone. `dayId` must belong to the trip (checked server-side), `label` ≤ 60 chars becomes the day's tooltip, raw array sliced at 2000. **A day takes at most one contribution, resolved whole**: within your own list the first entry for a day wins, and across plugins the first granted provider wins — so a day never flickers between colours, and a losing plugin cannot fill in the winner's untinted regions. Bounded by the trip's day count rather than a fixed item cap, which is why this is not `dayScheduleProvider` (≤60 items would tint only the first 60 days of a long trip). Also `GET /api/day-tints/:tripId` |
| `pdfSectionProvider.getSections(tripId, ctx)` → `PdfSection[]` | `hook:pdf-section-provider` | **live** — text-only sections appended to the trip PDF export. Each is `{title, paragraphs?, table?}`; the host escapes and lays everything out itself (no markup ever reaches the document), caps counts (≤5 sections/plugin, ≤20 paragraphs, ≤8 headers, ≤50 rows) + lengths (title 120, paragraph 2000, header 60, cell 200) and clips rows to the header width. Also `GET /api/pdf-sections/:tripId` |
| `atlasLayerProvider.getLayers(ctx)` → `AtlasLayer[]` | `hook:atlas-layer-provider` | **live** — country tint layers drawn over the Atlas world map (wishlists, advisories, …). **User-scoped**: the host binds the acting user, the hook takes no target parameter. Each layer is `{id, name?, countries: [{code, tone?, label?}]}`; codes must be ISO-3166 alpha-2 (uppercase-coerced), tone is enum-whitelisted, counts capped (≤3 layers/plugin, ≤300 countries/layer). Declarative only — plugin JS never runs on the map canvas. Also `GET /api/atlas-layers` |
| `journalEntryProvider.getRows(entryId, ctx)` → `{ label, value?, url? }[]` | `hook:journal-entry-provider` | **live** — extra rows rendered under a journal entry card (needs the Journey addon; the entry's journey is access-checked like the journal detail routes). Same hardening as place details plus server-side normalization: ≤12 rows/plugin, label ≤60, value ≤200, url http/https/mailto-only. Also `GET /api/journal-entry-rows/:entryId` |
| `tripCardProvider.getCards(tripIds, ctx)` → `TripCardContribution[]` | `hook:trip-card-provider` | **live** — small badges on the dashboard trip cards. Called ONCE with all visible `tripIds` (each already access-checked for the acting user), returns `{ tripId, id, label, value?, icon?, tone?, url? }[]`; the host bounds every field (label 64, value 256, tone enum, url http/https/mailto-only), caps the count (≤40/plugin) and drops any badge whose `tripId` wasn't requested. Declarative only. Also `GET /api/trip-card-contributions?tripIds=…` |
| `photoProvider.search(query, {page, limit}, ctx)` / `.getById(id, ctx)` | `hook:photo-provider` | **live** — plugin photo sources aggregated at `GET /api/plugin-photos/search` (+ `/sources`, `/item`) for the picker. Each `{id, title?, thumbnailUrl, fullUrl, takenAt?}`; thumbnail/full URLs must be http/https, per-source count capped, a failing source skipped |
| `calendarSource.getName(ctx)` / `.getEvents(userId, start, end, ctx)` | `hook:calendar-source` | **live** — plugin calendar events aggregated for the signed-in user at `GET /api/plugin-calendar?start=&end=`. Each `{id, title, start, end, allDay}` (ISO dates); count capped, a failing source skipped |
| `notificationChannel.send(msg, config, ctx)` / `.test(config, ctx)` | `hook:notification-channel` | **live** — registers a new notification channel. **Userless** (see below). See [Notification channels](#notification-channels) |
Each hook method receives its args plus the per-invocation `ctx`, so any `ctx.trips.*`
read it makes is membership-checked against the current user (like a route handler) —
with **one exception**, the notification channel, which has no acting user at all.
## Notification channels
`hook:notification-channel` lets your plugin become a delivery channel alongside TREK's
built-in email / webhook / ntfy — Gotify, Pushover, Telegram, whatever takes a message.
Scaffold one with:
```bash
npx create-trek-plugin my-gotify --type integration --template notification-channel
```
```js
module.exports = definePlugin({
hooks: {
notificationChannel: {
// msg is ALREADY RENDERED in the recipient's language, with the deep link built.
// config is that recipient's own scope:'user' settings, decrypted by the host.
async send(msg, config, ctx) {
const res = await fetch(`${config.serverUrl}/message`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'X-Gotify-Key': config.appToken },
body: JSON.stringify({ title: msg.title, message: msg.body + '\n\n' + (msg.url ?? '') }),
})
// THROW on failure — the host logs and isolates it, so a dead channel
// can never stop email/in-app/the other channels from being delivered.
if (!res.ok) throw new Error(`Gotify responded ${res.status}`)
},
// Optional — backs the "Send test" button in the user's notification settings.
async test(config, ctx) { /* … */ },
},
},
})
```
```json
{
"type": "integration",
"permissions": ["hook:notification-channel", "http:outbound:gotify.example.com"],
"egress": ["gotify.example.com"],
"capabilities": { "notificationChannel": { "title": "Gotify" } },
"settings": [
{ "key": "serverUrl", "label": "Server URL", "scope": "user", "required": true },
{ "key": "appToken", "label": "App token", "scope": "user", "required": true, "secret": true }
]
}
```
**This hook is host-initiated, so it runs with NO acting user.** A notification is dispatched
*to* an arbitrary recipient — nobody is "calling" your plugin — so:
- `ctx.settings.get()` returns `undefined` here, and `ctx.trips.*` reads are refused.
- The recipient's credentials arrive as the `config` argument instead: the host reads your
plugin's `scope:'user'` settings for that recipient, decrypts them, and hands them over.
That is deliberate. It is what lets a channel plugin be given someone's push token *without*
also being given the right to read their trips as them.
Notes:
- **`title`** names the column in the notification preferences matrix (defaults to your
plugin's name). **`events`** may *narrow* which events the channel carries; the default is
every non-admin event. Admin-scoped events (`version_available`) are never deliverable to a
plugin channel — those go over the admin's own credentials.
- **Configured-ness is inferred, not asked.** A user's column is "not configured" until every
`required`, `scope:'user'` field has a value. A plugin with no required user fields counts as
configured for everyone (an instance-wide channel, e.g. a shared workspace webhook).
- **Per-host egress is mandatory.** Bare `http:outbound` does **not** open any host at runtime —
you need `http:outbound:<host>` per host (see below).
- **For a self-hosted target, set `operatorEgress`** — see the next section. Your manifest can't
name the operator's Gotify; the admin does it after install.
- **Reaching a service on your own LAN** (a Gotify next to TREK) additionally needs
`TREK_PLUGIN_ALLOW_PRIVATE_EGRESS=on` on the TREK process — plugins may not reach private
addresses by default. It relaxes the policy for *every* installed plugin, so enable it only
if you trust them all.
## Settings-page actions
A plugin can put **buttons on its own settings page** — "Test connection", "Sync now",
"Clear cache". Declare them in the manifest and implement them on the definition:
```json
"actions": [
{ "key": "testConnection", "label": "Test connection", "hint": "Pings the API." },
{ "key": "purge", "label": "Delete my data", "danger": true }
]
```
```js
module.exports = definePlugin({
actions: {
async testConnection(ctx) {
const token = await ctx.settings.get('appToken') // the CLICKING user's own
const res = await fetch('https://api.example.com/ping', { headers: { authorization: token } })
return { ok: res.ok, message: res.ok ? 'Connected' : `Failed: ${res.status}` }
},
},
})
```
An action is **user-initiated**, which is what makes it different from the
`notificationChannel` hook: the acting user is *whoever clicked the button*. So
`ctx.settings.get()` returns **their** value and any trip read is membership-checked
against them — exactly what a "test my credentials" button needs.
Notes:
- Return `{ ok, message? }`. Throwing is the same as `{ ok: false }` with the error text.
The message is bounded (200 chars) and emoji-stripped host-side before it's shown.
- `danger: true` renders it destructively and asks for confirmation first.
- The key must be a valid settings key, and the host refuses any key the manifest didn't
declare — the key arrives from the URL, so it is never forwarded to your plugin blindly.
- Max 8 actions; label capped at 60 chars, hint at 200.
## Operator-supplied egress hosts (`operatorEgress`)
A plugin's `egress` list is fixed in its manifest **at publish time**. That works for a cloud
API (`api.pushover.net`), but not for a **self-hosted** service — you cannot know that a user's
Gotify lives at `gotify.alice.net`. Without a way out, a community plugin for a self-hosted
target could serve nobody.
Declare `operatorEgress` and the **admin** supplies the real hosts after install:
```json
{
"permissions": ["hook:notification-channel", "http:outbound:gotify.net"],
"egress": ["gotify.net"], // hosts you DO know (the cloud offering)
"operatorEgress": true // …plus whatever the admin adds
}
```
If there is **no** host you can name — a plugin for a service that is *only* ever self-hosted —
`operatorEgress` is what lets you declare outbound with an **empty `egress[]`** (omit the key).
It is the one case where that is legal; without the flag, `http:outbound` with no egress is a
manifest error:
```json
{
"permissions": ["hook:notification-channel", "http:outbound"],
"operatorEgress": true // every host comes from the admin
}
```
Such a plugin still **installs and activates normally** — it may have useful non-network
features — but until an admin adds a host it reaches *nothing*: the child's allow-list is the
union of your `http:outbound:<host>` grants and the admin's hosts, and both are empty, so every
outbound call fails with `egress: <host> is not in the plugin's declared hosts`. An empty
`egress[]` is never an allow-all.
The admin then opens **Admin → Plugins → ⋯ → Allowed hosts** and adds `gotify.alice.net`. The
runtime unions that into the child's allow-list and **re-spawns the plugin** — the egress guard
is installed once at child start and a second `init` is deliberately refused, so a live child's
allow-list can never be widened in place.
What this deliberately does *not* do:
- **An end user can never widen egress.** Only an admin can, and only for a plugin that
*declared* `operatorEgress` — so the consent given at install still bounds what is possible.
A plugin that never asked for it can never be given a host.
- **Hosts get the same validation as manifest egress**: no bare `*`, no whole-TLD `*.com`, no
scheme, no spaces. A wildcard needs a real multi-label suffix (`*.mydomain.com`).
- **Uninstalling drops the hosts**, so a later plugin reusing the id can't inherit consent
granted to a different one.
Users then point their own settings at one of the approved hosts. Anything else is refused by
the guard with `egress: <host> is not in the plugin's declared hosts`.
## Event subscriptions
React to core activity with `events` + the `events:subscribe` permission. Handlers
fire **without a user** (like a job) and receive
`{ event, tripId, entity?, entityId?, snapshot? }`: `entity` is the event family
(`'reservation'`, `'place'`, …), `entityId` says **which** entity changed, and
`snapshot` is a **whitelisted field view of the changed entity** — delivered only
when your plugin *also* holds the family's read grant (`db:read:trips` for
place/day/reservation/accommodation/assignment/trip, `db:read:costs` for budget,
`db:read:packing` for packing, `db:read:daynotes` for dayNote, `db:read:files` for
file). Without that grant you get exactly the id hint, as before:
```js
module.exports = definePlugin({
permissions: ['events:subscribe', 'db:read:trips'],
events: [
{ on: 'reservation:created', async handler({ event, tripId, entityId, snapshot }, ctx) {
// with db:read:trips the snapshot carries the reservation's fields
// (title, times, type, endpoints, ...) — no refetch needed
await ctx.db.exec('INSERT INTO seen (trip, res, title) VALUES (?, ?, ?)', tripId, entityId, snapshot?.title ?? '')
} },
{ on: '*', handler(e) { /* firehose: every core event */ } },
],
})
```
`entityId` is absent for bulk/reorder events and for events with no single entity;
`snapshot` is additionally absent for **deletes** (nothing left to show) and for a
**private packing item** (its broadcast is owner-scoped — #858). Never assume either
is set. The snapshot whitelist never carries user ids (owner/paid-by/participants),
`trips.feed_token` or other secrets — the id is only ever the changed entity's own
id (never a parent or a user id).
Delivery is fire-and-forget on a short timeout, so a slow subscriber never blocks a
core write. Because there's no user, trip reads (`ctx.trips.*`) are refused inside a
handler — beyond the snapshot, use the plugin's own `ctx.db`, `ctx.ws.*`, or an
outbound call. A plugin's own `plugin:*` broadcasts are never delivered back, so
handlers can't loop. Common events: `place:*`, `day:*`, `assignment:*`, `budget:*`,
`file:*`, `accommodation:*`.
## Dependencies
A plugin can declare that it needs certain **addons** enabled, or other **plugins**
installed, before it will run. Both are top-level manifest arrays, and both are
enforced at **activation** — installing always succeeds, so a missing dependency is a
fixable state, never a broken download.
### `requiredAddons`
```json
"requiredAddons": ["budget", "journey"]
```
Addon ids (see [[Addons Overview|Addons-Overview]]) that must be **enabled** for the
plugin to activate. If one is off, enabling the plugin is refused and the admin panel
names the addon to turn on. Turning a required addon **off** while the plugin is
running **auto-disables the plugin** (and anything that depends on it) — a plugin
never runs against a disabled addon. Ids are validated for shape only, so a plugin may
name an addon a given TREK build doesn't have; it just stays un-activatable there.
### `pluginDependencies`
```json
"pluginDependencies": [
{ "id": "koffi", "version": ">=1.2.0 <2.0.0" }
]
```
Other plugins that must be **installed and version-satisfied** (a standard semver
range) before this one activates. That range is the real contract for anything you
call on the dependency (see [Talking to other plugins](#talking-to-other-plugins)).
Enforcement, all at activation time:
- **Missing** dependency → activation is blocked and the panel offers a one-click
**download** that fetches the newest registry version satisfying your range (pulling
*its* own dependencies too), then retries.
- **Installed but out of range** → same block; the panel offers to update it.
- **Installed but disabled** → enabling your plugin **auto-enables the dependency
first**, transitively (deepest dependency first).
- **Disabling a dependency** cascades: every plugin that (transitively) depends on it
is disabled too.
- A dependency **cycle** (A → B → A) is refused with a clear error.
Dependencies are also resolved deps-first at boot, so a plugin's dependencies are
already up before it starts.
## Talking to other plugins
Isolation is the default — plugins can't see each other. To let a plugin be *used* by
the plugins that depend on it, it opts in by declaring a surface in its manifest
`capabilities`, and the host routes calls/events between the two child processes.
There is **no permission** for this: authorization is the dependency edge itself —
plugin A may call or subscribe to plugin B only if A declares B as a satisfied
`pluginDependency`, and only for the names B publicly declares.
### Exports — request / response
The **dependency** (B) exposes named functions and lists them in `capabilities.provides`:
```js
// plugin "koffi"
module.exports = definePlugin({
exports: {
// `args` is whatever the caller passed; `ctx` is a per-call context.
async convert({ amount, from, to }, ctx) {
return { amount: amount * rate(from, to), to }
},
},
})
// manifest: "capabilities": { "provides": ["convert"] }
```
The **dependent** (A) declares koffi as a dependency and calls it:
```js
// manifest: "pluginDependencies": [{ "id": "koffi", "version": ">=1.0.0 <2.0.0" }]
const out = await ctx.plugins.call('koffi', 'convert', { amount: 10, from: 'USD', to: 'EUR' })
```
- A call is refused (`RESOURCE_FORBIDDEN`) if the target isn't a satisfied dependency,
isn't currently active, or the function isn't in the target's `provides`.
- **The acting user is propagated:** B's export runs as A's current user, so any
`ctx.trips.*` read B makes is membership-checked against that user — B can't be
tricked into reading data the calling user couldn't see.
- The call is bounded by a timeout and recorded in the capability audit log
(`plugin:<target>#<fn>`), attributed to A and the acting user.
- B owns its contract: only functions in `provides` are reachable — routes, jobs and
helpers stay private. Because your `pluginDependencies` range pins B's version, B can
refactor internals freely and only breaks you on a major bump.
### Events — publish / subscribe
The **emitter** (B) declares event names in `capabilities.emits` and publishes them:
```js
// manifest: "capabilities": { "emits": ["rate.updated"] }
ctx.events.emit('rate.updated', { pair: 'USD/EUR', rate: 0.92 }) // fire-and-forget
```
A **dependent** (A) subscribes by naming the source plugin + event:
```js
module.exports = definePlugin({
subscriptions: [
{ plugin: 'koffi', event: 'rate.updated', async handler(payload, ctx) {
await ctx.db.exec('UPDATE cache SET rate = ?', payload.rate)
} },
],
})
```
- An event reaches A only if A declares `koffi` as a satisfied dependency **and**
subscribed to that `(plugin, event)`. Emitting an event not in your `emits` is refused.
- Like core [event subscriptions](#event-subscriptions), handlers run **without a
user** — but unlike them they **do** receive the emitter's payload. Delivery is
fire-and-forget on a short timeout; a slow subscriber never blocks the emitter.
## Testing without a running TREK
`createMockHost` gives you a `ctx` that enforces the **same** permission model, so
a test can prove your plugin degrades gracefully when a grant is missing:
```js
import { createMockHost } from 'trek-plugin-sdk/testing'
const { ctx, broadcasts } = createMockHost({
grants: ['db:read:trips'],
trips: { 1: { members: [42], data: { id: 1, name: 'Japan' } } },
})
await ctx.trips.getById(1, 42) // ok — member
await expect(ctx.trips.getById(1, 99)).rejects… // RESOURCE_FORBIDDEN
await expect(ctx.db.query('SELECT 1')).rejects… // PERMISSION_DENIED (no db:own)
```
The mock db is a recorder — set `queryResults` for canned rows, or use an
integration test for real SQL. To test inter-plugin calls, pass
`pluginExports: { koffi: { convert: (args) => … } }` and assert on `mock.emitted`
for anything your plugin publishes via `ctx.events.emit`.
### Driving your plugin's handlers
`createMockHost` also gives you `run(def)` — the other half of a test. Where the
`ctx` recorders (`calls`, `broadcasts`, `emitted`, `notifications`, `scheduled`)
capture what your plugin *read*, `run` fires each entry point so you can assert
what it *did*, all against the same mock `ctx`:
```js
const host = createMockHost({ grants: ['jobs:run', 'hook:trip-card-provider'], actingUserId: 7 })
const app = host.run(myPlugin)
await app.route({ method: 'GET', path: '/ping' }) // → the route's response
await app.job('refresh') // fire a background job (userless)
await app.scheduled('daily', { tripId: 1 }) // fire the `scheduled` handler
await app.event('place:created', { tripId: 1, entityId: 9 })// deliver a core event to `events`
await app.deleteUserData(42) // GDPR erasure handler
const dump = await app.exportUserData(42) // GDPR export handler
const cards = await app.hook('tripCardProvider', 'getCards', [1, 2]) // any provider hook
host.scheduled.get('daily') // timers the plugin armed via ctx.scheduler
```
A handler your plugin didn't declare throws a clear error (not a silent no-op),
so a test catches a missing `scheduled`/`deleteUserData`/job before release.
#### Settings actions and notification channels
These two have their own driver methods, because the host invokes them in ways a
plain `hook()` call can't express:
```js
const host = createMockHost({ actingUserId: 7, userSettings: { token: 'abc' } })
const app = host.run(myPlugin)
// A settings-page button. USER-INITIATED, so ctx.settings.get() returns the clicker's
// own value. You get back the result the user would actually see: a handler that
// returns nothing is { ok: true }, and one that THROWS is { ok: false, message } —
// the documented contract, so this never rejects.
await app.action('test') // → { ok: true, message: 'Connected' }
// A notification channel. USERLESS — the host fires it for an arbitrary recipient, so
// ctx.settings.get() returns undefined and trip reads are refused; the recipient's
// decrypted settings arrive as the `config` ARGUMENT instead (defaulting to the
// `userSettings` fixture). That asymmetry is the security property of a channel
// plugin — it is handed someone's push token without the right to read their trips.
await app.channel.send({ event: 'trip_invite', title: 'Hi', body: 'Japan' })
await app.channel.test({ token: 'abc' })
```
An event outside [`CHANNEL_EVENTS`](#notification-channels) — or outside your
manifest's `capabilities.notificationChannel.events`, which may only *narrow* that
set — is refused rather than delivered, so a test can't pass on a notification the
host would never route to you. Pass `declaredActions` / `channelEvents` to model
what your manifest declares.
## Rules
- **No native modules** (`.node`, `binding.gyp`, `prebuilds/`) — rejected at pack
and install time.
- **Don't vendor `trek-plugin-sdk`** — it's injected at runtime (devDependency
only). Vendor any *other* runtime deps: TREK never runs `npm install` on a plugin.
- **Ship built JS** in `server/index.js` and pre-built static files in `client/`.
`.ts` and `.map` files are stripped by `pack`.
- Declare every outbound host in `egress[]` whenever you use `http:outbound` — **and
grant each of them a matching `http:outbound:<host>` permission**. The runtime builds
the child process's network allow-list and the iframe's CSP from those *permissions*
only; it never reads `egress[]`, which exists for the admin's consent screen. A host
listed in `egress[]` with no matching permission installs, activates, consents — and
is then silently unreachable. `validate` errors on this.
## 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` \| `trip-page`. |
| `trek` | string, **required** | the TREK versions this plugin supports, as a semver **range**: `">=3.4.0 <4.0.0"` (what the scaffold writes). Must be *satisfiable* (`">=4.0.0 <3.0.0"` parses but nothing can satisfy it — rejected). **Enforced at install and at activation** (see below); it is copied verbatim onto the registry entry, which is the entry's only compatibility field. Keep it **bounded** — an unbounded range claims support for TREK versions that do not exist yet. |
| `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 — **unless** `operatorEgress` is `true`, in which case it may be empty/omitted and the admin supplies the hosts. |
| `capabilities.widget` | object | `{ title, slot, defaultSize }` — `slot` is `sidebar` (default), `hero`, `place-detail`, `day-detail`, or `reservation-detail`. |
| `capabilities.tripPage` | object | `{ replaces?, position? }` for `trip-page` plugins — `replaces` names core planner tabs to hide while active (`transports`, `buchungen`, `listen`, `finanzplan`, `dateien`, `collab`; never `plan`), `position` is the tab's 0-based index in the bar (0–50; omitted = appended). |
| `actions` | array | Buttons on the plugin's own settings page — `{ key, label, hint?, danger? }` (max 8). Implement each as `actions[key](ctx)` on the definition. **User-initiated**, so `ctx.settings.get()` returns the clicking user's value. See [Settings-page actions](#settings-page-actions). |
| `operatorEgress` | boolean | The plugin talks to a **self-hosted** service whose hostname only the operator knows. The admin adds the real hosts after install (Admin → Plugins → Allowed hosts) and the runtime unions them into the egress allow-list. Requires an `http:outbound` permission, and is the only way to declare one with an empty `egress[]`. See [Operator-supplied egress hosts](#operator-supplied-egress-hosts-operatoregress). |
| `capabilities.notificationChannel` | object | `{ title?, events? }` for a plugin implementing the `notificationChannel` hook — `title` names the column in the notification preferences matrix (default: the plugin's `name`), `events` **narrows** which events the channel carries (default: every non-admin event; admin-scoped events are never deliverable). Requires the `hook:notification-channel` permission. See [Notification channels](#notification-channels). |
| `capabilities.routeProfiles` | array | up to 3 `{ id, label, icon? }` entries 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-]` (max 24 chars) and is what `getRoute` receives as `request.profile`; `label` (≤40 chars) is shown to the user. Requires the `hook:route-provider` permission. |
| `capabilities.provides` | string[] | function names this plugin exposes to its dependents via `ctx.plugins.call` (see [Talking to other plugins](#talking-to-other-plugins)). |
| `capabilities.emits` | string[] | event names this plugin publishes to its dependents via `ctx.events.emit`. |
| `requiredAddons` | string[] | addon ids that must be **enabled** for the plugin to activate (see [Dependencies](#dependencies)). |
| `pluginDependencies` | `{ id, version }[]` | other plugins (semver range) that must be installed + version-satisfied to activate. |
| `settings` | array | setting fields (below). |
#### TREK version compatibility (`trek`)
The `trek` range is a **hard contract**, enforced in both directions:
- **Install** is refused when the running TREK falls outside it — on every path:
registry install, an explicitly pinned version, an update, a sideloaded archive,
and a dev-link. A manifest with no range (or an unsatisfiable one) cannot be
installed at all.
- **Activation** re-checks it, which is the half install cannot cover: a plugin
legitimately installed on 3.3 is still on disk after an upgrade to 4.0, and if it
declared `<4.0.0` it now refuses to start. It stays installed and listed,
switched off, with the reason shown (`TREK_VERSION_INCOMPATIBLE`; a plugin that
declares no range at all reports `TREK_VERSION_UNKNOWN`).
- **No admin override.** The range is the author's own statement that the plugin
does not work there — unlike a rotated signing key, there is nothing an operator
could verify out-of-band and wave through.
Two behaviours follow. "Install latest" resolves to the newest version *this* TREK
can run rather than the newest published, so shipping a 2.0.0 that needs TREK 4
doesn't strand 3.x users. And an **update** that would move a working plugin out of
compatibility is refused rather than performed.
One gap, by design: a host whose `APP_VERSION` is not a semver version — the Docker
build arg defaults to the literal `dev` — has nothing to compare a range against, so
the check is skipped and an unversioned build installs anything. Plugins should still
guard optional `ctx.*` namespaces.
**Permissions** — the commonly-used core subset below; the **full list of ~50**
(all read/write scopes, the notify/ai/oauth brokers, every provider hook) lives in
**[[Plugin Permissions|Plugin-Permissions]]**. Unknown values are rejected at activation.
| Permission | Grants |
|---|---|
| `db:own` | `ctx.db` — your own SQLite file |
| `db:read:trips` | `ctx.trips.*` (membership-checked, route handlers only) |
| `db:read:packing` | `ctx.packing.list(tripId)` — a trip's packing items (membership-checked) |
| `db:read:files` | `ctx.files.list(tripId)` — a trip's files, trash excluded (membership-checked) |
| `db:read:costs` | `ctx.costs.getByTrip` / `ctx.costs.listMine` (Costs addon, route handlers only) |
| `db:write:costs` | `ctx.costs.create/update/delete` (Costs addon + acting user's `budget_edit`) |
| `db:write:places` | `ctx.places.create/update/delete` (acting user's `place_edit`) |
| `db:write:days` | `ctx.days.create/update/delete` (acting user's `day_edit`) |
| `db:write:itinerary` | `ctx.itinerary.assign/unassign` (acting user's `day_edit`) |
| `db:write:trips` | `ctx.trips.update` (acting user's `trip_edit`) |
| `db:write:packing` | `ctx.packing.create/update/delete` (acting user's `packing_edit`; private items stay owner-scoped) |
| `db:meta` | `ctx.meta.*` — your own namespaced data on a trip/place/day/reservation/accommodation |
| `db:read:users` | `ctx.users.getById` |
| `events:subscribe` | receive core activity events via `events: [...]` (event name + tripId + a { entity, entityId } hint, plus a whitelisted entity **snapshot** when the plugin also holds the family's `db:read:*` grant; never a user) |
| `hook:trip-card-provider` | `hooks.tripCardProvider` — small badges on the dashboard trip cards |
| `jobs:run` | run declared background `jobs` on their cron schedule **and** `ctx.scheduler` runtime timers → `scheduled` handler (opt-in; no user, so trip reads are refused) |
| `ws:broadcast:trip` | `ctx.ws.broadcastToTrip` |
| `ws:broadcast:user` | `ctx.ws.broadcastToUser` |
| `http:outbound` or `http:outbound:<host>` | outbound HTTP to `egress[]` hosts |
| `hook:place-detail-provider` | `hooks.placeDetailProvider` — extra place rows TREK renders (see [Provider hooks](#provider-hooks)) |
| `hook:trip-warning-provider` | `hooks.warningProvider` — validation warnings in the planner (see [Provider hooks](#provider-hooks)) |
| `hook:table-contributor` | `hooks.tableContributor` — host-rendered columns/actions in the reservations, transports, places, day, costs, packing, files and todos views (see [Provider hooks](#provider-hooks)) |
| `hook:map-marker-provider` | `hooks.mapMarkerProvider` — bounded markers on the trip map |
| `hook:map-layer-provider` | `hooks.mapLayerProvider` — bounded vector overlays (routes, corridors, zones) on the trip map |
| `hook:route-provider` | `hooks.routeProvider` — routing profiles for the planner's route toggle (declared in `capabilities.routeProfiles`) |
| `hook:day-schedule-provider` | `hooks.dayScheduleProvider` — time contributions in the day plan (charging, buffers), totalled in the route footer |
| `hook:day-tint-provider` | `hooks.dayTintProvider` — colours for a day card's badge / header / activity list in the Plan sidebar (e.g. which leg of the trip a day belongs to). One contribution per day: first granted provider wins |
| `hook:pdf-section-provider` | `hooks.pdfSectionProvider` — sections appended to the trip PDF export |
| `hook:atlas-layer-provider` | `hooks.atlasLayerProvider` — per-user country tint layers on the Atlas map |
| `hook:journal-entry-provider` | `hooks.journalEntryProvider` — extra rows on a journal entry card |
| `hook:trip-card-provider` | `hooks.tripCardProvider` — small badges on the dashboard trip cards (`getCards(tripIds, ctx)` → `{ tripId, label, value?, icon?, tone?, url? }[]`; host bounds every field + access-checks each tripId) |
| `hook:user-data` | `deleteUserData` / `exportUserData` handlers — honour GDPR erasure (durable, retried) and data-export for a deleted/requesting user (userless; own db only) |
| `hook:photo-provider` / `hook:calendar-source` | reserved (see [Provider hooks](#provider-hooks)) |
> 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` — the installed-manifest parser only consumes `capabilities.widget`, so
there is nothing else to set. `icon` must be a real lucide name: TREK resolves it at
render time and silently falls back to `Blocks`, which makes a typo invisible locally,
so `validate` rejects one.
See [[Plugin Permissions|Plugin-Permissions]] for the full permission model.
## The `trek-plugin` CLI
Run `npx trek-plugin-sdk` **with no command** in a terminal and you get an
interactive menu — create / dev / status / shot / publish, with validate, pack,
signing and the registry-entry commands under **Advanced…** It picks which command to
run and asks which plugin directory to run it against; that command then prompts for
whatever else it needs. Pass a command explicitly to skip the menu (and for scripts and
CI). `trek-plugin help <command>` — or `trek-plugin <command> --help` — prints a full
page for any command.
**The path is four commands**, and the other nine are steps one of them already does:
```bash
trek-plugin create [name] [--type integration|page|widget|trip-page]
trek-plugin dev [dir] [--port 4317]
trek-plugin status [dir]
trek-plugin publish [dir] --repo owner/name --tag vX.Y.Z [--sign]
```
`status` is the one to reach for when you are not sure what to do next. It runs every
gate the TREK-Plugins registry enforces that can be answered **without a network** —
which is nearly all of them — and prints the whole journey as a checklist grouped by
stage (Manifest, Code, Docs, Release, Repo), with the one command to run next. It never
exits non-zero: it is for orientation, not for gating.
```bash
# The gate. The SAME checks as `status`, but it exits 1. This is the form for CI.
trek-plugin validate [dir]
# 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. It refuses a plugin that could not LOAD, but deliberately does not
# enforce the publish gates (an unwritten README, a missing screenshot) — packing is
# how you sideload a plugin to try it locally.
trek-plugin pack [dir] [--out plugin.zip] [--json]
# Boot the dev server, render the plugin in the themed /preview frame, and write a
# 1600x900 docs/screenshot.png. Needs Playwright (not an SDK dependency — it ships a
# browser): npm i -D playwright && npx playwright install chromium
trek-plugin shot [dir] [--port 4317] [--out docs/screenshot.png] [--dark] [--no-serve]
# Ed25519 signing. keygen once; `publish --sign` is the usual way to use the key.
trek-plugin keygen [--key file]
trek-plugin sign [zip] [--key file]
# Emit the ready-to-PR registry entry: commitSha (resolved from the git tag),
# downloadUrl, sha256, size, and the manifest's `trek` range verbatim — plus
# requiredAddons and pluginDependencies, which the registry parity-checks against the
# manifest. --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]
# The registry checks that genuinely need the network: the tag resolves to the commit
# the entry pins, the released artifact downloads and hashes to the pinned sha256, the
# id is not bound to another GitHub owner, and an update does not drop or rotate a
# signing key you already published under. `publish` runs this for you.
trek-plugin preflight [dir] --repo owner/name --tag vX.Y.Z [--entry file.json] [--all]
# Pack -> cut the GitHub release -> print the entry, without opening the registry PR.
trek-plugin release [dir] --repo owner/name --tag vX.Y.Z [--sign] [--merge entry.json]
# Fork the registry, write registry/plugins/<id>.json, open the PR. Needs `gh`.
trek-plugin submit [dir] --repo owner/name --tag vX.Y.Z [--registry o/n] [--draft]
```
`publish` chains those last steps in the order that matters: **check → pack → release →
preflight → submit**. The local gates run *before* anything is tagged or released,
because a GitHub release is effectively immutable — the registry pins its sha256. See
[[Publishing a Plugin|Plugin-Publishing]].
## 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]].