Files
TREK/wiki/Plugin-Publishing.md
T
jubnl ebc98f0a3f v3.4.0 (#1527)
* fix(plugins): unknown column

* fix(mcp): reuse MCP sessions instead of creating one per tool call

The /mcp CORS layer never set exposedHeaders, so Access-Control-Expose-Headers
was absent and browser-context MCP clients (Claude Desktop connectors,
Claude.ai, MCP Inspector) could not read Mcp-Session-Id off the initialize
response. Unable to echo it back, every request looked like a fresh initialize:
one McpServer and one session per tool call, until the per-user cap returned a
429 and the integration died until the container was restarted.

The idle sweep was not at fault — it expires on lastActivity with a 1h default,
so sessions born seconds apart are nowhere near expiry, hence 'cleaned 0'.

- expose Mcp-Session-Id, MCP-Protocol-Version and WWW-Authenticate
- evict a user's least-recently-active session at the cap rather than
  refusing the request, so a client that cannot persist its session id (or a
  proxy that strips the header) can never wedge the server
- close the McpServer/transport orphaned by every session-less non-initialize
  POST, which was leaked: never mapped, never swept, never closed
- return the cap error as JSON-RPC so clients surface the real reason
- warn on session-less POSTs to make a header-stripping proxy diagnosable

* chore(deps): declare @modelcontextprotocol/sdk ^1.29.0

Matches the version already resolved in the lockfile; no dependency-tree change.

* docs(mcp): add the reverse proxy specs for MCP

* fix(plugins): stop the row ⋯ menu being clipped by the sidebar

The menu was an in-flow `absolute` div, and its ancestor (PageSidebar) is
`overflow-hidden` — which clips absolutely-positioned descendants regardless
of z-index. With enough plugins installed a row sits low enough that its menu
runs past the sidebar's bottom edge and gets chopped, taking Delete with it,
so the plugin could no longer be uninstalled from the UI.

Portal the menu to <body> and position it `fixed` against the ⋯ button,
flipping upward when the bottom is tight and re-anchoring on scroll/resize.
A fixed child of <body> has no overflow ancestor, so nothing can clip it.

Closes #1523

* feat(plugins): surface author-signature status and add a scoped re-trust override

TREK has always verified an author's Ed25519 signature and TOFU-pinned the key on
first install, but none of it was ever shown: a successfully-installed UNSIGNED
plugin looked identical to a signed one, and a signature-refused update left the
plugin quietly pinned at its old version with the reason dying in a toast.

Give the four refusal conditions machine-readable codes (SIGNATURE_MISSING /
_INCOMPLETE / _KEY_CHANGED / _INVALID), persist a refusal on the plugin row so the
admin list keeps showing it, and badge Signed/Unsigned in the list and in Discover.

Only SIGNATURE_KEY_CHANGED is overridable — an author can legitimately rotate a key;
a signature that does not verify means the bytes are not what the author signed, and
there is no story where waving that through is right. The override re-pins and
updates in ONE call (POST :id/retrust): a re-pin that waited for a follow-up /update
would leave the plugin pinned to a key no install had ever been verified against if
that second call never came. The artifact must still verify under the new key, so a
re-trust moves the pin from one verified key to another.

assertRetrustable re-derives the condition server-side, so the UI hiding the button
is a convenience, not the control, and it echoes back the full key the admin was
shown so a re-key since the dialog rendered is refused. The rotation is written to
the admin audit log with both fingerprints — after an incident, "which key did we
move from, and to what?" is the question a single key cannot answer.

* fix(plugins): let a plugin's frame reach the hosts an admin added for it

The frame's connect-src was built from the manifest's http:outbound grants alone, but
the child's egress guard is the UNION of those and the hosts an admin added after
install for an operatorEgress plugin (a self-hosted Gotify, an ntfy — hosts the author
cannot know in advance). So such a plugin WITH A UI could call the operator's host from
its server and was CSP-blocked in its own iframe.

Match the frame to the child. The admin consented to these hosts at install and the
child already reaches them, so this widens no trust boundary that isn't already crossed.
Both sources stay validated on the way in, and the interpolation filter is unchanged.

* fix(plugins): report a rejected events.emit on the plugin's own log stream

The host can reject an emit (an undeclared event name, a rate limit). The rejection must
not escape — a detached rejection crashes the child and terminally disables the plugin
over one bad emit — but swallowing it silently left an author with no way to discover
that `emits` was missing from their manifest. Surface it as a warning instead.

* feat(plugin-sdk): expose the raw request body for webhook signature checks

A webhook author must run their HMAC over the exact bytes the sender signed. `body` is
the PARSED value, and re-serializing it will not reproduce those bytes — key order,
whitespace and unicode escaping all differ — so the signature never matches. Document
`rawBodyBase64`, which the host already sets on auth:false routes.

* fix(plugin-sdk): refuse to overwrite a released artifact, and keep the packed zip

A released artifact is IMMUTABLE: the registry pins its sha256, so overwriting the bytes
of a release already in the registry breaks the checksum for everyone who installed that
version — they can no longer install or update it. The old code blanket-caught every
`gh release create` failure (auth, network, a bad repo) and turned it into a --clobber
upload. Probe for the release explicitly and refuse unless --force.

Also stop deleting plugin.zip on the way out. It is the exact bytes the release and the
entry's sha256 pin were computed from; a re-pack on another machine or SDK version can
differ (CRLF, walk order), so anyone re-running `entry`/`sign` afterwards must hash THAT
file, not a rebuild.

* fix(plugin-sdk): don't fail submit when the fork already has an upstream remote

`gh repo clone` of a fork may already have wired `upstream`, in which case a bare
`remote add` exits non-zero and took the whole submit down. Set it either way.

* fix(plugin-sdk): make the dev server behave like the host

Three ways `dev` lied to an author about how their plugin would run in production:

- ctx.db: one try/catch wrapped both the node:sqlite probe AND opening the database, so
  an mkdir/permission failure silently degraded to the in-memory stub — which swallows
  every write while reporting success. A db:own plugin "worked" in dev and persisted
  nothing. Probe separately: fail loudly on a real error, degrade only on old Node, and
  say plainly that the stub discards writes.
- notificationChannel: the host fires it with no acting user and hands the recipient's
  decrypted settings in as a separate `config` argument — send(msg, config, ctx) /
  test(config, ctx). Firing it like an ordinary hook passed `ctx` where `config` belongs,
  so a channel plugin read its settings off ctx and was broken in production.
- /preview pinned tripId 42 while the scaffold seeds trip 1, so the widget's first
  trek:invoke hit assertMember(42) and 500'd. Preview against a trip that exists.

* fix(plugin-sdk): reject unknown flags, wire up create's new flags, and add --help

`parse()` accepts any --x, so a flag a command does not read was silently dropped:
`create --template notification-channel` cheerfully scaffolded a blank plugin. Silently
ignoring an author's explicit instruction is worse than refusing it, so unknown flags are
now an error, and create actually forwards --template/--egress/--required-addons.

A bare --permissions used to split the string "true" into a permission literally named
`true`; listFlag() now treats a valueless flag as absent.

Since an unknown flag is now fatal, `--help` has to exist: it is intercepted before the
flag check and prints usage on stdout with exit 0.

* chore(plugins): drop three unused eslint-disable directives

The no-console rule is not enabled for these files, so the directives were dead and
eslint reports them as unused.

* fix(plugin-sdk): bring preflight back in step with the registry's gates

preflight exists to tell an author what TREK-Plugins' CI will say before they open the
PR. The registry now verifies author signatures, and preflight didn't — so it drifted
into the one failure mode it must never have: a false green. An author trusts a green.

- Verify the signature against the artifact bytes. preflight already downloads them for
  the sha256 check, so this costs one call. Without it, signing with the wrong key (or
  re-packing after signing) sails through and is caught at review.
- Check the signature SHAPE (checkSignatureShape): a key with no signed version, a
  signature with no key, a malformed key or signature. TREK refuses to install a
  half-signed entry, so such an entry is dead on arrival.
- Default apiVersion to 1 before comparing. It is OPTIONAL in the manifest — install/
  manifest.ts and `entry` both default it — so a manifest that legally omits it was
  failing preflight with "manifest apiVersion undefined != entry 1" while the registry
  passed it. A false RED, which teaches authors to ignore the tool.
- Check requiredAddons/pluginDependencies parity, and operatorEgress without an
  http:outbound grant.

The verifier is a port of the host's install/verify-signature.ts (the registry has its
own port); sign.ts's verifyArtifact only understands the bare key/signature pair the SDK
itself emits and cannot judge a minisign key. A test pins all three to the same verdicts.

