Trek_CN/wiki/Plugin-Development.md
jubnl 91025683bb
3.2.1 (#1433)
* fix(plugins): prevent arbitrary api access

* fix(planner): disable drag & drop on mobile so the places list scrolls (#1432)

On touch devices the draggable rows hijack the scroll gesture, so dragging
to scroll started an HTML5 drag and popped up the file-import overlay instead
of scrolling. Gate the draggable rows and the sidebar file-drop handlers on
!isMobile across the places sidebar and the day plan (places, transports,
notes), and hide the grip handle — the arrow reorder buttons take over there.

* fix(inspector): make the remove-from-day button icon-only on mobile

* fix(collections): show the save picker above the mobile place detail

* fix(plugins): seal IPC parent/child for good

* test(inspector): match the icon-only remove-from-day button

* feat(sdk): switch plain ts for clack/prompts interactive session

* feat(plugins): force-refresh the registry from the rescan button

The registry is cached for 30 min server-side and GitHub serves it with a
5-min CDN cache, so a freshly published plugin could take up to ~35 min to
appear. The rescan/reload button now force-pulls the registry: it bypasses the
in-memory cache and appends a cache-buster + no-cache headers to beat the CDN,
and refreshes the browse grid immediately.

* feat(sdk): bump plugin version

* fix(stored settings): prevent local storage drop when update not successful

* feat(plugins): sideload plugins by uploading a .zip

Adds an admin "Upload plugin" button + drag-and-drop to the plugins panel for
installing a plugin archive directly — handy for testing a build before it goes
to the registry. It reuses the registry install pipeline (slip/bomb-safe
extract, strict manifest validation, native-binary scan) via a new
POST /admin/plugins/upload, and only skips the registry sha256/signature checks
that a sideload can't have.

Sideloaded plugins are flagged (source "local:upload", a "Sideloaded" badge, no
GitHub link, no auto-update) and always land INACTIVE — replacing a running or
active plugin stops it and clears the active flag first, so new code never runs
without a fresh activation + permission consent.

* fix(planner): keep the day-plan collapse state after fully closing the page

The expanded/collapsed days were stored in sessionStorage, which survives a
reload but is wiped when the tab or window is closed — so every fresh open
re-expanded all days, which is tedious to re-collapse on long (10+ day) trips.
Store it in localStorage instead so a collapsed layout sticks until it's
changed.

* fix(i18n): correct Vietnamese translation of 'Disabled' (#1438)

'Tàn tật' means physically handicapped/disabled-person, not the
off/disabled state of a toggle. Replace with 'Tắt' (off), matching
the existing 'admin.plugins.stateOff' translation.

Affects admin.notifications.none and admin.addons.disabled.

* add the code of conduct

* fix(plugins): let widgets follow the in-app dark-mode toggle

The plugin frame is sandboxed at an opaque origin (no parent DOM access) and we
only sent the context — including the theme — once, on trek:ready. So toggling
dark mode in TREK left already-mounted widgets on the old theme until a reload.
Watch the <html> `dark` class and re-post the context when it flips; plugins
already re-apply the theme on trek:context.

* fix(plugins): deliver widget context on load so the theme is right on first paint

* fix(plugins): give widget cards the native glassy look and auto-height

Widget plugins rendered in a plain card with a fixed 180px body, so they looked
foreign next to the glassy dashboard tools and taller widgets had their controls
clipped. Mirror the native `.tool` surface (glass background/border/blur, uppercase
title) and let the body grow to the height the widget reports over trek:resize.

* feat(plugins): add read/rwite costs

* feat(plugin): better readme/index.js

* feat(plugin): better readme/index.js

* feat(plugins): hand widgets TREK's theme tokens, formats and display identity

Extends trek:context with a non-secret `tokens` map (TREK's resolved CSS design
tokens for the current theme), `formats` (currency/date/units/timezone) and a
`user` display object (name/avatar/isAdmin — never the email, role only as a
boolean). Re-sent on every theme toggle. A widget can now apply the tokens and
match the host exactly, in both themes and under a custom appearance, instead of
hard-coding a palette that drifts — so plugins feel native, not bolted-on.

* feat(plugins): hand plugins the full palette and appearance state

The theme context only carried a ~19-token subset read off <html> and only
followed the dark-mode toggle. Widen it to the whole global (:root/.dark)
palette — surfaces, text, borders, the accent family, semantic + soft fills,
shadows, radii and fonts — so a plugin tracks the user's chosen accent scheme,
custom accent and high-contrast live, not just light/dark. Also send an
`appearance` block (scheme, density, reduced-motion, no-transparency) mirrored
from the attributes applyAppearance writes on <html>, and re-post the context
whenever any of those actually change (a small signature dedupes unrelated
mutations) so plugins restyle in step with the app.

* feat(plugins): ship a design kit so plugin UIs look native

A plugin's UI is a sandboxed, opaque-origin iframe that can't load TREK's
stylesheet — so authors had to re-derive the whole look by hand, and most
didn't. Ship it instead: a token-driven stylesheet (glass, hover, buttons,
inputs, chips, rows) that consumes the tokens the host already sends and swaps
light/dark, plus a small bootstrap that applies those tokens, mirrors the
appearance flags, auto-reports the frame height and exposes a `window.trek`
helper over the existing bridge. Both are plain strings meant to be inlined
(the CSP forbids external assets for an opaque frame); `injectTrekUi` expands a
`<!-- trek:ui -->` marker. No new capability — only a native look.

* feat(plugins): deliver the design kit — native scaffold + inline on dev/pack

A new page/widget scaffolds a native, glassy starter that talks over
window.trek. The source keeps a single `<!-- trek:ui -->` line; `dev` (when it
serves /ui) and `pack` (as the file enters the archive) expand it into the
inlined kit — so the file stays a one-line opt-in and a rebuild always ships
the current kit. Existing plugins opt in the same way, by dropping the marker.

* feat(plugins): faithful themed host preview in dev

`dev` served the plugin UI raw at /ui — top-level, with no host — so the theme,
context and bridge never fired and authors couldn't see the design kit render.
Add /preview: it embeds /ui in a sandboxed opaque-origin iframe (exactly TREK's
isolation) and plays the host — posts trek:context with a theme/accent/appearance
toggle, proxies trek:invoke to your /api routes as the dev user, and surfaces
resize/notify/navigate. /ui stays as the raw doc for debugging.

* docs(plugins): document the design kit, window.trek and the token contract

Rewrite the client section of the Plugin Development wiki kit-first: the
`<!-- trek:ui -->` marker, the component classes, the `window.trek` bridge, the
`/preview` host preview, the full `trek:context` payload (now the whole palette
plus an `appearance` block) and how to apply tokens by hand. Add a "Build a
native UI" section + the new exports to the SDK README.

* feat(budget): add 'Outstanding amount' card

* fix(translations): finish translating new keys

* feat(plugins): trip-page plugins — a plugin tab inside every trip

Adds a `trip-page` plugin type whose sandboxed iframe mounts as a tab in the
trip planner (Plan / Transports / … / <plugin>), scoped to the open trip, with
no dashboard nav entry. This is the most-asked planner-extension request from
discussion #1429 (a plugin that lives in the trip, e.g. SimMesg20's budget
planner). It reuses PluginFrame and the existing tab system — the frame already
receives the current tripId over trek:context — so there is no bridge or
security change: only the manifest type enum (server + SDK), the client feed
classification (pluginStore.tripPages), and one render branch in the planner.
The SDK scaffolds it with `create --type trip-page`.

* fix(apple wallet): support for .pkpasses

* feat(plugins): permission-gated write APIs for the planner (#1429)

Plugins can now WRITE core planner data, not just read it, through curated,
membership-checked methods — so downstream features can live in plugins instead
of long-lived core patches. Four new scopes: db:write:places (create/update/delete
places), db:write:days (days), db:write:itinerary (assign/unassign a place on a
day) and db:write:trips (update trip fields).

Each ctx method mirrors costs.create: it validates the input against the SAME
@trek/shared schema the web app uses, binds the acting user host-side (a job/onLoad
has none, so its writes are refused), checks trip access AND the app's edit
permission (place_edit / day_edit / trip_edit), delegates to the real services,
broadcasts the same events so open sessions update live, and records the write in
the tamper-evident capability audit. No new route, no sandbox or CSP change — the
isolation boundary is unchanged; a plugin can only change what its user could change
by hand. Consent UI + permission labels in all 22 locales, SDK types + mock host,
and the wikis are updated.

* docs(plugin): ensure wiki correctness

* feat(plugins): plugin metadata on core entities — db:meta (#1429)

Plugins can now attach their OWN namespaced key/value data to a trip, place or
day without forking the core schema (#1429, request 2). New `db:meta` scope +
`ctx.meta.get/set/list/delete`. Storage is one plugin_entity_metadata table
(migration 161) keyed (plugin_id, entity_type, entity_id, key) — a plugin only
ever sees its own rows. Every call is membership-checked: the entity must belong to
a trip the host-bound acting user can access. Quotas guard the shared volume (≤64KB
per value, ≤100 keys per entity); rows are purged on uninstall-with-delete-data and
recorded in the capability audit. SDK types + mock host, a consent chip + labels in
all 22 locales, and the wikis. No new route, no sandbox change.

* feat(plugins): place-detail plugin slot in the trip planner (#1429)

A widget plugin can declare `capabilities.widget.slot: 'place-detail'` to mount
its sandboxed frame inside the trip planner's place-detail panel, scoped to the
open place — the frame receives the `placeId` in trek:context alongside the tripId.
This is the UI half of the place-detail-providers ask (reviews/ratings/popular
times shown on a place). It reuses the existing widget mechanism: PluginFrame gains
an optional placeId, the feed/store learn the new slot, and PlaceInspector renders
the slot at the foot of its body in trip mode. Admin chip + label in all 22 locales,
wiki updated. No sandbox or permission change.

* fix(plugins): green the server tests + harden the new capability surface

The in-memory uninstall fixture was missing the new plugin_entity_metadata table,
so uninstall's DELETE threw "no such table" and failed the server test job. Add the
table to the fixture schema.

Self-review hardening of the write/metadata surface:
- trips.update now reproduces the web UI's per-field gate: is_archived needs
  trip_archive and cover_image needs trip_cover_upload, not just trip_edit — so a
  member who may only edit can't archive or re-cover a trip.
- Plugin metadata WRITES now also require the entity's edit permission
  (place_edit/day_edit/trip_edit), not just trip access, so a read-only member can't
  overwrite or delete metadata another user created. Reads stay access-gated.
- Cap the metadata key length (<=256 chars) alongside the value/count quotas — the
  key was attacker-controlled and uncapped, defeating the disk-DoS guard.

* test(plugins): cover the new write/metadata deps to hold the coverage gate

The new create-rpc-host write + metadata deps were untested, dropping the
src/nest branch coverage below the 80% gate. Add a seeded in-memory core db plus
mocked core services to exercise every dep end-to-end: places/days/itinerary
create/update/delete + not-found paths, trips.update with the archive/cover
per-field gates and the Validation/NotFound/unknown-error mapping, metadata CRUD
+ key/value/count caps + access checks, the costs deps, and users.getById
scoping. Plus rpc-host cases for meta writes on place/day and a no-acting-user
refusal. Tests only — no production code change.

* fix(costs): freeze FX on every cost + settlement write path (#1445)

Settled foreign-currency costs kept re-opening with a few-cent residual
when live rates drifted. The #1335 freeze only ran on the REST create/
update path, so two gaps remained:

- Foreign-currency items created via MCP create_budget_item or booking-
  import bypassed the freeze and stored exchange_rate = 1, so settlement
  re-converted them with live rates. Promote freezeForeignRate into the
  shared budgetService and call it from every write path.
- Settle-up transfers were stored currency-less and re-converted with
  live rates on each recompute. Add currency + exchange_rate to
  budget_settlements (migration), freeze the display-currency rate at
  settle time, and convert with it in calculateSettlement. Legacy rows
  (currency = NULL / rate = 1) keep live-rate behaviour until re-edited.

Also expose guarded cost update/delete to plugins: costs.update and
costs.delete under db:write:costs, gated exactly like costs.create
(addon + trip access + the acting user's budget_edit permission).
updateCost reuses BudgetService.update so a plugin write re-freezes the
FX rate too; both broadcast the same budget:updated / budget:deleted
events the REST controller emits. Wired through the host, the runtime
SDK context and the published trek-plugin-sdk (types + mock host).

* feat(plugins): provider hooks — placeDetailProvider, wired (#1429)

Turn "hooks" from a declared-but-dead surface into a real host→plugin capability.
Add an invoke.hook branch to the child + a supervisor hook registry
(providersOf) + PluginRuntimeService.invokeHook, reusing the existing invoke
transport and its timeout (a short 5s deadline so a slow provider can't delay a
response; a job/onLoad has no user, host-bound as ever). Also fixes a real bug: the
in-repo runtime SDK copy was missing the `hooks` field entirely and could not even
parse a plugin that declared one — synced it with the published SDK.

The first wired hook is placeDetailProvider: a plugin returns extra rows
({label,value?,url?}) for a place, and TREK renders them natively at the foot of the
place-detail panel. Consumer is a new, additive, fail-safe endpoint
GET /api/place-details/:placeId (membership-checked; any provider that errors or
times out is simply skipped — it never breaks the panel). New hook:place-detail-
provider scope + consent chip in all 22 locales. SDK types (both copies), a
controller test, and the wiki. photoProvider/calendarSource stay reserved but the
transport now exists for them. No sandbox or CSP change.

* fix(files): handle pkpass in booking uploads and files-tab open (#1447, #1448)

Both bugs were client-only; the server already allows .pkpass and serves it
as application/vnd.apple.pkpass.

#1448: the reservation/transport attachment inputs hard-coded an accept list
that omitted pkpass, so macOS grayed it out. Add .pkpass/.pkpasses (+ wallet
MIME types) to the accept attribute in both modals.

#1447: the files-tab open path routed every non-media/non-markdown file into
the in-app PDF preview object. Add isWalletPass() and route wallet passes
through the shared blob openFile helper (as bookings already do), which
downloads them so the OS hands them to Apple Wallet.

* feat(plugins): validation/warning contributions via warningProvider hook (#1429)

Second wired provider hook, reusing the invoke.hook infra from the last commit. A
plugin implements warningProvider.getWarnings(tripId, ctx) → {level, message,
dayId?, placeId?}[] to flag problems on a trip (overpacked day, place closed on its
planned date, missing booking, …). TREK surfaces them as a non-blocking overlay
banner at the top of the trip planner (the wrapper ignores pointer events so it
never covers the map/panels; only the pills are interactive).

Consumer is a new additive, fail-safe endpoint GET /api/trip-warnings/:tripId
(membership-checked; a provider that errors or times out contributes nothing and
never blocks the planner). New hook:trip-warning-provider scope + consent chip in
all 22 locales, SDK types (both copies), a controller test, and the wiki. This is
the validation half of the scheduling+validation block; feeding durations/travel
times back into core recalculation stays out (it would touch core planner
computation — deliberately deferred to keep the no-breaking-changes guarantee).

* fix(plugins): enforce the hook:* grant on provider dispatch (#1429 audit)

The adversarial audit of the #1429 additions found one real (medium) gap: the
hook:* permission was never enforced at runtime. providersOf() selected provider
plugins purely by the hooks their CODE declares (sup.hooks, reported by the child
as Object.keys(def.hooks)) and never intersected that with sup.granted — so a
plugin that merely implemented placeDetailProvider/warningProvider got wired in as
a provider even when the admin never consented to hook:place-detail-provider /
hook:trip-warning-provider. The downstream capability router still held (the hook's
ctx can only do what the plugin's OTHER grants allow), but a plugin could obtain an
auto-triggered, user-bound execution context on a passive UI browse without the
hook being consented — a consent-integrity gap that contradicts the documented
invariant.

Gate it host-side: a hookName→permission map, and providersOf now returns a plugin
only if it is active, implements the hook, AND holds the matching hook:* grant. An
unmapped hook resolves to nobody. invokeHook additionally re-checks membership in
providersOf (defense-in-depth against a direct caller). Unit test proves the
grant/implements/active intersection.

* docs(plugins): add the Plugin Cookbook + a trip-doctor example (#1429 eco)

Fosters plugin authoring by turning the new #1429 capabilities into copy-paste
recipes. New wiki page Plugin-Cookbook (read a trip, write to the itinerary, tag an
entity with metadata, contribute native place details, raise trip warnings,
broadcast, match the TREK look) linked in the sidebar, plus a complete runnable
example — trip-doctor — a hooks-only plugin that showcases warningProvider +
placeDetailProvider + ctx.meta with zero UI of its own. Manifest validates against
the SDK. Docs/example only; no product code.

* fix(collections): don't reset saved-place status to 'idea' on edit (#1437)

The update schema reused collectionStatusSchema, whose .default('idea')
survives .optional() — so a PATCH that omits status had 'idea' injected by
the validation pipe and written to the DB, clobbering 'want'/'visited'.
Strip the default on the update field with .removeDefault(), keeping the
.catch guard. Add a shared schema regression test and an e2e round-trip.

* docs(plugin): ensure plugin scopes are the same everywhere

* feat(plugins): read scopes for packing + files (#1429 eco)

Extend the read side of the capability model beyond trips/costs: db:read:packing
→ ctx.packing.list(tripId) and db:read:files → ctx.files.list(tripId). Both mirror
the existing trip reads exactly — the host membership-checks the trip against the
invocation's user (tripRead) before delegating to the same packingService/
fileService the REST paths use (so bags/assignees hydrate and trash is excluded),
and each is a separate scope (packing doesn't unlock files). ctx types in both SDK
copies + mock-host, consent labels + cap chips in all 22 locales, rpc-host +
create-rpc-host tests, and the wiki (perm table + cookbook recipe).

* feat(plugins): core event subscriptions (#1429 eco)

A plugin can react to core activity by declaring events: [{ on, handler }] + the
events:subscribe grant. websocket.broadcast announces every CORE trip event (name +
tripId ONLY, never the payload) through a tiny dependency-free relay
(plugin-event-sink); the runtime registers a sink in onModuleInit and the supervisor
fans each event out to subscribed, granted, active plugins via a fire-and-forget
invoke.event on a short timeout — so a slow subscriber can never block a core write.

Safety by construction: handlers run with NO user (like a job) so trip reads are
refused — they react to the fact, using the plugin's own ctx.db/ws/outbound; the
grant is enforced host-side (deliverEvent checks events:subscribe); plugin:* re-
broadcasts are never delivered back, so handlers can't loop; and only the event name
+ tripId cross the boundary. SDK types (both copies), consent label + cap chip in all
22 locales, supervisor gating + broadcast-tap tests, and the wiki + cookbook.

The relay lives in its own module (not websocket) so it doesn't drag `ws` into the
runtime and tests that mock ./websocket don't strip the sink.

* feat(plugin-sdk): typed ctx returns + native trek.ui DOM helpers (#1429 eco)

Two author-DX wins, SDK-only.

Typed reads/writes: ctx.trips.getById/getPlaces/getReservations, packing.list,
files.list, costs.*, places/days/itinerary writes and users.getById now return
proper entity types (Trip, Place, Day, Reservation, PackingItem, TripFile,
BudgetItem, Assignment, User) instead of unknown — real autocomplete for authors.
Only `id` is guaranteed and every shape keeps an index signature, so it mirrors the
raw DB row honestly (no column hidden, no false guarantees). mock-host matches.

Native UI helpers: window.trek now carries `trek.ui` — a tiny bundler-free DOM
builder (el/button/card/chip/input/mount) that emits the kit's trek-* classes, so a
widget builds themed UI with no CSS and no build step. Ships inlined via the same
<!-- trek:ui --> marker. Wiki updated.

* fix(plugins): scope packing.list to the acting user's #858 visibility (eco audit)

The final eco audit found one real (medium) gap: the db:read:packing delegate
called packingService.listItems(tripId) with NO userId, which takes the UNFILTERED
branch and returns every member's private (is_private=1) packing items — leaking
another member's personal/surprise-gift items to a plugin the normal UI/REST hides
them from. The handler had the host-bound acting user but dropped it when delegating.

Thread it through: tripRead now hands the membership-checked userId to the read
callback, packing.list forwards it to listPackingItems(tripId, userId), and the
service applies its three-tier #858 filter — a plugin now sees exactly what its user
sees. files.list is unaffected (no per-user file visibility). Tests assert the user
is passed. The other three audited surfaces (event subscriptions, trek.ui, and the
regression sweep of the capability boundary) were clean.

* security(plugins): prevent open redirect

* fix(plugins): resolve PR #1433 full-audit findings (code + tests)

The comprehensive PR audit confirmed 21 findings; this fixes the code/test ones I own:

- ctx.users.getById was DEAD: the runtime SDK omitted the _inv tag, so actingUser
  never bound and every call hit RESOURCE_FORBIDDEN. Add _inv (the test had codified
  the bug — corrected).
- Plugin place writes bypassed the REST STRING_LIMITS (a 100k-char name the web app
  rejects). Mirror the caps (name 200 / description 2000 / address 500 / notes 2000).
- packing.list / files.list were missing from the capability audit log while every
  other core read is audited — add them to isAuditable + auditResource.
- SDK lockstep: CalendarSource.getEvents drifted (published Date vs runtime string);
  the host->plugin boundary is JSON, so align both to string.
- Admin panel didn't know the new trip-page plugin type (unlocalised badge, missing
  filter) — add it to KNOWN_TYPES + the type filter + a 22-locale label.
- Tests for previously-uncovered paths: the child-side invoke.hook/invoke.event
  dispatch (real fork, hook + event + non-matching-subscription), and invokeHook's
  defense-in-depth grant re-check.

Julien's settlement-FX-refreeze finding is his budget code (flagged, not touched).

* docs(plugins): correct the wiki against the shipped capability surface (#1433 audit)

Fixes the 11 doc findings from the PR audit — every corrected claim was cross-checked
against the code:

- Plugin-Development: CSP connect-src is built from granted http:outbound:<host>, not
  egress[]; dropped the stale "costs.create is the first and only core mutation";
  documented costs.update/delete + ctx.packing/ctx.files; the manifest permission table
  gained the six missing scopes (db:write:places/days/itinerary/trips, db:meta,
  hook:trip-warning-provider); the widget slot table gained place-detail.
- Plugin-Cookbook: days.create no longer passes a title the schema drops;
  broadcastToUser uses the real (userId, event, data) signature; fixed the broken
  #the-trek-ui-design-kit anchor and noted window.trek.ui.
- Plugin-Permissions: added db:read:packing, db:read:files and events:subscribe;
  the provider hooks are implemented in `hooks: {...}` on the definition, not on ctx.

* fix(unsplash) allow api key usage

* fix(guests): scope guest display names per-trip, not globally (#1446)

A guest is a per-trip person, but their name lived in the globally UNIQUE
users.username, so uniqueGuestUsername() auto-renamed a second "Jake" (on any other
trip) to "Jake 2". Add a non-unique users.display_name: a guest now stores the human
name there and gets a uuid-based username that is never shown, and every member view
(members list, day-assignment participants, budget members/payers, packing
recipients/contributors/bags/assignees) COALESCEs display_name over username. Rename
updates display_name with no dedup. Real users are unchanged (display_name NULL →
COALESCE falls through to username). Migration adds the nullable column; existing
guests keep their current username via the COALESCE fallback.

This also unblocks ctx.users.getById (the audit's #4 fix), whose projection selects
display_name. Tests: two "Jake" guests on two trips both keep the name; the two
codified-the-old-behaviour guest tests corrected.

* fix(costs): don't re-freeze a settlement's FX rate on an unrelated edit (#1445)

The full audit found that updateSettlement called freezeForeignRate without the
"currency unchanged" guard the item path has, so any edit of a foreign-currency
settlement (e.g. correcting from/to) re-fetched the LIVE rate and overwrote the
frozen one — re-opening an already-balanced position with a small residual, the
exact drift #1445 was meant to prevent.

freezeForeignRate's unchanged-check was item-centric (it queried budget_items),
which a settlement (a different table) can't use. Give it an explicit
existingCurrency param; updateSettlement now reads the settlement's stored currency
and passes it, so an edit that doesn't change the currency keeps the frozen rate
(the service UPDATE already preserves exchange_rate when it's left unset). Tests
cover both: unchanged currency keeps the rate, a real currency change re-freezes.

* feat(plugins): add inter plugin dependency support and addon dependency support

* feat(plugins): add inter plugin dependency support and addon dependency support

* docs(plugins) inter dependencies

---------

Co-authored-by: Maurice <mauriceboe@icloud.com>
Co-authored-by: trongbinhnguyen <43725147+trongbinh15@users.noreply.github.com>
2026-07-06 00:27:42 +02:00

35 KiB
Raw Blame History

Plugin Development

Build a plugin with the trek-plugin-sdk package. A plugin is a directory with a manifest (trek-plugin.json), a built server entry, and — for page/widget plugins — a static client bundle. TREK runs your server code in an isolated child process and reaches it only over RPC; the browser part runs in a sandboxed, opaque-origin iframe. There is no other way in or out.

Scaffold

npx trek-plugin-sdk create                # interactive wizard
npx trek-plugin-sdk create my-plugin --type integration|page|widget|trip-page   # or direct
cd my-plugin

The wizard (run create with no name) asks for the id, type, author and permissions; the direct form takes them as flags.

This emits:

my-plugin/
  trek-plugin.json      # manifest
  package.json          # CommonJS marker + the SDK as a devDependency
  server/index.js       # your plugin code (built, plain JS)
  client/index.html     # native UI via the design kit (page / widget / trip-page only)
  README.md             # fill this in — the registry requires a screenshot

Run it locally with hot reload

npx trek-plugin-sdk dev        # http://localhost:4317

dev works straight after create — no npm install needed, because it injects require('trek-plugin-sdk') from the CLI itself, exactly like TREK injects it in production. It loads your server/index.js through the same definePlugin contract the host uses and gives you a real request loop without a full TREK: a dashboard listing your routes, the routes served under /api/<path>, your page/widget UI at /ui, a themed host preview at /preview (a real sandboxed frame with a theme/accent/appearance toggle, trek.invoke() proxied to your routes), and a reload on every save. The injected ctx enforces exactly the permissions your manifest grants — an ungranted call throws PERMISSION_DENIED, so you catch a missing grant here rather than after install. db:own is backed by a real SQLite file (.trek-dev/db.sqlite) when the runtime has node:sqlite.

  • Hit a route as an unauthenticated request with ?_anon=1 (an auth: true route then returns 401, mirroring the host).
  • Feed ctx.trips / ctx.users by dropping a dev-fixtures.json next to the manifest: { "trips": { "1": { "members": [1], "data": { … } } }, "users": {} }.

The plugin types

  • integration — background logic (jobs, routes) with no UI of its own. Photo- provider / calendar-source hook types exist in the SDK but are not yet wired into the host — see Provider hooks. The placeDetailProvider hook IS wired.
  • page — adds a nav entry that opens a full-page sandboxed iframe.
  • widget — adds a card to the dashboard (sidebar slot), a hero-bar overlay (hero slot), or a panel inside the trip planner's place-detail view (place-detail slot — the frame also receives the open placeId in trek:context, so it can show place-specific info like reviews or ratings). Set the slot in capabilities.widget.slot.
  • trip-page — adds a tab inside every trip planner, so your UI lives in the trip alongside Plan / Transports / Files. The frame is the same sandboxed iframe as a page, but it receives the current tripId in trek:context (so you can scope data to the open trip) and it has no dashboard nav entry. The tab shows on desktop and mobile.

The SDK package

trek-plugin-sdk is injected at runtime — the host makes require('trek-plugin-sdk') resolve inside the child, so do not vendor it into your artifact. Add it as a devDependency only, so you get types, createMockHost for tests, and the trek-plugin CLI:

npm i -D trek-plugin-sdk

Writing the server

Your server/index.js exports a definePlugin(...) object. Everything reaches TREK through the ctx argument.

const { definePlugin } = require('trek-plugin-sdk')

module.exports = definePlugin({
  // Runs once when the plugin is activated. NOTE: onLoad has no user context —
  // ctx.trips.* is refused here (see the ctx table).
  async onLoad(ctx) {
    await ctx.db.migrate('001_init', 'CREATE TABLE IF NOT EXISTS cache (k TEXT PRIMARY KEY, v TEXT)')
    ctx.log.info('loaded')
  },

  // Runs once on deactivation/stop. Use it to flush or release resources.
  async onUnload(ctx) {
    ctx.log.info('unloading')
  },

  // HTTP routes, mounted at /api/plugins/<id><path>.
  routes: [
    { method: 'GET', path: '/status', auth: true, async handler(req, ctx) {
      const rows = await ctx.db.query('SELECT COUNT(*) AS n FROM cache')
      return {
        status: 200,
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ n: rows[0].n, user: req.user?.username }),
      }
    }},
  ],

  // Scheduled jobs — TREK owns the cron and calls your handler (no user context).
  jobs: [
    { id: 'refresh', schedule: '*/15 * * * *', async handler(ctx) { /* … */ } },
  ],
})

The routes and job ids you declare here are the authoritative ones: the host reads them off your loaded definition (a route's array index is its internal id). The routes block the scaffold writes into trek-plugin.json is only a declaration for readers — the manifest parser does not consume it.

The ctx object

Area Methods Requires
ctx.db query(sql, …args) / exec(sql, …args) / migrate(id, sql) against your own SQLite file db:own
ctx.trips getById / getPlaces / getReservations (membership-checked) db:read:trips
ctx.trips.update(tripId, fields) update trip fields (title/dates/currency/reminder_days/…) db:write:trips
ctx.places create(tripId, fields) / update(tripId, placeId, fields) / delete(tripId, placeId) db:write:places
ctx.days create(tripId, {date?, notes?}) / update(tripId, dayId, {notes?, title?}) / delete(tripId, dayId) db:write:days
ctx.itinerary assign(tripId, dayId, placeId, notes?) / unassign(tripId, assignmentId) — place↔day db:write:itinerary
ctx.meta get / set / list / delete your own namespaced data on a trip/place/day (enrich core entities without forking the schema) db:meta
ctx.packing list(tripId) — a trip's packing items (membership-checked, respects private-item visibility) db:read:packing
ctx.files list(tripId) — a trip's files, trash excluded (membership-checked) db:read:files
ctx.costs getByTrip(tripId) / listMine() — read budget items (membership-checked) db:read:costs
ctx.costs (write) create(tripId, input) / update(tripId, itemId, input) / delete(tripId, itemId) — broadcasts budget:* db:write:costs
ctx.users getById(id) — public profile only (id, username, display_name, avatar) db:read:users
ctx.ws.broadcastToTrip(tripId, event, data) broadcast to a trip's members (event forced to plugin:<id>:<event>) ws:broadcast:trip
ctx.ws.broadcastToUser(userId, event, data) broadcast to one user ws:broadcast:user
ctx.plugins.call(id, fn, args?) call a function another plugin exposes and get its result — id must be a declared, satisfied pluginDependency that lists fn in its capabilities.provides a plugin dependency (no permission)
ctx.events.emit(name, payload?) publish an event to dependents that subscribed — name must be in your capabilities.emits — (no permission)
ctx.config your resolved settings (secrets delivered decrypted)
ctx.log info / warn / error → your error log
ctx.id your plugin id (string)

Calling a method your manifest didn't grant returns PERMISSION_DENIED; a method the host doesn't expose at all returns UNKNOWN_METHOD.

ctx.trips only works inside a route handler. The host binds the acting user from the authenticated request and membership-checks every trip read against it. onLoad and jobs have no user, so their trip reads are refused with RESOURCE_FORBIDDEN. The SDK's getById(tripId, asUserId?) signature keeps an asUserId parameter for source compatibility, but the host ignores it — you cannot read another user's trips by passing an id.

Writes (ctx.trips.update / ctx.places / ctx.days / ctx.itinerary / ctx.costs.create) are route-context only too, and doubly gated: the host checks the acting user can access the trip AND holds the app's edit permission for that entity (place_edit / day_edit / trip_edit), exactly like the web UI. They run through the same services and broadcast the same events, so open sessions update live. Input is validated against TREK's own schemas (a bad payload is BAD_PARAMS), and every write is recorded in the tamper-evident capability audit log against the acting user. A plugin can only change what its user could change by hand.

ctx.costs ("costs" = budget items) behaves exactly like ctx.trips: reads are membership-checked against the request's user and only work inside a route handler (onLoad/jobs have no user → RESOURCE_FORBIDDEN). getByTrip(tripId) returns one trip's budget items (hydrated with members/payers); listMine() aggregates budget items across every trip the acting user can access. create/update/delete(tripId, …) mutate a trip's budget items — gated exactly like a normal budget write (the same model the planner write scopes db:write:places/days/itinerary/trips use): the acting user needs the budget_edit permission on that trip, the input is validated against TREK's budget schema, and a successful create broadcasts the same budget:created event the app emits. Every ctx.costs.* call also requires the Costs (budget) addon to be enabled — if the admin has turned it off, the call is refused with RESOURCE_FORBIDDEN.

Route auth

Routes are authenticated by default (req.user is the logged-in user). Set auth: false for OAuth callbacks or webhooks that can't carry a session. The proxy forwards only { method, path, query, body, user } — your code never sees raw headers or the session cookie.

Writing the client (page / widget)

The iframe is served same-origin from /plugin-frame/<id>/… but sandboxed without allow-same-origin, so it runs at an opaque origin: it can't read cookies or the parent DOM, and the CSP forbids external <link>/<script src> — so everything must be inlined into your index.html. It talks to TREK only via postMessage (target origin must be '*' — an opaque frame has no nameable origin).

Because the frame can't load TREK's stylesheet, we ship it. Drop one line in your client/index.html <head>:

<!-- trek:ui -->

dev and pack expand that marker into the inlined TREK design kit — a token-driven stylesheet plus a window.trek bridge. It costs nothing to keep the source a one-liner, and a rebuild always ships the current kit. The kit:

  • gives you native components — glass panels, cards, buttons, inputs, chips, list rows, hover — that swap correctly between light and dark;
  • follows the user's live accent scheme, custom accent and high-contrast (it applies the tokens TREK sends);
  • mirrors the host's appearance flags (reduced-motion, no-transparency, density);
  • auto-reports your height (widgets/pages self-size — no manual trek:resize);
  • installs window.trek so you never hand-roll postMessage.

window.trek also carries trek.ui — tiny DOM builders that emit kit-styled elements, so you can build UI with no bundler and no CSS:

const { ui } = trek
ui.mount(ui.card([
  ui.el('div', { class: 'trek-title', text: 'Nearby' }),
  ui.button('Refresh', { variant: 'primary', onClick: refresh }),
  ui.chip('open now', 'success'),
]))
// ui.el(tag, props, children) is the general builder; props take class/text/html/on:{event}.

The scaffold seeds a working example. A minimal client:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <!-- trek:ui -->
</head>
<body>
  <div class="trek-glass trek-stack" style="margin:16px">
    <div class="trek-title">Your plugin</div>
    <p class="trek-muted" id="hello"></p>
    <button class="trek-btn trek-btn--primary" id="go">Say hello</button>
  </div>
  <script>
    trek.onContext((ctx) => { document.getElementById('hello').textContent = 'theme: ' + ctx.theme })
    document.getElementById('go').addEventListener('click', async () => {
      try { const data = await trek.invoke('/hello'); document.getElementById('hello').textContent = 'Hello ' + data.hello }
      catch (e) { trek.notify('error', e.message) }
    })
  </script>
</body>
</html>

Component classes (the bootstrap adds trek-ui to <body>):

Class What
.trek-glass the signature frosted-glass surface
.trek-card a solid card
.trek-interactive add to a glass/card for the native hover-lift
.trek-btn + --primary / --secondary / --ghost / --danger buttons
.trek-input / .trek-textarea / .trek-select / .trek-label form controls
.trek-chip + --accent / --success / --danger / --warning / --info chips / badges
.trek-row a hover-highlight list row
.trek-title / .trek-muted / .trek-faint text helpers
.trek-stack / .trek-cluster vertical / horizontal flex with gap

The window.trek bridge:

Call Does
trek.onContext(cb) run cb(context) now (if already received) and on every update; returns an unsubscribe fn
trek.context the last context (or null)
trek.invoke(sub, { method, body }) call your own route; returns a Promise (rejects with an Error, .code = HTTP status)
trek.notify(level, message) toast (info/success/warning/error)
trek.navigate(to) in-app navigation (relative paths only)
trek.resize(px) override the auto height
trek.ready() / trek.requestContext() re-handshake / re-request the context

Preview it: npx trek-plugin-sdk dev, then open /preview — it renders your UI in a real sandboxed frame with a theme/accent/appearance toggle and proxies trek.invoke() to your routes.

The raw bridge (without the kit)

If you'd rather not use the kit, talk to the frame yourself. Announce readiness and handle messages:

window.parent.postMessage({ type: 'trek:ready' }, '*') // TREK replies with trek:context
window.addEventListener('message', (e) => {
  if (e.source !== window.parent) return          // opaque frame: trust the parent window
  const m = e.data
  if (m.type === 'trek:context') { /* m.theme, m.tokens, m.appearance, … (below) */ }
  if (m.type === 'trek:response') { /* m.requestId, m.data */ }
  if (m.type === 'trek:error')    { /* m.requestId, m.code, m.message */ }
})
window.parent.postMessage({ type: 'trek:invoke', requestId: '1', sub: '/status', method: 'GET' }, '*')

Messages you send to TREK:

Message Payload Effect
trek:ready TREK replies with trek:context
trek:context:request re-request the context
trek:navigate { to } in-app navigation (relative paths only)
trek:notify { level, message } toast; level = info/success/warning/error
trek:resize { height } set the iframe height (capped at 2000px)
trek:invoke { requestId, sub, method, body } call your own route; resolves as trek:response or trek:error

Messages TREK sends you:

Message Payload
trek:context { tripId, userId, theme, locale, hostOrigin, user, formats, tokens, appearance } (see below) — re-sent whenever the theme or appearance changes
trek:response { requestId, data } — a successful trek:invoke
trek:error { requestId, code, message } — a failed trek:invoke (code is the HTTP status or "error")

The frame's CSP is locked down per plugin: default-src 'none', own inline scripts/styles only, connect-src limited to the hosts you were granted via http:outbound:<host> permissions (not merely the egress[] you declared), no popups.

The context payload

Field Type
tripId number | null — the trip in view (a trip-page tab, or a widget on a trip), else null
placeId number | null — the place in view (a place-detail slot), else null
userId string | null
theme 'light' | 'dark'
locale e.g. 'en'
hostOrigin the app origin
user { name, avatar, isAdmin } | nullnever an email; role only as a boolean
formats { locale, currency, timeFormat, distanceUnit, temperatureUnit, timezone }
tokens TREK's resolved CSS design tokens for the current theme (see below)
appearance { scheme, density: 'comfortable'|'compact', reducedMotion, noTransparency }

Matching the TREK look by hand (m.tokens)

tokens is the whole global palette resolved for the current theme — surfaces (--bg-card, --bg-hover, …), text (--text-primary/-secondary/-muted/-faint), borders, the accent family (--accent, --accent-text, --accent-hover, --accent-subtle), semantic + soft fills (--success/--danger/--warning/--info -soft), shadows (--shadow-*), radii (--radius-*) and fonts (--font-system). Apply them as CSS variables and your UI matches the host exactly — in both themes and under a custom accent or high-contrast — instead of hard-coding a palette that drifts:

function applyContext(m) {
  document.documentElement.dataset.theme = m.theme                 // for your dark rules
  for (const k in m.tokens) document.documentElement.style.setProperty(k, m.tokens[k])
  const a = m.appearance || {}
  document.documentElement.toggleAttribute('data-reduce-motion', !!a.reducedMotion)
  document.documentElement.toggleAttribute('data-no-transparency', !!a.noTransparency)
}
// in your trek:context handler: applyContext(m)

tokens/appearance are non-secret display values only, re-sent on every theme or appearance change so plugins feel native rather than bolted-on. (The glassy tokens the dashboard uses — --glass-*, --r-*, --sh-* — aren't in tokens; the design kit bakes those, since they only change with light/dark, not the accent.) Honour appearance.reducedMotion / noTransparency, and the frame also inherits the OS prefers-reduced-motion. Dashboard widgets are wrapped in the native glassy tool card and auto-size to the height you report via trek:resize, so render flush and transparent — the design kit reports your height for you.

Settings

Declare settings in the manifest; TREK renders the form (you write no settings UI). scope: "instance" settings are set once by the admin; scope: "user" settings are per-user. secret: true fields are stored encrypted and delivered decrypted through ctx.config (server-side only) — never to the iframe. Resolved values arrive in ctx.config.

Provider hooks

A hook is core calling into your plugin for data (host→plugin). Declare it on the plugin definition and grant the matching hook:* permission:

module.exports = definePlugin({
  hooks: {
    placeDetailProvider: {
      // Return extra rows TREK renders natively on a place. Runs with the current
      // user bound, on a short timeout — a slow/failing call is skipped, never fatal.
      async getDetails(placeId, ctx) {
        return [{ label: 'Crowd', value: 'Quiet now' }, { label: 'Guide', url: 'https://…' }]
      },
    },
  },
})
Hook Permission Status
placeDetailProvider.getDetails(placeId, ctx){ label, value?, url? }[] hook:place-detail-provider live — shown in the place-detail panel; also GET /api/place-details/:placeId
warningProvider.getWarnings(tripId, ctx){ level, message, dayId?, placeId? }[] hook:trip-warning-provider live — validation warnings shown as a non-blocking banner in the trip planner; also GET /api/trip-warnings/:tripId
photoProvider / calendarSource hook:photo-provider / hook:calendar-source reserved — declared + the invoke.hook transport exists, but no core consumer calls them yet

Each hook method receives its args plus the per-invocation ctx, so any ctx.trips.* read it makes is membership-checked against the current user (like a route handler).

Event subscriptions

React to core activity with events + the events:subscribe permission. Handlers fire without a user (like a job) and receive only the event name + tripId — never the payload — so a plugin can react to activity without seeing content:

module.exports = definePlugin({
  events: [
    { on: 'place:created', async handler({ event, tripId }, ctx) {
        await ctx.db.exec('INSERT INTO activity (trip, evt) VALUES (?, ?)', tripId, event)
    } },
    { on: '*', handler(e) { /* firehose: every core event */ } },
  ],
})

Delivery is fire-and-forget on a short timeout, so a slow subscriber never blocks a core write. Because there's no user, trip reads (ctx.trips.*) are refused inside a handler — use the plugin's own ctx.db, ctx.ws.*, or an outbound call. A plugin's own plugin:* broadcasts are never delivered back, so handlers can't loop. Common events: place:*, day:*, assignment:*, budget:*, file:*, accommodation:*.

Dependencies

A plugin can declare that it needs certain addons enabled, or other plugins installed, before it will run. Both are top-level manifest arrays, and both are enforced at activation — installing always succeeds, so a missing dependency is a fixable state, never a broken download.

requiredAddons

"requiredAddons": ["budget", "journey"]

Addon ids (see Addons Overview) that must be enabled for the plugin to activate. If one is off, enabling the plugin is refused and the admin panel names the addon to turn on. Turning a required addon off while the plugin is running auto-disables the plugin (and anything that depends on it) — a plugin never runs against a disabled addon. Ids are validated for shape only, so a plugin may name an addon a given TREK build doesn't have; it just stays un-activatable there.

pluginDependencies

"pluginDependencies": [
  { "id": "koffi", "version": ">=1.2.0 <2.0.0" }
]

Other plugins that must be installed and version-satisfied (a standard semver range) before this one activates. That range is the real contract for anything you call on the dependency (see Talking to other plugins).

Enforcement, all at activation time:

  • Missing dependency → activation is blocked and the panel offers a one-click download that fetches the newest registry version satisfying your range (pulling its own dependencies too), then retries.
  • Installed but out of range → same block; the panel offers to update it.
  • Installed but disabled → enabling your plugin auto-enables the dependency first, transitively (deepest dependency first).
  • Disabling a dependency cascades: every plugin that (transitively) depends on it is disabled too.
  • A dependency cycle (A → B → A) is refused with a clear error.

Dependencies are also resolved deps-first at boot, so a plugin's dependencies are already up before it starts.

Talking to other plugins

Isolation is the default — plugins can't see each other. To let a plugin be used by the plugins that depend on it, it opts in by declaring a surface in its manifest capabilities, and the host routes calls/events between the two child processes. There is no permission for this: authorization is the dependency edge itself — plugin A may call or subscribe to plugin B only if A declares B as a satisfied pluginDependency, and only for the names B publicly declares.

Exports — request / response

The dependency (B) exposes named functions and lists them in capabilities.provides:

// plugin "koffi"
module.exports = definePlugin({
  exports: {
    // `args` is whatever the caller passed; `ctx` is a per-call context.
    async convert({ amount, from, to }, ctx) {
      return { amount: amount * rate(from, to), to }
    },
  },
})
// manifest: "capabilities": { "provides": ["convert"] }

The dependent (A) declares koffi as a dependency and calls it:

// manifest: "pluginDependencies": [{ "id": "koffi", "version": ">=1.0.0 <2.0.0" }]
const out = await ctx.plugins.call('koffi', 'convert', { amount: 10, from: 'USD', to: 'EUR' })
  • A call is refused (RESOURCE_FORBIDDEN) if the target isn't a satisfied dependency, isn't currently active, or the function isn't in the target's provides.
  • The acting user is propagated: B's export runs as A's current user, so any ctx.trips.* read B makes is membership-checked against that user — B can't be tricked into reading data the calling user couldn't see.
  • The call is bounded by a timeout and recorded in the capability audit log (plugin:<target>#<fn>), attributed to A and the acting user.
  • B owns its contract: only functions in provides are reachable — routes, jobs and helpers stay private. Because your pluginDependencies range pins B's version, B can refactor internals freely and only breaks you on a major bump.

Events — publish / subscribe

The emitter (B) declares event names in capabilities.emits and publishes them:

// manifest: "capabilities": { "emits": ["rate.updated"] }
ctx.events.emit('rate.updated', { pair: 'USD/EUR', rate: 0.92 })   // fire-and-forget

A dependent (A) subscribes by naming the source plugin + event:

module.exports = definePlugin({
  subscriptions: [
    { plugin: 'koffi', event: 'rate.updated', async handler(payload, ctx) {
        await ctx.db.exec('UPDATE cache SET rate = ?', payload.rate)
    } },
  ],
})
  • An event reaches A only if A declares koffi as a satisfied dependency and subscribed to that (plugin, event). Emitting an event not in your emits is refused.
  • Like core event subscriptions, handlers run without a user — but unlike them they do receive the emitter's payload. Delivery is fire-and-forget on a short timeout; a slow subscriber never blocks the emitter.

Testing without a running TREK

createMockHost gives you a ctx that enforces the same permission model, so a test can prove your plugin degrades gracefully when a grant is missing:

import { createMockHost } from 'trek-plugin-sdk/testing'

const { ctx, broadcasts } = createMockHost({
  grants: ['db:read:trips'],
  trips: { 1: { members: [42], data: { id: 1, name: 'Japan' } } },
})
await ctx.trips.getById(1, 42)                        // ok — member
await expect(ctx.trips.getById(1, 99)).rejects       // RESOURCE_FORBIDDEN
await expect(ctx.db.query('SELECT 1')).rejects       // PERMISSION_DENIED (no db:own)

The mock db is a recorder — set queryResults for canned rows, or use an integration test for real SQL. To test inter-plugin calls, pass pluginExports: { koffi: { convert: (args) => … } } and assert on mock.emitted for anything your plugin publishes via ctx.events.emit.

Rules

  • No native modules (.node, binding.gyp, prebuilds/) — rejected at pack and install time.
  • Don't vendor trek-plugin-sdk — it's injected at runtime (devDependency only). Vendor any other runtime deps: TREK never runs npm install on a plugin.
  • Ship built JS in server/index.js and pre-built static files in client/. .ts and .map files are stripped by pack.
  • Declare every outbound host in egress[] whenever you use http:outbound.

Manifest reference (trek-plugin.json)

Field Type Notes
id string, required lowercase slug, ^[a-z][a-z0-9-]{2,39}$ (340 chars). Must match the directory name.
name string, required display name; also the page nav label.
version string, required semver (1.2.3, optional pre-release).
apiVersion number plugin API version (currently 1; PLUGIN_API_VERSION). Defaults to 1.
type string, required integration | page | widget | trip-page.
trek string supported TREK range, e.g. ">=3.2.0 <4.0.0". Its lower bound becomes minTrekVersion in the registry entry.
author string shown in the store.
description string one-line summary for the store.
icon string lucide-react icon name (default Blocks); used for the page nav entry.
homepage string project URL.
license string shown in the store detail (read from the manifest, not enforced).
nativeModules boolean must be false/absent — true is rejected.
permissions string[] see below.
egress string[] allowed outbound hosts; required (non-empty, no bare *) when any http:outbound permission is present.
capabilities.widget object { title, slot, defaultSize }slot is sidebar (default), hero, or place-detail.
capabilities.provides string[] function names this plugin exposes to its dependents via ctx.plugins.call (see Talking to other plugins).
capabilities.emits string[] event names this plugin publishes to its dependents via ctx.events.emit.
requiredAddons string[] addon ids that must be enabled for the plugin to activate (see Dependencies).
pluginDependencies { id, version }[] other plugins (semver range) that must be installed + version-satisfied to activate.
settings array setting fields (below).

Permissions (unknown values are rejected):

Permission Grants
db:own ctx.db — your own SQLite file
db:read:trips ctx.trips.* (membership-checked, route handlers only)
db:read:packing ctx.packing.list(tripId) — a trip's packing items (membership-checked)
db:read:files ctx.files.list(tripId) — a trip's files, trash excluded (membership-checked)
db:read:costs ctx.costs.getByTrip / ctx.costs.listMine (Costs addon, route handlers only)
db:write:costs ctx.costs.create/update/delete (Costs addon + acting user's budget_edit)
db:write:places ctx.places.create/update/delete (acting user's place_edit)
db:write:days ctx.days.create/update/delete (acting user's day_edit)
db:write:itinerary ctx.itinerary.assign/unassign (acting user's day_edit)
db:write:trips ctx.trips.update (acting user's trip_edit)
db:meta ctx.meta.* — your own namespaced data on a trip/place/day
db:read:users ctx.users.getById
events:subscribe receive core activity events via events: [...] (name + tripId only)
ws:broadcast:trip ctx.ws.broadcastToTrip
ws:broadcast:user ctx.ws.broadcastToUser
http:outbound or http:outbound:<host> outbound HTTP to egress[] hosts
hook:place-detail-provider hooks.placeDetailProvider — extra place rows TREK renders (see Provider hooks)
hook:trip-warning-provider hooks.warningProvider — validation warnings in the planner (see Provider hooks)
hook:photo-provider / hook:calendar-source reserved (see Provider hooks)

There is no ws:broadcast:* — use ws:broadcast:trip and/or ws:broadcast:user explicitly.

Settings field (settings[]):

Key Notes
key required identifier; empty-key entries are dropped.
label form label.
input_type snake_case; e.g. text (default), password, number, select.
scope instance (default) or user.
required boolean.
secret boolean — encrypted at rest, decrypted only into ctx.config.
placeholder, hint form hints.
options [{ value, label }] for select inputs.
oauth { initPath, callbackPath } for OAuth flows.

Page nav: the host builds a page plugin's nav entry from the top-level name and icon. create-trek-plugin also scaffolds a capabilities.nav block, but the installed-manifest parser only consumes capabilities.widget — set name/icon to control the nav entry.

See Plugin Permissions for the full permission model.

The trek-plugin CLI

Run npx trek-plugin-sdk with no command in a terminal and you get an interactive menu (create / dev / validate / pack / publish, with signing and registry-entry commands under Advanced…); it just picks which command to run, then that command prompts for whatever it needs. Pass a command explicitly to skip the menu (and for scripts/CI).

Author commands (from trek-plugin-sdk):

# 1. Manifest + layout checks (a subset of the registry CI — CI additionally
#    verifies the GitHub release exists, the artifact sha256, and the README
#    over the network).
trek-plugin validate [dir]

# 2. Build plugin.zip in the installer's exact layout. Prints sha256 + byte size,
#    refuses native binaries, enforces the same size limits (25MB/file, 50MB total).
#    Ships trek-plugin.json, README.md, LICENSE(.md), package.json + server/ + client/.
#    docs/ is intentionally NOT shipped — the store fetches docs/screenshot.png
#    from your repo. --json prints a machine-readable result.
trek-plugin pack [dir] [--out plugin.zip] [--json]

# 3. Emit the ready-to-PR registry entry: commitSha (resolved from the git tag),
#    downloadUrl, sha256, size and minTrekVersion (derived from the manifest
#    'trek' range) all computed for you. --merge prepends a new version onto an
#    existing entry (the update case, kept newest-first).
trek-plugin entry --repo owner/name --tag vX.Y.Z [--zip plugin.zip] [--merge entry.json] [--out file]

# 4. One shot: pack -> create the GitHub release (via gh) -> print the entry.
trek-plugin release [dir] --repo owner/name --tag vX.Y.Z

To publish, open a PR that adds the emitted JSON as registry/plugins/<id>.json in the TREK-Plugins registry.

Registry & publishing

  • No reserved namespaces — any unique slug id is accepted. (A tiny set of ids like registry/install/rescan is blocked only because they'd collide with admin API routes.)
  • Owner-binding still prevents anyone but the original author from repointing an existing id to a different repo.
  • Optional author signing: an entry may carry authorPublicKey (stable, TOFU-pinned on first install) and each version a signature over the artifact bytes. Unsigned plugins install on sha256 alone; a plugin that was signed can't later go unsigned or swap its key without an explicit admin re-trust.

Full walkthrough: Publishing a Plugin. Overview: Plugins.