Files
TREK/plugin-sdk
Maurice 2c2d008dc2 fix: clear every open Sonar security issue, and half the reliability ones (#2037)
* fix(security): mint idempotency keys properly, and resolve the extractor by path

Three findings out of a sweep of every open Sonar security issue. The other 49
turned out to be false positives and are documented in sonar-project.properties
rather than changed — each entry scoped to a file, never a whole rule, so a
future Math.random() on an auth path still fails.

The idempotency key is the one that can lose data. Both producers fell back to
Math.random when crypto.randomUUID was missing, and randomUUID needs a secure
context — TREK's own quickstart is http://localhost:3000 and LAN installs run on
http://192.168.x.x, so on the deployments the README documents the fallback is
what actually runs. The axios one was
`Math.random().toString(36).slice(2)`, which is not length-stable: 0.5 yields a
single character and 0 yields the empty string, which the interceptor reads as
"no key" and skips deduplication for entirely. Two writes sharing a key collapse
into one — the second gets the first one's cached response and the edit is gone
with no error anywhere. Both now come from utils/randomId, which has the
getRandomValues rung that works in insecure contexts too.

The extractor probe stored the bare binary name, so every extraction re-resolved
it through whatever PATH held at that moment. The official image sets
KITINERARY_EXTRACTOR_PATH and never reaches that branch, but a bare-metal install
without it ran an unqualified name from a PATH it does not control. It now walks
the search path itself and keeps the absolute path of the file it verified;
execFileSync also drops the /bin/sh hop the old probe needed. PATH comes through
app-config because process.env is walled off in src/.

* fix: four tests and sorts that were not doing what they claimed

Sonar reported 43 high-impact reliability issues. Reading all of them, four are
real; the rest are cosmetic or would be made worse by the suggested change.

budget.test.ts hand-built `paid: true`, but both emitters send `paid: body.paid
? 1 : 0` and the handler stores the value verbatim — the test asserted a payload
the server never sends. The line below it already used a number.

admin.controller.test.ts had a `.resolves` assertion with no await. The promise
was dropped, so that line could not fail the test.

The two to-do category lists sorted with a bare .sort(), which is byte order:
every accented name files behind the whole ASCII range, so in a German trip
"Übernachtung" lands after "Zelt". TREK ships 23 locales, so this is real data.
One side effect worth knowing: katColor picks a category's dot colour by its
index, so re-sorting can move which category shows which colour — it already
moves whenever a category is added, and the colour carries no meaning.

Deliberately not changed: the two `| 0` truncations (Math.trunc is not
equivalent — `| 0` wraps at 32 bits and these carry millisecond timestamps), the
two .map() callbacks whose second parameter is declared and used, and the sha1
in thumbnail.service (its own comment says a swap would orphan every existing
thumbnail).

* refactor: give every button a type, and prefer the Number statics

Two mechanical sweeps, no behaviour change intended in either.

A <button> with no type attribute is type="submit" per the HTML spec. Outside a
form that does nothing today, but it turns into a surprise the moment the markup
is wrapped in one, which is what Sonar's S9011 is warning about. 870 buttons get
an explicit type="button". Two things were left alone on purpose: buttons that
take a {...spread}, because the spread may carry a type and the merge order
decides which wins, and anything inside a <form> — there the answer is a
judgement call, and checking all eleven form files found every real submit button
already carrying type="submit".

The Number sweep covers only the three globals that are exactly equivalent to
their Number twin: parseInt, parseFloat and NaN. isNaN is NOT in it — the global
coerces its argument, so isNaN('abc') is true where Number.isNaN('abc') is false,
and renaming it would change what the code does. It reaches server and plugin-sdk
as well as the client; both copies of egress-policy.ts got the identical edit, so
the mirror the SDK's parity test guards stays intact.
2026-08-25 15:22:38 +02:00
..
2026-07-18 22:13:52 +02:00
2026-07-18 22:13:52 +02:00
2026-08-24 23:48:36 +02:00
2026-07-05 01:13:04 +02:00
2026-08-24 23:48:36 +02:00
2026-07-05 01:13:04 +02:00
2026-08-24 23:48:36 +02:00
2026-07-05 01:13:04 +02:00
2026-07-05 01:13:04 +02:00

trek-plugin-sdk

The SDK for building TREK plugins.

The path is four commands: create → dev → status → publish. Everything else the CLI can do is a step one of those already does for you.

Scaffold a plugin

npx trek-plugin-sdk                        # no command? a guided menu of everything below
npx trek-plugin-sdk create                 # interactive wizard (id, type, icon, permissions, egress)
npx trek-plugin-sdk create my-plugin --type widget   # or non-interactive
cd my-plugin

The wizard asks for the id, type, icon (validated against lucide), permissions, egress hosts and required addons, and offers to initialize a git repo and install dependencies for you. In a non-interactive shell (CI, pipes) every command stays flag-driven with plain output — no prompts, and machine output (entry JSON, pack --json, PR URLs) stays on stdout.

What you get runs and packs immediately — but it is not publishable yet: the README is a template and there is no screenshot. That is deliberate. Writing those is your job, and status tells you exactly what is left.

Develop with a live reload loop

dev runs your plugin locally — no full TREK needed. It injects a ctx that enforces exactly the permissions your manifest grants (an ungranted call throws PERMISSION_DENIED, so you catch missing grants), backs db:own with a real SQLite file, serves your routes and your page/widget UI, and reloads on save.

npx trek-plugin-sdk dev        # http://localhost:4317 — dashboard, routes, UI

Open /preview to see a page/widget rendered in a real sandboxed frame with a theme/accent/appearance toggle (trek.invoke() is proxied to your routes). Hit a route as an unauthenticated request with ?_anon=1. Drop a dev-fixtures.json (trips, users, config) next to your manifest to feed ctx.trips / ctx.users.

Know where you are

npx trek-plugin-sdk status        # never fails — it's a map, not a gate

status runs every check the TREK-Plugins registry enforces that can be answered without a network — which is nearly all of them — and prints the whole journey as a checklist grouped by stage (Manifest, Code, Docs, Release, Repo): what passes, what does not, and the one command to run next. Run it whenever you are not sure what to do; it deliberately never exits non-zero.

validate is the same checks with an exit code. It is the form for scripts and CI; status is the form for humans. A plugin that passes validate will pass every registry gate that does not require the tag and the release to exist — those four run in preflight, which publish does for you.

Build a native UI (page / widget)

The UI is a sandboxed, opaque-origin iframe that can't load TREK's stylesheet — so the SDK ships it. Put one line in your client/index.html <head>:

<!-- trek:ui -->

dev and pack expand it into the inlined design kit: token-driven styles that follow the host's theme and accent (glass, cards, .trek-btn, .trek-input, .trek-chip, .trek-row, hover), plus a window.trek bridge:

trek.onContext((ctx) => { /* ctx.theme, ctx.tokens, ctx.appearance, ctx.user, ctx.tripId */ })
const data = await trek.invoke('/status')   // calls your own route, host-proxied
trek.notify('success', 'Saved')

Keep UI state for this browser tab

The opaque frame cannot use browser storage directly. The bridge provides host-managed, JSON-only session state instead. It survives an iframe remount and page reload in the same browser tab, but is not durable data. Do not use it to store secrets.

// Default: this user + this plugin + this browser tab.
await trek.session.set('dismissed-onboarding', true)
const dismissed = await trek.session.get('dismissed-onboarding')

// Explicitly partition state by the trip in view (fails without a trip context).
await trek.session.set('filters', ['flight', 'hotel'], { scope: 'trip' })

get() returns undefined for a missing key. set, get, remove, and clear accept { scope: 'plugin' | 'trip' }; plugin is the default. Use your plugin's own logical key (for example reservation:88:expanded) for any finer partitioning. Each plugin or individual trip scope is limited to 32 keys, 64 characters per key, and 1 KiB per JSON value.

The kit applies the theme, mirrors the appearance flags (reduced-motion, no-transparency) and auto-reports your height. It also upgrades any native <select> into a host-styled, keyboard-accessible dropdown that matches TREK — the OS-drawn popup never could. Write a plain <select> and it just works; add data-trek-native to opt a field out. See the Plugin Development wiki for the full component + token reference.

Write a plugin

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

module.exports = definePlugin({
  async onLoad(ctx) {
    await ctx.db.migrate('001', 'CREATE TABLE cache (k TEXT PRIMARY KEY, v TEXT)')
  },
  routes: [
    { method: 'GET', path: '/status', auth: true, async handler(req, ctx) {
      return { status: 200, headers: { 'content-type': 'application/json' }, body: '{"ok":true}' }
    }},
  ],
})

Your plugin runs in an isolated child process. ctx is the only way to reach TREK, and it grants exactly the permissions your trek-plugin.json declares — an ungranted call throws PERMISSION_DENIED.

Test without a running TREK

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

const { ctx, broadcasts } = createMockHost({
  grants: ['db:read:trips', 'ws:broadcast:trip'],
  trips: { 1: { members: [42], data: { id: 1, name: 'Japan' } } },
})
// the mock enforces the SAME permission model, so you can prove your plugin
// degrades gracefully when a permission is missing.

Runtime limits

The host enforces these limits on every ctx call and hook invocation. They are generous for a legitimate plugin and only bite a runaway or abusive one — build against them so dev/mock-host behavior matches production.

Area Limit
ctx.ai 200 calls/day per plugin (UTC midnight rollover); past it throws "daily AI budget exhausted (resets at UTC midnight)"
ctx.notify 100 calls/day per plugin (UTC midnight rollover); past it throws "daily notification budget exhausted (resets at UTC midnight)"
RPC (every ctx.* call) burst 60, sustained 20/s, 16 in-flight per plugin; a throttled call is refused with HOST_ERROR: rate limit exceeded — slow down ctx.* calls
ctx.db (your own sqlite) 256 MB per plugin
ctx.meta 64 KB per value, 256 chars per key, 100 keys per (plugin, entity)
Plugin process 300 MB RSS ceiling; auto-disabled after 5 crashes in 5 minutes
Event redelivery buffer 200 events held per plugin, dropped unreplayed after 15 minutes

Hook contribution caps (declarative content the host renders — a slow/failing call is skipped, never fatal):

Hook Cap
pdfSectionProvider ≤5 sections per provider
atlasLayerProvider ≤3 layers per provider
mapMarkerProvider ≤200 markers per provider
warningProvider ≤20 warnings per provider, each message ≤300 chars
placeDetailProvider ≤12 items per provider
photoProvider ≤60 photos per page
calendarSource ≤500 events per source per request
tableContributor ≤20 columns / ≤10 actions per entity
tripCardProvider ≤4 badges per trip, ≤240 total per provider

OAuth broker

ctx.oauth.getAccessToken() is host-brokered outbound OAuth: the plugin never becomes a direct OAuth client. The host runs the whole flow (authorize → callback → token exchange → refresh) with PKCE and a 10-minute state TTL, and holds the refresh token + client secret — a plugin only ever sees a short-lived access token for the ACTING USER, or null when they haven't connected (or in a userless context).

Provider config is the plugin's admin-owned INSTANCE settings — declare scope:'instance' manifest fields named oauth_authorize_url, oauth_token_url, oauth_scopes (optional), oauth_client_id, oauth_client_secret, and the admin fills them in from Settings → Plugins → Connect. Both endpoint URLs must be https and may not point at loopback, .local/.internal names, or private/metadata addresses — the host refuses to start a flow against them. The host exposes /api/plugin-oauth/:id/status|connect|callback|disconnect to drive the connect UI; plugin code itself only ever calls getAccessToken().

Capture the screenshot

The registry requires a screenshot that resolves to a real image, and the store card shows it. shot boots the dev server, renders your plugin in the same themed frame TREK uses, and writes a 1600×900 docs/screenshot.png:

npm i -D playwright && npx playwright install chromium   # once
npx trek-plugin-sdk shot                                 # --dark for the dark theme

Playwright is deliberately not a dependency of this SDK — it ships a browser, and most authors never need one. An integration plugin has no UI to render, so shot cannot help; screenshot the TREK surface your plugin changes instead.

Publish — one command

Commit and push your plugin to its public GitHub repo, then:

npx trek-plugin-sdk publish --repo you/repo --tag v1.0.0

publish is the whole release, in five steps and in this order:

  1. check — every registry gate that can be checked locally
  2. pack — build plugin.zip
  3. release — git tag, push, and cut the GitHub release with the artifact
  4. preflight — the gates that need the tag and the release to exist
  5. submit — open the PR against the TREK-Plugins registry

Step 1 comes first for a reason: a GitHub release is effectively immutable, because the registry pins its sha256. If a check fails, nothing is packed, tagged, pushed or released — so you can fix it and re-run against the same version. (--no-checks skips step 1; it is an escape hatch for a re-run, never right on a first publish.)

Needs git and an authenticated gh. It prints the PR URL at the end.

Updating a listed plugin: bump version in the manifest, commit, and run publish again with the new tag — it detects the existing entry and prepends the new version, newest-first. Your TREK-Plugins fork is fast-forwarded automatically before the PR branch is cut, so a fork left behind doesn't break the submit (a diverged fork gets a warning and the gh repo sync --force command to fix it).

When a publish fails

A failure after the release is cut (a preflight or PR problem) rolls back exactly what that run created — the GitHub release, the pushed tag, the local tag — so the same tag is free to re-run against once you've fixed the problem. Nothing that existed before the run is ever touched. Pass --keep-release to keep the release instead; the error then prints the manual cleanup commands.

Two related conveniences:

  • A leftover tag from an earlier failed run (it has no release and doesn't point at HEAD) is recognised as debris and moved to your current commit instead of silently releasing the stale one.
  • trek-plugin unrelease <vX.Y.Z> --repo you/repo deletes a stranded release + remote tag + local tag in one go. It checks the published registry index first and refuses to touch a version that is actually published — those artifacts are immutable (the registry pins their sha256), so the only way forward there is a new version.

Prefer to drive the steps yourself? They still exist individually — pack, release (pack → GitHub release → entry), preflight, submit (opens the PR), and entry (just prints the JSON).

In a terminal, publish asks whether to sign, and creates the key for you if you have none — you don't have to know --sign or keygen exist. Scripts and CI are never prompted: pass --sign.

A signature proves the artifact came from you, not just that its bytes match what the registry saw. Signing is dependency-free Ed25519 over the artifact bytes.

It is a one-way door you may walk through late:

  • Unsigned throughout is fine — the sha256 pin is the only guarantee, and TREK accepts it.
  • Unsigned → signed later breaks nobody. Nothing is pinned until a signed version installs, so adding a key at v1.4.0 is a real option, not a lost cause. The registry does require every version signed once your key appears in the entry — your first signed update retro-signs the older versions for you (each pinned artifact is downloaded, verified against its sha256, and signed with the same key).
  • Signed → unsigned is refused forever, on every instance that already has the plugin. publish refuses that at step 1, before anything is tagged or released.

So back the key up (~/.trek-plugin/signing.key). Losing it means you cannot ship an update to your own plugin without a registry maintainer override.

npx trek-plugin-sdk publish --repo you/repo --tag v1.1.0 --sign   # or just answer the prompt

Exports

  • definePlugin(def) + all the plugin types (PluginContext, PluginRoute, PluginJob, PhotoProvider, CalendarSource).
  • PLUGIN_API_VERSION — embed as apiVersion in your manifest.
  • validateManifest(json) — the manifest rules the server loader uses.
  • createMockHost(opts) (from trek-plugin-sdk/testing).
  • TREK_UI_CSS, TREK_THEME_JS, TREK_UI_MARKER, injectTrekUi(html) — the design kit, for authors who inline it themselves (a bundler, a custom build). Most plugins just use the <!-- trek:ui --> marker instead.

Commands

Run any of these with npx trek-plugin-sdk <command> (or the short trek-plugin bin if you install the package). trek-plugin help <command> — or trek-plugin <command> --help — prints a full page for any of them.

The path:

  • create [name] [--type t] [--template blank|notification-channel] [--interactive] — scaffold a plugin; a wizard if you omit the name.
  • dev [dir] [--port 4317] — run locally with a real request loop, SQLite db:own, and hot reload.
  • status [dir] — where am I? what's left? Every offline registry gate as a checklist, plus the next command. Never fails.
  • publish [dir] --repo o/n --tag vX [--sign [key]] [--no-checks] [--no-preflight] — the lot: check → pack → release → preflight → open the PR. In a terminal it offers to sign (and makes you a key); scripts pass --sign.

Also:

  • validate [dir] — the gate: the same checks as status, but it exits non-zero.
  • pack [dir] [--out plugin.zip] [--json] — build the artifact, print sha256 + size. Refuses a plugin that could not load; does not enforce the publish gates, because packing is how you sideload a plugin to try it.
  • shot [dir] [--port 4317] [--out docs/screenshot.png] [--dark] [--no-serve] — capture docs/screenshot.png. Needs Playwright.
  • keygen [--key file] — create an Ed25519 signing key.
  • sign [zip] [--key file] — print a signature + public key for an artifact.
  • entry --repo o/n --tag vX [--merge f] [--sign [key]] [--out f] — emit the registry entry JSON.
  • preflight [dir] --repo o/n --tag vX [--entry f] [--all] — the registry checks that need the network: the tag resolves to the pinned commit, the released artifact downloads and hashes, the id is not bound to another owner, and an update does not drop or rotate a published signing key.
  • submit [dir] --repo o/n --tag vX [--registry o/n] [--draft] — open the registry PR for you.
  • release [dir] --repo o/n --tag vX [--sign [key]] [--merge f] — pack → GitHub release → entry, without opening the PR.
  • unrelease <vX.Y.Z> [dir] --repo o/n [--yes] — delete a stranded release + remote tag + local tag. Refuses a version that is published in the registry (immutable); --yes consents when the registry index can't be checked.

Update notice

Both CLIs (trek-plugin and create-trek-plugin) tell you when a newer SDK has been published. This matters more than the usual "you're on an old version" nag: the registry entry format, the manifest rules and the permission catalog all move with the TREK host, so a stale SDK can pack and submit an entry that today's registry CI rejects.

It is powered by update-notifier, the standard for npm CLIs:

  • At most once every 24 hours, a detached background process asks registry.npmjs.org for this package's latest version and caches the answer under $XDG_CONFIG_HOME/configstore/ (or ~/.config/…).
  • Your command never waits for it — the notice is printed from that cache, so a fresh install learns about an update on a later run.
  • The notice goes to stderr, so pack --json and entry keep piping clean JSON.

The request is an unauthenticated GET for a public package — the same one npm install makes (npm's servers see your IP, as for any download). TREK has no telemetry, and this isn't any. To turn it off:

export NO_UPDATE_NOTIFIER=1

It is already silent in CI (any CI env var), under NODE_ENV=test, and whenever stdout isn't a terminal (i.e. when piped or redirected).

The SDK tooling in this repo is MIT. Your plugin is your own code under your own license.