* feat(plugins): enforce compatibility range

* chore: bump sdk version

* test(e2e): repair the trip-creation specs

create-trip and trip-planner have been failing for a while — long enough for
three separate UI changes to drift past them, which nothing caught because CI
runs vitest only and never invokes Playwright.

Each failure was masking the next:

- The release-notice modal greets a freshly seeded user and its backdrop
  swallowed the click on .add-trip-card. Added a shared dismissSystemNotices()
  helper (the X only shows on the notice's last page, so it has to page through
  first).
- .modal-backdrop no longer exists — the class was namespaced to
  .trek-modal-backdrop so content blockers stop hiding it.
- input[type=text].first() is no longer the Title field: the cover-image search
  inputs now sit above it, so the specs were typing the trip name into the photo
  search box and creating an untitled trip that never matched getByText(title).

* fix(planner): gate drag & drop on pointer type, not viewport width (#1432)

The 3.2.1 fix disabled drag on "mobile", but nothing in the client has ever
detected touch — "mobile" was inferred from viewport width, at four independent
breakpoints. A tablet is a coarse-pointer device at a *desktop* width, so an
iPad (820-1366px) fell on the wrong side of all four, which is why iPhone was
fixed and iPad was not:

- useTripPlanner's isMobile (<768px) is what disarmed `draggable`, so on iPad
  rows stayed draggable and a scroll swipe became an HTML5 drag.
- TripPlannerPage hardcoded isMobile={false} on the desktop PlacesSidebar, so
  its drop handlers and the drop-to-import overlay could never be disabled —
  that overlay is the reported symptom.
- The arrow-button reorder fallback was revealed only below 767px, leaving iPad
  with no drag *and* no fallback.
- touchDragPolyfill loaded drag-drop-touch at >=1024px, synthesising drags from
  touchmove — on a landscape iPad that re-armed the very gesture hijack 3.2.1
  removed.

Adds useIsTouch() ((pointer: coarse)) as a signal separate from isMobile: layout
stays width-driven, so the iPad keeps the desktop two-pane planner, while every
drag affordance is gated on isMobile || isTouch. The reorder arrows now show on
coarse pointers, and the polyfill loads only on hybrid laptops
((pointer: fine) and (any-pointer: coarse)), which also removes the #1440
phantom-dblclick map zoom on tablets.

Guarded by unit tests plus an e2e spec on real WebKit in an iPad Pro 11 context
— the engine matters, since every browser on iPadOS is WebKit underneath, which
is why the reporter hit this in all three they tried.

* test(planner): guard the day-plan reorder arrows against phantom clicks

The hover rule that reveals the reorder arrows had been dead since the
TypeScript migration: it targeted `.place-row:hover .reorder-btns`, and neither
class exists — the component renders `.reorder-buttons` inside an unclassed row.
The buttons sat at opacity:0 on desktop with nothing to reveal them.

That was not merely invisible. opacity:0 still hit-tests, so every itinerary row
and day note carried an invisible, fully clickable target that silently
reordered the trip:

  opacity: "0", visibility: "visible", pointerEvents: "auto"
  elementFromPoint(centre) -> BUTTON, inside .reorder-buttons

The repair — pointer-events: none while hidden, plus a working
.dp-row:hover/:focus-within reveal (focus-within so the buttons are also
reachable by keyboard, which the file's JS-hover pattern cannot do) — lives in
index.css and DayPlanSidebar.tsx. Both files also carry the #1432 drag gating,
so they went with that commit rather than being split mid-file; this commit is
the regression guard for them.

E2E rather than unit, because jsdom does not evaluate :hover. Against the old
CSS it fails on exactly the right assertion: "hidden arrows must not swallow
clicks" — expected false, received true.

* fix(pdf): keep the header gap on day-header overflow pages (#1531)

The repeated <thead> day header (#1471) carried no gap before the first
card on overflow pages: the 12px sat in .day-body's block-start padding,
which a fragmented box only paints on its first fragment. Move it to the
thead cell so it repeats with the header; the day's first page renders
pixel-identically.

* fix(budget): keep "no one paid yet" when editing an expense (#1533)

The ExpenseModal payer initializer fell back to the current user whenever the
edited item had no payer, so reopening an expense saved with "no one paid yet"
silently reselected "You" — and re-saving then recorded the current user as the
payer, corrupting the balances. An absent payer on an existing expense is a
deliberate value, so only a brand-new expense defaults to me.
2026-07-13 12:40:00 +02:00

13 KiB

Publishing a Plugin

Plugins are distributed from a static registry — the TREK-Plugins GitHub repo. There is no upload server and no account: you host the code in your own public GitHub repo, attach a built plugin.zip to a release, and list it with a pull request.

The trek-plugin CLI (shipped in the trek-plugin-sdk package) does almost all the mechanical work — you rarely hand-type a hash, size, commit, or JSON field.

The short version — one command

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

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

publish runs the whole release: pack → tag + GitHub release → preflight (the registry CI checks, locally) → open the registry PR. If preflight finds a problem it stops before submitting, so a broken entry never becomes a doomed PR. It prints the PR URL at the end. Sign it with --sign.

The individual steps below still exist for when you want one by hand — release (pack → GitHub release → entry), preflight, and submit (opens the PR) — or entry --out registry/plugins/<id>.json to write the file and open the PR yourself.

1. Host and build your plugin

Put your plugin in a public GitHub repo (convention: trek-plugin-<id>). create-trek-plugin scaffolds the layout:

npx trek-plugin-sdk create flight-tracker --type widget   # integration | page | widget | trip-page

A publishable plugin has, at the repo root:

  • trek-plugin.json — the manifest (see Plugin Development)
  • package.json — the CommonJS marker ("type": "commonjs"), with the SDK as a devDependency at most
  • server/index.js — the built server entry (required)
  • client/ — the built frontend (only for page/widget/trip-page plugins)
  • README.md — filled in, with a real screenshot (the quality gate is strict — see below)
  • docs/screenshot.png — the store card image, committed to the repo

2. Validate

npx trek-plugin-sdk validate .

validate runs the manifest checks plus a light layout/README sanity pass: it fails if trek-plugin.json is invalid or server/index.js is missing, and warns if the directory name doesn't match the plugin id, the README has no screenshot, or the README still contains scaffold placeholders. This is a subset of what CI runs — CI additionally verifies the release, the artifact's SHA-256, and the README over the network — so a clean local run predicts a clean CI run but doesn't replace it.

3. Pack

npx trek-plugin-sdk pack .                 # writes ./plugin.zip
npx trek-plugin-sdk pack . --out dist.zip  # custom output path
npx trek-plugin-sdk pack . --json          # machine-readable result

pack validates first, then builds plugin.zip in the installer's exact layout and prints the sha256 and size you'd otherwise compute by hand. It ships only what the runtime needs — trek-plugin.json, README.md, LICENSE, package.json, and the server/ and client/ trees — and drops node_modules, .git, source maps and .ts sources. It refuses native binaries (.node, binding.gyp, prebuilds/) and enforces the same size limits as the installer (25 MB per file, 50 MB total, 4000 entries).

docs/ is intentionally not shipped. The store fetches your docs/screenshot.png straight from the repo at the pinned commit, so keep it committed to GitHub but out of the zip — pack handles this for you.

The plugin.zip pack produces is also the artifact for sideloading: hand it to an instance admin (or drag it onto Admin → Plugins) to install without the registry at all — no PR, no review, no SHA-256/signature pin. Sideloaded plugins are flagged as such and never auto-update, so the registry PR below is still the path for anything you want discoverable and updatable. See Plugins.

4. Create the GitHub release

Tag vX.Y.Z where X.Y.Z equals version in your manifest, and attach the packed plugin.zip as a release asset:

gh release create v1.0.0 plugin.zip --repo you/trek-plugin-flight-tracker

Prefer the uploaded plugin.zip asset — it's the exact bytes you packed and the registry pins their hash. Don't rely on GitHub's auto-generated source archives; they aren't the installer layout and their bytes aren't stable.

5. Generate the registry entry

npx trek-plugin-sdk entry \
  --repo you/trek-plugin-flight-tracker \
  --tag v1.0.0 \
  --out registry/plugins/flight-tracker.json

entry reads your manifest and plugin.zip and emits the complete entry — deriving commitSha (from git rev-parse <tag>^{commit}), downloadUrl, sha256, size, apiVersion, and trek — your manifest's range, verbatim. That range is the entry's only compatibility field: it is what TREK gates installs and activation on. (The older minTrekVersion/maxTrekVersion are deprecated and no longer emitted — the first merely restated the range's lower bound, and the second is inclusive, so it cannot express <4.0.0 at all. Entries published before trek existed may still carry them.) entry refuses to build an entry for a manifest with no usable trek range, because TREK would refuse to install it. Flags: --zip (default plugin.zip), --commit <sha> to override commit resolution, --asset to name a differently-named release asset, --merge for updates (below), and --out to write a file.

One-shot: release

npx trek-plugin-sdk release . --repo you/trek-plugin-flight-tracker --tag v1.0.0

release does pack → gh release create → entry in one go and prints the entry to stdout. It accepts --out, --notes, --commit, and --merge. (It requires the gh CLI, authenticated.)

6. Preflight — run the CI checks locally

Before you open the PR, run the exact registry CI checks against your pushed release, so you catch what CI would reject without a review round-trip:

npx trek-plugin-sdk preflight --repo you/trek-plugin-flight-tracker --tag v1.0.0

preflight mirrors both CI scripts over the network: the tag resolves to the pinned commitSha, the manifest at that commit matches the entry, the released artifact's sha256 + size match and it carries no native binaries, and the README passes the quality gate (required sections, real prose, a resolving screenshot, permission parity). It also accepts --entry <file.json> to check a hand-written entry. A green preflight predicts a green CI.

7. Open the registry PR

The fast path — submit does the whole fork/branch/commit/PR dance for you:

npx trek-plugin-sdk submit --repo you/trek-plugin-flight-tracker --tag v1.0.0

It forks TREK-Plugins (once), branches off the current main, writes (or, for an update, merges into) registry/plugins/<id>.json, pushes, and opens the PR — printing its URL. Add --draft for a draft PR, --registry <owner/name> for a mirror. (Requires gh, authenticated.)

By hand instead: fork the registry, add your generated file as registry/plugins/<id>.json, and open a PR back to main. Add only that file — dist/ is generated on merge, and CI rejects manual edits to it.

The entry follows schema/plugin-entry.schema.json; schema/example-entry.json is the canonical shape. size is required (a common omission), as are commitSha, downloadUrl, sha256, trek, apiVersion, and nativeModules: false on every version — all of which trek-plugin entry fills in for you. CI rejects an entry whose trek disagrees with the manifest at commitSha (and, on a legacy entry that still carries a minTrekVersion, one whose floor disagrees with that range).

What CI enforces

CI runs scripts/validate-entry.mjs and scripts/check-readme.mjs on every changed registry/plugins/*.json.

Entry (validate-entry.mjs): valid schema · id matches the filename and the slug pattern ^[a-z][a-z0-9-]{2,39}$ · your id is bound to your GitHub owner on first registration, so nobody can repoint it later (owner changes need a maintainer override) · homoglyph/mixed-script name check · the release tag exists and resolves to commitSha · manifest parity at that commit (id, version, type, apiVersion, and nativeModules must not be true) · the downloaded artifact's SHA-256 matches the pin and its size is within bounds · no native binaries in the archive · egress[] present (and no bare *) when http:outbound is declared. Any unique slug is fine except registry, install and rescan, which the install loader refuses (they collide with admin API route segments).

README (check-readme.mjs, fetched at the pinned commit): must exist at the repo root, contain the sections What it does / Screenshots / Permissions / Setup, carry at least one screenshot that resolves to a real image (a relative docs/screenshot.png is resolved against the commit), have real prose (≥ 400 characters after stripping headings/code/images/tables), contain no leftover scaffold placeholders, and explain every permission your manifest declares (each permission string must appear in the README).

Provenance & integrity

  • commitSha pins the exact source the maintainer reviewed (git tags are movable).
  • sha256 pins the exact artifact bytes TREK will run (release assets are mutable).

TREK verifies the downloaded bytes against sha256 and refuses to install on a mismatch. A reviewedAt date on your entry means a maintainer looked at that exact commit — it is not an ongoing guarantee. reviewedAt and boundOwner are maintained by CI on merge; don't set them yourself.

sha256 proves the bytes are the ones the registry vouches for. An author signature additionally proves the bytes were signed by you, so a compromised registry can't ship attacker code under your name. The entry schema allows two optional fields — authorPublicKey on the entry and signature on each version. TREK verifies the signature offline (minisign / Ed25519, no external service) and pins your key on first install (trust-on-first-use): a later release signed with a different key is refused until an admin re-trusts it.

The easy way — let the SDK do it (dependency-free Ed25519, no minisign needed):

npx trek-plugin-sdk keygen                                     # once: writes ~/.trek-plugin/signing.key
npx trek-plugin-sdk release --repo you/repo --tag v1.2.0 --sign
npx trek-plugin-sdk submit  --repo you/repo --tag v1.2.0 --sign

--sign signs the exact artifact bytes and fills both authorPublicKey (entry) and signature (version) for you; submit --sign even refuses to publish an update signed with a different key than the one already listed, so you can't lock yourself out by accident. Back up ~/.trek-plugin/signing.key — losing it means you can't ship signed updates.

By hand with minisign, if you prefer:

minisign -G            # writes minisign.key (keep secret) + minisign.pub

Put the base64 payload line from minisign.pub in your entry as authorPublicKey (stable across versions), then per release:

minisign -Sm plugin.zip   # writes plugin.zip.minisig

Add the base64 line from plugin.zip.minisig to that version as signature, next to its sha256:

{
  "id": "flight-tracker",
  "authorPublicKey": "RWQ…base64 minisign public key…",
  "versions": [{
    "version": "1.2.0",
    "sha256": "3b2a…",
    "signature": "RUR…base64 .minisig payload…"
  }]
}

Signing is opt-in: an entry without authorPublicKey/signature installs on sha256 alone. But once a plugin has shipped signed, an unsigned update is refused — don't drop the signature between versions.

Updating

Bump version in the manifest, re-pack, cut a new vX.Y.Z release, then fold the new version onto your existing entry with --merge:

npx trek-plugin-sdk entry --repo you/trek-plugin-flight-tracker --tag v1.1.0 \
  --merge registry/plugins/flight-tracker.json \
  --out registry/plugins/flight-tracker.json

--merge prepends the new version (keeping the array newest-first) and preserves the rest of the entry. PR the updated file. Instances see the update on their next registry poll; applying it is always an explicit admin action, and if a new version requests more permissions the admin must re-approve — see Plugin Permissions.