Files
TREK/wiki/Plugin-Permissions.md
Maurice d3be6fb2b8 fix(plugins): hold exec scripts to the time budget between statements
The time budget covered query() and tx() only. exec() without bound args
handed the plugin's script to Database#exec, which runs any number of
statements in one call with nothing checked between them, so a long
script held the host loop as long as it liked.

An exec script now runs one statement at a time. sql-script.ts cuts it
after every semicolon outside a string, a quoted identifier or a
comment; a piece SQLite reports as incomplete input (a trigger body cut
at its inner semicolon) is joined with the next until it prepares. The
clock is read after every statement, and past the budget the call
throws. As with Database#exec, the statements before that point stay
applied, and an error is SQLite's own; a transaction the script opened
itself is rolled back so the connection is not left inside it.

exec() with bound args is one statement and stays as it was. A single
statement cannot be bounded at all on the host thread, a write such as
INSERT ... SELECT over a cross join included, and the SDK README and the
wiki now say so next to the cross-join read. migrate() keeps running
unbudgeted on purpose: a migration stopped part of the way is rolled back
and fails the same way on every later start, leaving the plugin unable to
update its schema.

The ctx.db rows of the SDK README and the plugin wiki pages describe the
budget for scripts, and the lines touched there and in plugin-sdk's db
comment lose their em dashes.
2026-10-08 18:41:02 +02:00

41 KiB
Raw Permalink Blame History

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, db.tx 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/WITH RECURSIVE are refused, a query or tx stops at 100,000 rows, and a query, tx or exec script stops at 2 s.
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). Each row, and each row nested in it such as a day's notes or a reservation's endpoints, carries the fields TREK publishes for it (PLUGIN_ENTITY_FIELDS and PLUGIN_ENTITY_NESTED in the SDK), never the trip's calendar feed_token, and a column added in a later release is not delivered until it is published.
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/Web Push 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, which keeps it 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. A plugin that also implements the optional suggest is asked while the user types, so it receives what is typed into the place search from the second character on, not only the finished query, and adds up to three rows to the dropdown (GET /api/plugin-search/suggest). 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:poi-category-provider Add its own place categories to the trip map's Explore places pill and answer them for the map area the user is viewing Declare up to 4 chips in capabilities.poiCategories (each {id, label, labels?, icon, color}: the icon from a fixed list of lucide names, the colour #rrggbb only) and implement PoiCategoryProvider in hooks. When a user picks one of the chips, TREK calls getPois({category, bounds, lang?, limit}) on exactly that plugin, as that user, with the viewport narrowed to at most 0.5 degrees a side, so granting this means the plugin learns which map area each user who uses its chips is looking at. Its own permission for that reason, rather than a reuse of hook:search-provider: that hook is asked about a search somebody ran, this one about the map area somebody is looking at. The host keeps at most 60 places inside bounds, caps every string and the up-to-6 details rows, allows only http/https websites, namespaces ids as plugin:<id>:<ref>, and styles every marker with the declared icon and colour rather than anything the answer carries. 8 s timeout; a slow or failing plugin costs only its own chip. Nothing is cached, because the answer may depend on the user's own settings. Without the grant a declaration still installs but its chips never appear. Exposed at GET /api/plugin-pois and as the list_plugin_poi_categories and search_plugin_pois MCP tools.
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

{
  "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.

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.

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.

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 and Plugin Development.

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.