mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
44c92052a3
Every page was read against the code on dev. What changed: - Pages that described 4.2 behaviour: satellite view on the vector maps, the mobile journal timeline, Collab with its fifth tab, the Review badge, clustered pins on the share page, the Leaflet preset list, the strict guard on fe80::/10, the invite link tools and their scopes, the Public API scope rules, the permission count in the plugin SDK. - Features of this release that no page mentioned: videos in a journal entry, receipts on an expense, the Google transit backend, routing counters, the rating on a place, the navigation menu, car stops, the manual school holiday catalogue and its MCP tools, the Dawarich panel in the Atlas, the release notes modal, plugin frames going fullscreen, the corridor call into the search provider hook. - Statements that sent an admin the wrong way: DEFAULT_LANGUAGE is not in the Helm ConfigMap, env.PORT cannot be changed on the chart alone, the rotation script does not cover the document sync credentials, event handlers cannot broadcast, there is no per user MFA reset in the admin panel, the demo user can edit, an unparsable APP_URL stops the server. - Updating gets a section for 4.3.0: the TREK Places API as the new default, the new variables, the Overpass timeout, SSO sessions on an OIDC only instance, the migration that puts booked nights on the drive.
227 lines
39 KiB
Markdown
227 lines
39 KiB
Markdown
# Plugin Permissions
|
||
|
||
A plugin declares the permissions it needs in `trek-plugin.json`. You review that
|
||
list **before you install** — on the plugin's card under Discover — and it only
|
||
runs once you turn it on. Because plugins run in an isolated process, **an
|
||
ungranted capability is physically unreachable**, not just disallowed. See
|
||
[[Plugins]] for the isolation model.
|
||
|
||
## Reference
|
||
|
||
| Permission | Grants | Notes |
|
||
|---|---|---|
|
||
| `db:own` | Read/write the plugin's **own** SQLite file via `ctx.db` — `db.query`, `db.exec`, **and `db.migrate`** | A separate `plugin.db` per plugin under the plugin's data dir — never TREK's own `travel.db`. `db.migrate` runs a keyed, idempotent migration (schema/table creation, e.g. `CREATE TABLE`) once per id. `ATTACH`/`DETACH`/`VACUUM`/`PRAGMA` are refused. |
|
||
| `db:read:trips` | Read-only trip data via `ctx.trips` (`getById`, `getPlaces`, `getReservations`, `getDays`, `getAccommodations`, `listMine`, `members`) | Every call is **membership-checked** against the acting user — a plugin can't read a trip that user can't see. `getDays` returns each day with its `assignments` + `notes_items`; `getReservations` rows carry `endpoints` + `day_positions` like the REST list. `members` returns the roster (id + display fields only). |
|
||
| `db:read:users` | Read-only public profile via `ctx.users.getById` | Returns id, username, display name, avatar only — **never** password hashes, tokens, or secrets. |
|
||
| `db:read:packing` | Read-only packing items of a trip via `ctx.packing.list(tripId)` | Membership-checked, and scoped to the acting user's visibility — a plugin never sees another member's private packing items. |
|
||
| `db:read:files` | Read-only files of a trip via `ctx.files.list(tripId)` | Membership-checked; trashed files excluded. |
|
||
| `db:read:files:content` | Read a file's **byte content** (base64) via `ctx.files.getContent(tripId, fileId)` | Membership-checked; 10MB cap, trashed files refused. Deliberately split from `db:read:files` — listing metadata never exposes contents. |
|
||
| `db:read:costs` | Read-only costs (budget items) via `ctx.costs` (`getByTrip`, `listMine`) | Membership-checked; needs the Costs addon enabled. |
|
||
| `db:read:collab` | Read a trip's collab notes, polls and chat via `ctx.collab` (`listNotes`, `listPolls`, `listMessages`) | Membership-checked; needs the Collab addon. `listMessages` returns the newest 100 (oldest first), `before` pages back. |
|
||
| `db:read:journal` | The acting user's own travel journals via `ctx.journal.listMine` | User-scoped (across all their trips); needs the Journey addon. |
|
||
| `db:read:atlas` | The acting user's visited countries + regions via `ctx.atlas.visited` | User-scoped; needs the Atlas addon. |
|
||
| `db:read:vacay` | The acting user's vacation plan via `ctx.vacay.mine` | User-scoped; needs the Vacay addon. |
|
||
| `db:read:daynotes` | A trip day's notes via `ctx.daynotes.list(tripId, dayId)` | Membership-checked (trip-scoped). |
|
||
| `db:read:collections` | The acting user's saved-place collections via `ctx.collections` (`listMine`, `get`) | User-scoped; needs the Collections addon. |
|
||
| `db:read:categories` | The global place-category list via `ctx.categories.list()` | Read-only reference; no tenant data. |
|
||
| `db:read:tags` | The acting user's own tags via `ctx.tags.list()` | User-scoped (not trip-scoped); refuses a userless context. |
|
||
| `db:read:todos` | A trip's to-dos via `ctx.todos.list(tripId)` | Membership-checked (trip-scoped). |
|
||
| `weather:read` | The host's cached forecast via `ctx.weather.get(lat, lng, date?)` | Tenant-free read over the host's cache; no user needed. |
|
||
| `rates:read` | The host's cached currency exchange rates via `ctx.rates.get(base)` | Tenant-free read over the host's cache (a quote → rate map relative to `base`); `null` on an upstream failure. |
|
||
| `db:write:costs` | Create/update/delete costs via `ctx.costs.create` / `update` / `delete` | Trip access **+** the `budget_edit` permission **+** the Costs addon. |
|
||
| `db:write:places` | Create/update/delete places via `ctx.places` | Trip access **+** the `place_edit` permission. Input validated against TREK's schema; every write audited. |
|
||
| `db:write:days` | Create/update/delete days via `ctx.days` | Trip access **+** the `day_edit` permission. |
|
||
| `db:write:itinerary` | Assign/remove places on days via `ctx.itinerary` | Trip access **+** the `day_edit` permission (it's a day edit). |
|
||
| `db:write:trips` | Update trip details via `ctx.trips.update` | Trip access **+** `trip_edit`. Only schema-writable fields; **archiving** additionally needs `trip_archive` and **cover_image** needs `trip_cover_upload` (same split as the web UI). |
|
||
| `db:create:trips` | Create a **new trip owned by the acting user** via `ctx.trips.create(input)` | The acting user needs the app's `trip_create` right, and a bound user is required (a job can't create one). `title` required; input validated against TREK's trip schema. Unlocks importers (Google MyMaps, booking dumps, calendar sync). |
|
||
| `db:write:reservations` | Create/update/delete bookings via `ctx.reservations` | Trip access **+** `reservation_edit`. Full parity with the app — accommodation, budget-sync, booking notifications and `reservation:*` broadcasts all fire as they do in the web UI. Transport bookings take an `endpoints` array (from/to/stop legs with name + coordinates); on update, omitting the field keeps them, `[]` deletes them all. |
|
||
| `db:write:accommodations` | Create/update/delete lodging blocks (`day_accommodations`) via `ctx.accommodations` | Trip access **+** `day_edit` (accommodations live in the day service — same gate as the REST path, *not* `reservation_edit`). Creating one auto-creates its partner hotel reservation; deleting cascades the linked reservation/budget row, broadcasts included. |
|
||
| `db:write:daynotes` | Create/update/delete day notes via `ctx.daynotes` | Trip access **+** `day_edit`; broadcasts `dayNote:*`. |
|
||
| `db:write:packing` | Create/update/delete packing items **+ bags** via `ctx.packing` (items + `listBags`/`createBag`/`updateBag`/`deleteBag`/`setBagMembers`) | Trip access **+** `packing_edit`. Reproduces the #858 privacy model for items: a **private** item's events reach only its owner (+ recipients), never the whole trip room. Bags carry no privacy. |
|
||
| `db:write:tags` | Create/edit/delete the acting user's own tags via `ctx.tags` | User-scoped; ownership re-checked before each write. |
|
||
| `db:write:atlas` | Mark/unmark visited countries + regions and manage the bucket list via `ctx.atlas` | All rows are the **acting user's own** — no trip scoping, no cross-tenant surface. Needs the Atlas addon; unblocks e.g. AirTrail-style two-way sync. |
|
||
| `db:write:vacay` | Toggle PTO days + company holidays via `ctx.vacay` | The plan is resolved **host-side from the acting user's active plan** — a plugin can never name another plan; `toggleEntry` only toggles the acting user's own day. Needs the Vacay addon. |
|
||
| `db:write:journal` | Create/edit/delete journal entries, **attach photos to them**, **and create/delete whole journals** via `ctx.journal` (`createEntry`/`updateEntry`/`deleteEntry`/`addEntryPhoto` plus `createJourney`/`deleteJourney`) | Entry writes go through the journey domain service's `canEdit`: the journal's owner, or a contributor whose role is `editor`/`owner` — a `viewer` contributor is refused. `createJourney` has no journey-level gate (there is no journey yet); it always creates a **new journal owned by the acting user**, which is how importers bootstrap the journal they then fill. `deleteJourney` is **owner-only** (`isOwner`), not `canEdit` — a contributor, even an editor, cannot delete the journal. `addEntryPhoto` takes the bytes themselves, for an importer that holds an export archive and has no gallery photo to point at: images only, no SVG, 10 MB decoded, the stored filename is the host's, and the operator's allowed-file-types setting applies exactly as it does to the upload form. Every call needs a bound acting user (a no-user job is refused) and the Journey addon. |
|
||
| `db:write:collections` | Create/edit collections, save places, copy to a trip via `ctx.collections` | The service enforces the acting user's **per-collection role** (owner/admin/editor) itself. Needs the Collections addon. |
|
||
| `db:write:files` | Attach files + manage links via `ctx.files` (`create`, `createLink`, `update`, `softDelete`) | Trip access **+** the app's `file_upload`/`file_edit`/`file_delete` rights respectively. Content arrives as base64 (10MB cap); the extension is validated against the central blocklist before touching disk; link targets must live on the same trip. |
|
||
| `db:write:collab` | Post notes, polls and chat messages via `ctx.collab` | Trip access **+** `collab_edit`; needs the Collab addon. Broadcasts the same `collab:*` events as the app. |
|
||
| `db:write:members` | Add/remove a user on a trip via `ctx.trips.addMember` / `removeMember` | **Grants (and revokes) trip access** — deliberately its own permission behind the app's `member_manage` right (default: trip owner only), never bundled with a lower-risk write. The acting user is recorded as the inviter; the owner can never be removed. |
|
||
| `db:write:todos` | Create/edit/delete a trip's to-dos via `ctx.todos` | Trip access **+** `packing_edit` (the app gates to-dos with the same right). |
|
||
| `db:meta` | Store the plugin's **own** private key/value data on a trip/place/day/**reservation**/**accommodation** via `ctx.meta` | Namespaced per plugin (a plugin only sees its own rows). Reads need trip **access**; **writes** additionally need the entity's edit permission (`trip_edit`/`place_edit`/`day_edit`; reservations use `reservation_edit`, accommodations use `day_edit`). The natural home for an external-id mapping (AirTrail/calendar/booking-import sync) without forking the schema. Quotas: ≤256-char key, ≤64 KB value, ≤100 keys per entity. Purged on uninstall-with-delete-data. |
|
||
| `ws:broadcast:trip` | Push a real-time event to a trip room via `ctx.ws.broadcastToTrip` | Event types are force-namespaced `plugin:<id>:<event>` — a plugin can't forge a core event. |
|
||
| `ws:broadcast:user` | Push a real-time event to a user's connections | Same namespacing. |
|
||
| `notify:send` | Send a persisted notification (bell inbox + email/ntfy/webhook fan-out) via `ctx.notify.send` | Host-mediated: the host owns recipient resolution, channel fan-out and per-user preferences. Recipients are **forced** to the acting user (`scope:'user'`, `targetId` = the acting user) or a trip they belong to (`scope:'trip'`); `scope:'admin'` is refused. The plugin supplies only a plain-text title/body (caps 200/1000) + an optional in-app `link` (must be a relative `/…` path — open-redirect-safe). No arbitrary recipient, no impersonation. Budgeted: 100 sends/day per plugin, resetting at UTC midnight. |
|
||
| `oauth:client` | Become an OAuth *client* of a third-party service via `ctx.oauth.getAccessToken()` | Host-brokered: the host runs the whole flow (authorize→callback→token→refresh) with **PKCE + state** and **holds the tokens** — client secret + refresh token never leave the host. Provider config (`oauth_authorize_url`/`oauth_token_url`/`oauth_scopes` + the secrets `oauth_client_id`/`oauth_client_secret`) is the plugin's **admin-owned instance settings**; endpoints must be **https** (SSRF backstop). Each user connects under **Settings → Plugins**; tokens are per-user + encrypted at rest. The plugin only ever gets a **short-lived access token** for the acting user. |
|
||
| `ai:invoke` | Run the admin/user-configured LLM via `ctx.ai.complete` / `ctx.ai.extract` | Host-mediated: the host holds the (encrypted) credential and runs the call under the acting user's resolved provider — the plugin never sees a key. Refused when no provider is configured; prompt/text capped at 20 000 chars. Output is **DATA** — `complete` returns `{ text }`, `extract` returns `{ results }` for your JSON schema — and is never auto-written, so prompt-injection can't reach a write without your own gated call. Budgeted: 200 calls/day per plugin, resetting at UTC midnight. |
|
||
| `events:subscribe` | React to core activity via `events: [{ on, handler }]` on the plugin definition | The handler gets the **event name + tripId + a `{ entity, entityId }` hint**, plus a **whitelisted `snapshot` of the changed entity when the plugin also holds the family's `db:read:*` grant** (per-plugin filtered at deliver time — no grant, no fields). It runs with **no user** (like a job), so nothing beyond the snapshot is readable. The whitelist never carries user ids, private packing items (#858) or secrets; deletes/bulk/reorder events carry no snapshot, a non-entity id (e.g. a userId) never surfaces. Fire-and-forget on a short timeout; `plugin:*` re-broadcasts are never delivered back. |
|
||
| `jobs:run` | Run the plugin's declared background `jobs` on their cron schedule **and** its runtime timers via `ctx.scheduler` (`at`/`in`/`every`/`cancel`) | **Opt-in.** Scheduled work runs with **no user** (its trip reads are refused), so a job can only touch its own `ctx.db` and declared egress. Invalid cron expressions are skipped; jobs stop when the plugin is deactivated. `ctx.scheduler` tasks are persisted (survive restarts), capped at 100/plugin with an 8 KB payload and a 60 s minimum recurring interval, and removed on uninstall. |
|
||
| `hook:photo-provider` | Register as a photo provider in Memories | Implement the `PhotoProvider` interface. |
|
||
| `hook:calendar-source` | Register as a calendar source | Implement the `CalendarSource` interface. |
|
||
| `hook:place-detail-provider` | Contribute extra details (reviews, ratings, links) to a place via the `hooks.placeDetailProvider` provider hook | Implement `PlaceDetailProvider` in `hooks` on the plugin definition (not on `ctx`) — shown in the place-detail panel; also exposed at `GET /api/place-details/:placeId`. |
|
||
| `hook:search-provider` | Answer place searches from an index TREK does not ship, beside its own results | Implement `SearchProvider` in `hooks` on the plugin definition — hits are drawn into the app’s search list; also exposed at `GET /api/plugin-search` and as the `search_places_via_plugins` MCP tool. Only these results can carry a `rating`: open data has none. The Road trip search along the route calls the same hook once per kind, with `category` and `bounds` added to the request and a fixed category phrase as `query`; hits outside `bounds`, or of a different road trip kind, are dropped. |
|
||
| `hook:trip-warning-provider` | Raise validation warnings on a trip via the `hooks.warningProvider` provider hook | Implement `WarningProvider` in `hooks` on the plugin definition (not on `ctx`) — shown as a non-blocking banner in the planner; also exposed at `GET /api/trip-warnings/:tripId`, and to a connected assistant as the `get_trip_warnings` MCP tool (≤20 warnings per provider, message ≤300 chars) — that path needs only the trips read scope, not `plugins:use`. |
|
||
| `hook:map-marker-provider` | Overlay bounded **markers** on the trip map via the `hooks.mapMarkerProvider` provider hook | Implement `MapMarkerProvider` in `hooks` — returns `{id, lat, lng, label?, popupText?, url?, icon?, tone?}[]` (#587 "show bookings on map"). **Declarative only** — plugin JS never runs on the map canvas. The host range-checks coordinates (−90..90 / −180..180), String-coerces + length-caps text, allowlists the popup url (http/https/mailto), and caps the marker count (≤200) per plugin; a failing provider is skipped. Exposed at `GET /api/map-markers/:tripId`. |
|
||
| `hook:map-layer-provider` | Overlay bounded **vector layers** (polylines, polygons, metric circles) on the trip map via the `hooks.mapLayerProvider` provider hook | Implement `MapLayerProvider` in `hooks` — returns `{id, name?, features}[]` where a feature is `{type, points?/center?+radiusM?, tone?, width?, dash?, opacity?, fill?, label?}`. **Declarative only** — the host draws everything; styling is the tone palette plus clamped numerics (width 1–8, opacity 0.05–1, radius ≤2000 km) and a dash enum, so a layer can't impersonate core UI. Budgets per plugin: ≤4 layers / ≤150 features / ≤8000 vertices / ≤2000 vertices per shape; invalid or oversized shapes are dropped whole (never truncated); a failing provider is skipped. Drawn beneath TREK's own routes. Exposed at `GET /api/map-layers/:tripId`. |
|
||
| `hook:route-provider` | Offer **routing profiles** the planner's route toggle can route days with, via the `hooks.routeProvider` provider hook | Implement `RouteProvider` in `hooks` and declare the profiles in `capabilities.routeProfiles` (max 3; each `{id, label, icon?}`). TREK calls `getRoute({tripId, dayId, profile, waypoints})` for exactly the profile the user picked — this is **targeted**, not a fan-out — with a 20 s timeout so the plugin can call an external solver through its declared egress. The result (`coordinates`, `distance`, `duration`, `legs`, `viaPoints?`) is validated whole (leg count must equal `waypoints−1`, ≤10000 vertices, ≤40 vias); anything malformed is discarded and the planner falls back to straight lines. Exposed at `POST /api/plugin-routes/:pluginId/:profileId` (member-gated per trip). |
|
||
| `hook:day-schedule-provider` | Attach **time contributions** to the day plan via the `hooks.dayScheduleProvider` provider hook | Implement `DayScheduleProvider` in `hooks` — returns `{id, dayId, assignmentId?/reservationId?/position?, minutes?, label, tone?}[]`. Rows render under their anchor (a place/booking row, or a day's start/end) on desktop and mobile, and `minutes` is **folded into the day's route-footer total** — the one hook whose output feeds displayed timing, which is why minutes are clamped (1–1440) and `dayId` is checked against the trip's own days. ≤60 items per plugin, labels sanitized + capped (≤120); a failing provider is skipped. Exposed at `GET /api/day-schedule/:tripId`. |
|
||
| `hook:day-tint-provider` | Colour-code **whole days** in the day plan via the `hooks.dayTintProvider` provider hook | Implement `DayTintProvider` in `hooks` — returns `{dayId, tone?, color?, badgeTone?, badgeColor?, headerTone?, headerColor?, activityTone?, activityColor?, label?}[]`, one entry per day you want coloured. A day card has **three separately tintable regions** — the day-number badge (which is also the mobile day chip), the header row, and the expanded activity list — so you can mark a leg boldly on the badge and leave the dense activity list plain. `tone` / `color` are shorthands that paint every region you do not name; omit both and only the regions you name are tinted, and the rest render exactly as they do without your plugin. An entry that would paint nothing at all — no region named and no usable shorthand — still means "tint this day", so all three regions fall back to the `default` tone. Paint a region with a palette tone or with your own colour — **`#rrggbb` only**, since the value ends up inside a CSS colour the host builds; a colour beats a tone within a region, and a region's own value beats the shorthand. **You choose the hue, the host chooses the weight**: it sets the alpha per theme and per region (a large surface behind dense text gets a fainter tint than a small badge) and clamps your colour's lightness into a band that reads on both the light and the dark sidebar, so no contribution can hand a user a day card they cannot read. A tinted header hovers to a deeper mix of its own colour instead of losing it. `dayId` is checked against the trip's own days, `label` (≤60) becomes the day's tooltip, the raw array is sliced at 2000. **A day takes at most one contribution, and it is resolved whole**: within one provider's list the first entry for a day wins, and across plugins the first granted provider wins — so a day can't flicker between colours, and a losing plugin can't fill in the winner's untinted regions. Unlike `hook:day-schedule-provider` there is no fixed item cap; the bound is the trip's day count, which is what makes this usable on a six-month itinerary. A failing provider is skipped. Exposed at `GET /api/day-tints/:tripId`. |
|
||
| `geolocation:read` | Ask the HOST for the browser's live position from the plugin's sandboxed frames (`window.trek.geolocation`) | A **bridge-level** permission — it unlocks no ctx RPC method. The sandbox itself never gains the geolocation API: the host page reads `navigator.geolocation` (the browser's own site permission prompt still applies) and posts plain `{lat, lng, accuracy, heading, speed, timestamp}` data into the frame over postMessage. `get()` resolves one fix; `watch(cb)` streams updates until unsubscribed, and the host force-stops the GPS watch when the frame closes. Nothing is sent to the server — a position only reaches the plugin's server code if its own client ships it through one of its routes (visible in that route's traffic like any other call). |
|
||
| `hook:table-contributor` | Contribute host-rendered **columns/actions** into a native planner view via the `hooks.tableContributor` provider hook | Implement `TableContributor` in `hooks` — returns declarative column/action leaves keyed by `entityId` (never markup). The host normalizes + bounds every field (length caps, `http`/`https`/`mailto`-only urls, enum tone/target) and renders them in the reservations, transports, places, day, costs, packing, files and todos views; an action opens your sandboxed frame or calls one of your routes. Also exposed at `GET /api/view-contributions/:view/:tripId`. |
|
||
| `hook:pdf-section-provider` | Append text-only **sections** to the trip PDF export via the `hooks.pdfSectionProvider` provider hook | Implement `PdfSectionProvider` in `hooks` — returns `{title, paragraphs?, table?}[]` of plain strings the export escapes and lays out itself; no markup ever reaches the document. The host caps the section/paragraph/header/row counts (≤5/≤20/≤8/≤50) and every string length, and clips table rows to the header width; a failing provider is skipped. Exposed at `GET /api/pdf-sections/:tripId`. |
|
||
| `hook:atlas-layer-provider` | Draw country **tint layers** over the Atlas world map via the `hooks.atlasLayerProvider` provider hook | Implement `AtlasLayerProvider` in `hooks` — returns `{id, name?, countries: [{code, tone?, label?}]}[]` for the **acting user** (the hook takes no target parameter, so a plugin can't ask for anyone else's map). Codes must be ISO-3166 alpha-2 (uppercase-coerced, anything else dropped), tone is enum-whitelisted, counts capped (≤3 layers, ≤300 countries each). **Declarative only** — plugin JS never runs on the map canvas. Exposed at `GET /api/atlas-layers`. |
|
||
| `hook:journal-entry-provider` | Contribute extra **rows** under a journal entry via the `hooks.journalEntryProvider` provider hook | Implement `JournalEntryProvider` in `hooks` — returns `{label, value?, url?}[]` per entry, rendered under the entry card. The entry's journey is access-checked against the acting user (owner/contributor, like the journal detail routes) and the Journey addon must be on. The host caps the row count (≤12) + lengths (label 60, value 200) and allowlists the url (http/https/mailto); a failing provider is skipped. Exposed at `GET /api/journal-entry-rows/:entryId`. |
|
||
| `hook:trip-card-provider` | Add small **badges** to the dashboard trip cards via the `hooks.tripCardProvider` provider hook | Implement `TripCardProvider` in `hooks` — `getCards(tripIds, ctx)` is called ONCE with all the trip cards currently on the user's dashboard (each already access-checked for the acting user), returns `{tripId, id, label, value?, icon?, tone?, url?}[]`. **Declarative only** — plugin JS never runs on the dashboard. The host String-coerces + length-caps every field, enum-whitelists the tone, allowlists the url (http/https/mailto), caps the count (≤4 badges per trip, ≤240 total per plugin) and drops any badge whose `tripId` the dashboard didn't ask about; a failing provider is skipped. Exposed at `GET /api/trip-card-contributions?tripIds=…`. |
|
||
| `hook:notification-channel` | Register a new **notification channel** (Gotify, Pushover, …) via the `hooks.notificationChannel` provider hook | Implement `NotificationChannel` in `hooks` — `send(msg, config, ctx)` receives a notification TREK has **already rendered** into the recipient's language (`{event, title, body, url?, tripName?}`) plus that recipient's own decrypted `scope:'user'` settings as `config`. Unlike every other hook this one is **host-initiated for an arbitrary recipient**, so it runs **userless**: `ctx.settings.get()` returns `undefined` and trip reads are refused — the recipient's credentials arrive as `config` precisely so the plugin never gains the right to read anything *as* them. Optional `test(config, ctx)` backs the "Send test" button. Declare the channel with `capabilities.notificationChannel: { title?, events? }`; `title` names the column in the preferences matrix (default: the plugin's name) and `events` may **narrow** which events it carries (default: every non-admin event — admin-scoped events are never deliverable to a plugin). Throw on failure: the host logs it and isolates it, so a dead channel can't stop the others. |
|
||
| `hook:user-data` | Honour GDPR **data-subject rights** — erase and export the data the plugin stores about a user — via the `deleteUserData` / `exportUserData` handlers | Implement `deleteUserData({userId}, ctx)` and/or `exportUserData({userId}, ctx)` on the plugin definition (not on `ctx`). Both are **userless** (no acting user; the plugin only learns the `userId` and touches its OWN db). When a TREK account is deleted, the host queues an erasure for every plugin holding this grant and retries it **durably** until the plugin ACKs — even across restarts — so implement `deleteUserData` idempotently. `exportUserData` returns a JSON-serialisable value the host aggregates for an admin at `GET /api/admin/plugins/user-data/:userId/export`. |
|
||
| `mcp:tools` | Publish **MCP tools** on TREK's own MCP server via the `hooks.mcpToolProvider` provider hook | Declare the tools in `capabilities.mcpTools` (≤8; name, required description, optional JSON-Schema `inputSchema` + MCP annotations) and implement `mcpToolProvider` in `hooks` — `tools: string[]` plus one `callTool({name, args}, ctx)` for all of them. Only the **intersection** of the manifest declaration and the `tools` array is advertised (silently — keep the lists identical), each as `plugin_<id>_<name>` to assistants holding the opt-in `plugins:use` OAuth scope. `callTool` runs **as the requesting MCP user** (route-like; membership-checked reads, 15 s timeout) and this grant unlocks no `ctx` method of its own — what a tool can do is set by the plugin's other grants. Arguments are validated against the declared schema before the plugin runs; results are capped (64 KiB / 32 blocks) and every string sanitised; annotation hints are clamped against the plugin's grants (`readOnlyHint` can only be lowered, `openWorldHint` is forced true with any `http:outbound*`). Any lifecycle/grant change closes live MCP sessions so the surface is re-advertised. The only hook permission not named `hook:*`. |
|
||
| `http:outbound` / `http:outbound:<host>` | Make outbound network requests | **Requires** a non-empty `egress[]` — unless the manifest sets `operatorEgress: true`. Only a **per-host** `http:outbound:<host>` actually opens a host at runtime — see below. |
|
||
|
||
## Outbound network — `http:outbound` vs `http:outbound:<host>`
|
||
|
||
This is the one permission with a subtlety worth reading twice.
|
||
|
||
Two independent guards restrict a plugin's network, and **both are built from the
|
||
`http:outbound:<host>` permissions you grant — not from the `egress[]` array**:
|
||
|
||
- the **runtime egress guard** inside the sandboxed child (any connect to a host
|
||
that isn't allow-listed is rejected), and
|
||
- the plugin iframe's **CSP `connect-src`** (the client can only fetch the same
|
||
hosts).
|
||
|
||
`egress[]` is a **separate declaration**: the manifest validator checks its shape,
|
||
but never cross-checks it against the `http:outbound:<host>` permissions you
|
||
granted. The rules it enforces are narrow:
|
||
|
||
- Only the permissions above are accepted; an unknown string fails validation.
|
||
- If **any** `http:outbound` permission (bare or per-host) is declared, `egress[]`
|
||
must be **non-empty** — *unless* the manifest sets `operatorEgress: true`, whose
|
||
hosts arrive from the admin after install (see below).
|
||
- Every entry must be a bare host or a `*.suffix` wildcard: a bare `*`, a whole-TLD
|
||
`*.com` and anything carrying a scheme are rejected.
|
||
|
||
### `operatorEgress` — hosts only the operator knows
|
||
|
||
A plugin that talks to a **self-hosted** service (a Gotify, an ntfy) cannot name the
|
||
operator's hostname at publish time. Setting `"operatorEgress": true` in the manifest lets
|
||
an **admin** add hosts after install (**Admin → Plugins → ⋯ → Allowed hosts**); the runtime
|
||
unions them into the child's allow-list and re-spawns the plugin.
|
||
|
||
It is not an escape hatch:
|
||
|
||
- Only a plugin whose manifest **declared** `operatorEgress` can be given hosts — so the
|
||
consent given at install still bounds what is possible.
|
||
- Only an **admin** can add one. An end user never widens a plugin's egress, even for a
|
||
plugin whose credentials they supply themselves.
|
||
- Added hosts are validated exactly like manifest egress (no bare `*`, no whole-TLD
|
||
wildcard, no scheme), and are dropped when the plugin is uninstalled.
|
||
- It requires an `http:outbound` permission — the manifest rejects it otherwise.
|
||
- It is the **only** way to declare outbound with an empty `egress[]` (a plugin whose target
|
||
is *always* self-hosted). Such a plugin activates, but reaches nothing until an admin adds
|
||
a host — an empty allow-list blocks everything, it never means "any host".
|
||
|
||
A LAN/loopback host additionally needs `TREK_PLUGIN_ALLOW_PRIVATE_EGRESS=on`, which relaxes
|
||
private-address egress for **every** installed plugin.
|
||
|
||
Because the validator never cross-checks `egress[]` against the granted hosts:
|
||
|
||
> [!WARNING]
|
||
> **A host you list in `egress[]` but forget to grant as `http:outbound:<host>`
|
||
> is silently blocked at runtime.** Validation passes, install passes — then every
|
||
> request to that host is refused by the egress guard and the iframe CSP, with no
|
||
> manifest error to warn you. **List every host you call as *both* an
|
||
> `http:outbound:<host>` permission *and* an `egress[]` entry, and keep the two
|
||
> identical.**
|
||
|
||
**Bare `http:outbound`** (no host) satisfies the "non-empty `egress[]`" rule but
|
||
contributes **no host** to either guard — on its own it reaches nothing at
|
||
runtime. Use it only alongside the specific `http:outbound:<host>` grants for the
|
||
hosts you actually call.
|
||
|
||
A host may be an exact name (`api.example.com`) or a `*.suffix` wildcard
|
||
(`*.example.com`, matching the apex and any sub-domain). Even an allow-listed host
|
||
is refused if it resolves to a loopback / private / link-local / metadata address
|
||
(the SSRF backstop).
|
||
|
||
## Declaring them
|
||
|
||
```jsonc
|
||
{
|
||
"permissions": ["db:own", "db:read:trips", "http:outbound:api.example.com"],
|
||
"egress": ["api.example.com"] // mirror every http:outbound:<host> here
|
||
}
|
||
```
|
||
|
||
## Publishing — the `trek-plugin` CLI
|
||
|
||
The `trek-plugin-sdk` package ships a `trek-plugin` CLI that builds the release
|
||
artifact and the registry entry for you, so you never hand-compute a sha256,
|
||
size, or commit sha. Run it with `npx trek-plugin-sdk <command>`. The full submission
|
||
flow is in [[Publishing a Plugin|Plugin-Publishing]].
|
||
|
||
| Command | What it does |
|
||
|---|---|
|
||
| `trek-plugin create [name]` | Scaffold a plugin. With no name it runs an interactive wizard (id, type, author, permissions); with a name it takes `--type`/`--author`/`--permissions` flags. |
|
||
| `trek-plugin dev [dir]` | Run the plugin locally with a real request loop + hot reload — no full TREK. The injected `ctx` enforces your granted permissions, `db:own` is a real SQLite file, routes serve under `/api/<path>`, and page/widget UI at `/ui`. |
|
||
| `trek-plugin validate [dir]` | Manifest + layout checks, and the gate for publishing: parses the manifest with the same rules as install, then runs every registry gate that can be answered from the working tree — exiting non-zero on any of them. It **fails** if `README.md` is missing, is missing one of the four required sections, has too little prose, still holds template placeholders, does not explain every permission the manifest declares, or is missing `docs/screenshot.png` (the exact file the store card loads — README image links pointing anywhere else do not satisfy the gate; `trek-plugin shot` writes it, and `preflight` re-verifies it at the pinned commit); it also fails if `server/index.js` was never built. Failures that only the registry cares about do **not** block `trek-plugin pack`, so the dev loop keeps working while the docs are still a stub — but they do stop `publish`, which runs these same checks first and refuses before anything is packed, tagged or released. A handful of checks are *warnings* that never fail the command: directory name ≠ plugin id, an unbounded `trek` range, an unknown required addon, emoji in manifest text, an undeclared egress host, and a page/widget not using the design kit. This is a **subset** of registry CI — CI additionally verifies the release tag/commit, the artifact's sha256, and the README over the network. A local pass predicts a CI pass. |
|
||
| `trek-plugin preflight --repo <o/n> --tag <vX>` | Runs the **full** registry CI checks locally over the network (tag→commit, manifest parity, artifact sha256/size, native scan, README quality gate) against your pushed release — so you catch a CI failure before opening the PR. |
|
||
| `trek-plugin submit --repo <o/n> --tag <vX>` | Opens the registry PR for you: forks TREK-Plugins, fast-forwards the fork, branches off the registry's current main, writes/merges `registry/plugins/<id>.json`, pushes, and creates the PR. Requires `gh`. |
|
||
| `trek-plugin publish --repo <o/n> --tag <vX>` | **The one-command release**: local gates → pack → tag + GitHub release → preflight → open the registry PR. Nothing is packed, tagged or released until the local gates pass (`--no-checks` skips them). A failure after the release is cut rolls back the tag and release that run created, so the same tag can be re-used (`--keep-release` opts out). Add `--sign` to sign it; `--sign --allow-key-change` rotates your signing key as part of the release. Requires `git` + `gh`. |
|
||
| `trek-plugin unrelease <vX> --repo <o/n>` | Deletes a stranded GitHub release + remote tag + local tag. Refuses a version that is published in the registry (its artifact is immutable); `--yes` consents when the registry index can't be checked. |
|
||
| `trek-plugin keygen` / `sign` / `rotate-key` | `keygen` creates an Ed25519 signing key; `sign` (or `--sign` on `entry`/`release`/`submit`) signs the artifact and fills `authorPublicKey` + `signature` so TREK pins your identity (TOFU). `rotate-key` moves a published plugin to a **new** key without shipping a version: it re-signs every pinned artifact with the new key and opens a rotation PR (a maintainer must label it `allow-key-change`; every admin must re-trust). `publish --sign --allow-key-change` rotates as part of a release. |
|
||
| `trek-plugin pack [dir] [--out plugin.zip] [--json]` | Validates, then builds `plugin.zip` in the installer's exact layout (`trek-plugin.json`, `README.md`, `LICENSE`, `package.json` at the root; `server/` and `client/` recursed) and prints its **sha256 + byte size**. Skips `node_modules`, `.git`, `.ts` and `.map` files, and **refuses native binaries** (`.node`, `binding.gyp`, `prebuilds/`) and over-size archives, same as the installer. **`docs/` is intentionally NOT shipped** — the store fetches your screenshot from `docs/screenshot.png` in the repo. |
|
||
| `trek-plugin entry --repo <owner/name> --tag <vX.Y.Z> [--zip plugin.zip] [--merge entry.json] [--out file]` | Emits the ready-to-PR registry entry: `commitSha` (resolved from the tag), `downloadUrl`, `sha256` + `size` (from the packed zip), and the manifest's **`trek` range verbatim** — the entry's only compatibility field. `--merge` prepends this version onto an existing `registry/plugins/<id>.json` for an update, keeping versions newest-first (registry CI enforces that ordering). |
|
||
| `trek-plugin release [dir] --repo <owner/name> --tag <vX.Y.Z>` | The one-shot: `pack` → `gh release create` (uploads the zip) → print the registry `entry`. Requires the `gh` CLI authenticated. |
|
||
|
||
### Registry policy
|
||
|
||
- **No reserved namespaces.** Any unique lowercase slug id is accepted (3–40
|
||
chars, `[a-z][a-z0-9-]*`). The only refused ids are `registry`, `install`, and
|
||
`rescan`, which would collide with admin API routes.
|
||
- **Owner-binding still holds.** An id is bound to its GitHub owner on first
|
||
registration, so nobody can repoint an existing plugin id to a different repo.
|
||
- **Optional author signing.** A registry entry may carry an `authorPublicKey`
|
||
(stable across versions) and a per-version `signature`. TREK verifies it offline
|
||
and pins the key trust-on-first-use. Signing is opt-in — an unsigned entry
|
||
installs on `sha256` alone — but once a plugin has shipped signed, an unsigned
|
||
update for it is refused, and a key change is only accepted as a deliberate,
|
||
maintainer-approved rotation (`rotate-key` / `--allow-key-change`) that each
|
||
admin must re-trust. See [[Publishing a Plugin|Plugin-Publishing]].
|
||
|
||
## Not a permission — settings-page actions
|
||
|
||
A plugin can contribute buttons to its own settings form (`actions` in the manifest — a
|
||
"Test connection", a "Sync now"). These need **no permission**: an action is the plugin's
|
||
own code, and it is run **for the user who clicked it**, so `ctx.settings.get()` returns
|
||
that user's value and any trip read is membership-checked against them — the same gates as
|
||
a route handler. Anything the action *does* (an outbound call, a trip write) still needs
|
||
that capability's own permission. The host refuses any action key the manifest didn't
|
||
declare.
|
||
|
||
A `scope: "instance"` action, which renders in **Admin → Plugins → ⋯ → Instance settings**,
|
||
is **admin-surfaced but not admin-privileged**: it runs as the clicking admin's own user
|
||
like any other action, so `ctx.settings.get()` returns *their* value and trip reads are
|
||
membership-checked against them — an admin who is not on a trip still cannot read it
|
||
through a plugin button. The scope is fixed by the route each button posts to, so a
|
||
user-scoped key can never be fired from the admin route, nor an instance-scoped one from a
|
||
user's settings page. See [[Plugin Development#settings-page-actions|Plugin-Development]].
|
||
|
||
## Not a permission — inter-plugin calls & events
|
||
|
||
Calling another plugin (`ctx.plugins.call`) and exchanging events
|
||
(`ctx.events.emit` / `subscriptions`) are **not** gated by a permission. Their grant
|
||
is the **dependency declaration**: a plugin may call or subscribe to another only if
|
||
it lists it as a satisfied `pluginDependency`, and only for the function/event names
|
||
that plugin publicly declares in `capabilities.provides` / `capabilities.emits`.
|
||
Calls run mediated by the host, carry the caller's acting user (so trip reads stay
|
||
membership-checked), and are recorded in the capability audit log. See
|
||
[[Plugin Development|Plugin-Development#talking-to-other-plugins]] and
|
||
[[Plugin Development|Plugin-Development#dependencies]].
|
||
|
||
## What is NOT covered
|
||
|
||
Isolation bounds *what* a plugin can touch, not its intent within a grant. A
|
||
plugin you allow to read trip data **and** reach `api.example.com` could send
|
||
that trip data there. So review the permissions and outbound hosts before you
|
||
install — grant only what you'd trust the plugin to do with your data. Prefer
|
||
**Reviewed** plugins and authors you trust. To build one, see [[Plugin Development|Plugin-Development]].
|