# 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::` — 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__` 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:` | Make outbound network requests | **Requires** a non-empty `egress[]` — unless the manifest sets `operatorEgress: true`. Only a **per-host** `http:outbound:` actually opens a host at runtime — see below. | ## Outbound network — `http:outbound` vs `http:outbound:` This is the one permission with a subtlety worth reading twice. Two independent guards restrict a plugin's network, and **both are built from the `http:outbound:` permissions you grant — not from the `egress[]` array**: - the **runtime egress guard** inside the sandboxed child (any connect to a host that isn't allow-listed is rejected), and - the plugin iframe's **CSP `connect-src`** (the client can only fetch the same hosts). `egress[]` is a **separate declaration**: the manifest validator checks its shape, but never cross-checks it against the `http:outbound:` 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:` > is silently blocked at runtime.** Validation passes, install passes — then every > request to that host is refused by the egress guard and the iframe CSP, with no > manifest error to warn you. **List every host you call as *both* an > `http:outbound:` permission *and* an `egress[]` entry, and keep the two > identical.** **Bare `http:outbound`** (no host) satisfies the "non-empty `egress[]`" rule but contributes **no host** to either guard — on its own it reaches nothing at runtime. Use it only alongside the specific `http:outbound:` grants for the hosts you actually call. A host may be an exact name (`api.example.com`) or a `*.suffix` wildcard (`*.example.com`, matching the apex and any sub-domain). Even an allow-listed host is refused if it resolves to a loopback / private / link-local / metadata address (the SSRF backstop). ## Declaring them ```jsonc { "permissions": ["db:own", "db:read:trips", "http:outbound:api.example.com"], "egress": ["api.example.com"] // mirror every http:outbound: 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 `. 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/`, 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 --tag ` | 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 --tag ` | Opens the registry PR for you: forks TREK-Plugins, fast-forwards the fork, branches off the registry's current main, writes/merges `registry/plugins/.json`, pushes, and creates the PR. Requires `gh`. | | `trek-plugin publish --repo --tag ` | **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 --repo ` | 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 --tag [--zip plugin.zip] [--merge entry.json] [--out file]` | Emits the ready-to-PR registry entry: `commitSha` (resolved from the tag), `downloadUrl`, `sha256` + `size` (from the packed zip), and the manifest's **`trek` range verbatim** — the entry's only compatibility field. `--merge` prepends this version onto an existing `registry/plugins/.json` for an update, keeping versions newest-first (registry CI enforces that ordering). | | `trek-plugin release [dir] --repo --tag ` | The one-shot: `pack` → `gh release create` (uploads the zip) → print the registry `entry`. Requires the `gh` CLI authenticated. | ### Registry policy - **No reserved namespaces.** Any unique lowercase slug id is accepted (3–40 chars, `[a-z][a-z0-9-]*`). The only refused ids are `registry`, `install`, and `rescan`, which would collide with admin API routes. - **Owner-binding still holds.** An id is bound to its GitHub owner on first registration, so nobody can repoint an existing plugin id to a different repo. - **Optional author signing.** A registry entry may carry an `authorPublicKey` (stable across versions) and a per-version `signature`. TREK verifies it offline and pins the key trust-on-first-use. Signing is opt-in — an unsigned entry installs on `sha256` alone — but once a plugin has shipped signed, an unsigned update for it is refused, 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]].