Skip to content

v3.4.0

Latest

Choose a tag to compare

@mauriceboe mauriceboe released this 18 Jul 21:01
3ca1ef3
Release 3 4 0 (1)

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-plugin becomes 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 as config), 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 trek is now a semver range, not a lower bound that cannot express "stops working at 4.0". install/host-compat.ts is the single answer for install, sideload, dev-link and activation; prereleases coerce to their target release, and an unparseable APP_VERSION warns 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_CHANGED is 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 generic Blocks tile 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 pack and submit something today's registry CI rejects. Backed by update-notifier, so it never blocks on the network, and it stays silent in CI and off a TTY.
  • rawBodyBase64 on PluginRequest — a webhook author needs the exact bytes the sender signed; re-serializing the parsed body will not reproduce them. The host already set it on auth:false routes; it's now part of the public type.
  • The SDK mock host can drive settings actions and notification channelshook() 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/test models the channel as userless with config passed 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 frozen exchange_rate is "units of the row's currency per 1 trip currency" and currency = NULL means "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 + frozen exchange_rate on 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/help fetched its markdown from raw.githubusercontent.com at runtime, so a self-hosted install was shown docs from main rather than the version it was actually running, and help broke without network access. It now reads the repo's wiki/ from disk, with no copy or build step — one __dirname anchor 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 — .dockerignore excluded wiki/ outright, so the COPY alone would have produced an image with no wiki, quietly masked by the fallback. TREK_WIKI_DIR is 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 pagecapabilities.settingsUi: true frames the plugin's settings.html as 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 dropped count instead of conflating bad provider data with "no routes exist".
  • A visited Atlas region can be hidden, cascading to the country — a new hidden_regions tombstone mirrors hidden_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; classifyConnect read host off it, got undefined, and fell back to localhost, 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_EGRESS could 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 manifesthttp:outbound with empty egress[] was rejected, forcing the scaffolder to invent a placeholder host. It's now legal iff operatorEgress: 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)safeFetchLlm DNS-pinned only the initial URL and left redirect: 'follow', so a benign-resolving endpoint could 302 to 169.254.169.254 and 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 during finalize(), so a WAL checkpoint could rewrite pages mid-read, with no -wal in the zip to recover from. Now archives a VACUUM INTO snapshot, 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 and renameSyncs it in; stale -wal/-shm sidecars are dropped first.
  • A Helm upgrade could deadlock the pod on its volume — no strategy meant 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 to Recreate (replicas are pinned to 1 anyway), still overridable for ReadWriteMany. Also repoints root docker-compose.yml off the :dev tag 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 at opacity: 0 with nothing to reveal them — and opacity: 0 still hit-tests. Fixed with pointer-events: none while hidden and a working :hover/:focus-within reveal, 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 in tripStore. 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 KEEP excluded small_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 absolute menu inside an overflow-hidden ancestor gets clipped regardless of z-index, so with enough plugins installed the menu was chopped at the sidebar's edge, taking Delete with it. Now portaled to <body>, positioned fixed, flipping up when the bottom is tight. Closes #1523
  • MCP created a new session per tool call until it 429'd/mcp CORS never set exposedHeaders, so browser-context clients couldn't read Mcp-Session-Id and 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 createItem re-derives total_price from 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 repeated thead cell; the first page is unchanged. Closes #1531
  • trips.getPlaces failed with "unknown column" — it ordered by day_id, position, which live on day_assignments, not places. Now returns the place pool by created_at DESC, matching REST; use trips.getDays for the itinerary.
  • A plugin's iframe couldn't reach hosts an admin added for it — the frame's connect-src used manifest grants alone, while the child's guard is the union of those and the admin's operatorEgress hosts, 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.emit was swallowed — it must not escape (a detached rejection kills the child over one bad emit), but silence left authors with no way to learn emits was missing from the manifest. Now surfaced as a warning on the plugin's log stream.
  • trek-plugin is 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 in preflight, which runs against a pushed tag — too late to learn your README is short. publish is reordered to check → pack → release → preflight → submit, so a failure means nothing was released. Adds status and shot, per-command help, and a parity test against the registry's real validator, because a lenient port is a false green.
  • trek-plugin dev green-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 fixes create'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 /ui with 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 preflight had 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: apiVersion is optional and defaulted to 1 everywhere, yet preflight failed the manifests that omitted it — which teaches authors to ignore the tool.
  • trek-plugin publish could overwrite a released artifact — the registry pins its sha256, so re-uploading bytes breaks install/update for everyone on that version. A blanket catch around gh release create turned any failure into a --clobber upload; it now probes for the release and refuses without --force. It also stops deleting plugin.zip, which is what the sha256 was computed from.
  • trek-plugin submit died when the fork already had an upstream remotegh repo clone may have wired it already, and the bare remote add exited non-zero. Now set either way.
  • trek-plugin dev misrepresented production in three ways — one try/catch around both the node:sqlite probe and the DB open meant a permission error silently degraded to the in-memory stub, so a db:own plugin "worked" and persisted nothing; notificationChannel was fired like an ordinary hook, passing ctx where the recipient's config belongs; and /preview pinned a tripId the fixtures don't seed, so a widget's first trek:invoke 500'd.
  • trek-plugin silently ignored flags a command didn't readcreate --template notification-channel cheerfully scaffolded a blank plugin. Unknown flags are now fatal (and create actually forwards --template/--egress/--required-addons), which meant --help finally had to exist.
  • Settle Up and Balances were inflated ~27x on a mixed-currency trip — the settlement route read currency off the row returned by canAccessTrip, 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: false restores 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 AbortController for 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 bootres.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 pluginsreq.user.isAdmin now derives from role, not the legacy is_admin column.
  • A malformed WebSocket frame could crash the server — socket error events 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_mode on the edit form lets a whole-trip move shift everything instead. Closes #1288
  • An AI import failure was invisible — providers that only support json_object 400'd the json_schema request and the user saw a generic "no reservations". The request now retries with json_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 guard64:ff9b::a9fe:a9fe embeds 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.quantity translated 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 specscreate-trip and trip-planner had 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-backdrop had been namespaced to .trek-modal-backdrop, and input[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 screenshots Playwright 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-sdk to 1.5.0 (rawBodyBase64, the trek range, icons, the update notice, the CLI rework and the preflight/publish/submit fixes above).
  • Declare @modelcontextprotocol/sdk ^1.29.0 in server/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.repository check 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-console directives 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 Storage work under Node's native Web Storage globals.
  • Update the repository URL across the project.