The plugin platform matures, and Costs goes properly multi-currency. A plugin can now deliver its own notifications (Gotify, Pushover, Telegram…), declare the exact TREK versions it runs on, carry a signature TREK verifies and badges, ship an icon and its own settings page — and
trek-pluginbecomes a guided path from scaffold to published. Costs gets an editable trip currency that re-anchors the whole budget, per-currency settle-up payments, and totals, place prices and the PDF that stop pretending everything is EUR. Plus AirTrail multi-leg imports, coordinate-first Atlas, an offline Help centre that ships with the build, Catalan as language 23, and a broad reliability, mobile and security pass.No breaking changes — every new capability is opt-in, and all migrations run automatically on startup.
The team is growing — and TREK has a new home
Two things beyond the changelog:
- A new maintainer. @subdee (Konstantinos) joins @jubnl and me steering TREK — and he's already all over this release. And @fbnlrz (Fabi), who builds the plugin-dev skill, is stepping in to help with community management. Thank you both. 🙌
- TREK now lives in its own GitHub organisation: github.com/liketrek. The repo is now
liketrek/TREK— existing links and clones keep redirecting, so nothing breaks, but that's the new home.
Features
- A plugin can register a notification channel — the four built-in channels (in-app, email, webhook, ntfy) were a closed set, so a plugin could produce a notification but never deliver one. A plugin now declares
hooks.notificationChannel+hook:notification-channel; email/webhook/ntfy become providers behind a channel registry, and the hook runs userless (the host passes the recipient's decrypted settings in asconfig), so a channel plugin gets a push token without gaining the right to read that user's trips. - AirTrail connecting flights import as one multi-leg booking — layovers were inexpressible (every flight came in as its own reservation, and the connection country counted as visited in Atlas). The picker joins chains by airport continuity within 24h, and a joined booking imports detached from sync, since AirTrail has no multi-leg entity to round-trip to. Closes #1535
- A plugin declares the TREK versions it supports, and the host enforces it — the manifest's
trekis now a semver range, not a lower bound that cannot express "stops working at 4.0".install/host-compat.tsis the single answer for install, sideload, dev-link and activation; prereleases coerce to their target release, and an unparseableAPP_VERSIONwarns rather than blocking. Discover badges an incompatible entry and offers the newest version that does fit. - Plugin signature status, and a scoped re-trust override — signatures were verified and TOFU-pinned but never surfaced: an unsigned plugin looked identical to a signed one, and a refused update died in a toast. Refusals now carry codes, persist on the plugin row, and are badged. Only
SIGNATURE_KEY_CHANGEDis overridable (keys rotate; bad bytes don't), re-trust re-pins and updates in one call, the artifact must still verify under the new key, and both fingerprints go to the audit log. - A plugin can declare an icon — a lucide icon name in the manifest, resolved in one place (
PluginIcon.tsx) and rendered everywhere a plugin appears, instead of a genericBlockstile everywhere. Validation warns rather than errors, since the SDK's icon-name snapshot can legitimately trail the host. - The plugin CLIs warn when the SDK is out of date — the entry format, validator and permission catalog move with the host, so a stale SDK can
packandsubmitsomething today's registry CI rejects. Backed byupdate-notifier, so it never blocks on the network, and it stays silent in CI and off a TTY. rawBodyBase64onPluginRequest— a webhook author needs the exact bytes the sender signed; re-serializing the parsedbodywill not reproduce them. The host already set it onauth:falseroutes; it's now part of the public type.- The SDK mock host can drive settings actions and notification channels —
hook()alone can't express them, so a plugin could pass its tests and still break in production.app.action()models a button as user-initiated,app.channel.send/testmodels the channel as userless withconfigpassed in. - The trip currency is editable, and changing it re-anchors the budget — the trip currency is the base every expense and settle-up is netted against, but the only picker for it lived in the legacy Budget addon panel, so on the Costs panel a trip was stuck with whatever it was created as. It's now a field on the trip form (create and edit, gated on
trip_edit); the REST and MCP write paths already accepted it. Changing it isn't a rename, though: a frozenexchange_rateis "units of the row's currency per 1 trip currency" andcurrency = NULLmeans "the trip's own", so both are relative to the outgoing base.rebaseTripCurrency()runs first, pinning the implicit rows to the outgoing currency and re-freezing every rate against the incoming one — no stored amount is rewritten, and each expense keeps its real-world value across the switch. - Settle-up payments carry their own currency — paying a rouble debt in euros is normal, and the server has stored a
currency+ frozenexchange_rateon every transfer since #1445, but the UI never let anyone choose one, so a payment silently inherited whatever the viewer's display currency happened to be. The payment modal now has a picker (and reopens a payment in the currency it was recorded in), and a foreign payment shows in the ledger as$30.00 → 27,00 €like a foreign expense does, instead of stamping the display currency's symbol onto the raw stored number. - The display currency is optional, falling back to the trip's — Costs already resolved
default_currency || trip.currency, but the setting could never actually be empty: the store seeded'USD', so a user who had never touched it silently had every trip converted into dollars. The picker now leads with a Trip currency option, so an unset preference means "show each trip in its own currency" instead of forcing them all through one code. An explicit empty value persists and beats the admin-set instance default — it's a deliberate choice, not an absence. This also brings the public share's fallback to life: the payload has resolved sharer's currency → trip currency since #1361, but that branch was unreachable while every owner had a currency forced on them. - The in-app Help pages are served from the wiki that ships with the build —
/helpfetched its markdown fromraw.githubusercontent.comat runtime, so a self-hosted install was shown docs frommainrather than the version it was actually running, and help broke without network access. It now reads the repo'swiki/from disk, with no copy or build step — one__dirnameanchor resolves in dev, a source install and Docker alike. GitHub survives only as a fallback for when that directory can't be found: a page missing from a present wiki is a genuine 404, since falling back per-file would reintroduce the version skew this removes. Shipping it took two Docker edits, not one —.dockerignoreexcludedwiki/outright, so theCOPYalone would have produced an image with no wiki, quietly masked by the fallback.TREK_WIKI_DIRis an off-by-default escape hatch for serving your own docs. Costs ~30 MB of image size. - Booking routes show per trip, with an account default and a bulk toggle — a new always show booking routes setting seeds any trip whose routes were never touched, and a show-all/hide-all button in the day-plan toolbar flips a trip explicitly. The per-leg toggle still overrides in either mode.
- A plugin can ship its own settings page —
capabilities.settingsUi: trueframes the plugin'ssettings.htmlas a card under Settings → Plugins, in the same sandbox and postMessage bridge as its widget. Hosts that predate the flag strip it at install, so old instances simply don't show the card. - Maps open framed on their places — a trip in Japan opened on a world view at 0,0 and then flew across the planet; each renderer now derives its opening camera from its places at construction. The home-city default centre/zoom settings are dropped, since nothing reads them anymore.
- MCP exposes public transit planning tools — search and journey creation in parity with the web app, accepting every mode the provider can actually return (the old whitelist silently dropped itineraries with e.g. an AIRPLANE leg) and reporting a
droppedcount instead of conflating bad provider data with "no routes exist". - A visited Atlas region can be hidden, cascading to the country — a new
hidden_regionstombstone mirrorshidden_countries; hiding a country's last visible region hides the country too, and re-marking clears both. - Catalan (ca) is a supported language — the full locale, at strict key parity with
en.
Fixes
- Every plain-HTTP request a plugin made was refused — Node normalises
net.connect()args into an array, which undici's HTTP (not TLS) connector passes through;classifyConnectreadhostoff it, gotundefined, and fell back tolocalhost, so a declared public host was rejected as "not in the plugin's declared hosts". Failed closed, so never a hole, and unnoticed only because the one shipped egress plugin uses HTTPS. TREK_PLUGIN_ALLOW_PRIVATE_EGRESScould never have worked — the guard reads it inside the plugin child, whose env the supervisor scrubs to a four-entry whitelist that never included it. A documented setting was wired to nothing, so no plugin could reach a LAN Gotify/ntfy/Ollama.- A self-hosted-target plugin couldn't express its manifest —
http:outboundwith emptyegress[]was rejected, forcing the scaffolder to invent a placeholder host. It's now legal iffoperatorEgress: true; the empty list means "reaches nothing until an admin adds a host", not allow-all. - A compromised LLM endpoint could redirect into the cloud-metadata service (GHSA-fmq9-ggh3-647p) —
safeFetchLlmDNS-pinned only the initial URL and leftredirect: 'follow', so a benign-resolving endpoint could 302 to169.254.169.254and reach IMDS. Redirects are now followed manually, re-resolved and re-checked per hop; Alibaba's metadata IPs are blocked explicitly. - A plugin spinning on
ctx.log.error()could freeze the instance — logs, child stdout/stderr and unknown topics bypassed the RPC rate limiter and hit a synchronous INSERT on the host thread. All plugin-driven log lines now pass a per-plugin token bucket (50 burst / 10 per sec), with dropped lines summarised so throttling is visible. - A backup taken during writes could contain a torn
travel.db— archiver reads lazily duringfinalize(), so a WAL checkpoint could rewrite pages mid-read, with no-walin the zip to recover from. Now archives aVACUUM INTOsnapshot, falling back to the checkpointed live file. - An interrupted restore could leave no valid
travel.db— the old DB was unlinked before the new one was fully written. Now copies to a temp file on the same filesystem andrenameSyncs it in; stale-wal/-shmsidecars are dropped first. - A Helm upgrade could deadlock the pod on its volume — no
strategymeant the default RollingUpdate tried to start a second pod on the same ReadWriteOnce PVC: Multi-Attach failure, or two writers on one SQLite DB. Defaults toRecreate(replicas are pinned to 1 anyway), still overridable for ReadWriteMany. Also repoints rootdocker-compose.ymloff the:devtag no workflow builds. - Uninstalling with "keep data" discarded pending GDPR erasures — the orphan reap keyed on the registry row being gone, but
uninstall(deleteData: false)removes that row while deliberately preserving the data and the queued erasure. It now reaps only once the data dir is actually gone. - An iPad couldn't scroll the planner: a swipe became a drag — "mobile" was inferred from viewport width at four separate breakpoints, and nothing ever detected touch, so a coarse-pointer device at a desktop width fell on the wrong side of all four (which is why iPhone was fixed and iPad wasn't). A new
useIsTouch()(pointer: coarse) gates every drag affordance while layout stays width-driven, the reorder arrows show on coarse pointers, and the drag polyfill loads only on hybrid laptops. Covered by an e2e spec on real WebKit, since every iPadOS browser is WebKit underneath. Closes #1432, closes #1440 - Invisible reorder buttons were silently reordering the trip — the hover rule targeted
.place-row/.reorder-btns, neither of which exists since the TS migration, so the arrows sat atopacity: 0with nothing to reveal them — andopacity: 0still hit-tests. Fixed withpointer-events: nonewhile hidden and a working:hover/:focus-withinreveal, plus an e2e guard (jsdom can't evaluate:hover). - The places filter reset on tab switch while the map stayed filtered — the filter existed twice (sidebar
useState+ page-level map state) synced only by a click callback, so unmounting the sidebar reverted its copy. Both collapse into one source of truth intripStore. Closes #1541 - A new accommodation checked out the same day it checked in — the hotel picker seeded
{start: day, end: day}. It now defaults check-out to the next day, falling back to same-day on the trip's last day. - A synced AirTrail flight that grew extra stops corrupted one side — a locally-merged multi-leg booking no longer matches the single flight it came from, so a push would rewrite the AirTrail flight to span the whole route and the next pull would flatten the chain. It now detaches instead of syncing, since no AirTrail shape can round-trip the merge.
- Automated public transit was offered on trips with no dates — a Transitous search needs a departure date, so the mode switch and the tram button led to a dead end. Both are gated on the trip having dates rather than shown disabled.
- The bundled airport list was stale and dropped smaller airports — the build script's
KEEPexcludedsmall_airport, discarding regional fields that have a real IATA code and scheduled service. Regenerated from OurAirports: 3,272 → 4,045 airports. - Plugin row ⋯ menu was clipped by the sidebar — an
absolutemenu inside anoverflow-hiddenancestor gets clipped regardless ofz-index, so with enough plugins installed the menu was chopped at the sidebar's edge, taking Delete with it. Now portaled to<body>, positionedfixed, flipping up when the bottom is tight. Closes #1523 - MCP created a new session per tool call until it 429'd —
/mcpCORS never setexposedHeaders, so browser-context clients couldn't readMcp-Session-Idand couldn't echo it back; every request looked like a fresh initialize until the per-user cap wedged the integration until restart. The headers are now exposed, the cap evicts the least-recently-used session instead of refusing, orphaned transports are closed rather than leaked, and the cap error comes back as JSON-RPC. - Editing an expense silently reassigned the payer — the payer initializer fell back to the current user, so reopening an expense saved with "no one paid yet" reselected "You" and re-saving corrupted the balances. An absent payer on an existing expense is now respected; only a new expense defaults to me. Closes #1533
- An expense fronted by several people couldn't be entered — the ledger has credited payers individually since 3.1.0, but the modal only ever sent one payer. Restores the multi-payer editor, with helpers keeping the amounts summing to the total — the invariant matters because
createItemre-derivestotal_pricefrom the payer sum. The other half of #1533: that fix stopped the field lying about who paid, this one lets it say several people did. - PDF: the day header lost its gap on overflow pages — the 12px lived in
.day-body's padding, and a fragmented box only paints that on its first fragment. Moved into the repeatedtheadcell; the first page is unchanged. Closes #1531 trips.getPlacesfailed with "unknown column" — it ordered byday_id, position, which live onday_assignments, notplaces. Now returns the place pool bycreated_at DESC, matching REST; usetrips.getDaysfor the itinerary.- A plugin's iframe couldn't reach hosts an admin added for it — the frame's
connect-srcused manifest grants alone, while the child's guard is the union of those and the admin'soperatorEgresshosts, so such a plugin could call the host from its server and was CSP-blocked in its own UI. Widens no boundary: the admin consented and the child already reaches them. - A rejected
events.emitwas swallowed — it must not escape (a detached rejection kills the child over one bad emit), but silence left authors with no way to learnemitswas missing from the manifest. Now surfaced as a warning on the plugin's log stream. trek-pluginis a guided path (create → dev → status → publish) — registry gates are declared once in a check registry and run as early as they can, rather than only inpreflight, which runs against a pushed tag — too late to learn your README is short.publishis reordered to check → pack → release → preflight → submit, so a failure means nothing was released. Addsstatusandshot, per-command help, and a parity test against the registry's real validator, because a lenient port is a false green.trek-plugin devgreen-lit plugins that would do nothing once installed — TREK gates hooks, events, jobs and egress silently, so an ungranted hook is simply never called; dev enforced none of it. Dev now warns on every implemented-but-ungranted entry point, denies the fire endpoints, and installs a real egress guard. Also fixescreate's permission picker, which had fallen 40 permissions behind the host.- A multi-file plugin UI 404'd its own assets in dev — the frame loaded at
/uiwith no trailing slash, so relative asset URLs resolved to the origin root. Dev now loads/ui/index.html, matching how the host loads the frame, rather than rewriting the paths. trek-plugin preflighthad drifted into a false green — it didn't verify author signatures the registry now checks, so a wrong-key or re-packed-after-signing submission sailed through to review. It also had a false red:apiVersionis optional and defaulted to 1 everywhere, yet preflight failed the manifests that omitted it — which teaches authors to ignore the tool.trek-plugin publishcould overwrite a released artifact — the registry pins its sha256, so re-uploading bytes breaks install/update for everyone on that version. A blanket catch aroundgh release createturned any failure into a--clobberupload; it now probes for the release and refuses without--force. It also stops deletingplugin.zip, which is what the sha256 was computed from.trek-plugin submitdied when the fork already had anupstreamremote —gh repo clonemay have wired it already, and the bareremote addexited non-zero. Now set either way.trek-plugin devmisrepresented production in three ways — onetry/catcharound both thenode:sqliteprobe and the DB open meant a permission error silently degraded to the in-memory stub, so adb:ownplugin "worked" and persisted nothing;notificationChannelwas fired like an ordinary hook, passingctxwhere the recipient'sconfigbelongs; and/previewpinned atripIdthe fixtures don't seed, so a widget's firsttrek:invoke500'd.trek-pluginsilently ignored flags a command didn't read —create --template notification-channelcheerfully scaffolded a blank plugin. Unknown flags are now fatal (andcreateactually forwards--template/--egress/--required-addons), which meant--helpfinally had to exist.- Settle Up and Balances were inflated ~27x on a mixed-currency trip — the settlement route read
currencyoff the row returned bycanAccessTrip, whose SELECT never included the column; a cast hid it, so the maths was told every trip is in EUR. Balances net in the trip currency and convert to the display currency once, so expenses in the trip's own currency still cancelled out — but one booked in a foreign currency was divided by its frozen rate into trip-currency units, then converted again as if those were euros, inflating it by the EUR/trip rate (a RUB trip with a $100 expense reported being owed 451,092 ₽ on a 25,669 ₽ trip). MCP was unaffected, which is why the endpoint looked correct when queried directly. The settlement maths was right all along; it was lied to about the base currency. Closes #1543 - Changing the trip currency silently redenominated place prices — a price with no currency of its own means "the trip's currency", but
rebaseTripCurrency()only pinned budget rows, so a €15 museum on a trip moved to JPY became ¥15. Currency-less priced places are now pinned in the same transaction — same class of mismatch as #1543, on the places surface. - Mixed-currency day and trip totals were raw sums under one label — a $2,730 hotel on a NOK trip read as "2730 NOK". Totals now convert into a base currency (marked ≈), falling back to an honest per-currency breakdown when no rate is available.
- The PDF export hardcoded EUR — amounts now render in the trip's actual currency via
formatMoney, and the €-glyph price-chip icon becomes a currency-neutral coin. - Expenses weren't re-split when a member was removed — their share is now redistributed among the remaining members.
- MapLibre right-drag rotation ping-ponged near mid-screen — MapLibre 5's around-center handler flips its sign across a line that drifts with the bearing.
aroundCenter: falserestores the 4.x behaviour: horizontal drag rotates, vertical pitches. Closes #1545 - Road routes drew as straight lines in local dev — StrictMode's simulated cleanup aborted a once-created
AbortControllerfor good, killing every car/bus/taxi route fetch before the real mount. Each effect run now gets a fresh controller, and unfinished jobs un-mark themselves so a remount retries. - A transit route vanished on a day with no other places — the endpoint-proximity declutter hid it despite its real rail/bus geometry. Geometry-carrying transit is now exempt in both renderers.
- The map POI search controls didn't show on mobile. Closes #1555
- Every plugin frame document 404'd, and a multi-file UI couldn't boot —
res.sendFile(absolutePath)resolves against the rewritten URL under the Nest adapter, and helmet's CORP made the browser drop the opaque-origin frame's own subresources. Assets are now served root-relative and marked cross-origin; sandbox + per-plugin CSP stay the isolation boundary. - A role-promoted admin wasn't an admin to plugins —
req.user.isAdminnow derives fromrole, not the legacyis_admincolumn. - A malformed WebSocket frame could crash the server — socket
errorevents are now handled instead of escaping as an uncaught exception. - Accommodations shifted when the trip's dates did — stays (and their linked hotel reservations) now re-anchor to the days holding their pre-change dates, and a new
date_shift_modeon the edit form lets a whole-trip move shift everything instead. Closes #1288 - An AI import failure was invisible — providers that only support
json_object400'd thejson_schemarequest and the user saw a generic "no reservations". The request now retries withjson_object, the error is logged, and the provider's message reaches the background-tasks widget. - The first Atlas boundary request paid an O(n²) parse — admin1 GeoJSON is now streamed per country with a single-pass feature splitter; the one-time build drops from ~4.2s to ~1.7s with byte-identical output.
- Atlas trusted Nominatim over its own polygons — region and country now resolve coordinates-first against the bundled admin1 shapes, so a resolved region always matches a bundle feature (Barcelona: ES-CT, not the absent ES-B) and "San Francisco, CA" stops resolving to Canada. Closes #1547
- NAT64/6to4/Teredo addresses bypassed the SSRF guard —
64:ff9b::a9fe:a9feembeds the metadata IP, and none of the guards recognised the transition ranges. The embedded IPv4 is now extracted and re-checked against each guard's own blocklist; transition addresses to public IPv4s stay allowed. - Packing templates applied into the wrong list — they now land in the active tab. Closes #1581
- The packing list was cramped on mobile, and the quantity label was English-only — layout fixes for narrow screens, plus
packing.quantitytranslated in every locale. - The vacay calendar ignored a week-start change — it now recomputes. Closes #1554
- Ticket item amounts were pushed out of view on narrow screens. Closes #1568
- A fresh instance mixed Fahrenheit and a 12-hour clock with metric distances — defaults collapse to one system (Celsius / metric / 24h) in a single exported
DEFAULT_SETTINGS; admin defaults and saved values still win. - 455 wiki links opened a tab and 404'd in the in-app reader — bare GitHub-wiki relative links are now rewritten in the renderer (code spans protected, so
actions[key](ctx)survives), and headings carry GitHub-compatible ids so in-page anchors actually scroll.
Tests
- Repair the trip-creation e2e specs —
create-tripandtrip-plannerhad been broken long enough for three UI changes to drift past them, since CI runs vitest and never invokes Playwright. Each failure masked the next: the release-notice modal's backdrop swallowed the click on.add-trip-card,.modal-backdrophad been namespaced to.trek-modal-backdrop, andinput[type=text].first()is now the cover-image search box — so the specs were typing the trip name into the photo search and creating an untitled trip. - A reproducible documentation-screenshot run — a
screenshotsPlaywright project seeds a demo trip over REST (in JPY, so the currency work is visible) and captures every surface from a dev build; tabs are located by visible label, so a rename fails the run loudly. Hand-retaken screenshots are what let the old ones drift two releases behind.
Chores
- Bump
trek-plugin-sdkto 1.5.0 (rawBodyBase64, thetrekrange, icons, the update notice, the CLI rework and the preflight/publish/submit fixes above). - Declare
@modelcontextprotocol/sdk^1.29.0inserver/package.json, matching the lockfile — no dependency-tree change. - Document the plugin notification-channel surface across the wiki (Plugins, Plugin-Development, Plugin-Cookbook, Plugin-Permissions, Notifications), including what operator-supplied egress deliberately does not permit.
- Document the AirTrail import and connection joining in
wiki/Reservations-and-Bookings.md. - Document the MCP reverse-proxy requirements (the headers a proxy must not strip) in the README and across the wiki.
- Don't run repo-specific workflows on forks — release, publish, wiki-deploy and triage now no-op via a
github.repositorycheck instead of failing or acting on the fork's own issues; Docker Scout skips fork PRs, whose login could never succeed. Tests and lint stay ungated. - Community copy pass on the Russian translations (Atlas, nav, settings — thanks @fxgn). No keys added or removed.
- Drop the superseded hand-rolled SDK update checker, which was never imported by either CLI.
- Drop three unused
eslint-disable-next-line no-consoledirectives in the plugins module. - Add a wiki Currencies page defining the trip, expense and display currencies together, and rewrite Budget-Tracking's currency section against it.
- Retake every wiki screenshot against 3.4.0 via the new screenshot run — 21 assets replaced, 13 surfaces captured for the first time, the four collab surfaces split out of one image, ~70% smaller files.
- Broader 3.4.0 docs pass — correct the settings, map and addon pages, document passkeys, calendar feeds, appearance and plugins, and point plugin authors at the agent skill and the registry.
- Make jsdom
Storagework under Node's native Web Storage globals. - Update the repository URL across the project.