* 3.3.0 (#1472)
* feat(plugins): grow the frame bridge — fill pages, confirm, openExternal, live context
- page/trip-page hosts pass fill: the frame pins to 100% height and ignores
trek:resize, so a kit plugin's auto height report no longer collapses a full
page into a floating island with dead space below (widgets keep self-sizing)
- trek:confirm renders the native ConfirmDialog host-side (the sandbox has no
allow-modals) and answers trek:confirm:result; one at a time
- trek:openExternal opens validated http(s) URLs in a noopener tab — the
sandbox has no allow-popups, so plugins simply couldn't link out before
- trek:notify accepts an optional duration, clamped to 1.5-15s
- context gains dir (rtl/ltr) and is re-pushed when locale or format settings
change, not just on appearance mutations
- core events for the trip in view are forwarded as trek:event — names only,
never payloads, mirroring the server-side events surface
* fix(plugins): move trip warnings out of the content area
The warning pills overlaid the top of every planner tab at full width, sitting
on the map and its toolbar. Now a warning from a plugin that owns a trip-page
tab renders as a compact chip in the navbar centre (click jumps to the tab; the
navbar centre is free on trip pages), and everything else floats above the
content at the bottom instead. Mobile keeps all warnings in the bottom overlay
since the desktop navbar isn't there. The trip tab's frame also opts into the
new fill mode.
* feat(plugin-sdk): 1.4.0 — motion library + new bridge helpers in the kit
Mirrors the host's animation vocabulary 1:1 into TREK_UI_CSS (menu/popover/
modal/backdrop/toast enters, drawer variant under 640px, page-enter, stagger,
skeleton shimmer, chart reveals) including the reduced-motion degrade to a
gentle fade. window.trek grows confirm(), openExternal(), onEvent() and a
notify duration, and applyContext now stamps lang/dir on the document so RTL
hosts get RTL plugin UIs.
* feat(plugins): surface registry download counts in browse
The registry now aggregates GitHub release download counts per plugin
(TREK-Plugins#18) as an entry-level downloadCount. Project it through
browse/detail and show it as a compact stat on the browse cards and in the
detail meta grid. Counts are raw asset downloads (updates and CI included),
so the UI says downloads, not installs.
* docs(plugins): document the grown bridge surface and motion classes
* fix(plugins): harden the new bridge paths
Review pass over the bridge additions:
- keep the unstable useToast() object out of the effect deps (ref instead) —
it re-created the effect on every parent render, and with the new live
repost that meant a trek:context flood into the frame
- reset loads/height/confirm state and key the iframe when a host swaps
pluginId in place (tab bar, /plugins/:id) — the new plugin's document was
refused as a 'navigated' frame and every kit promise hung
- confirm dialogs always lead with the host-controlled plugin name so a
plugin can't dress its dialog up as a TREK system prompt; answer/refuse
moved out of setState updaters (StrictMode ran them twice)
- Number.isFinite on the notify duration (NaN parked a sticky toast)
- don't forward other plugins' namespaced broadcasts as trek:event; a
plugin's own plugin:{id}:* broadcasts now reach its frame though
- teach the SDK dev preview the confirm/openExternal contract so
trek.confirm() resolves in /preview
- 999,950 downloads formats as 1M, not 1000k
* fix(planner): let plugin warning chips grow wider before truncating
The nav-centre chip capped at 340px, so a longer warning (e.g. the TREK x
Japan weather prompt) was ellipsised almost immediately. Scale it with the
viewport up to 520px so most messages read in full while still yielding on
narrow desktops.
* feat(plugins): sort the plugin browser by download count
Discover now honours the sort dropdown (it was always alphabetical) and adds
a 'Most downloads' option that ranks the registry by downloadCount. The sort
keys are scoped per tab — updates-first stays with Installed, most-downloads
with Discover — and snap back to name when the tab can't offer them.
* feat(plugin-sdk): auto-upgrade native <select> to a host-styled dropdown
A sandboxed plugin can't reach the host's components, and a native <select>
draws its popup from the OS — so plugin dropdowns never matched TREK. The design
kit now enhances every <select> into a keyboard-accessible listbox that uses the
kit tokens, keeping the real element as the value/form source (it still fires
change). Authors write a plain <select> and get the host look for free; opt a
field out with data-trek-native. validate warns when a plugin ships a <select>
without inlining the kit.
* feat(plugins): add issue url link
* feat(plugins): reservations write + cross-trip reads
- db:write:reservations -> reservations.create/update/delete, gated exactly like
the REST/MCP path (reservation_edit + trip membership, acting user host-bound,
no impersonation) and delegating to ReservationsService so the accommodation,
budget-sync, booking-notification and reservation:* broadcasts match the web
app 1:1 — a booking/flight/import plugin can finally write a reservation
- trips.listMine / reservations.listMine: enumerate every trip and booking the
acting user can access (membership baked into listTrips, never a raw
cross-tenant SELECT) — dashboards/aggregates were impossible before
- audit: derive auditability from METHOD_PERMISSION so a new capability method
can't be added un-audited by omission
- typed ctx.reservations.* / ctx.trips.listMine, perm label (en/de), wiki
* feat(plugins): read scopes for journal, atlas, vacay and day notes
- db:read:journal / db:read:atlas / db:read:vacay expose the acting user's OWN
journals / visited countries+regions / vacation plan across all their trips
(user-scoped like costs.listMine, each gated on its addon being enabled),
reusing the addon's existing readers
- db:read:daynotes -> daynotes.list(tripId, dayId), trip-scoped and
membership-checked like the other trip reads
- typed ctx.journal / atlas / vacay / daynotes, perm labels (en/de), wiki,
audit resource labels, tests
* feat(plugins): day notes write scope
- db:write:daynotes -> daynotes.create/update/delete, gated under the app's
'day_edit' permission (like days) with the day verified to belong to the trip;
reuses dayNoteService and broadcasts the same dayNote:* events so open
sessions update live
- typed ctx.daynotes.create/update/delete, perm label (en/de), wiki, tests
* feat(plugins): run declared background jobs on a schedule
- plugins already declared jobs {id, schedule} but the cron was never wired. The
host now schedules them: host-entry reports each job's schedule, the supervisor
starts the jobs (node-cron) when the plugin goes active and stops them on
kill/deactivate so nothing leaks
- opt-in via a new jobs:run permission — scheduled work runs with NO acting user
(its trip reads stay refused; it can only use ctx.db and declared egress), so
background execution is a distinct, admin-granted capability. Invalid crons are
skipped and a throwing job can't break the host
- extracted a small, unit-tested scheduler (plugin-jobs.ts); perm label (en/de),
wiki, tests
* feat(plugins): read scope for saved-place collections
- db:read:collections -> collections.listMine() / collections.get(id): the acting
user's own collections (user-scoped, gated on the Collections addon), reusing
collectionsService
- typed ctx.collections, perm label (en/de), wiki, audit resource labels, tests
* fix(plugins): translate new permission labels to all locales + cover the new wiring
- add the 8 new admin.plugins.perm.* labels (reservations/day-notes writes, the
journal/atlas/vacay/day-notes/collections reads and jobs:run) to the remaining
20 locales so the strict i18n key-parity test passes again
- cover the create-rpc-host reservation / day-note / cross-trip / addon-read deps
(the real side-effect wiring the mocked rpc-host tests don't exercise) so the
src/nest 80% branch-coverage gate holds
* feat(plugins): dev-link — hot-reload a local plugin against real data
Answers a plugin developer's ask: today you either get `trek-plugin-sdk dev`
(fast hot-reload but MOCK/fixture data) or the full build->pack->upload->activate
cycle (real data, no watcher). Neither gives "local dir + hot-reload + real data".
- POST /admin/plugins/link registers a plugin from a LOCAL built directory by
symlinking it into the plugins volume — the loader already forks the resolved
real path, so ZERO loader change — and registering it INACTIVE as `local:link`.
Validates the manifest + refuses native binaries exactly like a sideload.
- POST /admin/plugins/:id/reload re-forks a linked plugin via the existing
deactivate->activate primitive (same grants, no re-consent unless the manifest
widened perms). A best-effort fs.watch auto-reloads on rebuild.
- It runs through the UNCHANGED capability RPC host: real, membership-gated data,
acting user host-bound, no impersonation — code origin never touches the gate.
- Gated behind TREK_PLUGINS_DEV_LINK on top of admin + kill-switch, because a
linked plugin bypasses the install-time signature model and, under `npm run
dev`, the OS jail is off. Off by default; never reachable in production.
- discovery follows a symlinked <root>/<id>; uninstall/link never delete the
author's source (link-safe removal for POSIX symlinks and Windows junctions).
* docs(plugins): document the dev-link real-data hot-reload workflow
Adds a "Test against a real instance's data (dev-link)" subsection next to the
mock-data SDK preview: TREK_PLUGINS_DEV_LINK, POST /link with a local built dir,
activate + consent, hot-reload via the file-watch / POST /:id/reload / Restart,
and the dev-only security caveats.
* feat(plugins): dev-link admin UI
- surface devLink (TREK_PLUGINS_DEV_LINK) in GET /admin/plugins so the panel shows
the link form only where dev-link is enabled
- AdminPluginsPanel: a "Link a local plugin" form (path -> POST /link), a Dev-Link
badge for source_repo=local:link, and adminApi.pluginLink/pluginReload
- fix: the plugin menu treated any non-local:upload source_repo as a GitHub repo,
so a dev-linked plugin rendered github.com/local:link links — exclude local:link
- labels for the 6 new dev-link UI strings across all 22 locales
* docs(plugins): document the dev-link admin UI
The dev-link section showed only the curl call — surface the Admin → Plugins
"Link a local plugin" field (the primary path) and the Dev-link badge, with curl
kept as the scripting alternative.
* feat(plugins): enrich core events with { entity, entityId }
Subscribed plugins now learn WHICH entity changed, not just the event name — a
reservation/place/day/... id derived host-side from an explicit per-family
whitelist. Threaded through the six event hops WITHOUT touching actingUserId: the
handler still runs with no user, so the id is not dereferenceable (a trip read is
still refused; the id says what to react to, not what it contains). A non-entity id
can never surface — budget:member-paid-updated yields the itemId, never the userId
— and bulk/reorder/sub-entity payloads carry no id. The mapper is pure, synchronous
and never throws into the core broadcast. No new permission (reuses
events:subscribe); backend-only.
* feat(plugins): packing write scope with #858 privacy-scoped broadcasts
- db:write:packing -> packing.create/update/delete, gated under the app's
'packing_edit' permission (like the REST path) with the host-bound acting user
as owner; reuses packingService
- replicates the packing privacy model 1:1 (the controller/service helpers aren't
exported): create/delete fan out to the item's viewers only (owner + recipients,
or the whole room for a Common item); update runs the four public<->private
transitions, dropping a freshly-privatized item from the room BEFORE re-adding it
owner-only so it never leaks. A stale write is BAD_PARAMS with no broadcast
- typed ctx.packing.create/update/delete, perm label (22 locales), wiki, tests
(rpc-host gating + the four transitions + owner-scoped delete)
* feat(plugins): tableContributor hook — host-rendered view columns/actions (backend)
The registry backend for plugin-contributed columns/actions in the native planner
views (the tabular-reservations use case), mirroring placeDetailProvider:
- hook:table-contributor + the tableContributor hook (getContributions(view,
tripId, ctx) -> TableContribution[]), double-gated (implement + grant) like the
other provider hooks
- GET /api/view-contributions/:view/:tripId — view whitelist + membership gate +
per-provider timeout/fail-safe, plus the hardening the older provider hooks lack:
every field is String-coerced + length-capped, kind/tone/target enum-whitelisted,
per-provider counts capped (<=20 columns / <=10 actions), and a column url must be
http/https/mailto (a javascript:/data: url is click-XSS into the native DOM)
- typed pluginsApi.viewContributions + the ViewContribution union, perm label
(22 locales), wiki, hardening tests
* feat(plugins): render tableContributor columns/actions in the reservations view
The frontend for the tableContributor hook: a reusable PluginContributions layer
(usePluginViewContributions + PluginColumns/PluginActions) that renders the
host-normalized column/action leaves NATIVELY — a column is text/badge/link, an
action is a button that calls the plugin route or opens its sandboxed frame in a
modal (plugin markup only ever runs inside the opaque-origin iframe). Wired into the
reservations cards (both ReservationCard and TransitJourneyCard) as a strictly-
additive footer keyed by reservation id: zero change to a card when no plugin
contributes. Fetched once per view, fail-safe.
* docs(plugins): bring the permissions wikis current with this cycle
Plugin-Permissions.md was missing every permission added this cycle — add rows for
the read scopes (journal/atlas/vacay/daynotes/collections), the write scopes
(reservations/daynotes/packing, packing noting the #858 owner-scoping), jobs:run
and hook:table-contributor, and correct the events:subscribe row for the new
{ entity, entityId } hint. Add the jobs:run row to Plugin-Development.md too.
* fix(maps): stop quick one-finger pans zooming the map on mobile (#1440)
The global drag-drop-touch polyfill installs document-level touch listeners
on phones. On every single-finger touchend it records a timestamp, and if the
next touch starts within 500ms it synthesises a dblclick on the target, which
the map's default double-click-zoom turns into a zoom-in. Two quick one-finger
pans therefore zoomed instead of panning.
The polyfill only bridges HTML5 drag-and-drop to touch for planner reordering,
which is already disabled on mobile (#1432), so gate its import to viewports
>=1024px (the lg breakpoint useIsMobile uses). Removes the phantom-dblclick
source on phones while keeping touch DnD on large viewports; fixes both the
Leaflet and GL renderers.
* fix(feeds): emit TZID + VTIMEZONE so subscribed calendars respect time zones (#1453)
exportICS emitted timed DTSTART/DTEND as bare floating times (no Z, no TZID),
which iOS/Google Calendar render in the subscriber's local zone instead of the
zone TREK shows. Resolve an IANA zone per timed event — transport endpoints use
their stored timezone (departure drives DTSTART, arrival drives DTEND), while
assignments and hotel/restaurant reservations derive it from place coordinates
via tz-lookup — and attach TZID backed by a VTIMEZONE component. The all-trips
feed now carries deduped VTIMEZONE blocks so TZID references still resolve.
* feat(plugins): render tableContributor contributions in the places + day views
Extends the tableContributor frontend to all three planner views: hoist the shared
PluginCardFooter into PluginContributions, wire the places sidebar (keyed by place
id, rendered as a sibling after each row so the drag/scroll row stays untouched)
and the day panel (keyed by day id, guarded for a null day). Strictly additive +
fail-safe like the reservations view — nothing renders when no plugin contributes.
* fix(vacay): source holiday subdivisions from ISO 3166-2 so all states show
The state/region picker for public-holiday calendars was built from the
union of each holiday's counties for the current year, so a subdivision
only appeared if some holiday that year was tagged with it. States with
no state-specific holiday (e.g. US-WA, and AR/FL/NV/WY in 2026) silently
vanished, blocking calendar creation (#1456).
Source the full, correctly-named subdivision list per country from
ISO 3166-2 instead, merged with any nager county code ISO lacks. Only
region-partitioned countries get a picker, so nationwide-only countries
keep allowing a country-level calendar. No server change needed —
selecting a state already yields federal holidays via applyHolidayCalendars.
* fix(costs): settlements honor custom per-member splits (#1458)
calculateSettlement read each member's custom split amount but its query
never selected budget_item_members.amount, so hasCustomSplit was always
false and every settlement fell back to the equal split. Select bm.amount
so custom amounts drive the balances.
Also blank the Overview 'Per Person' / 'Per Person·Day' columns and CSV
for custom-split items, where a single averaged figure is meaningless.
* feat(plugins): read-convenience + todos + packing bags + tags + roster
A wave of small, high-value capabilities:
- weather:read (ctx.weather.get) — the host's cached forecast, tenant-free
- db:read:categories (ctx.categories.list) — the global place-category list
- db:read:tags / db:write:tags (ctx.tags) — the acting user's own tags, ownership
re-checked before each write
- trips.members (ctx.trips.members) — the trip roster (id + display fields),
membership-checked
- db:read:todos / db:write:todos (ctx.todos) — a trip's to-dos, gated by the app's
packing_edit like the REST path, broadcasts todo:*
- packing bags on ctx.packing (listBags/createBag/updateBag/deleteBag/setBagMembers)
under db:write:packing — no privacy, plain room broadcasts
perm labels (22 locales), both wikis, rpc-host gating + create-rpc-host wiring tests
* fix(dashboard): render next-trip boarding pass stats on Safari (#1459)
The boarding-pass bar carved its ticket-stub notches with a two-layer
radial-gradient mask composited via mask-composite: intersect (and legacy
-webkit-mask-composite: source-in). Safari mis-composites that multi-layer
path to fully transparent, hiding the entire stats bar while Chrome renders
it fine.
Split .hero-pass into an outer wrapper (left notch) and a .hero-pass-inner
glass panel (right notch), each carrying a single-layer mask so the
mask-composite path is never exercised. Renders identically across engines
and degrades safely where mask-image is unsupported.
* feat(plugins): write scopes for atlas, vacay, journal and collections
The write half of the user-scoped addon reads:
- db:write:atlas -> ctx.atlas.markCountry/unmarkCountry/markRegion/unmarkRegion +
bucket-list create/delete. Every row is the acting user's own (visited_countries/
visited_regions/bucket) — no trip scoping, no cross-tenant surface. Unblocks
AirTrail-style two-way sync (#214)
- db:write:vacay -> ctx.vacay.toggleEntry/toggleCompanyHoliday. The plan is
resolved HOST-SIDE from the acting user's active plan — a plugin can never name
another plan, and toggleEntry only toggles the acting user's own PTO day
- db:write:journal -> ctx.journal.createEntry/updateEntry/deleteEntry, self-gated
by journeyService.canEdit (owner/contributor) against the acting user
- db:write:collections -> ctx.collections.create/update/savePlace/copyToTrip/
deletePlace, schema-validated; the service's per-collection role checks
(assertAccess 404 / assertCanEdit 403) map onto RESOURCE_FORBIDDEN
All addon-gated, userless contexts refused, audited. Perm labels (22 locales),
both wikis, gating + wiring tests.
* fix(admin): name the Costs add-on consistently in the catalog
The budget add-on catalog entry still resolved to 'Budget' while the
feature is labeled 'Costs' everywhere else (trip tab, navbar). Align
admin.addons.catalog.budget.name with each locale's trip.tabs.budget
label. Closes#1464
* feat(plugins): file attach, collab content and gated member-add
- db:write:files -> ctx.files.create/createLink/update/softDelete under the app's
separate file_upload/file_edit/file_delete rights. Content arrives as bounded
base64 (10MB decoded cap, well under the app's 50MB), the extension is validated
against the central blocklist BEFORE anything touches disk, and link targets
must live on the same trip (findForeignLinkTarget). Broadcasts file:*
- db:write:collab -> ctx.collab.createNote/createPoll/votePoll/createMessage
under collab_edit + the Collab addon, emitting the same collab:* events as the
app; service-reported errors surface as BAD_PARAMS
- db:write:members -> ctx.trips.addMember. Adding a member GRANTS TRIP ACCESS, so
it is deliberately its own permission behind the app's member_manage right
(default: trip owner only) and never bundled with a lower-risk write; the acting
user is recorded as the inviter, target must exist, owner/duplicate adds no-op
Perm labels (22 locales), both wikis, gating + wiring tests.
* fix(maps): honor check-in/out times for hotel bookend legs (#1465)
The day route drew the accommodation as the day's start/end whenever the
edge stop was a place, ignoring the morningIsSleptHere/eveningIsOvernight
provenance already computed by getDayBookendHotels. On a check-in day an
airport placed before check-in got a spurious hotel -> airport leg, and on
a check-out day a later "home" stop still got a home -> hotel return leg.
Add time-aware shouldDrawMorningLeg/shouldDrawEveningLeg helpers: the
morning leg is the home-base default on a check-in day but is dropped when
the first place is timed before check-in; the evening return leg is off on
a check-out day unless the last place is timed at/before check-out. Wire
them into the map polyline, the sidebar hotel connectors, and the Google
Maps export so all three stay consistent.
* feat(plugins): host-mediated notifications and LLM access
Two host-owned integration primitives — the plugin supplies intent, the host
owns the sensitive part:
- notify:send -> ctx.notify.send({title, body, link?, scope, targetId}). Delegates
to notificationService.send with a new plugin_notification event (raw title/body
carried as passthrough params), so recipient resolution, channel fan-out
(bell inbox + email/ntfy/webhook) and per-user preferences all match core 1:1.
Recipients are FORCED to the acting user (scope 'user', targetId === uid) or a
trip they belong to (scope 'trip'); scope 'admin' refused; the in-app link must
be a relative /path (open-redirect-safe). No arbitrary recipient, no impersonation.
Users can mute plugin notifications like any other event.
- ai:invoke -> ctx.ai.complete(prompt) / ctx.ai.extract(text, jsonSchema). Runs the
admin/user-configured provider via resolveLlmConfig + the existing extraction
client under the acting user; the host holds the (encrypted) key, the plugin
never sees it. Refused when no provider is configured; 20k-char caps. Output is
DATA (complete -> {text}, extract -> {results}) and never auto-written, so
prompt-injection can't reach a write without the plugin's own gated call.
plugin_notification wired through the shared NotificationEventKey + all 22 locales
(inbox passthrough + external channels). Perm labels (22 locales), both wikis,
gating + wiring tests.
* fix(budget): offer every Frankfurter-supported currency (#1470)
The cost currency picker was gated by a hardcoded 47-code list, so
currencies the app can actually convert (OMR, CRC, UGX, MKD, ALL, and
~115 more) couldn't be selected. Replace CURRENCIES/SYMBOLS with the full
set the Frankfurter v2 FX API supports (archived BGN/HRK dropped), unify
the dashboard offline fallback onto it, and teach currencyDecimals about
the newly reachable zero- and three-decimal currencies. A currenciesWith
helper keeps a previously saved (now-archived) selection selectable so it
isn't silently wiped.
* feat(plugins): tableContributor into the costs, packing and files views
Extends the shipped tableContributor hook to three more native views — no new
permission, no new attack surface: the same host-normalized, length-capped,
url-allowlisted (http/https/mailto), enum-bounded, fail-safe pipeline, just more
render sites.
- server: add costs/packing/files to the view-contributions whitelist
- client: widen the ViewName union + the api view type; render PluginCardFooter
keyed by entityId in the budget category table (a colSpan footer row per item),
the packing category group (footer after each item row, drag untouched) and the
files list (footer after each row)
A currency plugin can now drop a converted-amount column onto a cost row, a
receipts plugin a 'view receipt' action onto a file, etc. Controller test asserts
the three new views are accepted; both wikis updated.
* fix(pdf): repeat day header on overflowing itinerary export pages (#1471)
* feat(plugins): map-marker provider hook — plugins can overlay trip-map markers
New declarative provider hook `mapMarkerProvider` (#587 "show bookings on map",
the single most-requested contribution class, with zero contribution point until
now):
- hook:map-marker-provider permission + MapMarkerProvider/MapMarkerContribution SDK
types + HOOK_PERMISSION wiring
- GET /api/map-markers/:tripId (MapMarkersController) mirrors the view-contributions
hardening: membership-gated, providers invoked host->plugin on a 5s timeout,
fail-safe. Every field normalized server-side — coordinates range-checked
(-90..90 / -180..180), strings String-coerced + length-capped, icon/tone enum-
whitelisted, popup url http/https/mailto only (a javascript:/data: url would be
click-XSS), marker count capped at 200 per plugin
- client: PluginMapMarkers layer renders the markers as plain Leaflet Marker+Popup
inside the trip map; plugin JS NEVER runs on the map canvas, every value is
host-vetted data. Threaded tripId through MapView; fail-safe fetch
Declarative-only by design, mirroring placeDetailProvider/tableContributor. Perm
label (22 locales), controller hardening test, both wikis.
* feat(plugins): show page plugins in the mobile bottom nav
Page plugins were reachable from the desktop nav pill (Navbar) but not the mobile
tab bar — you had to type /plugins/:id. BottomNav now reads page plugins from the
plugin store and appends them the same way global addons are, mirroring Navbar.
One-file client nav wiring; no new capability surface.
* feat(plugins): per-user plugin settings form + ctx.settings runtime read
Users can now enter their own per-plugin config (an API key, a preference) —
the prerequisite for almost every real integration, previously unreachable
(scope:'user' settings were only listed read-only in the admin panel).
- migration: plugin_user_config (plugin_id, user_id, config JSON) — each user's
own values, separate from the admin-owned instance plugins.config
- PluginsService.getUserConfig / updateUserConfig / getUserConfigDecrypted +
readUserSettingDecrypted: secrets encrypted at rest (apiKeyCrypto), masked to
the client, an unchanged secret (the mask) keeps its stored ciphertext, and only
DECLARED scope:'user' keys are ever stored
- GET/POST /api/plugin-settings/:id (PluginUserSettingsController) — its own
user-gated path (not the admin surface, not the /:id/* proxy), JwtAuthGuard only,
scoped to the acting user
- runtime: ctx.settings.get(key) -> the acting user's decrypted value (unconditional
RPC, not sensitive cross-tenant; userless job/onLoad gets undefined)
- client: a Plugins tab in Settings host-renders each active plugin's scope:'user'
fields as an editable form (secrets write-only), reusing the declarative field
shape — no plugin markup executes
i18n (22 locales), wiki, rpc-host + service + masking/encryption tests.
* fix(journey): keep skeleton suggestions in sync with linked trip places (#1473)
Journey skeleton suggestions mirror a linked trip's day-assigned places, but
sync relied on scattered per-event hooks that several assignment mutation paths
never called: unassign, move and time-change fired nothing, no remove-on-unassign
capability existed, and every MCP assignment tool synced nothing. Skeletons drifted
from the trip.
Add an idempotent reconcileTripSkeletons(tripId) that re-mirrors the trip's
day-assigned places onto every linked journey (add missing skeletons, refresh
date/time/location on move, remove skeletons for unassigned places; filled entries
are detached + noted, never destroyed). Call it from every REST assignment handler
and MCP assignment tool, and fire onPlaceDeleted on single MCP delete_place for
parity. Extract a shared insertSkeletonEntry helper.
* fix(memories): drop hidden Immich assets so Live Photo motion parts don't show a broken thumbnail (#1474)
* fix(transit): anchor arrive-by search time to the destination timezone (#1479)
* feat(plugins): host-brokered OAuth client + trustworthy inbound webhooks
Two integration primitives where the host owns the sensitive part.
Trustworthy webhooks:
- auth:false routes now receive req.headers, but ONLY an explicit, credential-free
allowlist (the common provider signature/event headers — stripe-signature,
x-hub-signature-256, svix-*, x-gitlab-event, …). Cookie/Authorization/X-Socket-Id
and every session/forwarded-auth header are stripped; authenticated routes get {}.
A plugin can finally verify a provider signature without any way to leak a session.
Host-brokered outbound OAuth (oauth:client):
- the HOST runs the whole flow — authorize -> callback -> token exchange -> refresh —
with PKCE + single-use, user-bound, TTL'd state, and HOLDS the tokens. The client
secret + refresh token never leave the host; the plugin only triggers connect and
reads a short-lived access token via ctx.oauth.getAccessToken() for the acting user.
- provider config (authorize/token url + scopes + client id/secret) is the plugin's
admin-owned instance settings; endpoints must be https (SSRF backstop, private/local
hosts refused). Tokens per-user + encrypted at rest (apiKeyCrypto).
- GET/POST /api/plugin-oauth/:id/{status,connect,callback,disconnect} — JwtAuth-gated,
the callback always redirects to an in-app /settings path (never leaks an error).
- Settings -> Plugins gains a Connect/Disconnect control per configured plugin.
migration: plugin_oauth_tokens + plugin_oauth_state. Perm labels + form strings
(22 locales), both wikis, service (PKCE/state/exchange/refresh/encrypt) + controller
+ proxy header-allowlist + rpc-host gating + create-rpc-host wiring tests.
* fix(navbar): re-measure sliding tab pill after font load and resize (#1481)
The active tab pill was measured once in a layout effect keyed only on activeTab, so on a hard reload it captured the active (bold) label's width against fallback-font metrics and never re-ran when the web font swapped in, leaving the pill slightly offset.
Re-measure after document.fonts.ready resolves and on ResizeObserver changes (container + active button), with an idempotent state update to avoid redundant renders.
* fix(collections): keep the Add-place button reachable after the first save
On a wide/desktop layout the collection toolbar (which hosts the Add
button) was gated on !mapOverlay, so it unmounted as soon as the list
gained its first place with coordinates — leaving only an easy-to-miss
"+" in the map overlay. Keep the toolbar rendered whenever the user can
add a place, and drop the now-redundant map-overlay Add button so there
is a single, predictable Add affordance in every state.
Fixes#1485
* feat(plugins): days + accommodations reads/writes, endpoints on the reservation write path
Community feedback on the 3.2.1 plugin surface: a plugin could write days but
never list them (no way to learn day ids), day_accommodations had no surface at
all, and trips.getReservations was the one reservation read that dropped the
endpoints/day_positions hydration.
- trips.getDays / trips.getAccommodations under db:read:trips (tripRead gate),
wired to the same dayService lists the REST GETs use
- trips.getReservations now returns the hydrated REST-parity list (endpoints,
day_positions, joins, normalized accommodation_id) - strict superset
- new db:write:accommodations scope: ctx.accommodations create/update/delete
gated by day_edit like the accommodations REST path, with the partner-hotel
reservation + delete cascade and broadcasts intact
- reservation create/update pin the endpoints shape up front (BadParams instead
of a mid-transaction NOT-NULL or a silently dropped row)
- perm label in all 22 locales, consent PERM_KEYS, wiki tables
* feat(plugins): day-detail widget slot in the day panel
Widgets can now mount inside the trip planner's day panel
(capabilities.widget.slot: 'day-detail'), scoped to the open day via a dayId in
trek:context - the same pattern as the place-detail slot. Covers the requested
per-day plugin content (logistics, outfit planning, live flight status) without
a new plugin type. Day-detail widgets stay off the dashboard, the consent panel
labels the slot in all 22 locales.
* feat(plugins): let the frame CSP serve a plugin's own static assets
The sandboxed frame runs at an opaque origin, so script-src 'self' never
matched and a plugin's own <script src>/<link> files were blocked - authors had
to inline entire React builds into index.html. Add a scheme-less host-source
pinned to the plugin's own /plugin-frame/<id>/ path (charset-checked Host +
plugin id so a stray token can't widen the policy; malformed Host falls back to
inline-only). Multi-file client builds now load as-is; remote hosts stay
blocked, so script URLs remain useless as an egress channel.
* fix(plugin-sdk): catch the package up to the server capability surface
The npm SDK's validator still knew only the 3.2.1 permission set, so
'trek-plugin-sdk validate' (and pack/publish, which run it) hard-rejected any
manifest using the newer scopes - db:write:reservations, notify:send,
hook:map-marker-provider and 25 more. Sync KNOWN_PERMISSIONS with the server
envelope (48 entries), mirror the full PluginContext (reservations,
accommodations, notify/ai/oauth/settings, packing writes + bags, file writes,
collab, tags/todos/daynotes/collections/atlas/vacay/journal, weather,
categories), type the tableContributor/mapMarkerProvider hooks + the
entity/entityId event hint, accept the day-detail widget slot, and extend
createMockHost so plugin unit tests can exercise all of it.
* feat(plugins): grant-scoped entity snapshots on core events
An events:subscribe handler so far learned only WHICH entity changed - useful
for cache busting, useless for reacting to content, and the userless handler
can't refetch. Now the broadcast tap derives a whitelisted field snapshot of
the changed entity and the supervisor attaches it per plugin, only where the
granted set holds the family's matching db:read:* permission (trips family ->
db:read:trips, budget -> db:read:costs, packing -> db:read:packing, dayNote ->
db:read:daynotes, file -> db:read:files). No acting user is ever synthesized.
The whitelists are explicit per family, so user ids (owner/paid_by/uploaded_by/
participants/members), trips.feed_token and future migration columns never
travel; a private packing item (#858) yields no snapshot at all because its
core broadcast is owner-scoped; deletes/reorders/bulk ops carry none.
* feat(plugins): pdf-section, atlas-layer and journal-entry provider hooks
Three more declarative provider surfaces in the map-marker mould - plugins
return data specs, the host normalizes, caps and renders; a slow or failing
provider contributes nothing:
- hook:pdf-section-provider: sections (title + paragraphs + a simple table)
appended to the trip PDF export, escaped into the same HTML/print pipeline
as the core content
- hook:atlas-layer-provider: per-user country tint layers on the Atlas map
(ISO 3166-1 alpha-2 codes only, tone-whitelisted, non-interactive pane so
mark/unmark clicks keep working)
- hook:journal-entry-provider: extra rows on a journal entry card, gated by
the same journey access check as the journal routes + the Journey addon
Permission labels in all 22 locales, consent PERM_KEYS, SDK types + manifest
validator in both SDK copies, wiki tables, per-controller hardening tests.
* feat(plugins): trip-page plugins can replace core planner tabs and pick their spot
A trip-page plugin that takes over a core surface (a transit planner
superseding Transports, a costs plugin superseding the budget tab) had to sit
awkwardly next to the tab it replaces. capabilities.tripPage now names the
core tabs to hide while the plugin is active - whitelisted (transports,
buchungen, listen, finanzplan, dateien, collab), 'plan' deliberately not
replaceable, and the tabs return the moment the plugin is deactivated - plus
an optional 0-based position for the plugin's own tab. The feed re-validates
the values out of the DB blob so a hand-edited row can't hide anything else,
the admin list chips a replacing plugin (all 22 locales), and a saved session
tab that got replaced falls back to the plan view.
Also fixes the plugins feed dropping the day-detail widget slot to 'sidebar',
which would have mounted a day-panel widget on the dashboard.
* fix(plugins): audit follow-ups — normalization, secret cleanup, cron leak, slot filter
Adversarial audit of the whole plugin PR surfaced 12 confirmed issues; this
addresses them:
- place-details provider was the ONE hook controller with no normalization: a
plugin's href/label/value went to the client raw and unbounded. Now normalized
like journal-entry-rows (safeUrl http/https/mailto, length + count caps).
- trip-warnings capped message length + per-provider count (was unbounded).
- uninstall(deleteData) now also purges plugin_user_config, plugin_oauth_tokens,
plugin_oauth_state, plugin_meta_migrations and the capability audit — encrypted
per-user API keys + OAuth refresh tokens no longer survive a 'delete all data'
and get silently re-adopted on a same-id reinstall.
- supervisor: a crash-restart cycle leaked the dead child's node-cron tasks and
re-scheduled fresh ones, so a job fired N+1 times per tick after N crashes.
onExit now stops them, mirroring kill().
- dashboard sidebar no longer mounts place-detail/day-detail widgets (they belong
in the planner panels).
- reservation endpoint validation relaxed to match the 3.2.1 service: a coord-less
endpoint is accepted and dropped downstream instead of BadParams (no breaking
change), while a bad role/non-string still rejects up front.
- a replaced core tab reached by programmatic nav now falls back to the plan view.
- trips.update caps title/description like the places path; plugin-db guard bans
load_extension as defense-in-depth.
- wiki: event snapshots, string-typed context ids, dayId in the payload, the live
provider hooks and the costs update/delete grant are now documented correctly.
* feat(plugins): phase-0 lifecycle hardening + per-plugin RPC rate limit
Operational-readiness fixes from the completeness audit:
- Re-activation after a failure worked again: a plugin left in 'error' state by
a load-failure or crash-auto-disable stayed in the running map, so the admin's
'enable' button was a silent no-op. activate() now replaces a dead entry.
- Per-plugin RPC rate limit at the dispatch boundary: every ctx.* call runs
synchronously on the host thread, so a plugin in a tight loop could freeze the
whole instance (and the reap sweep). A token bucket (generous burst) + an
in-flight cap now throttle a runaway plugin with a retryable HOST_ERROR; a
legitimate plugin never notices.
- plugin_error_log retention (500 rows/plugin) so a crash-looper can't grow
trek.db without bound; the crash-timestamp array is trimmed to its window too.
- TREK_PLUGIN_PERMISSIONS=off now logs a loud one-time warning that the OS
permission jail is disabled.
* feat(plugins): read symmetry + broker — collab/journal/atlas reads, file content, trip create, rates
The plugin API leaned write-heavy: collab and journal could be written but not
read, files listed but not read, and there was no way to create a trip or see
exchange rates. This closes those gaps in the established RPC+gate pattern (zero
architecture risk), and it's what unlocks the importer + finance plugin classes:
- collab reads: ctx.collab.listNotes/listPolls/listMessages under a new
db:read:collab (membership + Collab addon, like the REST GETs)
- ctx.journal.getEntries(journeyId): a journey's entries, journey-access-checked,
under the existing db:read:journal
- ctx.atlas.bucketList(): the acting user's bucket list, under db:read:atlas
- ctx.files.getContent(tripId, fileId): a file's bytes as base64 under a NEW
db:read:files:content grant (reading a passport scan is more sensitive than its
filename), size-capped at 10MB before it crosses the IPC pipe, trashed files
refused
- ctx.trips.create(input): a new trip owned by the acting user, gated by the app's
trip_create right + a bound user — the capability importers need
- ctx.rates.get(base): cached currency exchange rates, tenant-free like weather
Also caps trips.update title/description like the places path, and the plugin-db
guard now bans load_extension (defense-in-depth). SDK, mock-host, i18n (22
locales), consent labels and the wikis are all in lockstep.
* feat(plugins): deeper integration + user-facing activity transparency
Wave 2 of the completeness work — richer extension points, deeper metadata, and
the transparency that makes the broad read grants accountable:
- db:meta now attaches to reservations + accommodations too (not just
trip/place/day), gated by reservation_edit / day_edit respectively — the
natural home for an external-id mapping (AirTrail/calendar/booking-import sync)
without forking the core schema.
- reservation-detail widget slot: a widget can mount on a booking card, scoped to
the open reservation via reservationId in trek:context (the place-detail /
day-detail pattern, third instance).
- tableContributor gains the transports + todos views, so a plugin can add
host-rendered columns/actions there too.
- User activity log: GET /api/plugin-activity + a Settings → Plugins panel showing
every host-mediated action a plugin took bound to the signed-in user, across all
plugins, newest first — the user-facing half of the hash-chained audit. This is
what legitimizes the deliberately broad read grants: not just the admin, the
person whose data is read can see what was done in their name.
- DX: the local dev server now binds a default acting user, so the canonical
ctx.trips.getPlaces(tripId) call works locally instead of failing RESOURCE_
FORBIDDEN; the create scaffold drops the dead manifest routes[] / capabilities.nav
fields the host ignores.
SDK, i18n (22 locales), consent labels and the wikis are all in lockstep.
* fix(memories): load Immich album photos on Immich v3
Immich v3 removed the `assets` property from AlbumResponseDto, so
`GET /api/albums/:id` no longer carries album contents. TREK read album
photos from that property, which now parses as undefined and degrades to
an empty array — hence "No photos yet" in the Journey gallery picker even
though the album header shows the right count (that count comes from
`GET /api/albums` -> assetCount, which v3 still returns).
Two call sites read the removed property. Besides getAlbumPhotos (the
reported bug), syncAlbumAssets failed silently on v3: it reported
`success: true, added: 0` while syncing nothing.
Fetch album contents via an `albumIds`-filtered `POST /api/search/metadata`
when `assets` is absent, and feature-detect rather than probe a version.
The two paths are not interchangeable: on v2, searchMetadata
unconditionally scopes results to `[self, ...partners]`
(`asset.ownerId = ANY(userIds)`), so an albumIds search against an album
shared by a non-partner returns nothing. v3 added an albumIds branch that
checks AlbumRead and skips that owner filter. v2 also hard-defaults
`visibility` to `timeline`, dropping archived assets. So v2 must keep
reading the album detail body, which this preserves exactly.
`withExif: true` is required on the search path: it has no default and
gates an inner join, so without it Immich omits `exifInfo` entirely and
every photo's city/country goes null.
The existing test mock returned an album detail body *with* `assets` — it
encoded the v2 assumption, which is why this shipped green. It now models
v3 by default, with explicit v2 coverage asserting no search call is made.
Fixes#1492
* feat(plugins): daily AI/notify budgets, runtime scheduler & reliable event redelivery
Per-plugin daily caps on ai.complete/ai.extract and notify.send (defaults
200 / 100, overridable via TREK_PLUGIN_AI_PER_DAY / TREK_PLUGIN_NOTIFY_PER_DAY),
seeded from the capability audit so a mid-day restart resumes the count instead
of resetting it. Surfaced at GET /plugins/:id/budget.
ctx.scheduler (at / in / every / cancel): persistent, userless timers that
survive restarts and fire a scheduled() handler, riding the existing jobs:run
grant so no new consent or admin setup is needed. Backed by
plugin_scheduled_tasks, swept every 30s, capped at 100 tasks/plugin with an 8 KB
payload and a 60s recurring floor; rows are removed on uninstall.
Core events that fire while a subscriber is mid-restart are now held in a
bounded in-memory buffer (200/plugin, 15 min TTL) and replayed once it goes
active again, with the events:subscribe grant and snapshot gating re-evaluated
at replay time so nothing leaks if a grant was revoked while the plugin was down.
* feat(plugins): GDPR data-subject rights — durable per-plugin erasure + export
New hook:user-data grant with two userless lifecycle handlers a plugin can put
on its definition: deleteUserData and exportUserData. Neither carries an acting
user — the plugin only learns the userId and touches its own db — so the grant
reads nothing from core data; it exists purely so a plugin can honour a GDPR
erasure or data-access request.
When a TREK account is deleted (admin or self-service), every installed plugin
holding the grant gets a row in a new durable erasure queue and its
deleteUserData runs on the next sweep, retried until it ACKs — so erasure
survives the plugin being offline or the server restarting. The core deletion
path notifies the runtime through a dependency-free relay (like the event sink),
keeping the auth/admin services decoupled from the plugins layer, and a plugin
bookkeeping error can never fail the account deletion.
Portability is served by GET /api/admin/plugins/user-data/:userId/export, which
fans exportUserData out to the active granted plugins and aggregates what each
holds about the user. Queue rows are purged on uninstall; the grant is labelled
in all 22 locales.
* feat(plugins): atomic ctx.db.tx for consistent multi-write on a plugin's own db
Plugins could already query/exec/migrate their own SQLite file, but a multi-step
write (move an item between tables, decrement one row and increment another) had
no way to be atomic. db.tx([{sql, args?}, …]) runs up to 100 statements in a
single transaction — all commit or all roll back — and reads within the batch see
its own earlier writes, so read-modify-write is safe. Each op is one statement:
a read returns { rows }, a write { changes }. The same guard (no ATTACH/PRAGMA/
RECURSIVE, size + row caps) applies to every statement in the batch.
* fix(memories): filter hidden Immich assets at the source, not just the picker
#1474 has the same root cause as #1492: the Immich v3 migration. On v2,
searchAssetBuilder hard-defaulted metadata search to `timeline` visibility
(`visibility = options.visibility ?? Timeline`), so hidden Live Photo
motion parts could never come back from a search. v3 defaults to any
visibility except `locked`, so they do — which is why the reporter is on
Immich 3.0.1 and why the bug never appeared before.
Ask for `visibility: 'timeline'` explicitly on the search path. That
restores v2 semantics on both versions and stops hidden assets crossing
the wire, which also fixes a pagination wart: a full page half-made of
motion parts previously rendered as a half-empty page, because hasMore
counts the raw page length while the filter shrinks the rendered set.
The client-side filter was display-only, applied in searchPhotos and
getAlbumPhotos — both picker-listing paths. Nothing guarded persistence
or rendering: getOrCreateTrekPhoto stores any id it is handed, pipeAsset
forwards Immich's 400/404 verbatim, and the photo grid is a plain <img>
with no onError. So syncAlbumAssets, which filtered `type === 'IMAGE'`
only, could persist a hidden IMAGE as a permanently broken tile. It now
applies the same guard, extracted as isVisibleAsset().
Albums keep their filter rather than requesting `timeline` visibility:
albums legitimately contain archived assets, and both the v2 album body
and the v3 album search return them.
Does not address tiles already persisted before this — those still render
broken and need a separate fix.
Refs #1474
* docs(memories): correct Immich version boundaries in the hidden-asset comments
Verified against the v1.120.0 → v3.0.0 OpenAPI specs and server source. The
previous comments said "Immich v2 hard-defaulted metadata search to timeline
visibility". That is true only for 1.133–1.144.
- `visibility` was added in 1.133.0. Before that, searchAssetBuilder applied
`.$if(options.isVisible !== undefined, ...)` with no default, so pre-1.133
servers returned hidden assets too. #1474 was therefore not purely a v3
regression.
- Those servers strip the `visibility: 'timeline'` filter rather than
rejecting it: Immich validates with `whitelist: true` and no
`forbidNonWhitelisted`. So the request stays valid, the filter is a no-op,
and isVisibleAsset() is the ONLY guard there. Say so, so it does not get
removed later as redundant.
- `albumIds` only exists from 1.135.0. Because unknown properties are stripped,
an albumIds search against an older server would silently drop the album
filter and return the entire library as the album's contents. Feature
detection on `assets` (present through 1.144.1, absent on v3) makes that
unreachable; a version probe with a wrong boundary would not.
Also cite Immich's own enum, which documents AssetVisibility.Hidden as
"Video part of the LivePhotos and MotionPhotos".
Comments only — no behavior change.
* feat(plugins): dashboard trip-card badges + a mock-host driver for plugin tests
Two additions that round out the plugin platform's breadth and its authoring DX.
tripCardProvider hook (hook:trip-card-provider): a plugin returns small declarative
badges for the dashboard trip cards. The dashboard fetches all visible cards in one
call; the host access-checks every tripId for the acting user, bounds each field
(label/value length, enum tone, http/https/mailto-only url), caps the count and drops
any badge for a card that wasn't requested — plugin JS never runs on the dashboard.
Rendered as text chips under the card meta; labelled + gated in all 22 locales.
createMockHost now exposes run(def) — the other half of a plugin unit test. Where the
ctx recorders capture what a plugin read, run() fires its own entry points (route, job,
scheduled, event, plugin-event, deleteUserData, exportUserData, provider hooks) against
the same mock ctx, and host.scheduled surfaces the timers it armed. A handler the plugin
didn't declare throws a clear error instead of a silent no-op.
* feat(plugins): include plugin data + code in backups, applied on restart
A TREK backup archived travel.db + uploads + the encryption key, but each plugin's
own SQLite file — the ONLY copy of the user data it holds — and its installed code
lived in separate trees that were never captured, so a restore left the plugins rows
with no data or code behind them.
createBackup now adds plugins-data/ (each plugin's db + WAL sidecars, so SQLite
recovers a consistent snapshot) and plugins-code/ (skipping dev-links by realpath, so
an author's linked source is never bundled). Restore can't swap those live — the
runtime holds each plugin db open — so it STAGES the extracted trees beside the live
ones and the runtime swaps them in at the next boot, before it opens anything. Same
"applies on restart" model the bundled encryption key already uses: no plugin quiesce,
no swap under open handles, no new admin setup. Older archives without the trees restore
exactly as before.
* fix(plugins): audit — runtime robustness, security & data-lifecycle fixes
Fixes from an adversarial audit of the plugin system, host/runtime side:
Robustness:
- getPluginDataDb recreated a handle a terminal-failure dispose had closed but
left cached, so a re-enabled plugin's db:own threw on every call — recreate
when the cached handle is shut.
- ctx.ws.broadcast* now carry _inv, so the host can bind the acting user (the
capability was silently refused, i.e. dead, without it).
- ctx.events.emit swallows a rejected emit instead of crashing the child into a
terminal 'error'; an uncaught throw AFTER activation is treated as a crash
(restart with backoff), not a load failure.
- A crash-respawned child gets the same activation deadline as a first activation
and the buffered-event queue is cleared on the timeout path, so a hung onLoad
after a crash can't peg a core and orphan events forever.
- Expired buffered events are pruned by the reaper, not only at flush; the
scheduler + erasure sweeps scope their LIMIT window to ACTIVE plugins so a
backlog for inactive plugins can't starve deliverable work.
Security / integrity:
- Unix-domain-socket / named-pipe connects are refused by default in the egress
guard (a host-local pivot to docker.sock / DB sockets), under the same policy
as private IPs.
- db.tx refuses transaction-control statements (a raw COMMIT would break its
atomicity) and caps rows across the WHOLE batch, not per statement.
- plugin_capability_audit is retention-capped per plugin (chain-safe: retained
rows stay self-verifying), so it can't grow unbounded in the shared db.
- A cap of 0 in TREK_PLUGIN_AI_PER_DAY / _NOTIFY_PER_DAY now disables the broker
instead of falling back to the default.
GDPR data lifecycle:
- Account deletion now erases host-side per-user plugin tables (config, OAuth
tokens/state) and enqueues the own-db erasure from the CORE path, so it works
even when the runtime is disabled or pre-boot; guest deletion does the same.
- uninstall keeps a pending erasure when data is retained (deleteData=false);
erasure delivery is no longer grant-re-checked (a queued erasure is a duty);
export flags installed-but-inactive plugins as pending instead of omitting them.
Backup/restore:
- Plugin DBs are WAL-checkpointed before archiving (no torn/stale snapshots).
- Restore applies the staged trees immediately by quiescing the plugins (no
unbounded gap where a later unrelated restart would revert diverged data);
the swap is content-level (safe on a volume-mounted root) and preserves
dev-links; the decompressed-size cap is operator-raisable.
* fix(plugins): audit — hook-output hardening, dashboard slot & mock-host parity
- Map-marker and atlas-layer tones were validated on String(tone) but emitted
raw, so a non-string tone (an object with a matching toString) slipped through
and crashed the client that renders it — check the raw value against the enum.
- View-contribution column/action caps are now PER ENTITY, not per view, so a
plugin's columns no longer vanish from every table row past the first 20; the
dashboard trip-card badge cap is per card (≥ one on every visible card).
- A reservation-detail widget no longer also renders as a context-free dashboard
sidebar card (the inline filter was missing that slot).
- mock-host matches the real host: it ignores asUserId on trip reads (bind the
acting user), throws on a wrong user-scope notify target instead of coercing,
enforces the scheduler caps, and detects RETURNING as a read in db.tx — so a
passing author test can't hide a production RESOURCE_FORBIDDEN.
* feat(plugins): full ctx parity in the dev server + fire jobs/events/hooks locally
The trek-plugin dev server injected only ~6 of the ~35 ctx areas, so any plugin
touching ctx.costs/packing/files/notify/ai/settings/scheduler/meta/oauth/db.tx/…
hit a TypeError in local dev while the same code passed mock-host tests and worked
installed. It also could only exercise routes.
Delegate every non-db-own capability to a grant-enforcing mock host (the same one
unit tests use) while keeping the real node:sqlite for db:own and dev-native ws
capture + logging — so the whole surface works in dev with the exact production
permission rules. dev-fixtures.json now takes the createMockHost options shape, so
you can seed the full surface. New GET /__dev/fire/<kind>[/<name>][/<fn>] fires a
job, scheduled timer, event subscription, GDPR handler or provider hook against the
dev ctx, closing the "can't test non-routes locally" gap.
* feat(plugins): wire the photoProvider + calendarSource hooks to real core consumers
Both hooks were declared, typed and documented but NO core code ever invoked them,
so an author could build, mock-test and install a photo or calendar plugin that
silently did nothing. Give each a real consumer that fans out to it, exactly like
the other eight provider hooks:
- GET /api/plugin-photos/search (+ /sources, /item) aggregates photoProvider results
for the picker — {id, title?, thumbnailUrl, fullUrl, takenAt?}, thumbnail/full URLs
http/https-only (they become <img src>), per-source count capped, failing source
skipped.
- GET /api/plugin-calendar?start=&end= aggregates calendarSource events for the
signed-in user — {id, title, start, end, allDay} ISO, count capped, failing source
skipped, sensible default window.
Both run with the acting user bound. The SDK interfaces now pass ctx as the last arg
(so a source can reach ctx.settings/oauth/http for its backend), and the wiki marks
them live instead of "reserved — no core consumer".
* feat(plugins): close the create-heavy API asymmetries importers/sync hit
Core services implemented these but plugins had no path to them, so the flagship
importer/sync integrations hit real walls. Added, each reusing the EXISTING grant
(no new consent):
- ctx.trips.removeMember(tripId, userId) — reconcile DEPARTURES, not just additions
(db:write:members + member_manage). Never removes the owner (that would orphan the
trip); ownership transfer stays a separate deliberate action.
- ctx.journal.createJourney({title, subtitle?, trip_ids?}) / deleteJourney(journeyId)
— an importer can now bootstrap the journal it fills with entries and clean it up
(db:write:journal), instead of only appending to journals a human created first.
Wired end-to-end (envelope → rpc-host → create-rpc-host reusing tripService/
journeyService → both SDK copies → mock-host) and documented. (trips.delete needs its
own destructive permission + consent copy and collab edit/delete + collections.delete
remain — tracked as small follow-ups.)
* feat(plugins): strip emojis from plugin-rendered text so it matches TREK's lucide UI
Plugin authors (especially AI-generated ones) sprinkle emojis into the declarative
text TREK renders in its OWN chrome — hook contributions (badges, columns, warnings,
PDF sections, map-marker/atlas labels, journal rows, place details, trip-card badges,
calendar + photo titles) and notifications — which clashes with TREK's lucide-only icon
language.
A shared stripEmoji() removes emojis (incl. flag/ZWJ/variation-selector sequences) and
tidies the leftover whitespace, applied at the render boundary in every hook-contribution
normalizer and in notify.send — so no matter what a plugin returns, the text TREK draws
stays emoji-free. It does NOT touch a plugin's own sandboxed /ui frame (the author's to
design), and it leaves photo ids verbatim (they round-trip to getById). The validate CLI
warns when a manifest name/description contains emojis, nudging authors to the declarative
`icon` field (a lucide name) instead.
* fix(plugins): harden the restore-apply path — regressions from the backup/dev fix pass
A final audit of the fix pass caught three regressions clustered in the two newest
surfaces; the restore path could both crash the server and destroy data.
- CRITICAL: a restore quiesces plugins via supervisor.shutdownAll() AFTER closeDb(), but
shutdownAll killed children without first marking them stopped, so each child 'exit'
took the CRASH path and wrote crash-accounting rows into the now-closed core DB — the
throw escaped an EventEmitter listener as an uncaughtException and killed the whole
process mid-restore. shutdownAll now marks every entry stopped and drops it from
`running` BEFORE the kills (so onExit early-returns), and the onStatus/onLog DB hooks
are wrapped in try/catch (also covers the stderr→onLog path). This also stops a normal
shutdown from logging phantom "crashed" rows.
- HIGH: swapContents cleared live entries then MOVED staged ones in, so a crash mid-move
permanently deleted a plugin's only data copy (staging was already emptied, so a retry
couldn't restore it). It now COPIES each staged entry over the live one and only deletes
staging at the very end — `staged` stays the complete source of truth, making the whole
operation crash-idempotent.
- HIGH: the dev server lost the actingUserId=1 default in the mock-host refactor, so a
fresh scaffold refused every user-bound capability. Restored.
* fix(plugins): final-audit medium/low findings
- GDPR export flags an active plugin whose export errored/timed out as `pending`
instead of silently omitting it (collectUserExport now returns a discriminated
result), so a data-access export never reads complete while missing data.
- Account deletion also enqueues an erasure for plugins UNINSTALLED with retained
data (an orphan data dir) — a same-id reinstall now honours the deletion instead
of re-adopting the user's data forever.
- oauth.getToken returns null in a userless context (matching the SDK/mock contract)
instead of throwing RESOURCE_FORBIDDEN a background caller can't handle.
- Crash-backoff restart is identity-guarded (+ the timer is tracked and cleared like
the activation timer), so a disable + re-enable during the backoff window can no
longer respawn a ghost child from the replaced entry.
- db.tx transaction-control guard strips leading comments first, so `/* */COMMIT`
can't slip past the start-anchored check and break batch atomicity.
- createJournal inherits its cover only from a trip that was actually LINKED
(access-checked), closing a cross-tenant cover-image read on plugin + REST paths.
- trip-warnings drops a null array element instead of losing ALL of that provider's
warnings; plugin-activity floors a non-integer ?limit so it can't 500.
- The trek-plugin dev server binds loopback only and refuses cross-site requests to
its side-effectful /__dev/fire endpoints (it serves real routes + no-auth dev
actions).
* fix(plugins): clear no-misleading-character-class in the emoji stripper
The character class listed the ZWJ, variation selectors and combining keycap
marks as members, which eslint reads as an accidental combined grapheme and
rejected on CI. Pull the emoji glyphs out into Extended_Pictographic /
Regional_Indicator alternatives so only the joiner/selector code points stay in
the class, with a scoped disable where the rule still can't tell them apart.
While here, reset lastIndex before the /g regex is reused in hasEmoji() so a
second call can't resume mid-string and miss a leading emoji.
* fix(security): trip-scope note-file deletion and guard the LLM base URL
Two reported issues:
- deleteNoteFile only matched on the note id and file id, so a member of trip A
could delete a file attached to a note in trip B by guessing its id. Thread the
trip id through the service and controller and scope the delete to it, the way
every other collab operation already does.
- The LLM extraction clients fetched the user-configured base URL directly, so a
user could point it at the cloud-metadata endpoint (169.254.169.254) and read
the echoed error body. Route both clients through a new safeFetchLlm() that
blocks the link-local/metadata range while still allowing a local or LAN Ollama
(loopback and private ranges stay reachable), pinned to the resolved IP so a
hostname can't rebind to the metadata address after the check.
* fix(security): route every LLM client through the SSRF guard
The base-URL SSRF fix covered the openai-compatible and anthropic clients but
missed the native Ollama /api/chat client and the /api/tags + /api/pull model-
management calls, whic…
* fix(plugins): repair plain-HTTP egress and forward the private-egress opt-out
Two pre-existing bugs in the plugin egress guard, found by running a plugin
against a real service end to end.
1. Every plain-HTTP request a plugin made was refused, whatever host it had
declared. Node pre-normalises `net.connect()` args into an [options, cb]
array and passes THAT array as the single argument; undici's plain-HTTP
connector takes this path, its TLS connector does not. classifyConnect read
`host` off the array, got undefined, and fell back to 'localhost' — so a
fetch to a declared, public host was rejected with the nonsense message
"localhost is not in the plugin's declared hosts". It failed closed, so it
was never a security hole, and it went unnoticed because the only shipped
egress plugin uses HTTPS. unwrapConnectArgs() unwraps the normalised form
before anything reads host/path.
2. TREK_PLUGIN_ALLOW_PRIVATE_EGRESS could never have any effect. The guard that
reads it runs INSIDE the child, whose env is scrubbed to a four-entry
whitelist that never included it — so a documented setting (wiki/
Environment-Variables.md) was wired to nothing, and no plugin could reach a
self-hoster's LAN service no matter what the operator set. Forwarded only
when set, so the default stays the secure block-private policy.
Regression tests cover the normalised form in both directions: the real host is
now resolved, and an undeclared host, a private IP and a unix socket are all
still refused when passed that way.
* feat(notifications): let a plugin register a notification channel
TREK's four channels (in-app, email, webhook, ntfy) were a closed set:
notificationService.send() dispatched with four copy-pasted `if` blocks and no
provider abstraction, so a fifth channel meant editing eight files by hand. A
plugin could produce a notification via ctx.notify.send(), but never deliver
one.
A plugin now registers a channel with `hooks.notificationChannel` +
`hook:notification-channel` on a plain `type: 'integration'` — not a new manifest
type, so the TREK-Plugins registry schema and both its CI gates are untouched.
Core refactor
- New channel registry (services/notifications/): email/webhook/ntfy become
ExternalChannel providers wrapping the EXISTING send functions — no delivery
logic is rewritten, only relocated. In-app deliberately stays out: it writes
typed rows with scope/target/callbacks, not a rendered title+body, the same
line shared/ already draws with i18n/externalNotifications.
- The event text is now rendered once per recipient instead of once per channel.
- The channel set is open: NotifChannel becomes a string, the matrix is
registry-derived, and the UI columns are server-driven. The DB column was
already bare TEXT and the Zod contract already a string record — only the
TypeScript and the two UIs were ever closed.
The hook runs USERLESS. Every other hook is user-initiated, so actingUserId falls
out of the request; a notification is host-initiated for an ARBITRARY recipient,
so ctx.settings.get() would return undefined. The host resolves the recipient's
decrypted scope:'user' settings itself and passes them as an argument. That is
what lets a channel plugin be handed someone's push token WITHOUT being handed
the right to read their trips as them.
Enabling the plugin is the opt-in: a plugin channel is not gated on the admin's
`notification_channels` list. A built-in always exists in code and needs an
explicit switch; a plugin channel only exists because an admin enabled that
plugin. (Nothing could write a `plugin:` id into that CSV anyway, and the admin
toggle rebuilt it from three booleans, silently dropping anything else — so
requiring a second opt-in meant the channel could never be turned on at all.)
Also fixed, found while building this:
- Plugin settings keys were unvalidated, so a field named `__proto__` or
`constructor` resolved off Object.prototype: a REQUIRED field with such a name
reported as configured for every user who had configured nothing — enough, for
a channel, to be dispatched to everyone with no credentials. Keys are now
constrained at install and the config blob is parsed null-prototype, so it is
impossible even for an already-installed plugin.
- A `select` field's options were cast straight through, so the obvious
`["1","5"]` form rendered every dropdown entry BLANK (the client reads
value/label). Now coerced, and malformed options are rejected.
Also adds: operator-supplied egress hosts (a plugin talking to a self-hosted
service can't name the operator's host at publish time, so an admin adds it
post-install and the runtime re-spawns the child with the widened allow-list —
only for a plugin that DECLARED operatorEgress, and only an admin, never a user);
settings-page actions (a "Test connection" button, user-initiated so
ctx.settings.get() returns the clicking user's own value); and a Gotify-shaped
notification-channel template in the SDK.
Verified end to end against a real Gotify container, not just in tests.
* docs(wiki): document the plugin notification-channel surface
Covers the pieces added in the previous commits, in the pages a reader would
actually reach for:
- Plugins.md (the admin-facing page) had none of it: notification channels,
settings actions, and a full "Allowed hosts" section — including what
operator-supplied egress deliberately does NOT let anyone do.
- Plugin-Development.md: the notificationChannel hook (and why it is the one hook
with no acting user), settings-page actions, operatorEgress, and the manifest
reference rows.
- Plugin-Cookbook.md: a "become a notification channel" recipe and a
"Test connection button" recipe.
- Plugin-Permissions.md: hook:notification-channel, operatorEgress under the
outbound section, and settings actions under "not a permission".
- Notifications.md: plugin channels alongside the four built-ins.
* fix(sdk): allow empty egress if and only if operatorEgress is true
* ci: don't run repo-specific workflows on forks
Guard release, publish, wiki-deploy and issue/PR-triage workflows with a
`github.repository` check so they no-op in forks instead of failing or
acting on the fork's own issues, PRs, tags and registries.
Also skip the Docker Scout scan for pull requests from forks: Docker Hub
secrets are never exposed there, so the login step could not succeed.
Tests and lint stay ungated — they need no secrets and are the gate for
incoming fork PRs.
* feat(sdk): add missing methods in mock-host
* fix(airports): rebuild the json file
* fix(airports.json): add small airports too
* fix(public transit): only show public transit option when a trip has actual dates
* fix(plugins): reap a queued erasure only once the plugin's data is gone
The orphan reap deleted every queue row whose plugin had left the registry, but
uninstall(deleteData=false) removes the plugins row while deliberately keeping the
data dir AND the queued erasure so a same-id reinstall can still honour it. The reap
now deletes a row only when the plugin's data dir is actually gone; a deleteData=true
uninstall already clears the rows itself.
* fix(backup): snapshot the core DB and swap restores atomically
createBackup archived travel.db via the archiver's lazy live-file read, so a WAL
auto-checkpoint firing mid-stream could write a torn database into the zip. It now
VACUUM INTOs a point-in-time snapshot and archives that, the same guarantee plugin
DBs already get. restoreFromZip swapped the DB by unlink-then-copy, which on an
interrupted restore could leave no valid travel.db; it now copies to a temp file and
renames it into place (atomic), dropping the stale -wal/-shm sidecars first.
* fix(deploy): Recreate strategy for the SQLite volume, pin the root compose image
The Helm Deployment had no strategy, so the default RollingUpdate would start a second
pod holding the same ReadWriteOnce PVC before the old one exits — a Multi-Attach
deadlock or two writers on one SQLite file. Default to Recreate (overridable for
ReadWriteMany). The root docker-compose.yml pinned trek:dev, a tag no workflow builds,
so a clone-and-up at the release tag ran a stale image; pin it to :latest like the README.
* fix(security): re-validate LLM endpoint fetch redirects per hop (GHSA-fmq9)
safeFetchLlm left undici's default redirect:'follow', so a configured LLM
endpoint could 302 to http://169.254.169.254/ and reach cloud-metadata
credentials — the DNS pin does not cover an IP-literal redirect hop, since
net.connect skips the pinned lookup for a literal IP. Follow redirects
manually now, re-resolving/re-checking/re-pinning each hop (allowing LAN/
localhost as before). Also block the Alibaba metadata IPs directly.
* fix(plugins): throttle the plugin log channel to prevent host-thread DoS
The per-plugin RpcRateLimiter only guarded the ctx.* (req) channel; ctx.log.*,
stdout/stderr and unknown evt topics reached a synchronous INSERT+prune on the
host thread unthrottled, so a while(true) ctx.log.error(...) loop could freeze
the instance. Route every plugin-driven log path through a per-plugin log token
bucket; excess lines are dropped with a summary line on resume.
---------
Co-authored-by: jubnl <jgunther021@gmail.com>
Co-authored-by: Dieter Blomme <dieterblomme@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: sld272 <zjrdmczh@outlook.com>
Co-authored-by: Nguyen Trong Binh <nguytb15@VN1N07HO1CD1015.local>
* fix(plugins): prevent arbitrary api access
* fix(planner): disable drag & drop on mobile so the places list scrolls (#1432)
On touch devices the draggable rows hijack the scroll gesture, so dragging
to scroll started an HTML5 drag and popped up the file-import overlay instead
of scrolling. Gate the draggable rows and the sidebar file-drop handlers on
!isMobile across the places sidebar and the day plan (places, transports,
notes), and hide the grip handle — the arrow reorder buttons take over there.
* fix(inspector): make the remove-from-day button icon-only on mobile
* fix(collections): show the save picker above the mobile place detail
* fix(plugins): seal IPC parent/child for good
* test(inspector): match the icon-only remove-from-day button
* feat(sdk): switch plain ts for clack/prompts interactive session
* feat(plugins): force-refresh the registry from the rescan button
The registry is cached for 30 min server-side and GitHub serves it with a
5-min CDN cache, so a freshly published plugin could take up to ~35 min to
appear. The rescan/reload button now force-pulls the registry: it bypasses the
in-memory cache and appends a cache-buster + no-cache headers to beat the CDN,
and refreshes the browse grid immediately.
* feat(sdk): bump plugin version
* fix(stored settings): prevent local storage drop when update not successful
* feat(plugins): sideload plugins by uploading a .zip
Adds an admin "Upload plugin" button + drag-and-drop to the plugins panel for
installing a plugin archive directly — handy for testing a build before it goes
to the registry. It reuses the registry install pipeline (slip/bomb-safe
extract, strict manifest validation, native-binary scan) via a new
POST /admin/plugins/upload, and only skips the registry sha256/signature checks
that a sideload can't have.
Sideloaded plugins are flagged (source "local:upload", a "Sideloaded" badge, no
GitHub link, no auto-update) and always land INACTIVE — replacing a running or
active plugin stops it and clears the active flag first, so new code never runs
without a fresh activation + permission consent.
* fix(planner): keep the day-plan collapse state after fully closing the page
The expanded/collapsed days were stored in sessionStorage, which survives a
reload but is wiped when the tab or window is closed — so every fresh open
re-expanded all days, which is tedious to re-collapse on long (10+ day) trips.
Store it in localStorage instead so a collapsed layout sticks until it's
changed.
* fix(i18n): correct Vietnamese translation of 'Disabled' (#1438)
'Tàn tật' means physically handicapped/disabled-person, not the
off/disabled state of a toggle. Replace with 'Tắt' (off), matching
the existing 'admin.plugins.stateOff' translation.
Affects admin.notifications.none and admin.addons.disabled.
* add the code of conduct
* fix(plugins): let widgets follow the in-app dark-mode toggle
The plugin frame is sandboxed at an opaque origin (no parent DOM access) and we
only sent the context — including the theme — once, on trek:ready. So toggling
dark mode in TREK left already-mounted widgets on the old theme until a reload.
Watch the <html> `dark` class and re-post the context when it flips; plugins
already re-apply the theme on trek:context.
* fix(plugins): deliver widget context on load so the theme is right on first paint
* fix(plugins): give widget cards the native glassy look and auto-height
Widget plugins rendered in a plain card with a fixed 180px body, so they looked
foreign next to the glassy dashboard tools and taller widgets had their controls
clipped. Mirror the native `.tool` surface (glass background/border/blur, uppercase
title) and let the body grow to the height the widget reports over trek:resize.
* feat(plugins): add read/rwite costs
* feat(plugin): better readme/index.js
* feat(plugin): better readme/index.js
* feat(plugins): hand widgets TREK's theme tokens, formats and display identity
Extends trek:context with a non-secret `tokens` map (TREK's resolved CSS design
tokens for the current theme), `formats` (currency/date/units/timezone) and a
`user` display object (name/avatar/isAdmin — never the email, role only as a
boolean). Re-sent on every theme toggle. A widget can now apply the tokens and
match the host exactly, in both themes and under a custom appearance, instead of
hard-coding a palette that drifts — so plugins feel native, not bolted-on.
* feat(plugins): hand plugins the full palette and appearance state
The theme context only carried a ~19-token subset read off <html> and only
followed the dark-mode toggle. Widen it to the whole global (:root/.dark)
palette — surfaces, text, borders, the accent family, semantic + soft fills,
shadows, radii and fonts — so a plugin tracks the user's chosen accent scheme,
custom accent and high-contrast live, not just light/dark. Also send an
`appearance` block (scheme, density, reduced-motion, no-transparency) mirrored
from the attributes applyAppearance writes on <html>, and re-post the context
whenever any of those actually change (a small signature dedupes unrelated
mutations) so plugins restyle in step with the app.
* feat(plugins): ship a design kit so plugin UIs look native
A plugin's UI is a sandboxed, opaque-origin iframe that can't load TREK's
stylesheet — so authors had to re-derive the whole look by hand, and most
didn't. Ship it instead: a token-driven stylesheet (glass, hover, buttons,
inputs, chips, rows) that consumes the tokens the host already sends and swaps
light/dark, plus a small bootstrap that applies those tokens, mirrors the
appearance flags, auto-reports the frame height and exposes a `window.trek`
helper over the existing bridge. Both are plain strings meant to be inlined
(the CSP forbids external assets for an opaque frame); `injectTrekUi` expands a
`<!-- trek:ui -->` marker. No new capability — only a native look.
* feat(plugins): deliver the design kit — native scaffold + inline on dev/pack
A new page/widget scaffolds a native, glassy starter that talks over
window.trek. The source keeps a single `<!-- trek:ui -->` line; `dev` (when it
serves /ui) and `pack` (as the file enters the archive) expand it into the
inlined kit — so the file stays a one-line opt-in and a rebuild always ships
the current kit. Existing plugins opt in the same way, by dropping the marker.
* feat(plugins): faithful themed host preview in dev
`dev` served the plugin UI raw at /ui — top-level, with no host — so the theme,
context and bridge never fired and authors couldn't see the design kit render.
Add /preview: it embeds /ui in a sandboxed opaque-origin iframe (exactly TREK's
isolation) and plays the host — posts trek:context with a theme/accent/appearance
toggle, proxies trek:invoke to your /api routes as the dev user, and surfaces
resize/notify/navigate. /ui stays as the raw doc for debugging.
* docs(plugins): document the design kit, window.trek and the token contract
Rewrite the client section of the Plugin Development wiki kit-first: the
`<!-- trek:ui -->` marker, the component classes, the `window.trek` bridge, the
`/preview` host preview, the full `trek:context` payload (now the whole palette
plus an `appearance` block) and how to apply tokens by hand. Add a "Build a
native UI" section + the new exports to the SDK README.
* feat(budget): add 'Outstanding amount' card
* fix(translations): finish translating new keys
* feat(plugins): trip-page plugins — a plugin tab inside every trip
Adds a `trip-page` plugin type whose sandboxed iframe mounts as a tab in the
trip planner (Plan / Transports / … / <plugin>), scoped to the open trip, with
no dashboard nav entry. This is the most-asked planner-extension request from
discussion #1429 (a plugin that lives in the trip, e.g. SimMesg20's budget
planner). It reuses PluginFrame and the existing tab system — the frame already
receives the current tripId over trek:context — so there is no bridge or
security change: only the manifest type enum (server + SDK), the client feed
classification (pluginStore.tripPages), and one render branch in the planner.
The SDK scaffolds it with `create --type trip-page`.
* fix(apple wallet): support for .pkpasses
* feat(plugins): permission-gated write APIs for the planner (#1429)
Plugins can now WRITE core planner data, not just read it, through curated,
membership-checked methods — so downstream features can live in plugins instead
of long-lived core patches. Four new scopes: db:write:places (create/update/delete
places), db:write:days (days), db:write:itinerary (assign/unassign a place on a
day) and db:write:trips (update trip fields).
Each ctx method mirrors costs.create: it validates the input against the SAME
@trek/shared schema the web app uses, binds the acting user host-side (a job/onLoad
has none, so its writes are refused), checks trip access AND the app's edit
permission (place_edit / day_edit / trip_edit), delegates to the real services,
broadcasts the same events so open sessions update live, and records the write in
the tamper-evident capability audit. No new route, no sandbox or CSP change — the
isolation boundary is unchanged; a plugin can only change what its user could change
by hand. Consent UI + permission labels in all 22 locales, SDK types + mock host,
and the wikis are updated.
* docs(plugin): ensure wiki correctness
* feat(plugins): plugin metadata on core entities — db:meta (#1429)
Plugins can now attach their OWN namespaced key/value data to a trip, place or
day without forking the core schema (#1429, request 2). New `db:meta` scope +
`ctx.meta.get/set/list/delete`. Storage is one plugin_entity_metadata table
(migration 161) keyed (plugin_id, entity_type, entity_id, key) — a plugin only
ever sees its own rows. Every call is membership-checked: the entity must belong to
a trip the host-bound acting user can access. Quotas guard the shared volume (≤64KB
per value, ≤100 keys per entity); rows are purged on uninstall-with-delete-data and
recorded in the capability audit. SDK types + mock host, a consent chip + labels in
all 22 locales, and the wikis. No new route, no sandbox change.
* feat(plugins): place-detail plugin slot in the trip planner (#1429)
A widget plugin can declare `capabilities.widget.slot: 'place-detail'` to mount
its sandboxed frame inside the trip planner's place-detail panel, scoped to the
open place — the frame receives the `placeId` in trek:context alongside the tripId.
This is the UI half of the place-detail-providers ask (reviews/ratings/popular
times shown on a place). It reuses the existing widget mechanism: PluginFrame gains
an optional placeId, the feed/store learn the new slot, and PlaceInspector renders
the slot at the foot of its body in trip mode. Admin chip + label in all 22 locales,
wiki updated. No sandbox or permission change.
* fix(plugins): green the server tests + harden the new capability surface
The in-memory uninstall fixture was missing the new plugin_entity_metadata table,
so uninstall's DELETE threw "no such table" and failed the server test job. Add the
table to the fixture schema.
Self-review hardening of the write/metadata surface:
- trips.update now reproduces the web UI's per-field gate: is_archived needs
trip_archive and cover_image needs trip_cover_upload, not just trip_edit — so a
member who may only edit can't archive or re-cover a trip.
- Plugin metadata WRITES now also require the entity's edit permission
(place_edit/day_edit/trip_edit), not just trip access, so a read-only member can't
overwrite or delete metadata another user created. Reads stay access-gated.
- Cap the metadata key length (<=256 chars) alongside the value/count quotas — the
key was attacker-controlled and uncapped, defeating the disk-DoS guard.
* test(plugins): cover the new write/metadata deps to hold the coverage gate
The new create-rpc-host write + metadata deps were untested, dropping the
src/nest branch coverage below the 80% gate. Add a seeded in-memory core db plus
mocked core services to exercise every dep end-to-end: places/days/itinerary
create/update/delete + not-found paths, trips.update with the archive/cover
per-field gates and the Validation/NotFound/unknown-error mapping, metadata CRUD
+ key/value/count caps + access checks, the costs deps, and users.getById
scoping. Plus rpc-host cases for meta writes on place/day and a no-acting-user
refusal. Tests only — no production code change.
* fix(costs): freeze FX on every cost + settlement write path (#1445)
Settled foreign-currency costs kept re-opening with a few-cent residual
when live rates drifted. The #1335 freeze only ran on the REST create/
update path, so two gaps remained:
- Foreign-currency items created via MCP create_budget_item or booking-
import bypassed the freeze and stored exchange_rate = 1, so settlement
re-converted them with live rates. Promote freezeForeignRate into the
shared budgetService and call it from every write path.
- Settle-up transfers were stored currency-less and re-converted with
live rates on each recompute. Add currency + exchange_rate to
budget_settlements (migration), freeze the display-currency rate at
settle time, and convert with it in calculateSettlement. Legacy rows
(currency = NULL / rate = 1) keep live-rate behaviour until re-edited.
Also expose guarded cost update/delete to plugins: costs.update and
costs.delete under db:write:costs, gated exactly like costs.create
(addon + trip access + the acting user's budget_edit permission).
updateCost reuses BudgetService.update so a plugin write re-freezes the
FX rate too; both broadcast the same budget:updated / budget:deleted
events the REST controller emits. Wired through the host, the runtime
SDK context and the published trek-plugin-sdk (types + mock host).
* feat(plugins): provider hooks — placeDetailProvider, wired (#1429)
Turn "hooks" from a declared-but-dead surface into a real host→plugin capability.
Add an invoke.hook branch to the child + a supervisor hook registry
(providersOf) + PluginRuntimeService.invokeHook, reusing the existing invoke
transport and its timeout (a short 5s deadline so a slow provider can't delay a
response; a job/onLoad has no user, host-bound as ever). Also fixes a real bug: the
in-repo runtime SDK copy was missing the `hooks` field entirely and could not even
parse a plugin that declared one — synced it with the published SDK.
The first wired hook is placeDetailProvider: a plugin returns extra rows
({label,value?,url?}) for a place, and TREK renders them natively at the foot of the
place-detail panel. Consumer is a new, additive, fail-safe endpoint
GET /api/place-details/:placeId (membership-checked; any provider that errors or
times out is simply skipped — it never breaks the panel). New hook:place-detail-
provider scope + consent chip in all 22 locales. SDK types (both copies), a
controller test, and the wiki. photoProvider/calendarSource stay reserved but the
transport now exists for them. No sandbox or CSP change.
* fix(files): handle pkpass in booking uploads and files-tab open (#1447, #1448)
Both bugs were client-only; the server already allows .pkpass and serves it
as application/vnd.apple.pkpass.
#1448: the reservation/transport attachment inputs hard-coded an accept list
that omitted pkpass, so macOS grayed it out. Add .pkpass/.pkpasses (+ wallet
MIME types) to the accept attribute in both modals.
#1447: the files-tab open path routed every non-media/non-markdown file into
the in-app PDF preview object. Add isWalletPass() and route wallet passes
through the shared blob openFile helper (as bookings already do), which
downloads them so the OS hands them to Apple Wallet.
* feat(plugins): validation/warning contributions via warningProvider hook (#1429)
Second wired provider hook, reusing the invoke.hook infra from the last commit. A
plugin implements warningProvider.getWarnings(tripId, ctx) → {level, message,
dayId?, placeId?}[] to flag problems on a trip (overpacked day, place closed on its
planned date, missing booking, …). TREK surfaces them as a non-blocking overlay
banner at the top of the trip planner (the wrapper ignores pointer events so it
never covers the map/panels; only the pills are interactive).
Consumer is a new additive, fail-safe endpoint GET /api/trip-warnings/:tripId
(membership-checked; a provider that errors or times out contributes nothing and
never blocks the planner). New hook:trip-warning-provider scope + consent chip in
all 22 locales, SDK types (both copies), a controller test, and the wiki. This is
the validation half of the scheduling+validation block; feeding durations/travel
times back into core recalculation stays out (it would touch core planner
computation — deliberately deferred to keep the no-breaking-changes guarantee).
* fix(plugins): enforce the hook:* grant on provider dispatch (#1429 audit)
The adversarial audit of the #1429 additions found one real (medium) gap: the
hook:* permission was never enforced at runtime. providersOf() selected provider
plugins purely by the hooks their CODE declares (sup.hooks, reported by the child
as Object.keys(def.hooks)) and never intersected that with sup.granted — so a
plugin that merely implemented placeDetailProvider/warningProvider got wired in as
a provider even when the admin never consented to hook:place-detail-provider /
hook:trip-warning-provider. The downstream capability router still held (the hook's
ctx can only do what the plugin's OTHER grants allow), but a plugin could obtain an
auto-triggered, user-bound execution context on a passive UI browse without the
hook being consented — a consent-integrity gap that contradicts the documented
invariant.
Gate it host-side: a hookName→permission map, and providersOf now returns a plugin
only if it is active, implements the hook, AND holds the matching hook:* grant. An
unmapped hook resolves to nobody. invokeHook additionally re-checks membership in
providersOf (defense-in-depth against a direct caller). Unit test proves the
grant/implements/active intersection.
* docs(plugins): add the Plugin Cookbook + a trip-doctor example (#1429 eco)
Fosters plugin authoring by turning the new #1429 capabilities into copy-paste
recipes. New wiki page Plugin-Cookbook (read a trip, write to the itinerary, tag an
entity with metadata, contribute native place details, raise trip warnings,
broadcast, match the TREK look) linked in the sidebar, plus a complete runnable
example — trip-doctor — a hooks-only plugin that showcases warningProvider +
placeDetailProvider + ctx.meta with zero UI of its own. Manifest validates against
the SDK. Docs/example only; no product code.
* fix(collections): don't reset saved-place status to 'idea' on edit (#1437)
The update schema reused collectionStatusSchema, whose .default('idea')
survives .optional() — so a PATCH that omits status had 'idea' injected by
the validation pipe and written to the DB, clobbering 'want'/'visited'.
Strip the default on the update field with .removeDefault(), keeping the
.catch guard. Add a shared schema regression test and an e2e round-trip.
* docs(plugin): ensure plugin scopes are the same everywhere
* feat(plugins): read scopes for packing + files (#1429 eco)
Extend the read side of the capability model beyond trips/costs: db:read:packing
→ ctx.packing.list(tripId) and db:read:files → ctx.files.list(tripId). Both mirror
the existing trip reads exactly — the host membership-checks the trip against the
invocation's user (tripRead) before delegating to the same packingService/
fileService the REST paths use (so bags/assignees hydrate and trash is excluded),
and each is a separate scope (packing doesn't unlock files). ctx types in both SDK
copies + mock-host, consent labels + cap chips in all 22 locales, rpc-host +
create-rpc-host tests, and the wiki (perm table + cookbook recipe).
* feat(plugins): core event subscriptions (#1429 eco)
A plugin can react to core activity by declaring events: [{ on, handler }] + the
events:subscribe grant. websocket.broadcast announces every CORE trip event (name +
tripId ONLY, never the payload) through a tiny dependency-free relay
(plugin-event-sink); the runtime registers a sink in onModuleInit and the supervisor
fans each event out to subscribed, granted, active plugins via a fire-and-forget
invoke.event on a short timeout — so a slow subscriber can never block a core write.
Safety by construction: handlers run with NO user (like a job) so trip reads are
refused — they react to the fact, using the plugin's own ctx.db/ws/outbound; the
grant is enforced host-side (deliverEvent checks events:subscribe); plugin:* re-
broadcasts are never delivered back, so handlers can't loop; and only the event name
+ tripId cross the boundary. SDK types (both copies), consent label + cap chip in all
22 locales, supervisor gating + broadcast-tap tests, and the wiki + cookbook.
The relay lives in its own module (not websocket) so it doesn't drag `ws` into the
runtime and tests that mock ./websocket don't strip the sink.
* feat(plugin-sdk): typed ctx returns + native trek.ui DOM helpers (#1429 eco)
Two author-DX wins, SDK-only.
Typed reads/writes: ctx.trips.getById/getPlaces/getReservations, packing.list,
files.list, costs.*, places/days/itinerary writes and users.getById now return
proper entity types (Trip, Place, Day, Reservation, PackingItem, TripFile,
BudgetItem, Assignment, User) instead of unknown — real autocomplete for authors.
Only `id` is guaranteed and every shape keeps an index signature, so it mirrors the
raw DB row honestly (no column hidden, no false guarantees). mock-host matches.
Native UI helpers: window.trek now carries `trek.ui` — a tiny bundler-free DOM
builder (el/button/card/chip/input/mount) that emits the kit's trek-* classes, so a
widget builds themed UI with no CSS and no build step. Ships inlined via the same
<!-- trek:ui --> marker. Wiki updated.
* fix(plugins): scope packing.list to the acting user's #858 visibility (eco audit)
The final eco audit found one real (medium) gap: the db:read:packing delegate
called packingService.listItems(tripId) with NO userId, which takes the UNFILTERED
branch and returns every member's private (is_private=1) packing items — leaking
another member's personal/surprise-gift items to a plugin the normal UI/REST hides
them from. The handler had the host-bound acting user but dropped it when delegating.
Thread it through: tripRead now hands the membership-checked userId to the read
callback, packing.list forwards it to listPackingItems(tripId, userId), and the
service applies its three-tier #858 filter — a plugin now sees exactly what its user
sees. files.list is unaffected (no per-user file visibility). Tests assert the user
is passed. The other three audited surfaces (event subscriptions, trek.ui, and the
regression sweep of the capability boundary) were clean.
* security(plugins): prevent open redirect
* fix(plugins): resolve PR #1433 full-audit findings (code + tests)
The comprehensive PR audit confirmed 21 findings; this fixes the code/test ones I own:
- ctx.users.getById was DEAD: the runtime SDK omitted the _inv tag, so actingUser
never bound and every call hit RESOURCE_FORBIDDEN. Add _inv (the test had codified
the bug — corrected).
- Plugin place writes bypassed the REST STRING_LIMITS (a 100k-char name the web app
rejects). Mirror the caps (name 200 / description 2000 / address 500 / notes 2000).
- packing.list / files.list were missing from the capability audit log while every
other core read is audited — add them to isAuditable + auditResource.
- SDK lockstep: CalendarSource.getEvents drifted (published Date vs runtime string);
the host->plugin boundary is JSON, so align both to string.
- Admin panel didn't know the new trip-page plugin type (unlocalised badge, missing
filter) — add it to KNOWN_TYPES + the type filter + a 22-locale label.
- Tests for previously-uncovered paths: the child-side invoke.hook/invoke.event
dispatch (real fork, hook + event + non-matching-subscription), and invokeHook's
defense-in-depth grant re-check.
Julien's settlement-FX-refreeze finding is his budget code (flagged, not touched).
* docs(plugins): correct the wiki against the shipped capability surface (#1433 audit)
Fixes the 11 doc findings from the PR audit — every corrected claim was cross-checked
against the code:
- Plugin-Development: CSP connect-src is built from granted http:outbound:<host>, not
egress[]; dropped the stale "costs.create is the first and only core mutation";
documented costs.update/delete + ctx.packing/ctx.files; the manifest permission table
gained the six missing scopes (db:write:places/days/itinerary/trips, db:meta,
hook:trip-warning-provider); the widget slot table gained place-detail.
- Plugin-Cookbook: days.create no longer passes a title the schema drops;
broadcastToUser uses the real (userId, event, data) signature; fixed the broken
#the-trek-ui-design-kit anchor and noted window.trek.ui.
- Plugin-Permissions: added db:read:packing, db:read:files and events:subscribe;
the provider hooks are implemented in `hooks: {...}` on the definition, not on ctx.
* fix(unsplash) allow api key usage
* fix(guests): scope guest display names per-trip, not globally (#1446)
A guest is a per-trip person, but their name lived in the globally UNIQUE
users.username, so uniqueGuestUsername() auto-renamed a second "Jake" (on any other
trip) to "Jake 2". Add a non-unique users.display_name: a guest now stores the human
name there and gets a uuid-based username that is never shown, and every member view
(members list, day-assignment participants, budget members/payers, packing
recipients/contributors/bags/assignees) COALESCEs display_name over username. Rename
updates display_name with no dedup. Real users are unchanged (display_name NULL →
COALESCE falls through to username). Migration adds the nullable column; existing
guests keep their current username via the COALESCE fallback.
This also unblocks ctx.users.getById (the audit's #4 fix), whose projection selects
display_name. Tests: two "Jake" guests on two trips both keep the name; the two
codified-the-old-behaviour guest tests corrected.
* fix(costs): don't re-freeze a settlement's FX rate on an unrelated edit (#1445)
The full audit found that updateSettlement called freezeForeignRate without the
"currency unchanged" guard the item path has, so any edit of a foreign-currency
settlement (e.g. correcting from/to) re-fetched the LIVE rate and overwrote the
frozen one — re-opening an already-balanced position with a small residual, the
exact drift #1445 was meant to prevent.
freezeForeignRate's unchanged-check was item-centric (it queried budget_items),
which a settlement (a different table) can't use. Give it an explicit
existingCurrency param; updateSettlement now reads the settlement's stored currency
and passes it, so an edit that doesn't change the currency keeps the frozen rate
(the service UPDATE already preserves exchange_rate when it's left unset). Tests
cover both: unchanged currency keeps the rate, a real currency change re-freezes.
* feat(plugins): add inter plugin dependency support and addon dependency support
* feat(plugins): add inter plugin dependency support and addon dependency support
* docs(plugins) inter dependencies
---------
Co-authored-by: Maurice <mauriceboe@icloud.com>
Co-authored-by: trongbinhnguyen <43725147+trongbinh15@users.noreply.github.com>
* docs(wiki): document the snap Docker + no-new-privileges startup failure
* fix(setup): warn when ADMIN_EMAIL/ADMIN_PASSWORD are ignored, ship reset-admin
The first-run seeder only applies ADMIN_EMAIL/ADMIN_PASSWORD on an empty
database and then silently ignores them. People add the vars after the first
boot, or pull a fresh image without clearing ./data, restart, and cannot log
in with no hint why (#1339). The default is a generated password (not the
.env.example placeholder), printed once in the first-run box. Now: warn loudly
when the vars are set but a user already exists, and warn on a partial
(one-of-two) config instead of quietly falling back.
Also ship the reset-admin recovery script in the image -- it was never COPYed in
despite the wiki referencing it. node server/reset-admin.js resets/creates
admin@trek.local with a generated password (RESET_ADMIN_EMAIL/RESET_ADMIN_PASSWORD
overridable), picks a free username so it cannot trip UNIQUE(username), and sets
must_change_password.
* feat(extract): extract data using LLM
* fix(extract): auto-run the AI fallback when the addon is enabled
Booking import only fell back to the LLM when each user flipped an 'always retry with AI' toggle, so by default files kitinerary returned nothing for just failed. Run the fallback automatically whenever the AI Parsing addon is on (fallback-on-empty); drop the now-redundant per-user toggle and its setting.
* fix(extract): make AI imports reliable and fast on local models
client: the import call inherited the global 8s axios timeout and aborted long LLM extractions even though the server finished it; remove the timeout. server: raise the OpenAI-compatible LLM timeout 60s->180s (a cold Ollama model can take ~45s to first token). server: cap extracted text to 8000 chars before the LLM - multi-page T&C tails (30k+ chars) overflowed the context window, truncating the relevant head and making CPU inference crawl; booking details sit at the top.
* feat(extract): fill transport/booking fields, geocode endpoints, assign days
- rental car: request+map dropoffLocation, emit pickup->return from/to endpoints, set a location string (G1/G2/G3). - geocode endpoints (stations/stops/terminals/rental desks) on confirm via Nominatim; mapper now emits coordless named endpoints and confirm persists only the geocoded ones (G6). - assign every dated booking to the nearest trip day so it still shows when slightly out of range, and keep hotel accommodation from vanishing when a check date misses (G5/G10). - fix bus mislabelled as train + add bus_number metadata (G7/G8), flag malformed boats (G9), accept root start/end time for events (G11). - raise the local-LLM timeout to 300s for CPU-only Ollama.
* perf(extract): cap LLM input at 4000 chars for CPU-only speed
On a GPU-less host the model's prompt-eval time scales with input length and dominates total latency. Booking details sit at the top of a confirmation, so capping the extracted text at 4000 chars (was 8000) roughly halves extraction time (~50s warm for a capable local 7B model) with no loss of fields on real hotel/rental confirmations. Tunable if a long multi-segment itinerary needs more.
* feat(extract): capture seat, class, platform, price + event venue contact
Request and map root-level seat/class/platform and a total price/currency into reservation metadata (shown on the card; price reuses the existing label). Read both the root and reservationFor and tolerate common field-name aliases (priceAmount, priceCurrencyISO4217Code, fareClass, ...) since models name these inconsistently. Also capture event/attraction venue telephone + url onto the auto-created place, matching lodging/restaurant.
* feat(extract): create a linked cost from the booking price on import
When a confirmation carries a total price, record it as a real expense
linked to the reservation (in the matching Costs category) instead of
leaving the amount in metadata only. Gated on the Costs addon.
* fix(extract): refresh accommodations after a booking import
A freshly imported hotel links to an accommodation that lives outside the
trip store, so loadTrip alone left the reservation edit modal with blank
place/date fields. Reload the accommodations list once the import finishes.
* feat(extract): drive NuExtract with its native template
NuExtract isn't an instruct model — fed a plain chat prompt it just echoes the
schema back. Detect a NuExtract model by id and talk to it the way the model
cards document: the JSON template inlined in a single user message, no system
prompt, no json_schema, temperature 0. Its flat result is mapped back to the
same KiReservation shape the rest of the pipeline already uses, so nothing
downstream changes; every other model keeps the generic prompt.
Money is taken as a verbatim string and parsed locally (German "1.580,22 €"
otherwise comes back as 1.49772), a rental car's pickup/return ride the from/to
fields so a stray form label doesn't become the location, and a lodging with no
name falls back to its address instead of being dropped.
* fix(admin): tidy the AI parsing settings and recommend the 2B model
The provider picker is the shared CustomSelect now and the form is split into
clear sections rather than a flat stack of inputs. NuExtract 2.0 2B is the
recommended default — fastest on a CPU-only host and MIT licensed; the 4B
carries a non-commercial licence, so it's no longer flagged as recommended.
* feat(import): review each parsed booking before it's saved
Instead of writing parsed items straight to the trip, the import opens the
normal edit modal pre-filled for each one, so you can check and fix it before
saving — useful when a model guesses a wrong date or address. Hotels gained an
editable address field; on save an existing place is matched by name, otherwise
the reviewed address is geocoded and a new place is created.
* feat(extract): drive local parsing through a layered extraction router
The single-shot prompt was unreliable on multi-leg flights and longer
documents, and slow on a CPU host. For the local provider, run a small
router instead:
- deterministic vendor templates first, with no model call at all
- exactly one grammar-enforced call per document via Ollama's native
`format` (flights as a flat array of legs, everything else as one flat
reservation, the type picked from keywords or a union schema)
- booking-wide fields (booking reference, total price, the overnight
arrival day) filled deterministically from the text afterwards, and
dates coerced to ISO so a natural-language date can't slip through
Recommend qwen2.5 in the AI-parsing settings instead of NuExtract.
* feat(import): parse bookings in the background with a progress widget
Parsing a booking can take a while on a CPU host, so don't hold the
upload modal open for it. The async import endpoint returns a job id
right away; the parse runs server-side (one at a time per user) and
pushes progress over the user's WebSocket, and a small widget in the
bottom corner tracks it while the user keeps navigating and editing.
A finished job opens the per-item review from the widget.
* fix(import): create linked costs and accommodations from reviewed bookings
Reviewing an imported booking saves it through the normal reservation
form, which dropped the parsed price (so no linked cost was created) and
only created the accommodation when both nights matched a trip day.
Carry the parsed price into a linked cost on save, and create the
accommodation from whichever day the check-in/out dates resolve to.
* feat(extract): add Expedia and rental-broker booking templates
Pull the hotel/rental fields these vendors print in a stable text layout (name, address, stay/pickup dates, price, reference) deterministically, so the import stops depending on the local model for them. Handles German long/abbreviated months and English dates incl. 12-hour and comma forms.
* fix(extract): backfill booking code/total and harden the reference match
Apply the deterministic confirmation-code and total fill to vendor-template results too (not just model output), and require the captured reference to contain a digit so a bare 'Confirmation'/'Reference' label no longer grabs the next prose word.
* fix(import): keep the parse-progress widget across a reload
Persist the background-import tasks (id/trip/status only) and re-fetch each job's status on mount, so a parse still running when the page reloads keeps its widget instead of vanishing; expired jobs (404) are dropped and a restored 'done' task re-fetches its items.
* fix(reservations): skip un-geocoded endpoints instead of failing the save
reservation_endpoints.lat/lng are NOT NULL, so saving a reviewed transport whose pick-up/return couldn't be geocoded threw a 500 and lost the whole booking (dates, linked cost). Skip those rows; the dates still persist on reservation_time/reservation_end_time.
* fix(import): resolve an imported transport's day from its parsed dates
A reviewed transport (e.g. a rental car) arrived with only its parsed pick-up/return dates and no day_id, so the modal kept just the time and saved a bare "HH:MM" with no date. Resolve start/end day from the parsed dates (exact match, else nearest trip day) so the booking lands on the right days.
* fix(import): refresh costs after a booking review so imported expenses appear without a reload
Imported bookings auto-create their linked budget items server-side, but the saving client suppresses its own budget:created echo, so the Costs list stayed stale until a manual reload. Reload the budget items when the review session ends.
* refactor(extract): dedupe currency/day helpers, drop redundant casts, support JPY vouchers
Code-audit clean-ups: share one normCurrency between the router and the templates, lift the duplicated nearest-day resolver into formatters.resolveDayId, drop two needless as-unknown-as casts at the fillBookingWideFields call sites, restore routeExtraction's doc comment, and give the broker template readable names. Plus recognise ¥/JPY and fall back to a standalone symbol amount, so a Klook-style voucher whose price sits far from any label still yields a cost.
* feat(import): attach the parsed source document to each booking
Keep the uploaded files on the background task and hand them to the review flow, so each reviewed booking pre-fills its Files with the document it was parsed from (uploaded with the booking on save). The two modals also adopt the shared resolveDayId helper.
* fix(extract): disable model thinking for grammar-constrained extraction
Hybrid/reasoning models (Qwen3 and similar) default to emitting reasoning tokens, which collide with Ollama's format-grammar constraint — on CPU this produced null/unparseable output and blew the latency budget (qwen3:8b: null or 300s timeouts vs ~20s with thinking off). Send think:false on the /api/chat call; Ollama ignores it for non-thinking models (verified on qwen2.5:7b), so it's safe and unlocks the stronger Qwen3 family.
* feat(extract): recommend Qwen3-8B as the local extraction model
A/B against the prior default (qwen2.5:7b) on CPU showed Qwen3-8B is both faster and more accurate on tricky/multilingual booking docs (correct Airbnb year+price, correct DisneySea admission date), once thinking is disabled — which the router now does. Feature it as the recommended pull, keep qwen2.5:7b as the fallback.
* refactor(extract): drop vendor templates, let the model drive with deterministic backfill
Now that a capable instruct model (Qwen3-8B, thinking off) reads name/address/dates/legs reliably across formats, the per-vendor template short-circuit distorted more than it fixed: brittle on layout variations and overriding the better model output. Remove the template layer; the model extracts the structure and Schicht 2 backfills the confirmation/total and takes the currency from the document's own symbol (correcting model misreads like ¥→$). Per-type prompts now also ask for address and price/currency.
* fix(extract): require the hotel address and ask for the rental company
After dropping the vendor templates, the model skipped the (often unlabeled) Expedia-style hotel address — making address a required schema field forces it to emit the street-address line, restoring the booking's location/place. Also hint the rental company so a car booking gets a real title instead of the generic fallback.
* fix(import): refresh costs immediately after an imported booking is saved
The saving client gets no budget:created echo (X-Socket-Id) and the create response omits the linked budget item, so the booking's Costs section and the Costs tab stayed stale until a manual reload. Reload the budget items right after a create that carried a budget entry.
* perf(extract): cap single-booking text tighter; require rental company
A long single-booking PDF (e.g. an 11-page rental voucher) spent ~200s on CPU prompt-eval at the 16k cap, though its data sits in the first ~2k. Cap non-flight docs at 6k (flights keep 16k for all legs). Also make the rental operator a required field so the car gets a real title.
* fix(import): preview the parsed cost as linked in the review modal
During the per-item import review the booking isn't saved yet, so the Costs section showed an empty 'Create expense' even though a linked cost will be created on save. Show the parsed price (amount + category) as the pending linked expense so the user can verify it up front. Reuses existing i18n keys.
* fix(import): persist source files in IndexedDB so attach survives a reload
The source document was only kept in memory on the background task, so a page reload during the (now always-LLM ~25s) parse lost it and the booking saved without its file. Store the uploaded files in IndexedDB keyed by job id; the review loads them from there when the in-memory copy is gone, and a 1h TTL prunes abandoned imports.
* chore(extract): recommend only Qwen3-8B (drop Qwen2.5 from the curated list)
Qwen3-8B is the identified default; the prior Qwen2.5 entries are no longer needed in the pull list.
* feat(settings): let users set their own AI parsing model
Adds an "AI parsing" section under Settings -> Integrations where a user can choose the LLM provider, model, base URL, API key and multimodal option used for booking extraction. This per-user config applies when an admin has not configured an instance-wide model. Reuses the existing encrypted user settings: the API key is stored encrypted, never prefilled, and a blank field keeps the stored one. Adds settings.aiParsing.* across all 20 locales.
* fix(settings): show the Integrations tab when only AI parsing is enabled
hasIntegrations gated the tab on memories/mcp/airtrail only, so a user with just the llm_parsing addon enabled saw no Integrations tab and could not reach the AI parsing config. Include llmEnabled in the gate.
* feat(settings): use the shared custom dropdown for the AI parsing provider
Swap the native select for CustomSelect so the provider picker matches the rest of the app's styling (dark mode, portal dropdown).
* refactor(planner): move the import-review bridge effect into the page hook
TripPlannerPage held a useEffect (the background-import → review bridge), which trips the page-pattern check (pages must stay wiring containers). Move the effect and its store/IndexedDB wiring into useTripPlanner where the rest of the import-review state already lives.
* test(llm-parse): cover the extraction router, client factory and import jobs
The new LLM extraction router shipped with little branch coverage, dropping src/nest below the 80% gate. Add unit tests for routeExtraction (flights/single/union/error paths, deterministic booking-wide fill), the native Ollama format client, the provider factory, the local-router service path with its type-aware text cap, the flat->schema.org mapper's remaining reservation types, and the background import-jobs runner. Also remove the now-unused validate.ts (only its FlatLike type was still referenced; moved to flat-schemas).
* test(setup): stub websocket addListener/removeListener in the global mock
BackgroundTasksWidget (mounted globally in App) subscribes via addListener/removeListener from api/websocket, but the global test mock didn't export them, so every test that renders <App/> threw on mount. Add the two stubs. (Surfaced now that the page-pattern check passes and the client test step actually runs.)
* fix(i18n): add Swedish translations for the AI booking-import settings
The Swedish (sv) locale landed on dev (#1325) after this branch added the
AI-parsing settings/reservation keys to the other locales, so sv was missing
them — strict i18n key parity failed after rebasing onto dev. Adds the 3
reservations.import.* and 17 settings.aiParsing/aiAlwaysRetry keys in sv.
* fix(extract): don't let the day-clamp fallback break reservation resync (#1288)
This branch added a clamp-to-nearest-day fallback to resolveDayIdFromTime so an
imported booking whose exact date has no day row still lands on a day. After
rebasing onto dev, that collided with #1288's resyncReservationDays, which
relies on the original "null when no exact day" semantics to leave a booking
whose date now falls outside the range untouched — instead it snapped to an edge
day (TRIP-SVC-019 failed: expected day_id kept, got the clamped one).
Make clampToNearest an opt-in parameter (default true, preserving the import
behaviour for create/update) and have resyncReservationDays pass false, so
out-of-range bookings keep their day_id. Full server suite green (4082).
* Added focus to search places in placeFormModal
* fix(airtrail): import departure/arrival times for manually-entered flights (#1336)
The mapper read only `departureScheduled`/`arrivalScheduled`, but those columns
are optional in AirTrail and stay null for manually-entered flights — where
`departure`/`arrival` are the only times set. So the import dropped the departure
clock (date-only) and the whole arrival (no date, no time), exactly as reported.
AirTrail's own rule is "use departure if available, otherwise fall back to
departureScheduled". Mirror that: prefer the scheduled instant, fall back to the
primary departure/arrival, in mapFlightToReservation, normalizeFlight, and the
sync hash. Hashing the resolved instant means flights already imported without a
scheduled time re-sync once and pick up their clock automatically; flights that
do have scheduled times are unaffected (no spurious re-sync).
Tests: 3 new mapper cases (fallback mapping, picker preview, hash tracking);
two existing cases that asserted the scheduled-only behaviour updated to the
"neither time set" case. Full server suite green (4085).
* fix(pwa): stop unregistering the service worker on offline boot (#1346)
Opening the installed PWA offline showed Chrome's "no internet" page instead of
the cached app. On boot the axios response interceptor reacts to a failed
request with no response by probing /api/health; the probe collapsed "genuinely
offline" and "edge-proxy auth wall" into a single reachable=false, so the
interceptor unregistered the service worker and reloaded — straight into a dead
network. navigator.onLine is true on mobile while offline, so the existing guard
didn't help. This also defeated the offline data layer (withOfflineFallback,
authStore's offline branch), which runs later in the chain.
Fix: connectivity.probe() now returns a discriminated state
('online' | 'offline' | 'proxy-wall'). A fetch that throws, or navigator.onLine
false, is 'offline'; a cross-origin redirect (CF Access, via redirect:'manual'
→ opaqueredirect) or an HTML auth wall (Pangolin) is 'proxy-wall'. The
interceptor only tears down the SW on 'proxy-wall'; on plain offline it lets the
request reject so the cached shell + IndexedDB serve the app. CF Access /
Pangolin reauth still works — the proxy always presents a reachable redirect or
HTML wall, which the probe now detects positively.
Regression dates to v3.0.16 (#964), surfaced by the 3.1.0 rewrite.
Tests: 6 new connectivity cases (offline/online/proxy-wall discrimination);
client tsc clean, full client suite green (2850).
* fix(map): keep the mobile GPS button above the day-detail panel (#1348)
On mobile the location (GPS) FAB sat at bottom: calc(var(--bottom-nav-h) + 12px),
which only clears the bottom nav. When a day is selected, DayDetailPanel slides
up over the map from bottom: navh+20 and spans nearly full width at z-index
10000, covering the button's band — so the button was hidden behind it.
DayDetailPanel now publishes its live measured height to a root CSS var
--day-panel-h (ResizeObserver, reset to 0 on unmount), and both map renderers
lift the button above the panel when it's open, reusing the hasDayDetail prop
they already receive:
hasDayDetail
? calc(var(--bottom-nav-h) + 20px + var(--day-panel-h) + 12px)
: calc(var(--bottom-nav-h) + 12px)
Applied to both the Leaflet (MapView) and GL (MapViewGL) renderers. When the
panel closes, hasDayDetail is false and the offset falls back to the bottom-nav
value. Desktop is unaffected — the button is mobile-only.
Tests: new DayDetailPanel case asserting --day-panel-h is published and reset on
unmount; client tsc clean, full client suite green (2851).
* feat(mobile): make the bottom-nav "+" context-aware per trip tab (#1349)
On mobile the bottom-nav "+" always created a new place (except on the Costs tab,
where it added an expense). It now matches the active trip tab: Bookings adds a
reservation, Transports adds a transport, Costs adds an expense, and everything
else (Plan, plus tabs that have no create modal — Lists / Files / Collab) keeps
adding a place.
Follows the existing ?create=<intent> pattern: BottomNav.useCreateAction emits the
per-tab intent, and useTripPlanner consumes create=reservation|transport to open
the booking / transport modals (both already mounted at page level). Place and
expense were already wired; this just extends the mapping.
Tests: 4 new BottomNav cases (plan/bookings/transports/costs → correct intent +
navigate target); client tsc clean, full client suite green (2855).
Implements mauriceboe/TREK#1349
* [+] Unsplash
* [+] i18n
* feat(trips): download chosen Unsplash covers into uploads (#1277)
Previously a selected Unsplash photo was stored as a remote
images.unsplash.com hot-link, so covers broke offline and on link
rot. The trip PUT handler now fetches the picked image through the
SSRF guard and saves it under uploads/covers, rewriting cover_image
to the local path (502 if the download fails). Also debounces the
cover search so a slow earlier request can no longer overwrite newer
results, drops a dead userId parameter, and reverts an unrelated
vite proxy change.
* test(trips): cover the Unsplash cover download and search-race guard (#1277)
Adds unit coverage for saveUnsplashCover (host check, content-type
and size limits, download failure), the searchUnsplashPhotos error
and success paths, and the PUT handler internalising a hot-link.
Updates the existing PUT tests for the now-async handler.
* fix(docker): keep server/reset-admin.js in the build context (#1339)
The Dockerfile copies server/reset-admin.js (the admin recovery
script), but .dockerignore also listed it, so it was stripped from
the build context and the image build failed with a not-found error.
Drop the ignore entry so the COPY resolves again.
* fix(llm): stop the browser autofilling the LLM base URL (#1301)
The AI-parsing base URL and model inputs had no autoComplete, so a
browser password manager could drop the saved login email into the
base URL field. In the admin addon config onBlur then fired a model
lookup against e.g. "admin@trek.local", which the server rejected
with 400. Mark the base URL and model inputs as type=url /
autoComplete=off in both the admin addon config and the per-user
connection section.
* feat(appearance): add per-user appearance config contract
Shared AppearanceConfig (color scheme, accent, transparency, per-tier type scale, density, reduce-motion and per-device dashboard widgets) stored as one JSON blob under the existing settings key. normalizeAppearance never throws, so a malformed/partial/future blob degrades to the neutral default and can never reach the DOM. No DB migration; the default reproduces today's look exactly.
* feat(appearance): token-driven theme engine with schemes and FOUC-safe boot
applyAppearance is the single writer of styling to the DOM (the .dark class plus data-scheme/-no-transparency/-density/-reduce-motion and the custom-accent/type-scale CSS vars). An external pre-paint /theme-boot.js replays a cached snapshot before first paint and complies with the production CSP (script-src 'self'), fixing the long-standing theme FOUC. Adds seven color schemes (incl. a true high-contrast that raises neutral contrast), a custom accent with auto-derived legible text, an extended token layer (accent variants, status/shadow/overlay/inverse), a scheme-gated legacy accent bridge, and a transparency-off layer. The default scheme sets no attributes, so existing users are unaffected.
* feat(settings): appearance settings tab
New Appearance tab with color mode (moved out of Display), color-scheme swatches, a custom accent picker with a live WCAG contrast hint, transparency and reduce-motion toggles, density, a global text-size slider with advanced per-tier controls, and per-device dashboard widget toggles. Edits preview live and commit on a short debounce. i18n keys added across all locales, translated for German.
* feat(dashboard): per-device widget visibility with layout reflow
Dashboard widgets (currency, timezones, upcoming reservations, atlas and the stat tiles) can be shown or hidden independently on desktop and mobile from the appearance settings. The stat grid spreads its visible tiles to full width, and disabling the right sidebar collapses the layout to a single centered column.
* chore(appearance): add theme:lint guard for hardcoded styles
A theme:lint script (modeled on i18n:parity) flags new inline color/fontSize literals and arbitrary-hex Tailwind classes that bypass the design tokens, so future code stays themeable. Map/PDF surfaces are exempt. The token taxonomy and the six theming rules are documented in src/theme/README.md.
* fix(appearance): scale inline px font sizes so text-size reaches all content
The global text-size control only set the root font-size, which scales rem-based text (navbar, menus) but not the dense inline px sizes used across the trip planner, budget, journey and panels — so place titles and addresses stayed fixed. applyAppearance now also exposes the factor as --fs-scale-text, and a codemod wraps inline numeric fontSize in calc(<px> * var(--fs-scale-text, 1)) across components and pages (map popups and PDF excluded). Sizes are byte-identical at 100%; the control now visibly resizes the actual content.
* fix(appearance): clearer widget settings, density hint, solid surfaces with transparency off
Dashboard widget settings are grouped by where they sit on the dashboard (below the hero / right sidebar / bottom of page); the right-sidebar master toggle now nests its individual widgets and greys them out when the sidebar is off, instead of a confusing flat list mixing the master with its children. Density gains an explanatory hint plus a real compact spacing effect. Transparency-off also solidifies the Atlas glass panels and tooltip, Leaflet zoom controls and GL popups — class-based surfaces via CSS, the Atlas inline panels via a noTransparency flag.
* fix(appearance): keep i18n key parity and update the scaled-emoji test
Add the new appearance settings keys (widget group titles, sidebar/density hints) to every locale so the strict key-parity check passes, and update the single-emoji chat test to expect the now-scalable calc() font size.
* feat(appearance): granular per-size text scaling with live preview
The text-size control now adjusts each size class (Large / Medium / Normal / Small) independently as well as all-at-once. Inline px sizes are mapped to a class by their value, so the per-class sliders reach real content; each class variable = global factor x its per-class factor (no double-scaling with the root font-size that handles rem text). The settings UI gains a live preview that resizes as you drag, and the four size sliders sit behind a clear toggle.
* feat(appearance): show per-size text controls inline with examples
The four size-class sliders (Large/Medium/Normal/Small) are now always visible instead of behind a disclosure, each with a live sample rendered at that size and an example of what it affects (e.g. Normal = place names/descriptions, Small = addresses/labels).
* fix(appearance): shorten the Auto color-mode label to 'Auto' on mobile
* fix(appearance): make the dashboard hero boarding-pass solid with transparency off
* feat(appearance): mark the Readability section as experimental
Transparency-off, density and per-size typography are best-effort while the token migration is ongoing, so the section carries an Experimental badge. Adds the i18n key across all locales.
* chore(about): remove the monthly supporters section
* refactor(settings): rename the Display tab to General and group its settings
The Display tab became a catch-all once theming moved to its own Appearance tab, and its 'Display' label no longer fit. It is now 'General' (Allgemein) and split into 'Language & region' and 'Travel & map' sections. Tab labels and the new section titles are added across all locales.
* refactor(admin): group the admin sidebar tabs into sections
The admin sidebar had 11 flat tabs. PageSidebar now supports optional group headings (backward-compatible; the Settings sidebar stays flat), and the admin tabs are grouped into Users, Configuration, Integrations and Maintenance. Group labels added across all locales.
* feat(help): embed the TREK wiki as an in-app help centre
Add a Help section (profile menu, /help) that renders the GitHub wiki inside
TREK. /api/help fetches the wiki markdown — the nav from _Sidebar.md, pages,
and proxied images — from GitHub and caches it (1h TTL, serves stale on
outage), so it auto-syncs on wiki edits with no redeploy and the client never
calls GitHub directly. The page is styled to match TREK with a section
sidebar, search and react-markdown; wiki [[links]] are rewritten to in-app
routes and HTML-comment placeholders are stripped. Page state lives in a
useHelp() hook per the page pattern. Adds nav.help and a help namespace
across all locales.
* feat(auth): explain the plain-HTTP secure-cookie gotcha on login
When the server issues a Secure session cookie but the request arrived over
plain HTTP (the common LAN install over http://ip:3000), the browser drops
the cookie and the next request dead-ends on a bare "Access token required" —
the top source of avoidable install issues. The login response now flags this
exact case and the login page shows a localized box explaining the fix (use
HTTPS, or set COOKIE_SECURE=false) with a link to the Troubleshooting guide.
It only triggers in the real failure case, never for correct HTTPS setups.
* feat(costs): Splitwise-like cost splitting
Add per-payer and per-member custom split amounts with Equally, Custom and
Ticket split modes on top of the existing equal split, keep legacy "paid by"
expenses working, and document the modes in the Budget Tracking wiki page.
* feat(i18n): add Vietnamese translations
* chore(i18n): sync Vietnamese with latest dev keys
Add the keys dev gained since this PR opened so the new vi locale keeps full
parity: the help namespace (wiki help center), settings appearance options,
costs split modes, dashboard Unsplash cover search, the insecure-cookie login
hint, nav.help and the admin group labels.
* feat(helm): Add existingClaim variable for custom PVC usage.
* fix(helm): emptyDir is used as a fallback when persistence is disabled.
* docs(helm): clean up existingClaim notes
Strip stray zero-width characters from the persistence docs, move the PVC
note out of the ENCRYPTION_KEY usage block into its own Persistence section
in NOTES.txt, and document that persistence.enabled=false falls back to an
ephemeral emptyDir.
* feat(feeds): subscribable ICS calendar feeds for trips
Adds TripIt-style live calendar subscriptions alongside the existing one-time
.ics download. A trip (or all of a user's trips) exposes a secret, revocable
feed URL that Google/Apple/Outlook poll to stay in sync.
- Public read endpoints GET /api/feed/trip/:token.ics and /api/feed/user/:token.ics
(no auth — the secret token is the credential), reusing the existing exportICS()
generator and adding REFRESH-INTERVAL / X-PUBLISHED-TTL hints.
- JWT-guarded token endpoints to generate (lazy, idempotent) and regenerate/revoke
per-trip and per-user feed tokens; tokens stored in nullable feed_token columns.
- All-trips feed excludes archived trips and trips ended >90 days ago.
- UI: ICS toolbar button becomes a Download/Subscribe menu; modal offers one-click
"Add to Google Calendar" (render?cid=webcal://) and a webcal:// link for
Apple/Outlook, plus copy-link fallbacks. All-trips feed reachable from dashboard.
- Feed base URL read from the existing APP_URL env var.
Purely additive: new endpoints + two nullable columns, no breaking changes.
Tests: server/tests/e2e/feeds.e2e.test.ts covers lazy token generate + idempotency,
regenerate-invalidates-old, 401/404 auth+access, public feed content-type + hint
injection, unknown-token 404, and the archived/>90-day all-trips exclusion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* harden calendar feeds: absolute URLs, real disable, folding, schema sync
- Resolve feed URLs against the request host when APP_URL is unset, so the
webcal:// / Add-to-Google links work on a default install (not just behind a
configured reverse proxy).
- Give the public link a real off switch: POST enables, PUT rotates, DELETE
clears the token (feed_token = NULL). The subscribe dialog no longer mints a
token just from being opened — the user opts in explicitly.
- Fold ICS content lines at 75 octets (UTF-8 safe) in exportICS, so download
and feed both stay RFC 5545-compliant for long/non-ASCII summaries.
- Extract VEVENTs by structural line scan instead of a lazy END:VEVENT regex
that user text could truncate.
- URL-encode the Google Calendar cid; mirror feed_token into schema.ts.
- Collapse the duplicated all-trips modal into the shared IcsSubscribeModal.
* feat(mcp): add bulk_update_places tool
Apply the same field values to many places in one call instead of one
update_place per place — e.g. re-categorising 80 POIs at once. Adds the
updatePlacesMany service (one transaction, trip-scoped, partial patch
built on updatePlace) and the bulk_update_places MCP tool with the usual
demo/access/place_edit guards and a place:updated broadcast per place.
* feat(dashboard): show the year on trip dates from other years
Trip dates only showed month + day, so trips from other years were ambiguous
(#1323). Dashboard cards and the boarding-pass hero now include the year, and
so does the shared formatDate (planner day headers etc.) — but only when it
isn't the current year, so this year's trips stay compact. Order and
punctuation follow the locale (EN "Sep 10, 2026", DE "10. Sep 2026").
* feat(places): bulk "change category" from the selection toolbar
Closes the UI half of #1168: in the Places selection mode, a new tag button
before delete opens a category picker that applies one category (or "No
category") to every selected place in a single request. Adds a REST
/places/bulk-update endpoint reusing updatePlacesMany, an offline-aware repo +
store action that patches both the place pool and the day-assignment
projections, undo grouped by each place's prior category, and the i18n keys
across all locales.
* feat(map): include the day's route in the map fit (#1128)
Selecting a day already fits the map to that day's destinations; this also
folds the route polyline into the bounds. BoundsController fits the
destinations immediately, then re-fits once — when the day's route finishes
computing asynchronously — to destinations + the full route, so a route that
bulges past its stops (a detour or ferry) stays in view. One-shot per day
selection, so later route-profile toggles don't re-zoom.
* feat(offline): detect update conflicts on the server for places and packing
Update handlers accept an optional X-Base-Updated-At token and reject a stale overwrite with 409, returning the current server row. An absent token keeps the existing last-write-wins behaviour, so older clients are unaffected. packing_items gains an updated_at column (migration + stamped on every insert) so it can take part in conflict detection too.
* feat(offline): force-offline mode, selective sync and a conflict queue
A force-offline override routes every read to the cache and every write to the queue; preparing for offline downloads trip data, documents and map tiles up front and waits for them to finish. Map tiles and individual trips can be left out of the cache. Queued edits carry the version they were based on so the queue can surface server conflicts for a keep-mine / keep-theirs decision; chained offline edits to one entity no longer conflict with each other, and evicting a trip preserves its unsynced writes.
* feat(offline): Settings -> Offline controls and a status banner
The Offline tab gains a force-offline switch, a prepare-for-offline download with progress, per-trip and map-tile storage toggles, and a conflict resolver with a default strategy. The floating status pill now reflects forced-offline and unresolved conflicts.
* i18n(offline): offline settings strings across all locales
* docs(wiki): document force-offline, selective storage and conflicts
* feat(video): media_type discriminator + local gallery video upload (server)
trek_photos gains a media_type column (migration) so the registry can hold video as well as images. A new POST :id/gallery/video endpoint accepts a video plus a client-captured poster (500 MB cap, video MIME/extension allowlist), stores the poster as the thumbnail, and the photo stream serves the poster for the thumbnail kind and the raw file (HTTP Range) for the original — without running the image thumbnailer on video bytes.
* feat(video): play local gallery videos in the journey gallery
Picking a video in the journey gallery now captures a poster frame + duration in the browser and uploads the raw clip; the grid shows the poster with a play badge and the lightbox plays it with a native video player (HTTP Range seeking). Images keep their existing HEIC-normalised path. No server-side transcoding.
Server media_type work was committed separately.
* feat(video): use Plyr for the gallery video player
Swaps the bare <video> element for a Plyr-wrapped player so playback controls match a consistent, cleaner skin. The instance is created per source and destroyed on unmount, so the lightbox stops playback when you navigate away.
* feat(video): link and stream Immich videos in the journey gallery
Immich timeline and album listings no longer filter out videos; each asset now carries its media type, which the provider picker forwards when linking. A linked video streams through Immich's transcoded /video/playback endpoint, and the asset proxy forwards the viewer's Range header (and passes 206/Content-Range back) so the player can seek. Synology video stays excluded until its stream API is verified.
Adds media_type/media_types to the provider-photos request contract.
* test(photos): assert the forwarded Range arg on the original stream
Follow-up to the Range-aware photo proxy.
* feat(video): upload and play videos in the trip file manager
The file manager (which already attaches files to a place/activity) now accepts video uploads up to the larger video cap — other types stay at the document limit — and the lightbox plays them with the Plyr player over the plain same-origin download URL, so cookie auth and HTTP Range both work. Videos are excluded from the offline blob prefetch so one clip can't evict a trip's documents.
* fix(video): harden upload handling and fix video playback edge cases
Security: the gallery-video poster is now always stored as .jpg instead of the client-supplied extension, so a poster declared image/* but named x.html / x.js can't be written with that extension and served inline same-origin; local gallery files are also served with X-Content-Type-Options: nosniff.
Robustness: rejected/unauthorised uploads no longer orphan their bytes on disk (the gallery-video and file-manager handlers unlink before throwing); the file-manager per-type size cap is keyed on the extension like the filter, so a real video labelled application/octet-stream isn't wrongly rejected. UX: the file-manager thumbnail strip shows a play placeholder for video instead of a broken image; shared (public) journeys now return media_type and play videos with a play badge; and a poster-less video shows a neutral tile instead of a broken thumbnail.
* test(video): update gallery accept selector + complete fileService mocks
The gallery upload input now accepts image/*,video/* — update the two JourneyDetailPage selectors that matched the old value. The files/journey e2e suites mock fileService and were missing the new MAX_VIDEO_SIZE / isVideoExtension / isVideoMime exports, which broke module load.
* test(video): cover the new upload-handler branches
Add controller tests for the gallery-video route (success / no-video / not-allowed / cleanup-on-reject), the per-asset media_types loops (gallery + entry, batch + single), and the file-manager per-type cap + unlink-on-rejection — restoring branch coverage on src/nest above the 80% gate.
* feat(bookings): add a dedicated URL field to reservations (#935)
Bookings get a first-class url column (migration) instead of users pasting links into notes. It's editable in the booking modal and rendered as a clickable link on the reservation card. The reservation request schemas are open passthroughs, so only the entity schema + service SQL enumerate it.
* feat(files): render uploaded Markdown files inline (#1345)
Markdown (.md/.markdown) is now an allowed upload type and opens in a rendered preview in the file manager instead of just downloading. Reuses the existing react-markdown stack with rehype-sanitize (these are untrusted uploads, so output is sanitized) and detects markdown by extension first since browsers send unreliable MIME for .md.
* feat(lists): reorder packing/to-do lists and private packing items (#969, #858)
Add drag-to-reorder to the packing and to-do lists, mirroring the budget
panel's native HTML5 drag pattern. A drag within a filtered/grouped view is
mapped back onto the global order so untouched items keep their place, and the
order persists optimistically via the existing reorder endpoints.
Packing items can now be marked private (#858): a private item is visible only
to its owner. createItem/bulkImport stamp the owner, listItems filters by the
viewer, and the WebSocket broadcasts are scoped to the owner so a private item
never reaches another member's screen — including the public/private toggle
transitions. Owners get a lock toggle and a private indicator on their items.
* feat(trips): transfer trip ownership to a member (#973)
Add POST /api/trips/:id/transfer so the owner can hand a trip to one of its
existing members. The swap runs in a transaction: the new owner takes
trips.user_id and the former owner is kept on as a regular member, so nobody
loses access. The endpoint is owner-only, writes a trip.transfer_ownership
audit entry and broadcasts the refreshed trip. The members modal gains a
"Make owner" action, shown only to the current owner.
* i18n: translate the booking link field across all locales (#935)
Fan out reservations.urlLabel / reservations.urlPlaceholder to the remaining
locales so the dedicated booking URL field is localised everywhere.
* fix(packing): drop the always-true guard in the row drag handler (#969)
The onDragOver guard `drag.isDragging || true` is a constant condition (eslint
no-constant-condition). The handler is already gated by canDrag, so run the
drag-over logic directly, matching the to-do row.
* feat(trips): guest members for accountless participants (#1362, #1291)
Add "guest" trip participants — people without a Trek account who can still be
assigned to costs, packing, to-dos and day-plan activities. A guest is a
credential-less users row (is_guest=1) joined into trip_members, so it is
assignable everywhere a real member is, with the cost-splitting, settlement,
packing and assignment paths working unchanged.
Guests are firewalled from everything account-related: they can never sign in
(password, OIDC and reset lookups skip them), never appear in the global user
directory, the member-add picker or admin user management, are never resolved as
notification recipients, can't be invited to another trip, and can't be made
owner. The trip owner manages guests from the share dialog in a dedicated,
clearly-labelled section (add / rename / remove), and guests carry a "Guest"
badge wherever members are picked. All 22 locales stay in parity.
* feat(packing): three-tier sharing — personal, shared-with-people, common pool (#858)
Rework the private-packing flag into a full sharing model. Every item is now
Common (the group pool — where all existing items live, so nothing breaks),
Personal (private to its owner) or Shared with specific people (it shows up on
those travelers' own lists, marked "by <bringer>"). is_private discriminates
restricted from common; a new packing_item_recipients table holds who a shared
item covers, and packing_item_contributors records "I can bring that too"
pledges on Common items.
The panel gains a Gemeinsam / Meine Liste view switch, each item a sharing
control (owner sets the tier + the people it covers), and Common items can be
co-brought or cloned onto your personal list. Visibility is enforced server-side
in listItems and the WebSocket broadcasts are scoped to exactly who can see an
item across every tier transition. All 22 locales stay in parity.
* style(packing): small gap between the list and the luggage sidebar divider
The luggage sidebar's left border sat flush against the right-hand category
card. Add a little left margin so the divider has minimal breathing room.
* feat(map): group GL place markers into clusters on zoom-out (#1385)
MapLibre/Mapbox showed every place as its own rich HTML marker with no
grouping when zoomed out, unlike the Leaflet map. Feed the place points
through a clustered GeoJSON source: clustered points render as a dark
count bubble (click to zoom in and expand) while the rich HTML photo
markers are only drawn for the points the source reports as unclustered.
Always on, matching the Leaflet MarkerClusterGroup.
* fix(map): match the GL place hover tooltip to the Leaflet map (#1385)
The MapLibre/Mapbox hover showed an anchored popup with a large photo
thumbnail, completely unlike the Leaflet map's slim, cursor-following
name/category/address card. Drop the anchored photo popup for places and
render the same cursor-following overlay the Leaflet map uses (no photo,
matching fonts/padding/shadow), so the two maps hover identically.
* feat(collections): backend for the Overall Places addon (#1081)
Adds the Collections addon backend: a server-wide-per-user library of saved
places, independent of any trip, with multiple named lists, an idea/want/visited
status, and Vacay-style fusion invitations to share a list with other users.
- Data: collection / collection_members / collection_places / collection_place_tags
tables (+ migration and baseline schema). Saved places carry the owner plus a
nullable saved_by so a member deleting their account can't drop shared content.
- Service: list + place CRUD with owner-or-accepted-member visibility, dedup,
status, save-from-trip and copy-to-trip (reusing the trip copy column list),
and the full fusion-invitation state machine mirrored from vacay (send / accept
/ decline / cancel / leave) with a websocket broadcast and an invite
notification. Deleting a list snapshots its members and notifies them.
- NestJS module + addon guard (404 before auth), registered in the app module.
- Widens the place photo cache reference check to count collection places so the
nightly sweep no longer evicts photos a saved place still uses.
- collection_invite notification wired across all 22 locales.
* feat(collections): /collections page, entry points and i18n (#1081)
Adds the client side of the Collections addon:
- A distinct /collections page (Atlas pattern, page/hook split) gated behind the
addon: a multi-list rail, a Grid (default) / List / Map view switch, the
idea/want/visited status with a one-tap badge, search and status filters, and
considered empty states. Store + hook + model + websocket wiring; the place
detail reuses the trip place inspector via a mode guard.
- Entry points: a "Save to Collection" button next to Open-in-Google-Maps in the
place inspector (and the two sidebar context menus), a "Copy to trip" modal,
and a desktop-only two-column add-place picker (mobile keeps the single-column
form).
- The collection namespace and the new keys across all 22 locales.
* feat(collections): fusion sharing UI + dashboard widget + per-user toggle (#1081)
- ShareCollectionModal: the owner manages a list's members and invites users
(available-users picker → invite, cancel pending); a member can leave a shared
list. The incoming accept/decline surface stays in the lists rail. A Share
button is added to the collections header for owners (and members, to reach
Leave).
- CollectionsWidget: a dashboard glass card after the currency widget showing the
saved count and the most recent saved places, double-gated by the admin addon
and a new per-user appearance flag.
- Appearance: a 'collections' dashboard-widget flag (desktop + mobile defaults)
wired into the appearance settings, surviving normalize.
- Sharing + settings strings across all 22 locales (parity strict passes).
* feat(collections): redesign the page on the dashboard glass language (#1081)
Rebuilds the /collections page from the functional placeholder into the
dashboard's glass visual language (light + dark):
- A colour-washed hero per list: eyebrow + member avatars, big title, and
stat chips (All / Idea / Want / Visited) that double as the status filter.
- A sticky glass list rail (owned + shared + invites) with a mobile drawer.
- Gradient/photo cover place cards modelled on the trip cards, via a new
rectangular PlaceCover (photoService-backed, gradient fallback) + a shared
gradients util. List and map views restyled to match.
- Status pill rendered as a role=button span so it survives the .trek-dash
button reset and can nest inside the card; the share member-count badge is
now owner-only.
- New hero eyebrow strings across all locales.
* feat(collections): list+map split, taller rail, list-menu popover fix (#1081)
- List view splits into a scrollable list + a sticky map on wide screens;
clicking a place pans/highlights it on the map (single selectedPlaceId, no
inspector over the map). Narrow screens keep the single-column list.
- Keep the list rail at least as tall as the hero (measure the hero via a
small useElementSize hook and feed its height as the rail's min-height).
- List row kebab menu: portal the menu/colour popover to the body so the
rail's overflow + backdrop-filter can't clip it ("renders only in the
module"), and fade the place count on hover so the kebab stops overlapping it.
* feat(collections): list+map default, map-only toggle, deselect + tooltip fixes (#1081)
- Drop the grid/tile view. The list view is now the default and, on wide
screens, a list + persistent map split; a top-left control on the map
collapses the list to a full-width map (and back), animating smoothly (the
map stays mounted and is nudged to re-layout during the transition). The
place search moves onto the map (top-right); mobile keeps a list/map toggle.
- Let a place be deselected again: clicking it once more, clicking the map
background, or picking another all toggle the selection (collections map now
wires onMapClick).
- Fix the stuck hover tooltip: selecting a place swaps its marker's DOM node so
the browser never fires mouseout/mouseleave, orphaning the fixed-position
tooltip (it hung on screen and drifted with scroll). Both map stacks now clear
the hover on selection change and on scroll.
- Remove CollectionGrid + PlaceCover; add hero eyebrow + map control strings.
* feat(collections): list details, place detail sheet, add-place, fusion kick (#1081)
Dashboard widget (B): the collections tool now shows the user's LISTS as
compact colour-washed badges (cover image tinted with the list colour, or a
gradient) that jump to the list — one list() call, no N+1.
List details (C): lists gain a description, a custom cover image (uploaded to
/uploads/covers, tinted with the list colour in the hero) and links. A shared
ListEditorModal handles both create and edit; the hero shows the description +
link chips. New `links` JSON column on collections + collection_places
(migration 151) with parse/serialize in the service; a POST :id/cover upload
endpoint mirroring trips; cover-file cleanup path-confined locally.
Place detail (D): clicking a place opens a bottom sheet (no backdrop, so the
map stays visible) — status cycle, copy-to-trip, remove, and an edit mode with
a markdown description + links editor (collectionsApi.updatePlace, now wired
via a store action). A "+" next to the search adds a place to the list via the
maps search.
Fusion + fixes (E): the owner can now remove an accepted member (kick) — new
removeMember service/route/store + a button in ShareCollectionModal, with a
collections:removed WS bounce. findMembership no longer matches on name alone
(coordinate proximity required, killing "Starbucks everywhere" false positives).
loadCollection swallows a 403/404 after a leave/remove so the URL sync can't
throw uncaught. Grid remnants gone; the map select toggle moved onto the map.
New strings across all 22 locales; i18n parity strict passes.
* fix(collections): review follow-ups on the B–E work (#1081)
- Block the list-cover upload in demo mode (mirror the trips cover endpoint).
- Restrict list/place links to http(s) (schema) and normalise scheme-less URLs
to https:// on save, so a bare "booking.com" no longer resolves as a relative
SPA route (and javascript:/data: hrefs are rejected).
- Place detail: surface save errors with a toast instead of silently swallowing
a 400 and leaving the sheet stuck in edit mode.
- List editor: don't create a duplicate list when a retry follows a cover-upload
failure (reuse the created id); revoke the cover preview object URL.
- Map controls: one top bar (left toggle/select, right add/search) so they can't
overlap on a narrow split map — the search shrinks instead.
- Dashboard list badge: full-opacity colour wash so the name stays legible over
bright covers.
* fix(collections): detail-sheet, edit-refresh, map + rail polish (#1081)
- Editing a place (status, description, title, …) no longer reloads the view or
closes the detail: the WS echo now refreshes via loadCollection, which keeps
the current selection + select-mode instead of setActive resetting them.
- Place detail: docks over the list column on the desktop split (measured rect)
instead of centred over the map, and the card is now opaque (was too see-through).
- Map: click a marker in full-map view to drop back to the split; picking a place
scrolls its list row into view; the select toggle is disabled in full-map view;
the floating controls are one non-overlapping top bar and less transparent.
- Hero: drop the New-list button (it's already in the rail).
- Rail: the kebab is always visible (easy to hit); menu is Edit + Delete only
(colour moved into the editor); "Rename" → "Edit".
- Add-place: pick a result, then set description (markdown) / links / status
before saving, all in one step.
- Share modal: member roster as cards with clearer role badges + a count.
* feat(collections): detail redesign + categories, close-on-map, highlight fix (#1081)
- Rebuild the place detail as a clean, opaque, sectioned sheet (cover → meta →
status segment → description → links) with a proper footer action bar — the
loose "white lower half" is gone.
- Assign a place to a central (admin-defined) category, both in the detail edit
and when adding a place; categories are fetched once for the page.
- Add-place now sets category + description (markdown) + links + status in the
same step, closer to the trip's place form.
- Switching to the full-map view now closes the (list-docked) detail.
- Fix the selected-row highlight: it was clipped by the column's overflow — use
an inset ring and only clip during the map-collapse animation; a picked row
now scrolls into view above the detail sheet.
- New category strings across all 22 locales.
* feat(collections): filters, add-place popup, category badges, map-click hardening (#1081)
- Map: markers no longer rebuild on every unrelated re-render (memoised the
mappable list + only update the hero size when it really changes), the floating
controls bar is click-through except its buttons, and the collection map runs
with the hover tooltip off. Together these stop a marker click from landing on
a mid-rebuild element / the tooltip so the pick actually registers.
- Filters moved out of the hero into a compact status + category dropdown row
above the places (custom dropdowns); the hero no longer carries the stat chips.
- Add-place is a single popup now: search fills the location, and name / status /
category / description / links are all editable together before saving.
- Category shown as a badge top-left on the detail cover and next to the status
in each list row (divided by a hairline).
- Slimmer hero: shorter, tighter spacing, links tucked into the eyebrow row
instead of their own line.
* feat(collections): hero edit/share row, place photos, edit-echo fix, wider page (#1081)
- Editing a place (category, status, …) no longer reloads the view: the mutating
client's own socket is now excluded from the WS broadcast (x-socket-id threaded
through save/update/status/delete + list update/cover), so the optimistic update
stands on its own instead of being chased by an echoed refetch.
- Detail sheet pulls a higher-res cover photo from the maps provider when the
place has no image of its own (the avatar thumbnail was too low-res).
- Hero: Share moved onto the title row (no more empty top band) with an Edit
button beside it; editing/deleting a list now happens there. The list rail drops
its per-row kebab entirely (and with it the janky open animation).
- The list editor can delete the collection from its footer (owner only).
- Wider, screen-relative page (max-width min(2100px, 95vw)).
- List rows: the place avatar no longer shrinks when the address is long.
* fix(collections): copy-to-trip labels + Unsplash cover search (#1081)
- Copy-to-trip modal showed blank rows: trips are keyed by `title`, not `name`,
so nothing rendered. Read `title` and add the trip's date range under it.
- List editor gains an Unsplash cover search (same source as trip creation) next
to the upload button; picking a photo sets it as the list cover.
- Add-place result rows: pin keeps a hard min width so a long address can't
squeeze it.
* fix(collections): stop the address pin from shrinking on long addresses (#1081)
The little map pin in front of a place's address sits in a flex row with the
address text but had no flex-shrink guard, so a long address squeezed the icon
smaller. Pin the SVG to its size.
* fix(collections): white screen when editing a place (undefined in places) (#1081)
updatePlace wrote `res.place` into the places list, but the endpoint returns the
updated place directly (not wrapped in { place }, unlike savePlace) — so an
`undefined` slipped into the list and the category-filter's presentCategories()
crashed on `undefined.category_id`, blanking the whole page. The WS echo used to
mask it by refetching; excluding the editor's own socket exposed it.
- Read the updated place directly and guard against a falsy response.
- Fix the api return types to match (updatePlace/setStatus return the place).
- Harden filterPlaces / statusCounts / presentCategories / mappablePlaces against
a stray undefined entry so a single bad row can never white-screen the page.
* feat(collections): select toolbar — select-all, move/duplicate to another list (#1081)
- The select toggle now sits at the right of the filter row (same height as the
status/category dropdowns) instead of the top toolbar.
- Select mode gains a "select all / deselect all" toggle and shows even with
nothing selected yet.
- Selected places can be moved or duplicated into another of your lists via a
target-list picker (move re-points collection_id; duplicate re-saves the place
data, carrying description / category / notes / etc.).
- New strings across all 22 locales.
* style(dashboard): accent follows the user's theme instead of a fixed orange (#1081)
The .trek-dash scope (dashboard, collections, vacay, atlas) hardcoded an orange
accent, ignoring the appearance theme. Drop the override so --accent inherits the
theme tokens (index.css): monochrome black/white by default, coloured per
data-scheme / custom accent. --accent-ink/-soft now map onto --accent-on/-subtle,
and accent-filled elements use --accent-text for legible text on any scheme.
Category colours are set explicitly per element and stay untouched.
* fix(collections): saved-places picker height + list filter, all-saved first-load (#1081)
- Trip "Saved places" picker: drop the fixed 360px cap so the list fills the
panel instead of stopping half-way, and add list + status filter dropdowns
(filter by which collection the place is saved in).
- "All saved" showed nothing on first open: setActive(ALL_SAVED) unioned the
lists from the store, but on first load those aren't fetched yet (loadAll still
running). Load them first when empty so the union isn't blank.
* test(collections): unit-test the nest controller (branch coverage) (#1081)
The collections nest module had no controller test, dragging src/nest/** branch
coverage below the 80% gate. Cover the controller's branches: reorder/deleteMany
payload validation, owner-gated invite/cancel/remove/available-users, invite +
accept error surfacing, the cover demo-mode + no-file guards, and the x-socket-id
forwarding on the mutating endpoints.
* fix(collections): mobile polish — touch targets, safe-areas, overflow (#1081)
From a mobile UX audit of the collections page:
- Detail sheet: the read-mode footer no longer clips "Remove from list" (it wraps,
drops the growing spacer) and clears the home indicator (safe-area padding,
84dvh instead of 84vh).
- Bigger touch targets on phones (≥40px): view toggle, filter dropdowns, select-bar
buttons, detail close/actions, drawer rail rows, and the interactive status badge
(enlarged tap area via a pseudo-element, look unchanged).
- Select action bar breaks its bulk actions onto their own line instead of
stranding them behind a growing spacer.
- Lists drawer honours device safe-areas and gets an explicit close button.
- Page honours the top safe-area and goes full-width on phones (drop the 95vw cap);
filter popovers cap their width so long category names don't overflow.
- Add-place: Cancel/Add pinned in the modal footer (reachable without scrolling),
status pills wrap.
- Drop dead hero mobile CSS left over from the hero refactor.
* feat(collections): per-member permission roles on shared lists (#1081)
The owner now assigns each member a role — viewer (read + copy-to-trip only),
editor (default: add + edit places) or admin (full incl. delete). The owner is
always full. Existing members default to editor via migration 152, so nothing
regresses.
- Server: role column on collection_members (migration 152 + schema); roleOf +
assertCanEdit (save/update/status/list-meta) + assertCanDelete (delete) layered
on assertAccess; sendInvite takes a role; new setMemberRole (owner-only) +
POST members/role; members payload carries each role.
- Client: Share modal gains a role picker on invite and a per-member role select
for the owner (read-only role badge for others); the page hides add / edit /
status / move / delete for roles that can't perform them (server still enforces).
- Roles in all 22 locales; service + controller tests for the new gating.
* feat(collections): bulk-add selected trip places to a list (#1081)
Add a "Save to collection" action to the trip place list's selection bar (next to
bulk category + delete): it opens a list picker and copies every selected place
into the chosen list in one request, instead of one-by-one from each place.
- Server: saveFromTripPlaces (one access check + one WS notify), POST
places/from-trip-many; dedups by name/coords, skips missing ids, honours force.
- Client: saveFromTripMany api + SaveTripPlacesToListModal; the selection-bar
button is gated on the collections addon being enabled.
- Copy count / skipped-duplicates toast; strings in all 22 locales.
- Service + controller tests for the bulk path.
* style(collections): custom dropdown for the permission role pickers (#1081)
Swap the two native <select> role pickers in the share modal (invite + per-member)
for the app's CustomSelect (portal dropdown, size sm) so they match the rest of
the UI instead of the browser's native control.
* style(collections): widen the share modal (#1081)
* test(collections): client component tests + select in All saved, off the map (#1081)
- Add client tests for the new collections UI (80 tests): collectionsModel (incl.
the undefined-entry guards that fix the white-screen regression), StatusBadge,
CollectionFilterBar, CollectionList, CollectionPlaceDetail (permission gating),
MoveToListModal.
- Offer the select toggle in "All saved" too (server enforces per-place rights).
- Drop the now-duplicate select button from the map controls (it lives in the
filter row).
* docs(wiki): add Collections addon page (#1081)
New wiki/Collections.md in the style of the other addon pages (lists, status,
categories, adding/bulk-adding places, place detail, filters + bulk actions,
fusion sharing with member roles, dashboard widget). Add it to the Addons
overview table + the sidebar navigation.
* feat(date-picker): add month/year drill-down navigation and keyboard input trigger
- Add three-level calendar view (days → months → years) via clickable
header label, allowing fast navigation to distant dates without
repeated arrow clicks
- Replace double-click text input affordance with a visible keyboard
icon button; compact/borderless variants show the icon in the
calendar footer
- Pre-fill text input with locale-aware numeric date (DD.MM.YYYY)
when a value is already selected
- Add aria-label and aria-pressed to all interactive calendar elements
for screen reader support
- Update existing tests to reflect new two-button trigger layout
- Add FE-COMP-DATEPICKER-018 through 027 covering drill-down
view transitions, prev/next behaviour per view, aria-pressed
state, and keyboard icon trigger
* fix(date-picker): locale-aware keyboard input parsing and i18n control labels
- Replace fixed-order date parser with locale-aware implementation
using Intl.DateTimeFormat.formatToParts to detect field order;
adds swap fallback for unambiguous inputs (day > 12) to handle
locale mismatches gracefully
- Pre-fill keyboard input with locale-formatted numeric date
(e.g. 14.06.2026) instead of raw ISO value
- Replace all hardcoded English aria-labels and titles with t()
calls; add new keys under common.datepicker.* namespace across
all locale files
- Update FE-COMP-DATEPICKER-013 to use unambiguous day value (> 12)
to avoid locale-dependent test failures
* fix(date-picker): add missing locale file and fix let-to-const lint error
- Add missing common.datepicker.* keys to overlooked locale file
- Change reassigned `let` to `const` where value is not mutated
to satisfy lint rules
* chore(i18n): backfill datepicker keys for sv + vi locales added on dev
* fix: back-merge v3.1.4 hotfixes into dev (#1371)
Port the three main-only fixes onto dev's (post-rewrite) architecture:
- fix(backups): prevent recursion when the backup path sits inside the backed-up dir
- fix(share): convert budget items to the viewer's base currency instead of a flat EUR
- fix(files): surface the descriptive server error for unsupported upload types (#1363)
Cherry-picked from 819aa793 on main; the SharedTripPage and useTripPlanner
conflicts were resolved to keep dev's font-scaling and full import set while
taking the fixes' currency conversion and translateApiError wiring.
* fix: resolve a batch of reported bugs (planner, budget, atlas, bookings, mobile)
- #1394 planner: two transports on one day no longer draw a phantom airport→airport
road route between them (a run is only a drive when it holds a real place); mirrored
in the map hook and the sidebar's leg list, with a regression test.
- #1392 planner: the per-day Route button now points the selection at the tapped day
before toggling, so on mobile it computes that day's route instead of the previously
selected one, and only the selected day's button reads as active.
- #1372 planner: the "open in Google Maps" route now includes the day's hotel bookends,
matching the drawn map route.
- #1375 planner: a multi-day accommodation no longer thrashes the plan scroll — the
auto-scroll lock keys on the selection identity, not the per-day row.
- #1377 planner: the reset-orientation compass is now shown on small screens too.
- #1382 budget: settlement nets in the trip's canonical currency and converts to the
display currency once, so balances no longer drift with live FX and no phantom
third-party micro-flows appear (identity, hence unchanged, when they're the same).
- #1366 atlas: countries reached only by a transport booking (no lodging/place) now
count as visited, on both the dashboard and the Atlas page.
- #1383 bookings: a hotel linked to an accommodation shows only its day-range, not a
duplicate stamped date row, and the range stays correct after an edit.
- #1353 bookings: any non-hotel reservation can now link an existing trip place/activity.
- #1390 i18n: fix the Polish word for "buddies" (Towarzysze → Współpodróżnicy).
- #1265 planner: drag-and-drop of places now works on touch devices via a polyfill.
* feat(planner): add an "Open in OpenStreetMap" button to the place inspector
Next to the existing "Open in Google Maps" action, add an OpenStreetMap button that
opens the place on openstreetmap.org (a marker at its coordinates, or a name search
when it has none) — the same map source TREK already renders, and a jumping-off point
for OSM-based apps like OrganicMaps / CoMaps. Requested in discussion #880.
Strings across all 22 locales; a unit test for the URL builder.
* feat(planner): shorten the map-open button labels to "Google Maps" / "OpenStreetMap"
* feat(planner): show a day's route distances inline on mobile
Seeing the driving/walking distances between a day's places on mobile
meant tapping the day (which closes the plan sheet), reopening it, then
tapping Route. Now the per-day Route button in the mobile footer toggles
that day's leg distances in place, so the sheet stays open and you get the
distances between places without selecting the day first.
The leg computation runs for every route-toggled day instead of only the
selected one, and the leg/hotel-bookend maps are nested per day so several
toggled days can't overwrite each other's segments. Desktop is unchanged.
Discussion #1374
* feat(oidc): use the picture claim as avatar when none is uploaded
When a user signs in via OIDC and hasn't uploaded a custom avatar, their
`picture` claim is now used as their avatar. The users.avatar column holds
either an uploaded file name or an absolute https URL from the claim, and a
single resolver on each side (server avatarUrl, client avatarSrc) renders
both. An uploaded avatar always wins and is never overwritten; the picture
refreshes on each login otherwise. Only https URLs are stored, matching the
image CSP.
All the scattered /uploads/avatars/ builders now go through the resolvers,
which also fixes collection member avatars that were rendering a bare file
name.
Discussion #1399
* feat(trips): trip invite links + optional trip binding on admin invites
Trip invite links (#1143): each trip can have one rotating invite link in its
Share panel. An existing, logged-in user who opens /join/<token> is added to
the trip as a member; an anonymous visitor is sent to the login page and
returned to the invite afterwards — there is no registration from this link.
Reading, rotating or disabling the link all require the share_manage permission.
Admin invite trip binding (#1402): the admin create-invite dialog can now bind
a registration invite to a trip. Someone who registers via that link is
auto-added to the trip as a member (password and OIDC paths), inside the same
atomic step that consumes the invite.
Adds a trip_invite_tokens table and a nullable invite_tokens.trip_id, a shared
owner-safe/idempotent add-by-id helper, the manage + join endpoints, the
JoinTripPage and Share-panel section, the admin trip picker, and the new i18n
keys across every locale. Wiki updated.
Discussion #1143
* fix(join): extract JoinTripPage state into a useJoinTrip hook
The page container held useState/useEffect directly, tripping the CI
page-pattern check. Move the token preview + accept logic into a co-located
useJoinTrip() hook; the page is now a thin presentational shell.
* feat(costs): filter expenses by category and by a single day
Adds two filter dropdowns next to the Search Expenses field (height-matched
to it): one filters by expense category, the other narrows to a single day.
Selecting a day shows a prominent summary banner with that day's total, and
hides the now-redundant per-day header. Both filters work on the desktop and
mobile layouts and compose with the existing search + all/mine/owed filters.
New i18n keys (costs.filter.allCategories / allDays, costs.expensesCount)
across every locale.
* fix(admin): use TREK's CustomSelect for the invite trip picker
The "add to trip" dropdown in the admin create-invite dialog was a native
<select>; swap it for the shared CustomSelect so it matches the rest of the UI
(searchable once there are many trips).
* feat(planner): public transit routing via Transitous (#1065)
Each day header gets a transit button (replacing the rename pencil, which
moved next to the day name in the day detail panel). It opens a route search
backed by Transitous/MOTIS — free, open data, no paid provider: from/to stop
search with the day's own places as quick picks, depart/arrive time, mode
filters (train, subway, tram, bus, ferry, cable car) and ranking by best
route, fewer transfers or less walking. Results show local times, duration,
transfers, walking time and line badges in their official colors, with a
stop-by-stop breakdown per connection.
Adding a connection saves it as a regular transport reservation — typed by
its dominant leg, timed from the itinerary's wall-clock departure/arrival
converted to station-local time (tz-lookup), with the origin, transfer stops
and destination as endpoints and the compact legs in metadata.transit. It
slots into the day timeline by time and inherits editing, deletion and
drag-reordering from the existing transport machinery; the transport detail
view renders the full itinerary. Re-saving a transit transport through the
edit modal preserves the stored itinerary while the route is unchanged.
The server proxies the Transitous API (JWT-guarded, rate-limited, identifying
User-Agent, short response cache, strict mode whitelist); TRANSIT_API_URL
lets self-hosters use their own MOTIS instance. New i18n keys in every
locale, wiki page updated.
Discussion #1065
* fix(build): declare tz-lookup as a client dependency
It was present in the lockfile but undeclared, so the local install had it
while the Docker client build (npm ci --workspace=client) did not.
* test(maps): add buildUserAgent to the mapsService mock
transitService imports it at module load, and the full-app integration boot
now pulls the transit module in — the factory mock lacked the export.
* feat(planner): make transit journeys first-class entries (#1065)
A saved transit route is now its own reservation type instead of piggybacking
on train/bus: it gets a tram icon and its own color everywhere, and the day
timeline renders the itinerary inline — line badges in their official colors
with walk segments, plus the transfer count and walking time — instead of a
generic transport row.
Clicking the row opens the itinerary view (journey summary, stop-by-stop legs
with times, platforms, headsigns and operators) rather than the edit form;
editing stays reachable from an Edit action inside that view. The transit
type is registered across the timeline merge, transport modal, reservations
panel, file manager, map overlays and detail panels, with a translated type
label in every locale.
* feat(planner): integrate transit into the transport system as Automated mode
The add-transport dialog gains a Manual/Automated switch: Automated embeds the
public-transit search (day picker + from/to + modes + preferences + results)
right in the dialog, and the day header's tram button opens it directly in
that mode. The standalone search modal is gone.
Saved journeys get their own roomy journey view — the stop-by-stop itinerary
(times, platforms, lines, headsigns, operators) together with the editable
booking fields, delete, and a "Change route" action that re-runs the search
pre-seeded with the journey's origin/destination and replaces the itinerary on
save. The generic transport form no longer opens for transit entries, from the
timeline or from the Transports tab, where journeys now sit in their own
"Automated public transit" section with their line badges on the card.
New i18n keys in every locale; wiki updated.
* feat(planner): polish the transit journey UI and fix its tab placement
Transit entries were classified as bookings by the planner's transport-type
list and landed in the Bookings tab — they now sit in the Transports tab's
own section, rendered as proper journey cards (tram icon, arrow title, leg
chips, journey stats) instead of the generic booking card.
The journey modal got a redesign: the title renames inline in the header
with an icon arrow, the stats become three full-width tiles (duration /
transfers / walking, each with an icon), status and booking-code fields are
gone, and notes take the full width with a markdown write/preview toggle.
The Automated search mode gains a proper header (icon, hint, day picker)
and the day-plan row now shows walks with their minutes inside the chip
sequence (🚶 3 › U2 › 🚶 3) instead of a detached direct/walk summary.
"A → B" titles render with an arrow icon everywhere. The Transports tab's
add button is simply "Transport" now that the dialog covers both modes.
* test(nav): the bottom-nav add button is labelled Transport now
* feat(planner): markdown toolbar for journey notes + calmer transit search form
The journey notes gain a proper markdown toolbar (bold, italic, strike,
heading, list, checklist, link, code) that wraps the selection or prefixes
the current lines. The transit search options settle into one calm card:
depart/arrive + time + date and the ranking preference share the top row,
the mode filters and the search button share the bottom row, with the mode
chips restyled from heavy filled pills to quiet toggles. The day-plan row
drops the transfer count — the leg chips already tell the story.
* feat(planner): badge meta rows + inline itinerary expansion for transit
Dot-joined meta text becomes quiet badge chips everywhere transit facts are
listed: the journey modal's per-leg line (time, duration, stops, headsign
with an arrow icon, operator de-emphasised), the search results' leg details,
and the Transports-tab journey card (day, date, time span, duration — the
transfer count is gone from the card).
The day-plan transit row swaps the map-connections toggle for an expander:
the chevron folds the stop-by-stop itinerary out right inside the timeline —
times, line badges, stations with platform and stop counts — sized for the
sidebar.
* feat(planner): walk legs as centred dividers + journey-card note line
Walk segments in the journey modal and the day-plan inline itinerary
collapse from two lines into a single centred divider — dashed rules left
and right, the walk in the middle (foot icon, destination, minutes). Leg
meta badges sit tighter under their titles. The Transports-tab journey card
shows a dimmed first-line note preview, and the journey modal now reads the
reservation from the live store, so an update is visible the moment the
entry reopens.
* feat(map): draw transit journeys along their real rail and bus alignments
MOTIS leg geometry (encoded polylines) now travels through the proxy and is
stored per leg, so both map renderers draw the journey along the actual
tracks instead of a straight line: colored cores in each line's GTFS color
over a white casing, walks as dotted grey connectors. Transit journeys are
always visible on the map — they are part of the plan itself, not an opt-in
overlay — and the day route already anchors to their stations, so the
journey slots into the route computation end to end. Entries saved before
this keep the straight-line fallback.
Also: stronger dashes on the walk dividers, notes open rendered (preview
tab) when present, and MOTIS's START/END placeholders are replaced with the
places the user actually picked.
* fix(planner): elegant walk-divider hairlines + proven notes preview
The walk dividers switch from dashed borders to 1px hairlines that fade
towards the outer edges — strongest next to the walk text. A regression
test pins the journey modal opening existing notes on the rendered
markdown preview rather than the raw text.
* fix(map): transit polish — earlier label collapse, route-toggle coupling, md note preview
Station badges on transit journeys collapse to icon dots much earlier when
zooming out (label threshold 900px instead of 400). The drawn transit paths
now ride the day-route toggle: turning the route off hides them too, since
they are part of the computed route. The journey card's note preview renders
its first line as inline markdown instead of raw asterisks.
* fix(settings): booking route labels default to off
The map endpoint labels only render when the user explicitly enables them;
an unset preference now means hidden, matching the calmer default the
transit paths brought to the map.
* style(settings): TREK-styled text-size sliders
The appearance tab's native range inputs become proper TREK sliders: a thin
pill track filled up to the current value in the accent color, with a soft
round thumb that scales slightly on hover/drag.
* fix(planner): mobile layouts for the transit popups
The journey modal and the transit search now lay out properly on phones:
from/to stack vertically with the swap rotated between them, the ranking
segment and search button go full width, the day picker in the automated
header spans the row, the three stat tiles compress to centred mini tiles,
the itinerary tightens its gutters, and the footer wraps with an icon-only
delete. Desktop is unchanged.
* fix(planner): tighter mobile transit search + vertical journey itinerary
* fix(planner): wrap-safe mobile itinerary text — platform below the stop, minutes-first walks
* feat(plugins): plugin system scaffold — registry tables + admin panel
First slice of the plugin system. Lays down the data model and a read-only
admin surface; nothing executes yet.
- Migration 155: plugins, plugin_meta_migrations, plugin_error_log and
plugin_settings_fields tables. Plugin data will live in a per-plugin sqlite
file under /plugins-data, never in these tables.
- New Nest module server/src/nest/plugins with GET /api/admin/plugins
(admin-gated, returns the installed list + the runtime-enabled flag).
- TREK_PLUGINS_ENABLED kill switch (config.pluginsEnabled), off by default.
- Admin → Plugins tab with a read-only panel: installed list, status badges,
and a clear banner when the runtime is disabled by server config.
- i18n keys for the tab and panel across all locales.
Install, activation, the isolated runtime and the registry browser follow in
later slices.
* feat(plugins): isolated per-plugin runtime + capability RPC (M1)
Every plugin now runs in its own forked child process with a scrubbed env
(no JWT_SECRET, no db path, nothing inherited). It talks to TREK only over a
JSON-RPC channel, and the host's capability router registers ONLY the methods a
plugin's granted permissions unlock — so an ungranted call is unreachable, not
merely refused. The plugin's own data lives in a separate sqlite file it can
never open directly; core reads (trips/users) go through membership-checked,
column-projected host methods; ws broadcasts are force-namespaced.
- protocol/envelope: the wire types + method→permission map (pure, shared by
host and the isolated child)
- host/rpc-host: the capability router = the enforcement point (dispatch,
BAD_PARAMS / PERMISSION_DENIED / RESOURCE_FORBIDDEN / UNKNOWN_METHOD)
- host/plugin-data: the per-plugin sqlite file (db:own), guarded against
ATTACH/PRAGMA escape, idempotent migrations
- host/create-rpc-host: wires the router to the real db/websocket (host-only)
- runtime/plugin-sdk + plugin-host-entry: the child bootstrap + definePlugin
ctx; turns each ctx call into an RPC, never imports a privileged module
- supervisor: spawn on activate, heartbeat/reap, crash backoff + auto-disable,
graceful shutdown — a plugin crash/OOM/hang can never reach the Nest loop
- paths: code/data layout, dist-vs-tsx child entry resolution
Nothing is wired into activation yet (that's the next slice); exercised by unit
tests for the router/sdk/data and an integration test that forks a real child.
* feat(plugins): activation, HTTP route proxy + instance settings (M2)
Wires the isolated runtime into TREK. Admins can now activate a plugin from the
panel and its HTTP routes work end to end, still behind the kill switch.
- PluginRuntimeService owns the supervisor: activate spawns the child with its
granted permissions + decrypted instance config, deactivate kills it, status
and errors are persisted to the plugins / plugin_error_log tables, and active
plugins are booted on startup (OnModuleInit).
- Bidirectional RPC: the child now handles host→child invokes (routes/jobs) and
reports its declared routes on load; the supervisor gained invoke()/routesOf().
- /api/plugins/:id/* proxy controller — a single static route that matches the
plugin's declared routes, enforces per-route auth (auth:false routes are public
for OAuth callbacks/webhooks), forwards only a whitelisted request view (never
the session cookie), and strips unsafe response headers.
- Admin endpoints: POST :id/activate, POST :id/deactivate, GET/PUT :id/config —
instance settings with secret fields encrypted (apiKeyCrypto) and masked.
- Kill switch moved to its own module so it never collides with test config mocks.
Photo/calendar hook consumers are deferred to a later slice.
* feat(plugins): sandboxed page/widget frames + trekBridge (M3)
Plugins can now render UI. Page plugins appear as a nav entry and open a
full-page sandboxed iframe; the frame talks to TREK only over the postMessage
bridge.
- Server serves plugin client assets at /plugin-frame/:id/* with a strict path
guard and a locked-down, per-plugin CSP (default-src none; connect-src limited
to declared outbound hosts; sandbox WITHOUT allow-same-origin -> opaque origin,
so the frame can't read the session cookie or the parent DOM). Global CSP
frameSrc relaxed from 'none' to 'self' for exactly these frames.
- GET /api/plugins feed lists active plugins for the client.
- Client: pluginStore + PluginFrame (the trekBridge host) authenticates every
inbound message by SENDER WINDOW IDENTITY (event.source), not by a claimed id
or origin; pushes context (theme/locale/tripId/userId), validates navigation,
renders notifications as text, resizes widgets, and proxies trek:invoke to the
plugin's own routes host-side (session cookie stays with the host).
- Page route /plugins/:id + Navbar nav injection for page plugins.
Dashboard widget slot and the trek:request core-data bridge are deferred to a
later slice.
* feat(plugins): secure installer — manifest, discovery, safe extract/fetch/scan (M4)
Plugins placed on the /plugins volume are now discovered, validated and registered
as inactive, ready to activate.
- manifest.ts: strict trek-plugin.json validation (id/version/type, known
permissions only, egress required with http:outbound, native modules rejected).
- discovery.ts: scans the volume on startup + on demand (POST /api/admin/plugins/
rescan), upserts rows INACTIVE, refreshes settings-field descriptors, and never
downgrades or wipes an already-installed plugin's status / grants / config.
Invalid or native-carrying plugins are skipped and logged.
- Activation now grants the DECLARED permissions (the consent gate) and persists
them before spawning.
- install/ utilities for the registry installer (M5), each independently tested:
- safe-fetch: host allowlist (GitHub only) + private-IP refusal + manual
redirect following + size cap + sha256 (constant-time compare).
- safe-extract: zip/tar-slip-safe extraction with its own minimal tar.gz + zip
readers; rejects traversal, absolute paths, symlinks, oversized/too-many
entries, and unsupported formats.
- native-scan: refuses .node / binding.gyp / prebuilds, never follows symlinks.
* feat(plugins): TREK-side registry — browse + one-click install (M5)
Connects TREK to the static GitHub registry (mauriceboe/TREK-Plugins). The
registry repo + CI gates were already live; this is the server side.
- registry.service: fetches the single aggregated dist/index.json (never
per-plugin GitHub API calls — the HACS rate-limit lesson), caches it 30 min,
soft-fails to a stale/empty registry, and installs a pinned version through
the M4 pipeline: safe download -> sha256 verify -> slip-safe extract ->
manifest re-validate -> native re-scan -> atomic move -> discover (inactive),
recording repo/commit/sha provenance. Handles the codeload {repo}-{sha}/
wrapper directory.
- Admin endpoints: GET /api/admin/plugins/registry (browse metadata) and
POST /api/admin/plugins/install { id, version }. Install never executes code;
activation stays a separate, deliberate step.
* feat(plugins): trek-plugin-sdk package — types, mock host, scaffolder, validator (M6)
The author-facing SDK, a standalone dependency-free package (not wired into the
app workspaces, so it can't affect the app build).
- definePlugin + the full plugin type surface (PluginContext, PluginRoute,
PluginJob, PhotoProvider, CalendarSource) mirroring what the isolated runtime
injects; PLUGIN_API_VERSION.
- createMockHost (trek-plugin-sdk/testing): a PluginContext that enforces the
SAME permission model + membership checks, so authors can unit-test that their
plugin degrades gracefully — no running TREK needed.
- validateManifest: the exact rules the registry CI runs, so a local pass
predicts a CI pass.
- CLIs: create-trek-plugin (scaffolds a working plugin + README + starter iframe)
and trek-plugin validate (manifest + README sanity).
Consolidating the server loader to import this shared validator is a follow-up.
* docs(plugins): plugin wiki + reference plugin (M7)
- Wiki pages (sync to the GitHub wiki on push to main): Plugins overview + trust
model, Plugin Development (SDK, definePlugin, ctx, routes/jobs, the client
bridge, testing with the mock host), Plugin Permissions reference, and
Publishing (registry PR + CI gates + provenance). Linked from the sidebar.
- Reference plugin plugin-sdk/examples/trip-countdown: a complete, minimal-
permission widget (reads trip data through ctx, renders in the sandboxed
iframe via the bridge, filled-in README). Validated in the SDK test suite so it
passes the exact gate authors face.
* feat(plugins): lifecycle polish — uninstall, error log, egress guard, widget slot (M8)
- Uninstall with data disposition: POST /api/admin/plugins/:id/uninstall kills the
plugin, removes its code + DB metadata, and (deleteData) drops its data dir,
error log and per-user settings.
- Error log: GET/DELETE /api/admin/plugins/:id/errors — the plugin's own crash /
request-failure log, surfaced in the admin panel.
- Egress guard: the isolated child wraps global fetch and refuses any outbound
host not in the plugin's declared egress[]; with none declared, all outbound is
blocked. Process-level defense in depth (the container runtime enforces it at
the network layer in v2).
- Admin → Plugins is now actionable: activate / deactivate / uninstall, a
registry browser (install), and per-plugin error log. i18n across all locales.
- Dashboard widget slot: active widget plugins render as sandboxed cards.
The trek:request core-data bridge + photo/calendar hook consumers remain follow-ups.
* docs(plugins): clarify the fork-and-PR publishing flow
* fix(plugins): allow GitHub's rotating release-asset host in the installer
GitHub 302-redirects release-asset downloads to a rotating *.githubusercontent.com
host (objects / github-releases / release-assets). The SSRF allowlist only had
objects.githubusercontent.com, so installs failed with 'host not allowlisted'.
Allow the whole *.githubusercontent.com suffix (plus github.com/codeload); the
private-IP check remains the SSRF backstop.
* fix(plugins): allow inline scripts in the sandboxed frame + fix server lint error
- Plugin frame CSP: the frame runs at an opaque origin (sandbox without
allow-same-origin), so script-src 'self' matches nothing and the widget's own
script never runs (stuck on 'Loading…'). Allow 'unsafe-inline' — the sandbox,
not this directive, is the isolation boundary, and the plugin author controls
the frame code either way.
- Fix a no-constant-binary-expression eslint error in registry.test.ts that was
failing the server lint:check (eslint .) in CI.
* fix(plugins): exclude /plugin-frame/ from the service-worker navigate fallback
The PWA service worker's navigateFallback served the SPA shell for any
navigation not on its denylist. /plugin-frame/ wasn't listed, so the SW
intercepted the sandboxed opaque-origin plugin iframe navigation, which Chrome
reports as 'Unsafe attempt to load URL … from frame with URL …'. Denylist it so
plugin frames are served straight from the network.
* feat(plugins): pass the dashboard's spotlight trip id to widget plugins
Widget plugins now receive the current (spotlight) trip id in their bridge
context, so a widget like Trip Countdown can show a real countdown instead of
the empty state.
* fix(plugins): trips.getById returns the actual trip row, not the access check
canAccessTrip only returns { id, user_id } (it's a membership check), but the
rpc-host's trips.getById returned it verbatim — so plugins saw a trip with no
title/start_date/etc. Fetch the real row after the access check. Also fix the
reference plugin to read t.title (the trips column is 'title', not 'name').
* feat(plugins): persist enable-intent across restarts + redesign admin page
The deactivation-on-deploy bug: `status` conflated the admin's ON/OFF intent with
runtime health, so a boot crash flipped status to 'error' and the plugin never
rebooted after the next deploy. Migration 156 adds an `enabled` flag (admin
intent) separate from `status` (runtime health); boot now retries every enabled
plugin regardless of last status, and a crash no longer erases the intent.
Admin → Plugins redesign:
- ON/OFF is a ToggleSwitch bound to `enabled`; runtime health shows separately as
a coloured status dot, with the last error inline when it crashed.
- "Update → vX" badge when the registry has a newer version (one click updates
and reactivates).
- Reviewed/unreviewed trust badges, cleaner cards, nicer empty state, registry
browser marks already-installed plugins. i18n across all locales.
* fix(plugins): backfill enabled for any plugin not explicitly deactivated
status at migration time can be error/stopped/starting after a crash or shutdown,
not just 'active' — so backfill enabled=1 for everything except 'inactive' (the
only status deactivate() sets).
* feat(plugins): hero widget slot + Koffi reference plugin
Widget plugins can now declare capabilities.widget.slot 'hero' to render as a
transparent, click-through overlay sitting on the boarding-pass bar's top edge
(migration 157 persists capabilities; manifest validation server+SDK, feed
exposes the slot, dashboard mounts hero frames above the pass). Sidebar stays
the default slot.
Replaces the trip-countdown example with Koffi, the TREK mascot: an animated
suitcase with a 14-state behavior engine driven by real trip data — walking,
waving, napping, trolley rolls, passport-stamp stickers, a split-flap luggage-
tag countdown under 7 days, and sunglasses while the trip runs. Validated by
the SDK suite like any author plugin; published as mauriceboe/trek-plugin-koffi
in the registry.
Migrations 156/157 follow the idempotent ALTER pattern (the reconciliation
test re-runs everything from v135, so duplicate-column must stay non-fatal).
* feat(plugins): richer admin panel + registry detail view
Admin list: flush-left header like Addons, type/reviewed badges, runtime
health as a dot on the icon tile (text badge only for problem states),
description + source-repo link on installed cards, manifest icon.
Browse: cards show the plugin screenshot (docs/screenshot.png at the pinned
commit) and open a detail dialog fed by GET /api/admin/plugins/registry/:id —
live-manifest permissions in plain language, egress hosts, setup preview,
repo/homepage links. Manifest fetched server-side through safeDownload at the
reviewed commit, cached per plugin for 30 min and only when a detail opens.
Also fixes the update flow (restart the running child around the install,
keep the admin's enabled intent instead of force-activating disabled
plugins), guards the icon lookup against Object.prototype names, reserves
ids that would shadow static admin routes, stops negative-caching failed
manifest fetches, and makes the version compare prerelease-safe.
* i18n: localize the plugins admin section across all locales
The admin.plugins block was still English filler in most locales; translate
it everywhere and add the new detail-view keys in all 22 languages.
* feat(plugins): denser browse grid + prominent install-risk disclaimer
Four cards per row on desktop with tighter card padding, and a full-width
warning banner above the browse grid: installs are at the admin's own risk,
a prior quick review does not rule out harmful content, inspect a plugin
yourself when in doubt — TREK accepts no responsibility. All 22 locales.
* feat(plugins): sandbox hardening — OS permission model, egress choke point, bound acting user, author signatures
Closes the four gaps the security review surfaced:
- OS permission model on the prod plugin child (Node --permission with
fs-read scoped to the compiled server dir + the plugin's own code dir, no
fs-write/child_process/worker/native). A plugin can no longer read trek.db
or the .jwt_secret/.encryption_key files, nor shell out — the direct-fs and
RCE escapes that bypassed the RPC layer. Opt-out via TREK_PLUGIN_PERMISSIONS=off.
- Egress guard extended from fetch to the net.Socket connect choke point, so
node:http/https/net/tls obey the declared-egress allowlist too (no declared
egress = no outbound). Under the permission model there is no clean escape to
an unwrapped runtime. Kernel/network-namespace containment remains the
container step.
- Trip reads are membership-checked against the acting user the HOST binds from
the authenticated invocation, not an asUserId the plugin supplies; a job/onLoad
(no user) can't read user-scoped trips.
- Optional minisign (Ed25519) author signatures verified offline, TOFU-pinned
(migration 158). Unsigned plugins install on sha256 alone; a signed plugin
can't silently drop its signature or swap its author key.
Server suite green (permission-model activation verified against the Koffi
reference plugin on dev1).
* fix(plugins): make the permission-model child load from the real plugin path
The prod data dir is a symlink (server/data -> volume), so resolving the plugin
under it tripped the permission model, and Node's module-type lookup walked up
into the (denied) data dir. Fork the child from the plugin's realpath and drop a
{"type":"commonjs"} package.json at its root so resolution stops there — trek.db
and the secret files stay unreadable, verified against Koffi.
* test(plugins): cover pluginRealCodeDir fallback + ensurePluginModuleType
* feat(plugins): zero-config L1 hardening — SSRF egress, RSS reaper, capability audit, no popups
Security that ships from the install itself, no self-hoster setup:
- Egress SSRF/rebinding backstop: the net.Socket connect guard now RESOLVES the
destination and refuses private/loopback/link-local/metadata/CGNAT/ULA
addresses, pinning the resolved IP (a declared host that re-resolves to an
internal address is blocked). Pure policy in egress-policy.ts + tests.
TREK_PLUGIN_ALLOW_PRIVATE_EGRESS=on opts back into internal targets.
- RSS memory reaper: the supervisor now kills a child that blows a real RSS
ceiling (TREK_PLUGIN_MAX_RSS_MB, default 300) — --max-old-space-size only
bounds the V8 heap, so Buffers could OOM the box under it.
- Hash-chained capability audit log (migration 159): every core-data / broadcast
call is recorded at the RPC boundary with the host-bound acting user and a
per-plugin hash chain, so wide grants stay attributable + tamper-evident.
Admin endpoint GET /api/admin/plugins/:id/audit.
- Drop allow-popups from the plugin frame (sandbox + CSP): window.open ignores
connect-src, so it was an egress/phishing bypass.
Server suite 207 green, client + migration reconciliation green.
* fix(plugins): close the UDP + DNS egress hole in the network guard
The egress guard only wrapped fetch and net.Socket.connect, so TCP and
HTTP were contained but two channels stayed wide open: a plugin could
send data out over UDP (node:dgram) or tunnel it inside DNS queries
(dns.resolveTxt & friends) to any host it never declared. Neither goes
through net.Socket.connect, so the allowlist never saw them.
Wrap both now against the same declared-host allowlist:
- dgram send/connect: the explicit destination is allowlisted and
private-IP-checked like a TCP connect (a null address keeps the
connected/localhost default, which the connect wrapper already vetted).
- the dns resolver family (module fns, dns.promises, Resolver.prototype):
a forward lookup for an undeclared name is refused, which kills DNS
tunnelling even when no socket is ever opened.
A plugin with no declared egress now really has no way out.
* feat(plugins): re-consent gate when an update wants new permissions
Updating a plugin used to just reinstall and reactivate, which silently
granted whatever the new version declared — so a plugin could quietly
widen its own rights on the next release.
Route updates through a new server-side update() that diffs the new
version's declared permissions against what the admin already granted:
- nothing new -> the plugin is restarted transparently on the new code.
- new permissions or a new outbound host -> the new code is installed
but the plugin is left OFF, and the delta is handed back so the admin
has to approve it before it turns on.
Install runs first, so a failed download/signature check leaves the
running plugin untouched. The client shows the delta in a consent dialog
and only then activates. An update can never widen a plugin behind your back.
* feat(plugins): honest security info in the admin panel + update consent UI
Reworks how the plugins panel talks about safety, since the old copy
oversold it. Drops the "install at your own risk" banner and the generic
trust note, and replaces them with:
- a collapsible security section that lays out plainly how a plugin is
contained, what the permissions actually mean (a hard limit on what a
plugin CAN do, not a promise of what it does), where the limits are,
and what a hostile plugin could do at worst.
- a short note on what "Reviewed" means: a maintainer scanned it for
malware each version, not for quality — not a guarantee it's harmless.
- the consent dialog for the update flow: when an update asks for rights
you never granted, it lists the new permissions and outbound hosts and
makes you approve before the plugin turns back on.
Full copy in all 22 locales.
* feat(plugins): redesign the admin plugins page — search, filters, cleaner cards
The panel was cramped and hard to scan. Rebuilt it as a proper management
surface:
- A segmented Installed/Discover switch with counts, and a real toolbar:
search, filter by type, filter by status (active/off/update/error), and
sort (name/recent/updates first).
- An "N updates available · Update all" bar.
- Installed rows are tidied up: a single health dot on the icon tile
instead of a wall of badges, and capability chips underneath that show
what each plugin can actually reach at a glance (reads your trips,
dashboard widget, the hosts it talks to) — the reach is now visible
without opening anything. Update, toggle and a ⋯ menu (restart, errors,
source, uninstall) sit on the right.
- The registry browser is now an App-Store-style card grid: screenshot
with the plugin's icon chip, a reviewed badge, consistent heights.
- The detail dialog gained "What it can access", "Connects to" and a
details grid (version, size, requires, reviewed).
To feed the capability chips, the installed list now returns each plugin's
declared permissions and capabilities. New copy is in all 22 locales.
* feat(plugins): make the plugins admin page work on small screens
The redesign was built desktop-first. On a phone the toolbar wrapped into
a mess and the rows were too cramped. Reworked the responsive behaviour:
- The toolbar stacks on mobile — tabs + rescan on top, full-width search,
then a right-aligned filter row — and collapses back into one row on
sm+ (via display:contents), so the desktop layout is unchanged. Filter
buttons drop their label on mobile and lead with an icon; their menus
are capped to the viewport width so they never push the page sideways.
- Installed rows use tighter spacing on mobile and the update button
shrinks to just its icon (full label from sm up).
- Horizontal padding, the discover grid and the detail dialog all get
mobile-friendly spacing.
* fix(plugins): make the detail dialog screenshot fill the full width
aspect-[16/9] together with max-h-64 made the browser shrink the image
width to keep the ratio once the height was capped, leaving a grey strip
on the right. Drop the max-height so the header image spans the dialog.
* feat(plugin-sdk): one-command publishing — pack, entry, release
Publishing a plugin meant hand-building the zip, running shasum + stat,
resolving the tag's commit, and hand-writing the whole registry entry.
The SDK does all of it now:
- `trek-plugin pack` builds plugin.zip in the exact layout the installer
reads (own tiny zip writer, so the SDK stays dependency-free and the
format can't drift from the reader), enforces the same native-binary and
size rules, and prints the sha256 + size. docs/ is left out — the store
fetches the screenshot from the repo, so it doesn't belong in the install
artifact (Koffi's went from 943 KB to 15 KB).
- `trek-plugin entry` emits the ready-to-PR registry entry from the manifest
+ the packed zip + the git tag: commitSha (deref'd), downloadUrl, sha256,
size, and minTrekVersion derived from the manifest's trek range. `--merge`
prepends a new version onto an existing entry for updates.
- `trek-plugin release` chains pack → gh release → entry.
Also: the scaffold now points the README at docs/screenshot.png (the path
the store actually fetches, was screenshot-1.png) with a size hint, and
stops hard-coding an MIT license — a plugin is the author's own code under
their own license. Round-tripped against the real server extractor; 18 tests.
* docs(plugins): rewrite the plugin wiki against the current code + tooling
The plugin wiki had drifted from the app and the SDK. Rewrote all four
pages, verifying every command, permission, field, path and UI behaviour
against source:
- Plugins: activation is a toggle (no separate consent screen); install is
the Discover tab (no "Browse plugins" button); you review permissions in
the detail modal before installing; documents update + re-consent, the ⋯
menu, toolbar filters, capability chips and the health dot.
- Plugin-Development: full manifest reference; ws:broadcast:trip/:user (there
is no ws:broadcast:*); onLoad + onUnload; the trek:error bridge message and
full context payload; trips.* only work in a route handler; asUserId is
accepted-but-ignored; integration hooks are declared but not yet wired.
- Plugin-Permissions: db:own also covers db.migrate; a host must appear as
both an http:outbound:<host> permission and an egress[] entry or it's
silently blocked; bare vs per-host outbound.
- Plugin-Publishing: the new one-command flow (validate → pack → release →
entry), size is a required entry field, signing reconciled with the schema,
no reserved namespaces, and the --merge update path.
* chore(plugin-sdk): make it npm-publishable so `npx` resolves for authors
The docs told authors to run `npx create-trek-plugin` / `npx trek-plugin`,
but nothing published under those names, so npx couldn't resolve them.
- Ship one package, `trek-plugin-sdk`, with a bin that matches the package
name (`trek-plugin-sdk`) so `npx trek-plugin-sdk <command>` resolves with
zero install. The dispatcher gained a `create` subcommand, so every step
(create/validate/pack/entry/release) runs through that one entry point.
The short `trek-plugin` / `create-trek-plugin` bins still work once
installed.
- Package hardening for publish: repository+directory (monorepo subdir),
homepage/bugs/author/engines, publishConfig public, a prepublishOnly that
builds + tests, and a LICENSE file.
- A publish workflow: pushing a `plugin-sdk-v*` tag builds and publishes with
the NPM_TOKEN repo secret.
- Docs (SDK README + the four wiki pages) now use `npx trek-plugin-sdk <cmd>`,
the invocation that actually resolves.
* feat(plugin-sdk): dev server, preflight, auto-PR submit, signing, wizard
Round out the author experience so the loop is create -> dev -> release/submit
without hand-work or a round-trip through registry review.
- `dev`: run a plugin locally with a real request loop and hot reload — no full
TREK. Injects a ctx that enforces the manifest's granted permissions (an
ungranted call throws, so you catch a missing grant), backs db:own with a real
SQLite file (node:sqlite), serves routes under /api and page/widget UI at /ui,
and reloads on save. Dependency-free (node:http + built-ins).
- `preflight`: run the registry CI checks locally over the network (tag->commit,
manifest parity, artifact sha256/size, native scan, README quality gate) so a
green run predicts a green CI.
- `submit`: fork TREK-Plugins, branch off current main, write/merge the entry,
push, and open the PR — the last manual publishing step, automated.
- `keygen`/`sign` + `--sign` on entry/release/submit: dependency-free Ed25519
author signatures over the artifact bytes, verified 1:1 against the server's
TOFU check. Fills authorPublicKey + signature and guards against a key change.
- `create` gains an interactive wizard (id/type/author/permissions) and flags.
- README + Development/Publishing/Permissions wikis document the new flow.
24 tests pass (sign round-trips through a server-shaped verifier; zip reader;
scaffold options; entry signing + key-change guard).
* feat(plugin-sdk): one-command `publish` (pack → release → preflight → PR)
Collapses the release into a single command: pack the artifact, tag + create the
GitHub release, run the registry CI checks locally (preflight), and open the
registry PR — stopping before it submits if preflight would fail, so a broken
entry never becomes a doomed PR. `--sign` signs it; `--no-preflight` skips the
gate. The individual pack/release/preflight/submit commands still exist.
README + the Development/Publishing/Permissions wikis lead with `publish` now.
* fix(plugins): security hardening from the PR #1415 audit
Remediates the findings from the adversarial audit (threat model: malicious
plugin author + malicious artifact). Highlights:
Critical
- proxy: force nosniff + Content-Disposition: attachment on every proxied reply
and drop location/content-disposition + non-2xx from the passthrough, so a
plugin can't serve an HTML document at TREK's origin (sandbox-escape → account
takeover) or an open redirect.
High
- db:own runs synchronously in the host: cap the plugin DB (max_page_count) and
row-cap query() via iterate() so a recursive CTE / huge blob can't stall the
event loop, OOM, or exhaust the shared volume.
- supervisor: measure child RSS host-side (/proc/<pid>/statm) instead of trusting
the spoofable heartbeat; add an activation timeout so a stuck onLoad can't hang
activate() or peg a core unreaped.
- safe-extract: enforce entry-count + cumulative-size limits INSIDE readZip
before inflating (decompression-bomb OOM).
- re-consent: activate() never widens granted permissions without explicit
consent (409 CONSENT_REQUIRED); the row toggle + "Update All" route through the
consent dialog, which now queues instead of overwriting.
Medium/low
- egress: gate dgram hostnames through the IP-vetting resolver; block the
low-level socket escape (process.binding) + lock the wrapped prototypes;
canonicalize IPv6 in isBlockedIp (hex-mapped/compressed metadata); reject
degenerate `*.` / whole-TLD / spaced outbound hosts in the manifest + CSP.
- ws:broadcast is membership-gated to the acting user's trips / own connections;
users.getById is scoped to users the acting user can see (no enumeration).
- safe-fetch streams + aborts at the byte cap (chunked codeload OOM); isPrivateIp
reuses the canonicalizing check.
- native-scan throws instead of silently passing past its entry cap.
- SDK: dev serves binary assets as raw buffers + handles EADDRINUSE; manifest
validator gains the reserved-id + outbound-host checks; wikis corrected.
Tests updated for the new membership-gated behaviour + regression tests added
(IPv6 canonicalization, wildcard hardening, outbound-host validation, ws/user
scoping). 234 plugin tests + 24 SDK tests green.
* fix(plugins): close the 4 PARTIAL findings + regressions from the fix-verify pass
A second adversarial pass over the first remediation found four findings only
partially closed and five issues the fixes themselves introduced. This closes
them:
Partial → closed
- re-consent gate keyed on `granted.length > 0`, so a plugin first activated with
ZERO permissions (granted '[]') was treated as never-consented and a later
widening was granted silently. Now discovery marks a never-consented plugin with
granted_permissions '' and activate() gates on "ever consented" (any non-empty
string, including '[]').
- db:own DoS: block WITH RECURSIVE outright (the one construct that spins the
synchronous host unboundedly regardless of the size/row caps, via query OR exec).
- dgram: also wrap `new dgram.Socket(...)` (bypassed createSocket) to inject the
IP-vetting lookup, and lock createSocket/Socket.
- frame self-navigation: documented as a bounded best-effort mitigation (inherent
to sandboxed iframes; exposure is the plugin's own routes + already-held context,
never the httpOnly cookie).
Regressions introduced by the first pass → fixed
- proxy: only real redirects (301/302/303/307/308) are gated, to a RELATIVE in-app
Location (supports OAuth-callback bounce, blocks open redirect); 300/304 pass
through; attachment only on non-redirects.
- supervisor: measure RSS via /proc/<pid>/status VmRSS (page-size independent);
activation-timeout awaits kill() before disposing the db handle.
- manifest HOST_RE: allow single-label hosts (self-hoster sibling services) while
keeping wildcards multi-label; mirrored in the SDK + frame CSP filter.
Regression tests added (re-consent incl. the '[]' case, WITH RECURSIVE + row cap,
single-label host). 236 plugin tests + 24 SDK tests green.
* docs: refresh README screenshots (8) + swap the second trip shot for Collections
Replaces all eight README gallery screenshots with current-UI captures and swaps
docs/screenshots/trip-iceland.png for collections.png (saved place lists).
* fix(plugin-sdk): make require('trek-plugin-sdk') actually resolve everywhere
A freshly scaffolded plugin could not load anywhere: the npm package is
ESM-only (no require condition in its exports map), so the scaffold's
require('trek-plugin-sdk') threw ERR_PACKAGE_PATH_NOT_EXPORTED under
`trek-plugin dev` - and the runtime injection the wiki promised for the
plugin child never existed, so a packed plugin (node_modules stripped)
crashed with MODULE_NOT_FOUND after a real install.
- plugin child: inject a frozen {definePlugin, PLUGIN_API_VERSION} shim
for require('trek-plugin-sdk'); subpaths fail with a pointed error
- trek-plugin dev: inject the exact same shim, so a fresh scaffold runs
with zero npm install and dev parity with production holds
- npm package: ship a real CommonJS build (dist/cjs + require export
conditions) so the installed package also requires cleanly on Node 18+
- create: scaffold a package.json (type commonjs, SDK as devDependency,
npx scripts); print resolvable `npx trek-plugin-sdk ...` hints
- wiki: package.json in the scaffold tree + publishing checklist, and
document the zero-install dev flow
* fix(plugins): tolerate a UTF-8 BOM in trek-plugin.json
Windows editors love to prepend a BOM, and a bare JSON.parse then dies
with an "Unexpected token" pointing at an invisible character - in the
SDK CLIs (dev/validate/entry/submit) and, worse, server-side: a BOM in
an author repo travels through pack into the artifact and fails
discovery and registry install. Strip it at every manifest/JSON read
(readJsonFile in the SDK, parseJsonText in the installer).
* fix(plugin-sdk): dev db binds an args array like the real host, and a failed onLoad stops the routes
* ci(plugin-sdk): publish on Node 22; skip the dev-db bind test without node:sqlite
* docs(wiki): document AI booking import, guest members and packing sharing
Fill the gaps left after the 3.2.0 feature work:
- add an AI Booking Import page for the AI Parsing addon (providers,
admin/per-user config, model pull, the review-before-save flow) and link
it from Reservations & Bookings and the sidebar
- document guest members on Trip Members and Sharing (owner-only, what they
can be assigned to, and the sign-in/notification/visibility limits)
- document the three packing sharing tiers and co-bringing on Packing Lists
- add TRANSIT_API_URL and the plugin variables to Environment Variables,
and correct the language list to 22 (add Swedish and Vietnamese)
- list the airtrail and llm_parsing addons in the Addons overview
* feat(plugins): enable the plugin system by default
The runtime and the Admin -> Plugins panel are now available out of the box;
TREK_PLUGINS_ENABLED becomes an opt-out (set it to false to switch the whole
system off). Installed plugins are still registered inactive and have to be
activated one by one, so no third-party code runs until an admin turns a
specific plugin on.
Update the kill-switch default test and the plugin/env-var wiki pages to match.
* fix(costs): KGS is selectable as default/expense currency (#1400)
* fix(map): render date-line-crossing routes as one continuous arc (#1411)
The great-circle sampler normalizes longitudes to [-180,180], so a
transpacific leg jumped +-360 between neighbours and got split into two
polylines pinned to opposite map edges. Unwrap the longitudes instead
(shared flightGeodesy module for both renderers): Leaflet additionally
draws a +-360-shifted copy so both halves show in the standard view, GL
maps repeat world copies themselves.
* fix(map): clear the hover card on marker click and camera moves (#1404)
Clicking an off-center place recenters the map under a stationary
cursor, so mouseout/mouseleave never fires and the hover card sticks.
Clear it on marker click and on movestart, and suppress re-shows while
the camera is animating (marker rebuilds re-fire mouseenter mid-pan).
feat(map): long-press + plain right-click add-place on GL maps (#1398)
The GL providers only bound middle-click, so mobile had no way to add a
place at a position (and Macs have no middle button). Add a 600ms touch
long-press with move tolerance and the map contextmenu event - both GL
libs suppress it while the right-button rotate/pitch drag is active, so
the gesture keeps winning.
* fix(mcp): keep SSE streams alive and stop invalidating sessions on unrelated saves (#1414)
Three separate causes for the reconnect-per-tool-call pain:
- no keep-alive on the standalone GET stream, so reverse proxies with
idle timeouts (nginx default 60s) killed it between calls - send an
SSE comment ping every 25s (MCP_SSE_KEEPALIVE, 0 = off) and count an
open stream as session activity
- the session TTL was hard-coded - MCP_SESSION_TTL (seconds, clamped to
24h) now works as the issue expected
- every addon save invalidated ALL sessions: config-only saves, photo
provider toggles and addons with no MCP surface included. Only a real
enabled-flip of an MCP-relevant addon (or an actual collab-feature
change) tears sessions down now.
* feat(api): OpenAPI/Swagger docs at /api/docs behind TREK_API_DOCS_ENABLED (#1412)
Swagger UI + raw spec (/api/docs-json, -yaml) over all controllers, with
a bearer button that works with a plain session JWT. Off by default -
the spec enumerates the whole surface incl. admin routes, so exposing
it is an explicit self-hoster decision (same kill-switch pattern as
TREK_PLUGINS_ENABLED).
Request bodies come from the Zod schemas the routes already validate
with: an enricher walks every controller, finds whole-body
ZodValidationPipe params and lifts their schema into the document via
zod v4's native z.toJSONSchema - nothing is annotated twice, and any
route that gains a Zod pipe is documented automatically.
* fix(map,mcp): review follow-ups for the issue-fix batch
- mapbox-gl (unlike maplibre) still emits the map contextmenu after a
right-button rotate/pitch drag on Windows - guard it with the pressed
position so ending a rotate can't open the Add-Place form (#1398)
- a long-press whose fire was deduped (or that never yields a click) no
longer leaves suppressNextClick armed to swallow a later real tap
- MCP_SSE_KEEPALIVE=0 keeps the open-stream-counts-as-activity
guarantee: the touch interval survives, only the pings stop (#1414)
- swagger-ui-dist ships @scarf/scarf install-time analytics - disabled
via scarfSettings in the root package.json, TREK sends no telemetry
- Budget wiki currency list: 47 incl. KGS (#1400)
* feat(map): real road routes for car/bus/taxi/bicycle bookings instead of straight lines
Road-based transport bookings drew an as-the-crow-flies line; only
transit journeys (Transitous) showed the real path. A shared
useTransportRoutes hook now fetches the OSRM road geometry (driving for
car/bus/taxi, cycling for bicycle) — reusing the day-route router and
its cache — and both renderers draw it in place of the straight arc,
falling back to the straight line until it loads or if routing fails.
Trains/other keep their straight line (not road-routable); a 2000 km
sanity cap avoids hammering the public router on cross-continent quirks.
* feat(transport): multi-leg train bookings (#1150)
Long train trips are usually several trains under one booking. Trains
now get the same multi-leg editor flights have: an ordered chain of
stations (station search instead of the airport picker) with a per-leg
train number + platform, saved as from/stop/to endpoints + metadata.legs
— mirroring the flight leg contract, so the map draws the whole chain
and the day plan splits it into one row per leg (drag/reorder/position
persistence come for free from the shared __leg machinery). A single-leg
train saves exactly as before (flat metadata, no legs), and the flat
train-fields block is gone in favour of the per-leg inputs. Day sidebar,
shared trip view and the PDF render each train leg like a flight leg.
* feat(collections): per-collection custom labels
Each list can now define its own labels (e.g. Berlin, Hamburg, Ostsee in a
"Germany 2026" list) and organise its places by them:
- manage labels (create / rename / recolor / delete) from a label manager
- assign labels to a place from its detail sheet, or to many places at once
from the selection toolbar
- filter the place list AND the map by label (multi-select, any-match)
Labels are scoped to a collection and shared by all its members. Managing and
assigning labels needs edit rights; filtering is available to everyone. Moving
a place to another list drops its labels, since they belong to the source list.
* test(collections): pass the required labels prop in CollectionPlaceDetail test
The per-collection labels feature (a5522e99) made `labels` a required prop
and renders `labels.filter(...)`, but the test's props cast to
Omit<DetailProps,'t'> hid the missing prop, so `labels` was undefined at
runtime and crashed the whole suite (Cannot read properties of undefined
reading 'filter'). Pass labels: [] like categories.
* docs(wiki): document collection labels, multi-leg trains and road-route overlays
- Collections: add a Custom labels section (manage / assign / filter), note the
label filter + bulk assign, and the view-vs-edit permission split
- Transport: rewrite the train fields as the multi-leg route editor, correct the
transport type list (nine types) and the map/day-plan behaviour
- Map Features: car/bus/taxi/bicycle overlays follow real roads; multi-leg trains
draw their full station chain; date-line routes render as one continuous arc
---------
Co-authored-by: jubnl <jgunther021@gmail.com>
Co-authored-by: jufy111 <jeffturner93@gmail.com>
Co-authored-by: Azalea <noreply@aza.moe>
Co-authored-by: Zorth Thorch <jasper_goens@hotmail.com>
Co-authored-by: leeduc <lee.duc55@gmail.com>
Co-authored-by: yael-tramier <tramier.yael@gmail.com>
Co-authored-by: michael-bohr <mjbohr@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Gio Cettuzzi <gio.cettuzzi@gmail.com>
Co-authored-by: mauriceboe <mauriceboe@users.noreply.github.com>
Co-authored-by: jubnl <66769052+jubnl@users.noreply.github.com>
* fix(backups): prevent recursion in path that is backed up
* fix(share): show user currency instead of the default euro in the share page
* fix(files): show descriptive error for unsupported upload type
Unsupported file uploads showed a generic 'Upload failed' toast even
though the server already returns a descriptive 400. The client catch
blocks discarded the error and always showed t('files.uploadError').
The server now emits the i18n key 'files.uploadErrorType' as its error
message; a new translateApiError() helper resolves a server message that
is a known translation key via t() and falls back to the generic key
otherwise. Wired into the three trip-file upload catch sites.
Closes#1363
The 3.1.3 docker build failed on linux/amd64 while bundling the client
(arm64 happened to pass): vite 8.0.16 pins rolldown 1.0.3, but tsdown
pulls rolldown 1.1.2, and on amd64 npm hoists the 1.1.2 native binding so
vite's 1.0.3 rolldown loaded a mismatched one ("builtin:vite-wasm-fallback
does not match any variant of BindingBuiltinPluginName"). Moving the client
to vite 8.1.0, which expects rolldown 1.1.2, lines the bundler up with the
hoisted binding. Verified by building the client in a clean linux/amd64
node:22 container.
A member of one trip could point a file at a reservation, place or
day-assignment belonging to another, private trip — on upload, on a
metadata update, or through the file-link endpoint. The reservation join
in the file list and the links list then returned that trip's reservation
title, disclosing it across the trip boundary and letting an attacker
enumerate foreign reservation titles by their id.
The file already had to belong to the caller's trip; now the linked
reservation/place/assignment must too. findForeignLinkTarget checks each
supplied id against the trip (assignments via day -> trip) and the upload,
update and link handlers reject a cross-trip reference with 400 before it
is stored. Same-trip links and clearing a link are unchanged.
The 3.0 "what's new" notices have served their purpose, so swap them for a single thank-you notice that comes back once on every fresh install and version bump. It carries Buy Me a Coffee and Ko-fi buttons and only shows on desktop. Adds a per-version recurring mode (new dismissed_app_version column) plus external-link CTAs to support it; the 3.0.14 whitespace-collision admin notice stays active.
Settle-up transfers are stored as fixed amounts, but a foreign-currency expense was re-converted with live rates on every settlement calc. When the rate drifted, the fixed transfer no longer cancelled the re-valued expense and a few-cent residual re-opened the settled position. Foreign-currency expenses now freeze the live rate at entry time into the existing budget_items.exchange_rate column, and the settlement converts with that frozen rate when working in the trip currency. Legacy rows (exchange_rate = 1) keep using live rates, so historical data is unchanged until re-edited; rate fetch failures fall back to live rates.
AirTrail returns each airline as {icao, iata, name}, but the import reduced it to the ICAO/IATA code, so an imported flight showed e.g. 'EWG' instead of 'Eurowings'. The picker and the stored reservation now use the airline name (falling back to the code when AirTrail has none). The raw code is kept in metadata.airline_code so the writeback to AirTrail still sends a code, not a name (#1240), and the change-detection snapshot hash stays on the code so existing flights don't spuriously re-sync.
Changing a trip's start date positionally re-dates the day rows (keeping their ids), so a dated booking's day_id stayed glued to a now-re-dated day and the booking visually shifted by the offset — until you re-opened and saved it. After a date-range change, non-hotel bookings are now re-anchored to the day matching their absolute reservation_time (the same derivation create/update already use). Bookings whose date falls outside the new range are left untouched; hotels and the relative positional shift of places/notes are unaffected.
On a GL map (Mapbox Standard) the basemap labels fell back to the browser/OS locale, so place and country names showed stacked in several scripts (e.g. 'India / भारत / India') regardless of the chosen language. Pin Mapbox Standard's basemap label language to the user's UI language via the basemap 'language' config property, mapping the few TREK codes that differ (br->pt, gr->el, zh/zhTw->zh-Hans/zh-Hant). Applies to both the trip map and the journey map; classic and MapLibre styles are left unchanged.
Pasting a single-place Google Maps share link (.../maps/place/...) into the list import failed with a cryptic 'Could not extract list ID from URL'. When the link is a single place it now returns a clear message telling the user to paste it into the place search box instead; other unrecognised URLs keep the existing list-link message.
getCountryFromCoords picked the country with the smallest bounding box containing the point, so a place just across a border (e.g. Strasbourg, which sits inside both the FR and DE boxes) landed in the wrong, smaller-box country. When more than one country box matches, it now disambiguates with the real admin0 polygon via point-in-polygon, smallest-box-first; a micro-territory with no admin0 polygon (HK, MO, SM, VA, ...) keeps the smallest-box win, and an unmatched point falls back to the old behaviour. The common single-candidate case is unchanged.
Asserts the route tools appear for one located place when a bookend accommodation exists, and stay hidden without one, guarding the #1330 visibility change.
Renders the public share page in German with a titleless day and asserts the i18n label 'Tag 1', guarding the t('dayplan.dayN') fix against a regression to a hardcoded English string.
The currency and timezone widgets stored their state only in browser localStorage, so a (docker) upgrade that clears site storage reset them to defaults — unlike every other preference, which is saved server-side. Persist them through the per-user settings store (no schema change; the settings table takes arbitrary keys) and migrate any existing localStorage values on first load so users keep their picks. dashboard_timezones is left unset by default so the widget can tell 'never chosen' from an explicitly emptied list.
The day's route tools were gated on having 2+ stops, so a day with one located place and accommodation optimization on hid them — even though the map already draws the hotel -> place -> hotel route. Treat a lone located place as routable when a bookend hotel with coordinates exists, mirroring what the map renders. Purely additive to the existing 2+ case.
On a day whose only content is checking out of one accommodation and into another, there are no waypoints for the hotel bookends to attach to, so no line was drawn. Add the A->B leg directly when both bookend hotels are real (excluding the day-1 arrival fallback per #1321) and distinct, so an ordinary same-hotel rest day still draws nothing.
icon-dark.svg is a black logo on a transparent background and is invisible on a dark browser tab strip (e.g. Edge dark mode). Point the favicon at icon.svg, which carries its own dark gradient background and reads on both light and dark chrome; icon-dark.svg keeps its in-app light-mode use.
Untitled days on the public share page rendered as a hardcoded English 'Day N' instead of the dayplan.dayN key used everywhere else, so they stayed English regardless of the viewer's language. The key is already translated in every locale.
Builds on @Hardik-369's instance-specific User-Agent idea and reworks the rest
of the #1309 fix:
- keep the unique User-Agent (buildUserAgent) — a shared UA gets the public
Overpass mirrors to rate-limit harder; it appends the configured instance
URL and is applied to every Nominatim/Overpass/Wikimedia call
- add OVERPASS_URL so an operator behind locked-down egress (e.g. a Kubernetes
cluster) can point the explore search at an internal/self-hosted Overpass
instance instead of the public mirrors
- keep the per-endpoint timeout default at 12s but make it tunable via
OVERPASS_TIMEOUT_MS for slow self-hosted instances; non-positive/invalid
values fall back to the default rather than 502-ing every search at a 0ms cap
- log each endpoint's failure reason before the 502 so blocked egress is
diagnosable instead of a bare "Overpass request failed"
Adds unit tests for the User-Agent, endpoint and timeout resolution plus the
all-mirrors-down path, and documents the two new env vars in .env.example, the
wiki and the Helm chart.
- Memoize USER_AGENT via IIFE so it's computed once, not per-request
- Only append instance URL when APP_URL or ALLOWED_ORIGINS is explicitly
configured; skip the getAppUrl() localhost fallback
- Bump OVERPASS_TIMEOUT_MS to 30000 (above the observed 25.7s TTFB)
The POI search endpoint (/api/maps/pois) returned 502 errors because:
1. OVERPASS_TIMEOUT_MS (12s) was shorter than mirror response times
(kumi.systems takes ~25.7s to first byte). Increased to 25s to match
the [timeout:20] query timeout.
2. The static User-Agent string was indistinguishable between instances,
making rate-limiting and throttling more likely. The new userAgent()
function appends the instance's APP_URL so each deployment identifies
itself uniquely, following Overpass API best practices.
The Swedish translation added 'sv' as the 21st language but left the
FE-COMP-I18N-009 length assertion at 20, so the full client suite went
red on this branch. Bump the count to 21 and add an 'sv' sample.
The Places API googleMapsUri is a cid-style URL with no ftid, so the
search/getPlaceDetails fixtures had stored a fabricated ftid. Switch them
to real cid URLs and assert google_ftid is null — the precise
query_place_id link still fixes the wrong-spot bug — and document the
behaviour on googleFtidFromMapsUrl.
- add a direct googleFtidFromMapsUrl test: extracts a real /place ftid,
returns null for a cid URL, rejects malformed/hostile values
- add placeGoogleMaps.test.ts covering the whole fallback chain
(ftid -> place_id -> details URL -> coords) and the hostile-ftid rejection
- PlaceInspector: use a freshly-fetched ftid when the place hasn't stored one
Adds MapLibre GL with OpenFreeMap as a tokenless third map provider
alongside Leaflet and Mapbox: a provider abstraction with style presets,
CSP + service-worker entries for tiles.openfreemap.org, and the
map_provider allow-list entry. Mapbox-only APIs stay gated behind the
mapbox provider, and existing Mapbox/Leaflet users are unaffected.
Maintainer review follow-ups folded in: the new map-settings strings are
translated across all locales; the GL engine is lazy-loaded so
Leaflet-only installs don't download it; MapLibre gets its own
maplibre_style slot so switching providers no longer overwrites a custom
Mapbox style; and the MapLibre render path plus the OpenFreeMap
style-guards are covered by tests.
- server: allow distance_unit as an admin default (+ value validation) so the
Admin "Default User Settings" toggle persists instead of returning 400
- i18n: add settings.distance to all 20 locales and translate the labels
through t() instead of hardcoding "Distance"
- route legs: include the unit in the OSRM cache key and recompute on a unit
switch, so map and sidebar distances refresh and never mix units
- keep wind speed tied to the temperature unit — a distance setting must not
silently flip existing Fahrenheit users from mph to km/h
- restore the sub-1km metres reading for metric, convert GPX elevation to feet
for imperial, and format distances with a '.' decimal in every locale
- add units.test.ts
Mirrors the existing temperature_unit pattern. Adds distance_unit to Settings,
a Display settings control, admin default, and a formatDistance helper applied
at distance render sites. Backward compatible (default metric). Closes#1300.
On the first day of a trip the morning hotel is only a check-in fallback
— you arrive from home, you didn't sleep there — so bookending the route
from that hotel to the flight/train departure point drew a phantom
hotel → departure leg, both on the map and in the day sidebar. The same
backwards leg showed up on a multi-day transport's arrival day, and its
mirror departure → hotel on an evening departure.
getDayBookendHotels now also reports whether the morning hotel is one you
actually slept in and whether you sleep in the evening hotel tonight. The
map and sidebar only draw a hotel↔transport bookend when that holds; a
hotel↔place leg is always kept, so the home-base loop and onward-travel
legs are unaffected. The optimizer keeps using the hotel values as before.
The map route ran first-activity to last-activity only, while the sidebar
already showed the hotel-to-first-stop and last-stop-to-hotel legs with
their drive times. Feed the day's accommodation bookends into the map
route too, reusing the same getDayBookendHotels lookup and the
"optimize from accommodation" gate, so the drawn line starts and ends at
the hotel, including single-activity and transfer days.
Adding an expense required at least one participant, so a cost you only
want to record — e.g. a booking paid on-site later — could not be saved
without splitting it. Drop the participant requirement: with nobody
selected the expense saves as a recorded total, counted in the trip
total and shown as Unfinished, and kept out of settlements until
who-paid is filled in. The shared schema and server already supported
this case.
Removing the only item of a user-created category deleted the whole
category. Turn that row back into the existing ... placeholder in
place instead, so the category keeps its position and colour; adding an
item reuses the placeholder slot. Deleting the placeholder (or the
category menu) still removes an empty category.
When the backend or identity provider was unreachable, a returning user with a
persisted session landed on the dashboard with an empty trip grid and no error.
That looks identical to a logged-in user who simply has no trips, so people
assumed their data had been lost.
Three client-side layers were quietly swallowing the failure: the auth check
only cleared state on a 401, so a 5xx or a network error left the stale session
in place and kept rendering the protected route; the offline-first trip repo
turned a failed fetch into the empty cache without throwing; and the dashboard
had neither an error nor an empty state, so a blank grid meant both "outage" and
"no trips".
The auth check now tells genuine offline (keep serving the cache silently, the
PWA happy path) apart from a server outage while online (keep the session but
flag it). The dashboard shows a reassuring "couldn't reach the server, your
trips are safe" banner with a retry, and a real zero-trip account finally gets a
proper empty state so the two cases never look alike. New strings added across
all locales.
On OIDC-only instances the bootstrap admin (first SSO user) rarely carries the configured admin claim, so a forced re-login — e.g. after a JWT-secret rotation — re-derived its role purely from claims and demoted it to user, locking the instance out with no recovery. The OIDC login role sync now skips a downgrade that would strip the last remaining admin, and the admin user-update endpoint guards the same case.
The day-plan route bar lost its Open in Google Maps action in the 3.1.0 redesign. A small button with the Google logo (monochrome, theme-aware) now sits next to the Route toggle and opens the day stops, in planned order, as a Google Maps directions link in a new tab.
The "How to Update" modal always rendered Docker commands and claimed the instance runs in Docker, even on bare-metal / LXC installs like Proxmox Community Scripts. It now branches on the is_docker flag the backend already returns: non-Docker installs get a generic "re-run your install method" note plus a link to the update guide. Docker stays the default when the flag is absent, so existing installs are unaffected.
The gzipped admin0 GeoJSON is still a few MB, so behind a slow reverse proxy or Cloudflare Tunnel it could exceed the global 8s axios timeout and abort, leaving the map with no countries. It now gets a 30s per-request timeout, matching the existing /maps/pois exception.
When no trip is ongoing the spotlight falls back to a trip gradient, and several of those are light enough that the white title vanished in light mode. A subtle text-shadow on the hero title and trip-card names keeps them readable without affecting dark covers or dark mode.
A long expense title pushed the "Unfinished" pill into the price on narrow screens. On mobile the status now shows as a small marker on the category icon, freeing the title and price row; desktop keeps the labelled pill.
Follow-up to raising the day-note time cap to 250: the unit test still sent 151 chars expecting a 400, which now passes validation and fell through to an unmocked service call.
The PVC templates rendered no storageClassName and values exposed no key, so clusters without a default StorageClass (or needing a specific class) couldn't install. Add persistence.{data,uploads}.storageClassName, omitted when empty so the default class is still used.
The PDF photo pre-fetch only fired for places with a google_place_id, so OSM/Nominatim places (osm_id only) fell back to category icons even though they show photos in-app. Recover osm_id from the full places pool (the assignment projection drops it) and key the photo off google_place_id || osm_id || coords, matching the UI.
The day-note 'time' field capped at 150 server-side while the dialog and shared schema allow 250, so 151-250 char notes 400'd with a confusing 'time must be 150...' message. Raise the controller and MCP limits to 250. Also lift the toast container above modal overlays so the error toast isn't rendered behind the modal's backdrop blur.
The trips/days widgets filtered out archived trips while places, countries and flight distance did not, so archiving a trip zeroed only those two. Drop the is_archived filter so all stats stay consistent.
Firefox/Chrome enforce object-src, so object-src 'none' blocked the inline <object> PDF preview (worked only in Safari). Relax to 'self' for same-origin file previews.
The admin-0 country GeoJSON served at /api/addons/atlas/countries/geo is
~30 MB uncompressed. With no compression in the request pipeline the
transfer aborts (~8s, net::ERR_FAILED despite a 200) behind reverse
proxies / Cloudflare Tunnel, so the Atlas map never colours visited
countries. LAN is unaffected.
Add the `compression` middleware to the shared applyGlobalMiddleware
pipeline (gzip brings ~30 MB down to ~4 MB). text/event-stream is
excluded so the /mcp StreamableHTTP (SSE) transport is not buffered.
Adds BOOT-008 asserting content-encoding: gzip on the geo endpoint.
Fixes#1254
Co-authored-by: pai <pai@stabpablo.eu>
The expense Total Amount, per-person "Who paid", and settlement amount
inputs used type="number" with a bare parseFloat. On desktop the number
input normalized comma→dot for free, but mobile keyboards drop the comma
before onChange fires, so parseFloat("39,99") silently became 39.
Switch the three inputs to type="text" inputMode="decimal" and normalize
comma→dot in their onChange handlers, matching the pattern already used
by the other budget inputs (BudgetPanelInlineEditCell, BudgetPanelAddItemRow,
DashboardPage). Both comma and dot now work on every device.
Closes#1256
* fix(shared-view): render each leg of multi-leg flights correctly
The read-only shared view showed the overall trip start/end airports and
the first leg's flight number on every leg of a multi-leg flight. The Day
Plan already expands legs (each carries __leg), but the renderer ignored it
and read flat top-level metadata; the Bookings tab had the same bug.
- Day Plan: use __leg for per-leg airline/flight number/route, plus dep-arr time
- Bookings tab: list each leg via getFlightLegs()
- unique React keys for multi-leg rows
Closes#1219
* feat(pdf): add legs to pdf export
* fix(demo): skip first-run admin seed in demo mode
When DEMO_MODE is on, the demo seeder creates its own admin (admin@trek.app,
username "admin") right after the generic seeds run. The first-run admin
bootstrap was grabbing username "admin" first, so the demo seeder hit the
UNIQUE(username) constraint and aborted before the demo user was ever created
- which surfaced as a 500 "Demo user not found" on demo-login. Skip the
generic admin bootstrap when demo mode owns the admin account.
* fix(docker): ship the encryption-key migration script in the image
The production image only copied server/dist, so the documented rotation
command `node --import tsx scripts/migrate-encryption.ts` failed inside the
container with a module-not-found error - the raw .ts was never present. The
script runs via tsx straight from source and only pulls node builtins plus
better-sqlite3 (both prod deps), so copying the single file into
/app/server/scripts is enough to make the rotation work again.
* fix(vacay): keep the mode toolbar above the mobile bottom nav
The floating Vacation/Company toolbar was pinned at bottom-3 with z-30, so on
mobile it landed in the same band as the fixed bottom nav (z-60) and got hidden
behind it - and could scroll out of reach entirely. Pin it above the nav with
the shared --bottom-nav-h variable (0px on desktop, so nothing changes there)
and reserve matching space below the calendar grid so it never gets swallowed.
* fix(dashboard): show the correct reservation date regardless of timezone
The upcoming-reservations widget built the date with new Date(reservation_time)
.toISOString(), which reinterprets the stored naive local time as UTC and can
roll the displayed day forward in non-UTC timezones (e.g. a 23:30 reservation
showing the next day). Read the date and time straight from the stored string
parts via splitReservationDateTime, and format the time with the shared
formatTime helper so it also honours the user's 12h/24h preference.
* fix(atlas): cursor-following tooltips and removing countries from search
Two related Atlas fixes:
- Country tooltips were bound with sticky:false, which anchors them at the
feature's bounds centre. For countries with overseas territories (e.g.
France) that centre sits far out in the ocean, so the tooltip popped up
nowhere near the area being hovered. Make them sticky so they track the
cursor.
- Selecting an already-visited country from the search bar always opened the
"Mark / Bucket" dialog, with no way to remove it. Tiny countries like
Vatican City or Singapore are hard to hit on the map, so search was the only
way in. Mirror the map-click behaviour: a manually-marked country opens the
Remove confirmation, a trip/place-backed one opens its detail.
* fix(oidc): keep dots in generated usernames
The OIDC username sanitizer stripped dots because they were missing from the
allowed character class, so a name claim like "first.last" became "firstlast".
Dots are valid usernames (the profile validator already allows
^[a-zA-Z0-9_.-]+$), so add the dot to the sanitizer.
* fix(collab): show poll option labels in the UI
The poll API formatted each option as { label, voters }, but the React poll
component renders opt.text - so every option button came out blank. Emit text
alongside label (kept for any other consumer) so options render again.
* feat(backup): make the upload size limit configurable
The restore upload was capped at a hard-coded 500 MB, so instances whose
backup archive (uploads/ included) grew past that got a 413 "File too large"
with no way to raise it. Add a BACKUP_UPLOAD_LIMIT_MB env var (default 500,
invalid values warn and fall back), documented in .env.example.
* feat(costs): create an expense from a booking, fix editing total-only items
Replace the inline price + budget-category fields in the Transport and
Reservation booking modals with a "Create expense" flow: the modal saves the
booking, then opens the full Costs editor prefilled (name + category mapped from
the booking type) and linked to the reservation. A booking with a linked expense
shows it inline with edit / remove.
Also fix the Costs editor so an expense with a recorded total but no payers
(transport-derived or pre-rework items) opens with its amount, lets you set the
currency, and saves - it previously showed 0 everywhere and could not be saved.
Legacy / localized categories now map to the fixed keys, and changing a booking's
type keeps its linked expense category in sync (unless it was manually set).
- shared: reservation_id on budget create, typeToCostCategory helper, i18n keys
- server: createBudgetItem stores reservation_id; keep total_price for payerless
items; a booking update no longer wipes its linked expense and syncs the
category on type change
- client: shared BookingCostsSection, exported ExpenseModal with prefill and an
editable total, page-level save-then-open wiring
* test(reservations): align syncBudgetOnUpdate unit tests with no-wipe + type-sync
The service now leaves a linked expense alone when no budget entry is on the
payload (only an explicit total_price 0 deletes it) and syncs the category on a
booking type change. Update the unit tests accordingly - the old "price cleared"
case passed entry: undefined, which is now a no-op and left a mocked return
queued that leaked into the next test.
* fix(planner): keep a reservation on its day when edited (#1237)
Editing a booking forced its day_id to the globally selected day, which is null
when editing from the Book tab - so the booking lost its day and vanished from
the Plan. Preserve the reservation own day_id on edit instead.
* fix(planner): derive a booking day from its date when none is set (#1237)
The client always sends day_id on a reservation update, so the server only
derived it from reservation_time when the field was absent. A non-transport
booking saved without a selected day (Book tab) therefore got day_id null and
vanished from the Plan, even though its date matched a day. Derive the day from
reservation_time whenever day_id is null, mirroring create.
* fix(planner): let a booking's day follow its date when edited (#1237)
Preserving the old day_id on edit left a re-dated booking on its previous start
day while end_day_id followed the new date, so it spanned both. Stop sending
day_id from the edit modal entirely - the server derives both ends from the
booking's date (and keeps the current day when there is no date), so a re-dated
booking moves cleanly to the matching day.
* fix(atlas): keep the continent breakdown in sync on mark/unmark (#1225)
The optimistic mark/unmark updates bumped the country total but never the
per-continent counts, so the continent column froze until a full reload. Move
the country to continent map into @trek/shared (single source for server and
client) and adjust the matching continent count at every optimistic site: the
country confirm flow plus the choose / region mark and region unmark handlers.
* feat(admin): let admins set a default currency for new users
Adds a currency picker to Admin > User Defaults. Stored as the default_currency
user-default, so users who have not picked their own currency inherit it in
Costs.
* fix(atlas): give every sub-national region a distinct code (#1217)
geoBoundaries fills shapeISO with the bare country code for some countries (every
Spanish region got "ESP", every Chinese "CHN", also Chile/Oman), so marking one
region lit up the whole country. build-atlas-geo.mjs now keeps shapeISO only when
it is a real "XX-..." subdivision code and otherwise synthesizes a unique
per-country id from the region name. Regenerated admin1.geojson.gz: Spain/China/
Chile/Oman now carry distinct region codes (countries with real codes, e.g.
Germany, are unchanged).
* fix(dashboard): never crash on a malformed reservation date
A reservation with an invalid date blanked the whole My Trips page: the old
Upcoming widget did new Date(value).toISOString(), which throws "Invalid time
value" (fixed in #1222 by reading the string parts). Also guard splitDate so a
bad date renders a dash instead of "Invalid Date" or throwing.
* fix(airtrail): gate airtrail update behind a user setting, on airtrail update: rebuild payload from fresh data to prevent any data loss
* fix(airtrail): add back missing tests
* fix(costs): rework the cost panel UX wise and apply prettier on the shared package
* chore(prettier) prettier this file
* fix(airtrail): don't use cabin class as seat on import
When an AirTrail flight has a cabin class but no seat number, the mapper
fell back to the class for metadata.seat, so reservations showed e.g.
"economy" as the seat. Use only the seat number; leave the seat blank
otherwise. The class is still surfaced separately in the import picker.
Closes#1246
* fix(airtrail): import scheduled flight times instead of actual
AirTrail exposes both scheduled (departureScheduled/arrivalScheduled) and
actual (departure/arrival) times. TREK read the actual times, so a delayed or
early flight imported the wrong time for planning.
Read the scheduled times on import and on poll-sync (both go through
mapFlightToReservation); when a flight has no scheduled time, leave the clock
blank (date preserved) rather than fabricating 00:00 or falling back to actual.
The change-detection hash now tracks the scheduled values, so existing linked
reservations re-sync once on the next poll. The opt-in writeback mirrors the
read, pushing TREK edits to the scheduled fields so they round-trip.
* fix(planner): hydrate per-assignment times when editing a place from the pool
Times live per day-assignment, not on the pool place, so reopening a
place from the Places panel / inspector showed empty Start/End fields
(#1247). The editor now resolves a place's lone assignment when no day
is in context and hydrates the fields from it; ambiguous (0 or 2+ days)
edits hide the fields instead of showing non-persisting inputs.
* fix(mcp): make write tools return client-valid, hydrated entities
Audit of all write tools under server/src/mcp/tools (issue #1244 anchor).
S1 (broken):
- create_budget_item / create_budget_item_with_members now default the
split to all trip members when member_ids omitted, so the entry passes
the client save-gate instead of being member-less (#1244).
- create_transport / update_transport backfill lat/lng/timezone for
code-only flight endpoints (NOT NULL columns) and return a clean error
for unresolvable endpoints instead of crashing.
S2 (under-hydration): set_budget_item_members, create_journey,
create_journey_entry, create_packing_bag, bulk_import_packing and
update_vacay_plan now return the hydrated shape the matching read/REST
route returns; bulk_import widened to accept bag/weight_grams/checked.
S3 (parity): check_in_end added to accommodation tools; atlas
mark_region_visited echoes the client shape; update_journey_entry/
update_journey_preferences, set_bag_members, set_packing_category_assignees,
apply_packing_template return hydrated payloads; set_vacay_color echoes
the color.
Auth: save_packing_template now requires admin, matching the REST gate.
Also refactors server/src/config.ts (JWT-secret handling).
Adds getBudgetItem hydrated getter, exports EndpointInput, and MCP
regression tests (incl. new tools-transports and tools-journey suites).
* fix(mcp): fix ICS/maps/accommodation bugs, add settlement & template tools
Bugs:
- export_trip_ics: include flights that store times per-endpoint
(local_date/local_time) instead of a top-level reservation_time
- resolve_maps_url: follow redirects for cid=/share links and fall back
to parsing the page body, all SSRF-guarded
- link_hotel_accommodation: normalize accommodation_id (TEXT column) to an
integer in the reservation read paths so it no longer returns "14.0"
Gaps:
- packing: save_packing_template returns the new template id; add
list_packing_templates (read) and delete_packing_template (admin)
- budget: update_budget_item accepts payers/member_ids; clarify create/
update/members descriptions to ask which members share the expense and
who paid
- budget: add settlement tools — get_settlement_summary, list_settlements,
create/update/delete_settlement (budget_edit, mirrors REST + WS events)
* chore: bump nodemailer
* chore: bump multer
---------
Co-authored-by: Maurice <mauriceboe@icloud.com>
* Phase 0 — NestJS + Zod foundation harness (F1–F8) (#1050)
Co-hosted NestJS app behind the existing Express server via a strangler-fig dispatcher, sharing the same better-sqlite3 connection and JWT httpOnly cookie. Additive and dormant: default routing stays on Express, Nest only serves its own /api/_nest diagnostics until a module opts in.
F1 @trek/shared Zod contract package; F2 Nest bootstrap co-hosted (fall-through, single Dockerfile/port); F3 shared better-sqlite3 provider; F4 JWT cookie auth guard (+ @CurrentUser, admin guard); F5 Zod validation pipe + error-envelope parity; F6 Nest test + coverage gates; F7 per-prefix strangler toggle (env, default Express); F8 CI build/typecheck/test/coverage.
Remaining F4/F6/F8 checklist items (trip-access + permission levels + MFA policy, e2e harness/seed + 80% gate, Nest↔Express parity test, Playwright PR-comment workflow) are tracked on the first consuming module cards (L1/A1/C1).
* feat(weather): migrate /api/weather to the NestJS pilot module (L1) (#1053)
First strangler migration (L1): /api/weather is served by a NestJS module.
- @trek/shared/weather Zod contract; Nest controller byte-identical to the legacy Express route (paths, query params, status codes, { error } bodies, lang default, ApiError/500 passthrough). Service reuses getWeather/getDetailedWeather (+ shared cache; MCP tools unchanged).
- Strangler routes /api/weather to Nest by default; the legacy Express route + its migration-time parity test were decommissioned in this PR.
- Frontend (FE2): weatherApi typed against the @trek/shared WeatherResult contract.
- Harness: reusable Nest-vs-Express parity harness, e2e harness (temp SQLite + seed/cookie helpers, real JwtAuthGuard), src/nest coverage gate raised to >=80%, src/nest test guide.
- Verified end-to-end on a prod mirror (dev1): 401/400/200 via Nest with real Open-Meteo data, Express route gone.
* fix(packing): multiply item weight by quantity in bag/total weight calcs (#898)
Quantity now counts toward bag and total weights. Generalised to an itemWeight() helper used by every weight sum (bag totals + max, unassigned, grand total; sidebar + bag modal) with unit tests.
* feat(i18n): add Korean (ko) translation (#977)
Korean translation by @ppuassi, topped up to full en.ts key parity. Language registration follows separately.
* feat(i18n): add Japanese (ja) translation (#829)
Japanese translation by @soma3978, at full en.ts key parity, registered in supportedLanguages + TranslationContext.
* Add Turkish (tr) translation + language registry (#1029)
Turkish translation by @SkyLostTR, at full en.ts key parity, registered in supportedLanguages + TranslationContext.
* i18n: register Korean + add Ukrainian translation (#1055)
Korean translation by @ppuassi (#977) — now registered. Ukrainian by @JeffyOLOLO (#902) — lifted onto a clean branch. Both at full en.ts key parity (2258 keys).
* chore: fix monorepo build pipeline and migrate shared to built package (#1056)
* chore: fix monorepo build pipeline and migrate shared to built package
- Root package.json: add workspace scripts (dev, build, test, test:cov, test:e2e)
that delegate to actual scripts in shared/server/client workspaces
- shared: add tsup build step (CJS + ESM dual output, .d.ts); consumers now import
from the built dist instead of raw TS source via path aliases
- server: replace tsc-alias with tsconfig-paths (tsc-alias mangled node_modules
paths); fix MCP SDK path aliases to point to root node_modules (../node_modules)
- server/scripts/dev.mjs: delay node --watch until tsc -w signals first-pass done,
eliminating the spurious restart on every dev startup
- client/vite.config.js + vitest.config.ts: remove @trek/shared path alias (no longer
needed now that shared is a proper package)
- Consolidate package-lock.json at the workspace root; drop per-workspace lock files
* chore: fix test script to reflect root package.json
* chore: add missing lint and prettier script in root package.json
* fix(ci): build shared before tests; fix vitest MCP SDK alias paths
vitest.config.ts aliases pointed at ./node_modules/ (server-local) but
packages are hoisted to the root node_modules/ in the npm workspace —
changed to ../node_modules/.
CI jobs now install and build shared before running server/client tests
so that @trek/shared's dist/ exists when vitest resolves the package.
* fix(docker): update Dockerfile and CI for monorepo workspace structure
Dockerfile:
- Add shared-builder stage that produces @trek/shared dist before
client and server stages need it
- Each build stage carries root package.json + package-lock.json so npm
can resolve @trek/shared as a workspace dependency
- Production stage installs via workspace context (npm ci --workspace=server
--omit=dev) so node_modules/@trek/shared symlinks to shared/dist correctly
- Copy server/tsconfig.json into the image so tsconfig-paths/register can
find the MCP SDK path aliases at runtime
- CMD cds into /app/server before starting node so tsconfig-paths baseUrl
resolves and ../node_modules points to /app/node_modules
- Remove mkdir for /app/server (now a real dir); keep symlinks for uploads/data
docker.yml version-bump:
- Replace manual per-workspace cd+npm-version calls with single:
npm version --workspaces --include-workspace-root --no-git-tag-version
(mirrors the version:* scripts in root package.json)
- git add now references root package-lock.json; adds shared/package.json
.dockerignore: add shared/dist
package.json: fix version:prerelease preid (alpha → pre)
* fix(tests): use in-memory SQLite per worker in test mode
vitest pool:forks spawns parallel worker processes that all called
initDb() on the same data/travel.db, causing SQLite "database is locked"
and "duplicate column name" races.
When NODE_ENV=test each fork now gets an isolated :memory: DB so migrations
run independently with no file contention.
* chore(ci): add ACT guards to skip DockerHub steps in local act runs
act sets ACT=true automatically. Guards added:
- docker login: if: ${{ !env.ACT }}
- build outputs: type=docker (local load) when ACT, push-by-digest when CI
- digest export/upload: if: ${{ !env.ACT }}
- merge job: if: ${{ !env.ACT }}
- release-helm job (docker.yml): if: ${{ !env.ACT }}
- version-bump git push (docker.yml): wrapped in [ -z "$ACT" ] shell guard
Run locally with:
./bin/act -j build -W .github/workflows/docker.yml \
-P ubuntu-latest=catthehacker/ubuntu:act-latest
* fix(ci): move ACT guards to step level; add guards to security.yml
env context is invalid in job-level if conditions — moved all ACT
guards down to individual steps. Also guards docker login + scout
in security.yml so act can run the build-only part of that workflow.
* fix(ci): skip git fetch and tag logic in act (no remote access in local containers)
* Revert "fix(ci): skip git fetch and tag logic in act (no remote access in local containers)"
This reverts commit 67cf290cda.
* Revert "fix(ci): move ACT guards to step level; add guards to security.yml"
This reverts commit f92b95e054.
* Revert "chore(ci): add ACT guards to skip DockerHub steps in local act runs"
This reverts commit 797183de08.
* fix(docker): add musl optional deps so alpine builds find native rollup/sharp binaries
npm prunes libc-constrained optional deps to the host libc (glibc) when
generating the lockfile, leaving no musl entry for Alpine containers.
Declaring the x64/arm64 musl variants as explicit root optionalDependencies
forces them into the lockfile so npm ci on Alpine can install them.
Covers shared-builder (tsup/rollup) and client-builder (vite/rollup + sharp
icon generation) for both linux/amd64 and linux/arm64 CI targets.
* fix(docker): copy client dist into server/public so the server resolves static files correctly
The server runs from /app/server and serves static files relative to that
directory, so the client build output must land at /app/server/public, not /app/public.
* feat(planner): real road routes (OSRM) with travel-time connectors (#1060)
* feat(planner): real road routes (OSRM) with travel-time connectors
Replace the straight-line "as the crow flies" route with real OSRM road
geometry (FOSSGIS routed-car/-foot) and an Apple-Maps style render
(blue casing under a lighter core) on both the Leaflet and Mapbox GL
maps. Routes are off by default and toggled per session, with a
driving/walking mode switch in the day footer.
Each day shows per-segment travel time/distance connectors between
places, computed from the OSRM legs and split at transport bookings.
Also redesigns the day header for visual consistency: vertical
number+weather capsule, name with a divider before the date, subtle
hotel/rental pills that stay on one line, and a hover-revealed 2x2
action square (edit / add transport / add note / collapse). Drops the
Google Maps button.
* test(planner): update route hook tests for calculateRouteWithLegs
* remove route_calculation setting, always use OSRM routing (#1064)
The per-user route_calculation toggle was a second, hidden on/off layer
on top of the day footer's show-route button, and made it easy to end up
with straight-line routes for no obvious reason. Drop the setting
entirely: routing is always on, the footer toggle stays the single
switch. Old stored values are simply ignored (settings are key-value, no
migration needed).
* chore: move i18n to shared package (#1066)
* chore: move i18n to shared package
* chore: move server translations to shared package and apply linter and prettier on entire shared package
* feat(dashboard): upcoming reservations endpoint + travel-stats country/distance
Adds GET /api/reservations/upcoming for the dashboard widget, switches travel-stats to the same country source as Atlas (manual + place-derived, ISO codes), and a distance service for flown km.
* i18n(dashboard): dashboard keys across locales
* feat(dashboard): boarding-pass hero, atlas row, live widgets + modal portal fix
Reworked dashboard layout: boarding-pass hero with hover + days-left countdown, atlas stats row with real flags, searchable currency widget, editable timezone widget, new-trip FAB. Modals now portal to document.body to avoid inheriting dashboard-scoped button/font styles.
* i18n(dashboard): sync all locales to one key set + German copy-dialog strings
Brings every locale's dashboard namespace to the same 149-key set (missing keys backfilled from English) and translates the previously English-only copy-trip dialog into German.
* refactor(dashboard): replace hardcoded strings with i18n keys
Hero, atlas row, trip cards, filters, currency and timezone widgets now resolve all visible copy through t() instead of hardcoded English/German.
* feat(i18n): add Greek translation (#1061)
* i18n: complete Turkish (tr) translation (#1075)
Fill in the remaining ~2100 UI strings in shared/src/i18n/tr so Turkish
matches the English catalog. Brand names, URLs, and technical placeholders
are left untranslated by design.
* chore: prettier + lint
* chore: enforce prettier & lint on shared package
* feat: Updated border of map markers to reflect category color. (#1062)
* feat(dashboard): mobile layout, glass UI, context bottom nav + OIDC PKCE (#1079)
* feat(dashboard): mobile layout, glass tiles, plain-text countdown, place photos
- Rework the mobile dashboard: cover hero, separate boarding-pass card,
trimmed atlas (trips + days only), stacked widgets
- New floating bottom tab bar with a centred context-aware + button
(new trip / place / journey / entry depending on the page)
- Move profile + notifications into a small top strip on the dashboard
- Desktop: glassmorphic tiles (light + dark), neutral dark palette,
plain-text countdown module, real place photos in the boarding pass
* i18n(dashboard): translate new dashboard keys across all locales
Fill the dashboard-rework keys (hero, atlas, fx, tz, upcoming, copy
dialog, aria labels, countdown) that were left as English placeholders,
plus the new startsIn/aria keys, for all 19 languages.
* feat(oidc): send PKCE (S256) in the OIDC login flow
The OIDC client now generates a code_verifier per login, sends the
S256 code_challenge on the authorize request and the code_verifier on
the token exchange. Works whether the provider has PKCE optional or
required (fixes login against providers that require PKCE, e.g. Pocket ID).
* Migrate TREK 3 to NestJS + React 19 (shared Zod contracts) (#1087)
* Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer
Brownfield strangler migration of the backend onto NestJS modules
(auth, trips, days, places, assignments, packing, todo, budget,
reservations, collab, files, photos, journey, share, settings, backup,
oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories,
tags, notifications, system-notices) served through a per-prefix
dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT
httpOnly cookie auth, with behavioural parity for every route.
Client: React 19 upgrade, "page = wiring container + data hook"
pattern across all pages, per-domain Zustand stores bound to
@trek/shared contracts, and decomposition of the large components
(DayPlanSidebar, PackingListPanel, CollabNotes, FileManager,
MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal,
BudgetPanel, PlaceFormModal, ...) into focused render units backed by
in-file hooks.
Apply the shared global request pipeline (helmet/CSP, CORS, HSTS,
forced HTTPS, the global MFA policy and request logging) to the NestJS
instance as well, so a migrated route is protected identically to the
legacy fallback rather than bypassing it.
* Finish the NestJS migration — drop the legacy Express app
NestJS now serves the whole surface: every /api domain plus the platform
routes (uploads, /mcp, the OAuth/MCP SDK + /.well-known metadata and the
production SPA fallback). Removed server/src/app.ts, all of
server/src/routes/* and the strangler dispatcher; index.ts and the
integration suite share a single buildApp() bootstrap so prod and tests
can't drift.
- Platform/transport routes extracted to nest/platform/platform.routes.ts
and mounted before app.init() — Nest's router answers an unmatched
request with a 404, so a route registered after init is never reached.
The SPA fallback is a NotFoundException filter and the catch-all uses a
RegExp (Express 5's path-to-regexp rejects a bare '*').
- New modules: memories (/api/integrations/memories — the Journey
gallery's Immich/Synology proxy), addons (GET /api/addons) and the
cross-trip GET /api/reservations/upcoming.
- TrekExceptionFilter reproduces the old multer / err.statusCode handling
so upload rejections keep their 400/413 { error } body and non-ASCII
filenames survive (defParamCharset).
- addTripToJourney and the MCP get_journey_share_link tool gained the
trip-access check they were missing.
- Re-pointed the 34 integration tests + the websocket test onto the Nest
app; removed the now-meaningless Express-vs-Nest parity tests and a few
orphaned client components.
* Restore the reset-password rate limit and fix copyTrip reservation links
Two correctness/security gaps the NestJS migration introduced:
- POST /api/auth/reset-password lost its per-IP rate limiter. Restore it
(5 attempts / 15 min on a dedicated bucket, same as the old resetLimiter)
so reset tokens can't be brute-forced unthrottled. Covered by AUTH-019.
- copyTripById did not copy reservations.end_day_id (a day reference — now
remapped through dayMap like day_id) or needs_review, so a duplicated trip
lost multi-day transport end-day links and reset the review flag.
* Clean up dead code, dedupe helpers, fix the reset-password contract
- Remove server exports orphaned by the Express removal: the immich
album-link helpers, seven route-only service exports, getFileByIdFull;
de-export internal-only helpers (utcSuffix).
- De-duplicate verifyTripAccess (9 identical copies -> services/tripAccess.ts)
and avatarUrl (3 -> services/avatarUrl.ts); name the bcrypt cost
(BCRYPT_COST) and the email regex (EMAIL_REGEX). Public API unchanged.
- resetPasswordRequestSchema declared `password`, but the client sends and
the service reads `new_password` — rename it so the contract matches and
the client types resolve.
- Make ATLAS-013 deterministic: stub the admin-1 GeoJSON download instead of
fetching ~4600 features from GitHub during the test (it hung the suite).
* Make the client typecheck runnable (vitest/vite ambient types)
The client had no `typecheck` script and tsc couldn't even start (the
baseUrl deprecation errored out, same as server/shared already silence).
Add `ignoreDeprecations: "6.0"` to match the other workspaces, a `typecheck`
npm script, and a src/vite-env.d.ts referencing vite/client + vitest/globals
so tsc knows the test globals (describe/it/expect/vi). This turns ~3600
phantom "Cannot find name" errors into a real, measurable count (~590 actual
type errors remain, to be worked down). Type-only; no runtime change.
* Derive client domain types from the shared schema contracts
Add entity/response Zod schemas to @trek/shared (place, trip, assignment, day, budget, packing, reservation), each matched against the producing server service, and re-export them from client types.ts instead of the hand-written duplicates that had drifted (name/title, amount/total_price, owner_id/user_id, cover_url/cover_image, ...). Updates the call sites and test fixtures the corrected types surfaced; type-only, no runtime behaviour change.
* chore(db): log swallowed errors in addon-disable migration + guard against destructive migrations
The migration that disables the legacy "memories" addon swallowed any
error in an empty catch, as did ~30 other catch blocks in the migration
runner (column adds, the journey rebuild, index probes). Replace each
silent catch with the existing console.warn('[migrations] ...') log so
failures are visible. Control flow is unchanged: every step stays
non-fatal, nothing new is thrown.
Add a static guardrail test that scans the migration source and fails
when a new destructive statement (DROP TABLE / DROP COLUMN / TRUNCATE /
DELETE FROM / ALTER ... DROP) appears outside a reviewed allowlist, and
when an empty/silent catch block is reintroduced. The existing
destructive statements are all legitimate table rebuilds or
bounded cleanups and are recorded in the allowlist with a reason.
* Re-check SSRF on every redirect hop when resolving short links
Replace the one-shot checkSsrf + fetch(redirect:'follow') in the maps and place short-link resolvers with safeFetchFollow, which follows redirects manually and re-runs checkSsrf against the DNS-pinned IP of each hop (max 5). A redirect to an internal/loopback address is now blocked even when the initial URL is public, while legitimate cross-host redirects (goo.gl -> maps.google.com) still resolve.
* Reject WebSocket tokens minted before a password change
Stamp the user's password_version onto the ephemeral ws token and verify it on connect, closing the socket (4001) when it no longer matches, so a token issued before a password reset can't be replayed. Tokens minted without a version are treated as version 0, matching the JWT pv-claim semantics.
* fix(i18n): guard locale key parity and finish the OAuth consent page strings
Every non-en locale now exposes the exact same flat key set as en. Keys that
had drifted out of sync are backfilled with the English source value (tagged
en-fallback) so t() resolves a real string instead of relying on the silent
runtime fallback; no existing translation was touched and no key was removed.
Add a parity test that imports each aggregated locale bundle and asserts its
key set matches en, with a diagnostic listing of any missing/extra keys. This
complements the file-level check in shared/scripts by guarding the merged
export the app actually serves.
Finish internationalising OAuthAuthorizePage: the ~15 remaining hardcoded
English chrome strings now go through oauth.authorize.* keys (English source
in en, en-fallback placeholders elsewhere). Markup and behaviour are unchanged.
* Add semantic theme color tokens to Tailwind
Map the CSS theme variables from src/index.css (:root light / .dark dark) to named Tailwind utilities — bg-surface, text-content, border-edge, bg-accent and their variants. This gives components a Tailwind-native target for the theme colors so we can replace inline `style={{ ... 'var(--...)' }}` with utility classes without changing the rendered values.
* Surface silent store failures to the user and validate API responses in dev
Reservation toggle, todo/packing toggle and budget reorder were swallowing API errors after rolling back, so the user saw the change silently snap back with no explanation. Route those failures through the existing toast channel (new store/notify.ts bridges to window.__addToast, the same channel SystemNoticeBanner uses); the reservation toggle re-throws so ReservationsPanel's own translated toast finally fires. Also wire the existing parseInDev/checkInDev response validation into the maps and notification-test endpoints to catch contract drift in dev.
* Migrate static theme inline styles to Tailwind utilities and extract page sub-components
Replace the static, color-only inline `style={{ ... 'var(--bg-primary)' ... }}` props with the new semantic Tailwind utilities (bg-surface, text-content, border-edge, ...) wherever the result is byte-identical; dynamic/conditional theme styles and hardcoded status colors are left inline. Extract the Atlas country-search autocomplete, the Admin update banner, and two Journey dialogs into their own presentational components to shrink the oversized page files, keeping behaviour and markup identical.
* Remove the unrouted photos page and its dead photo components
PhotosPage was never wired into the router and its usePhotos hook read a tripStore photos slice that was never implemented; the Photos gallery, lightbox and upload components were only reachable through it. Per-trip photos now live in the Journey gallery (Immich/Synology). Removed the dead page, hook and components — the live Journey PhotoLightbox is a separate component and stays.
* Resolve the remaining client type errors and the trip.title navbar bug
Drive the client typecheck to zero without any/ts-ignore: convert the tripId route param to a number once at the page boundary so it matches the numeric props and store actions it feeds, fix trip.name -> trip.title (the wire field is title, so the old read rendered blank in the files/offline views), and tighten the scattered handler-arity, DOM-cast and untyped-payload sites. No runtime behaviour change.
* Convert the remaining dynamic and hardcoded inline styles to Tailwind utilities
Second styling pass over the components and pages: move conditional theme colors into className ternaries (bg-accent / bg-surface-hover etc.), turn reused CSSProperties constants into className constants, and express static hardcoded hex/rgba colors as Tailwind arbitrary values so the exact rendered colour is preserved. Truly dynamic styling (computed geometry, gradients, multi-part shadows, data-driven colours, the undefined --sidebar/--nav layout vars) stays inline as it cannot be expressed as a static class. Updated three component tests that asserted the old inline active-state styles to assert the equivalent utility class instead.
Verified: client typecheck 0, full client suite green, and a live light/dark render check in the dev server confirms the semantic theme tokens resolve correctly (the earlier 'transparent popups' were a stale dev server that pre-dated the tailwind.config token addition, not a code issue).
* Add eslint flat-config for client and server and gate typecheck, lint and pages in CI
client and server had lint scripts but no eslint config (only shared was linted in CI). Add flat configs mirroring shared's stack (js + typescript-eslint recommended + eslint-config-prettier) plus the client's react-hooks/react-refresh plugins. Pre-existing patterns in this never-linted code (explicit any, require() in the CommonJS server, empty catches, exhaustive-deps) are set to 'warn' rather than 'error' so the gate passes at 0 errors without a repo-wide reformat — these can be ratcheted to errors over time. Wire blocking typecheck + lint + lint:pages steps into the client and server CI jobs (now that both typechecks are clean) and promote the server typecheck from informational to blocking.
* Decompose the remaining God Components into hooks, helpers and sub-components
FE6: split the oversized page and panel components into thin layout shells plus colocated use<Component> hooks, .constants.ts, .helpers.ts (with tests) and presentational sub-components, following the established 'logic in a hook, render in slices' pattern. Behaviour, markup, classes and effect order are unchanged. Largest reductions: PackingListPanel 1598->42, FileManager 1055->36, AdminPage 1525->167, BudgetPanel 1266->146, JourneyDetailPage 2822->547, PlacesSidebar 945->66, CollabChat 861->106, CollabNotes 1417->532. DayPlanSidebar's drag-and-drop render body was left intact (ref-identity sensitive) and only its toolbar/modals/constants were extracted.
* Fix duplicate React keys in the file-assign place list
When a place is assigned to the same day more than once it appeared twice in a day's list, so the place-button key={p.id} collided and React warned about duplicate keys. Key by place id + render index so siblings stay unique. Pre-existing in the old FileManager; behaviour unchanged.
* Format the shared package and drop an unused import to satisfy the lint gate
The i18n and schema changes added code that wasn't prettier-formatted, and place.schema.ts imported categorySchema without using it. Run prettier over shared and remove the import so 'npm run lint' + 'format:check' pass.
* Install all workspaces in the server CI job so SWC's native binary is present
The server vitest config transforms via unplugin-swc, which needs @swc/core's platform-specific native binary. A workspace-scoped 'npm ci --workspace server' skips that optional dependency, so vitest failed to load the config on the Linux runner. Use a full 'npm ci'.
* Re-resolve dependencies with npm install in the server CI job for SWC
Full 'npm ci' still skipped @swc/core's Linux native binary because the committed lockfile was generated on Windows and lacks the Linux optional-dep install metadata. 'npm install' re-resolves and fetches the platform-matching binary, which the server's unplugin-swc transform needs to load vitest.config.ts.
* Install @swc/core's Linux binary explicitly in the server CI job
Neither npm ci nor npm install fetched @swc/core-linux-x64-gnu on the Linux runner because the lockfile was generated on Windows and lacks the Linux optional-dep metadata. Add a step that installs the matching @swc/core-linux-x64-gnu version (no-save, no-lockfile) so unplugin-swc can load the server's vitest config.
* Use legacy-peer-deps when installing the SWC Linux binary in CI
The explicit @swc/core-linux-x64-gnu install re-resolved the tree and hit the pre-existing lucide-react/react-19 peer conflict that the lockfile was generated around. Add --legacy-peer-deps so the step matches the project's resolution and installs the binary.
* Keep the lockfile when installing the SWC binary so other deps stay pinned
Dropping --no-package-lock made npm re-resolve the whole tree and upgrade eslint, whose newer recommended config flagged no-useless-assignment as an error in the server lint step. Keep the lockfile so only @swc/core-linux-x64-gnu is added and every other dependency (incl. eslint) stays at its locked version.
* Fix a batch of reported bugs: Atlas regions, planner overlays, imports, Safari modals (#1094)
* Start the Journey date picker week on Monday (#1078)
The Journey entry date picker started the week on Sunday (firstDow = getDay(), headers Su-first) while every other picker (CustomDateTimePicker, VacayCalendar) starts on Monday. Align it: Monday-first leading offset ((getDay()+6)%7) and Mo-first weekday headers.
* Fix Taiwan resolving to CN-TW in the Atlas country search (#1049)
natural-earth gives Taiwan ISO_A2='CN-TW' (a subdivision-style value) with ADM0_A3='TWN'. The dynamic A2_TO_A3 augmentation added 'CN-TW'->'TWN', which then overwrote the legitimate TWN->TW entry in the reverse map, so Taiwan's country option resolved to 'CN-TW' — unresolvable by Intl.DisplayNames (no name, broken flag, not searchable). Only augment A2_TO_A3 with real 2-letter codes.
* Drop empty leftover dateless days when a trip gets a shorter dated range (#1083)
generateDays kept all unused dateless placeholder days after switching to an explicit (shorter) date range, so day_count (COUNT(*) FROM days) stayed inflated. Delete the empty leftovers (no assignments/notes/accommodations) like the dateless path already does, while preserving any that still hold content. Adds TRIP-SVC-017.
* Render GPX and route overlays once the Mapbox style has loaded (#1036)
The GPX and route geojson effects ran before the map 'load' event had
attached their sources, so on the first paint they hit the early return
and never re-ran. Add mapReady to their dependencies so they fire again
the moment the sources exist.
* Convert HEIC trip and journey covers to JPEG before upload (#1085)
HEIC/HEIF covers coming straight off an iPhone could not be rendered in
the preview or stored as a usable image. Route both cover pickers through
normalizeImageFile, the same conversion the journal entry editor already
uses, so the file becomes a JPEG before it leaves the browser.
* Name GPX routes and tracks after their source file so multiple imports stick (#1054)
Unnamed routes and tracks all fell back to the same generic 'GPX Route' /
'GPX Track' label, so the name-based import dedup dropped every one after
the first - importing several files (or one file with several tracks) only
kept a single place. Derive the default name from the source filename with
an index suffix when a file holds more than one geometry, thread the
filename down through the controller, and let the import modal take more
than one file at a time. Adds PLACE-SVC-037/038.
* Namespace the modal backdrop class so content blockers stop hiding it (#1027)
Generic class names like .modal-backdrop sit on the cosmetic filter lists
that content blockers (1Blocker, EasyList Annoyances) ship, and get hidden
with display:none. The shared Modal - used by New Trip and Add Place -
carried that class, so Safari users running such a blocker saw the modal
silently fail to open with no error and no network request. Rename it to
.trek-modal-backdrop.
* Highlight GB regions by resolving England/Scotland/Wales/NI to finer admin-1 codes (#1067)
A zoom-8 reverse geocode of a UK place only resolves to the constituent
country (GB-ENG/SCT/WLS/NIR), but Natural Earth's admin-1 polygons for GB
are counties and boroughs (GB-LND, GB-MAN, GB-CON, ...). Those four codes
match no polygon, so places in England never highlighted in the Atlas
while CH/IT/NL/etc. worked. When a GB lookup lands on a constituent
country, re-resolve it at a finer zoom where Nominatim exposes the
county/borough code the polygons actually carry. Other countries keep the
exact zoom-8 behaviour. Adds ATLAS-UNIT-021.
* Surface the real place-search error instead of a generic toast (#1092)
When a place search or detail lookup fails, the backend already forwards the
upstream reason - including descriptive Google Places API messages such as
'Places API (New) has not been used in project ... or it is disabled'. The
planner discarded it and always showed 'Place search failed', so a key that
is mis-enabled, unbilled, or pointed at the legacy API instead of Places API
(New) looked like an unexplained silent failure. Show the server-provided
message when present, and stop the Atlas bucket-list search from swallowing
its error without a trace.
* Await the async cover normalization in the TripFormModal paste test (#1085)
handleCoverSelect now normalizes the pasted file before previewing it, so
URL.createObjectURL is called a microtask later. The assertion moves into
waitFor; a non-HEIC file still passes through unchanged.
* fix(pwa): removed orientation from the manifest (#1058)
* fix(journey): raise PhotoLightbox z-index above MobileEntryView (#1101)
* feat(transport): add bus, taxi, bicycle, ferry and other transport types (#1105)
Closes#718. Adds five new transport reservation types alongside the
existing flight/train/car/cruise: bus, taxi, bicycle, ferry and a generic
'transport_other' catch-all. The new types are treated as first-class
transports everywhere — the transport modal, day plan, route calculation,
map overlays, file grouping and the PDF export — and are translated across
all 20 locales.
A dedicated 'transport_other' value is used for the catch-all so existing
'other' bookings are not reclassified as transport.
* feat(reservations): native booking-confirmation import via KDE KItinerary (#1102)
* feat(reservations): native booking-confirmation import via KDE KItinerary
Adds a two-step preview → confirm flow for importing booking emails,
PDFs, PKPass and HTML confirmations. The server invokes the KDE
kitinerary-extractor binary, maps JSON-LD schema.org output to TREK
reservation shapes, and persists via the existing createReservation
pipeline (accommodations, budget, places, WebSocket broadcasts).
- NestJS BookingImportModule: preview + confirm endpoints under
/api/trips/:tripId/reservations/import/booking{,/confirm}
- KitineraryExtractorService: spawns the binary, filters stderr noise,
handles QDateTime (@value) timezone-aware datetimes
- kitinerary-mapper: FlightReservation, TrainReservation, BusReservation,
BoatReservation, LodgingReservation, FoodEstablishmentReservation,
RentalCarReservation, EventReservation → typed preview items
- BookingImportService: auto-creates place rows; geocodes venues without
coordinates via Nominatim (name+address → address → name fallback);
resolves day IDs for accommodation linking
- BookingImportModal: drag-and-drop multi-file upload, preview cards
with type icons, per-item exclude toggle, confirm step
- Shared Zod contracts: BookingImportPreviewItem, PreviewResponse,
ConfirmRequest, ConfirmResponse — consumed by controller, service,
API client and modal
- Dockerfile: node:24-trixie-slim runtime; amd64 downloads KDE static
binary + locales; arm64 installs libkitinerary-bin + symlinks to
fixed path; ENV KITINERARY_EXTRACTOR_PATH set for both arches
- /api/health/features exposes { bookingImport: boolean } so the UI
hides the Import button when the binary is absent
- i18n keys (English), wiki docs, API.md, README one-liner
* i18n: add booking import translations for all 19 non-English locales
Adds 17 reservations.import.* keys and undo.importBooking to ar, br, cs,
de, es, fr, gr, hu, id, it, ja, ko, nl, pl, ru, tr, uk, zh, zh-TW.
* chore: enforce i18n parity
* docs(wiki): add KItinerary local setup instructions to dev environment guide
* feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile (#1106)
* fix(journey): authorize reads of the journey share link
GET /api/journeys/:id/share-link now requires journey access (canAccessJourney),
matching the create/delete share-link routes and the get_journey_share_link MCP
tool. Returns no link when the caller lacks access to the journey.
* feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile
Renames the Budget addon to "Costs" (UI only) and reworks it into a Tricount/
Splitwise-style cost tracker: multiple payers per expense, equal split across
chosen members, settle-up with persisted history + undo, 12 fixed categories,
per-expense currency with live FX conversion to a user-set display currency
(Settings -> Display), and locale-correct money formatting. Adds a desktop and a
dedicated mobile layout. A migration backfills existing budget items (single
payer, split members, currency). Closes#551 (per-expense currency).
Also switches the app font to self-hosted Poppins (Geist for secondary subtext),
replacing the Google Fonts CDN dependency.
* fix(costs): neutral dashboard dark palette + liquid glass, full page width, entry-count badge
- Dark mode used a warm oklch palette that read brownish; switch to the
neutral zinc tokens used by the dashboard (#121215 bg, #f4f4f5 ink) and add a
subtle backdrop-blur glass on cards.
- Costs now uses the full available page width on desktop instead of a 1280px cap.
- Render the expense count next to the Expenses title as a badge.
- Adapt budget/journey unit tests to the new payer-based settlement model and the
Costs rename (category default 'other', Costs tab/CostsPanel).
* fix(costs): drop the entry-count badge, always show row edit/delete actions
Removes the count badge next to the Expenses title and makes the per-row
edit/delete actions permanently visible (no longer hover-only) on desktop too.
* feat(costs): currency-native money formatting, custom select/date, rename addon to Costs
- Format every amount in its own currency convention (symbol position, grouping
and decimal separators) regardless of app language, via a currency->locale map
(EUR -> '12,00 €', USD -> '$12.00', JPY -> '¥12', ...). Previously Intl used the
app locale, so EUR showed the symbol in front under an English UI.
- Use TREK's CustomSelect (searchable, with symbols) and CustomDatePicker in the
add/edit expense modal instead of the native <select>/<input type=date>.
- Rename the 'Budget Planner' add-on to 'Costs' in the admin list (display only;
id/tables/permissions/MCP stay 'budget') via seed + a migration for existing DBs.
* feat(auth): configurable session duration via SESSION_DURATION
Adds a SESSION_DURATION env var (ms-style strings: 1h, 7d, 30d, ...) controlling
how long a session stays valid before re-login. It drives both the trek_session
JWT exp claim and the cookie maxAge from one source, so they never drift. Invalid
values warn at startup and fall back to the default (24h — unchanged). The MFA
challenge token and MCP OAuth tokens keep their own TTL.
Implements the request from discussion #946. Documented in the env-var wiki page,
.env.example and docker-compose.yml.
* feat: Passkey (WebAuthn) login (#1111)
* feat(auth): passkey (WebAuthn) login — server endpoints, schema + admin toggle
Add @simplewebauthn/server registration and primary (discoverable) login ceremonies under /api/auth/passkey, a webauthn_credentials + single-use webauthn_challenges schema (migration), the instance-wide passkey_login toggle (default off) enforced before auth by a guard, and require_mfa satisfaction via a verified passkey. RP ID/origin come only from server config (webauthn_rp_id/origins -> APP_URL), never request headers.
* feat(auth): passkey enrolment, login button + admin settings UI
PasskeysSection in account settings (add/rename/remove with a current-password step-up), a 'Sign in with a passkey' button on the login page, the admin enable + RP-ID/origins controls, and a per-user admin reset action.
* i18n(auth): passkey strings across all locales
Add login/settings/admin passkey keys to en and all 19 translated locales.
* chore: update kitinerary version
* Backend/frontend hardening & consistency cleanups (#1113)
* refactor(auth): session token validation and password-change consistency
* refactor(journey): entry field allow-list and public share-link consistency
* refactor(mcp): align tool authorization with the REST permission checks
* chore: input validation and sanitisation touch-ups (uploads, pdf, maps, backup, csp)
* feat: optimize routes around accommodation, confirm note deletions (#1123)
Optimize day routes around the accommodation
When a day has an accommodation set, the route optimizer now treats it as
the day's home base: it optimizes a loop that leaves the hotel and returns
to it, so the stop nearest the hotel comes first. On a transfer day -
checking out of one hotel and into another - the route runs from the first
hotel to the second instead.
The optimizer also gained a 2-opt pass on top of the nearest-neighbor
ordering, which removes the crossings the greedy pass used to leave behind.
A new display setting ("optimize route from accommodation", on by default)
lets you turn the anchoring off.
Confirm before deleting notes
Deleting a plan note or a collab note now asks for confirmation first. On
phones and tablets the edit and delete icons sit close together and were
easy to mis-tap, which deleted notes with no way back.
* fix: miscellaneous bug fixes (#1139)
* fix(share): serve place thumbnails in shared trip links (#1100)
Google-sourced place photos are stored as image_url pointing at the
JWT-guarded /api/maps/place-photo/:placeId/bytes endpoint, so they 401
for an unauthenticated shared-trip viewer and render as broken images.
Rewrite place image_url values in the shared payload to a public,
token-scoped proxy (/api/shared/:token/place-photo/:placeId/bytes) and
add an unguarded SharedController route that validates the token and that
the place belongs to its trip before streaming the cached bytes. Mirrors
the existing JourneyPublicController precedent. No client changes needed.
* fix(atlas): replace Natural Earth with geoBoundaries for up-to-date regions (#1119)
Atlas sourced country and sub-national boundaries from Natural Earth's GitHub
`master` at runtime. That data is stale (e.g. it still shows Norway's pre-2020
counties such as Oppland/Hordaland) and depicts some contested territory in
unwanted ways (nvkelso/natural-earth-vector#391), so Natural Earth is dropped
entirely.
- Country borders (admin0) now come from the geoBoundaries CGAZ composite;
sub-national regions (admin1) from per-country gbOpen, which carries ISO 3166-2
codes. A new script (server/scripts/build-atlas-geo.mjs) normalizes and quantizes
them into committed gzipped bundles under server/assets/atlas, read server-side at
runtime (no network at boot, no GitHub CSP allowlist entry).
- New GET /addons/atlas/countries/geo serves the country layer; the client fetches
it from the API instead of GitHub.
- A migration reconciles manually-marked visited_regions against the new bundle
(valid code -> keep; region name still matches -> re-code; curated merge crosswalk
for renamed reforms; else leave intact), with UNIQUE-safe dedup. bucket_list and
visited_countries hold only invariant alpha-2 country codes, so they are untouched.
- Attribution added (NOTICE.md + README) per geoBoundaries CC BY 4.0.
Closes#1119
* fix(packing): make templates admin-only to create, usable by members
Creating a packing-list template was gated only by trip access, so any
trip member could create one from the Lists feature, while applying a
template silently failed for non-admins because the apply dropdown was
populated from the AdminGuard-protected /api/admin/packing-templates
endpoint.
- save-as-template now returns 403 for non-admins; the Save-as-Template
button is hidden unless the user is an admin (both the TripPlanner
toolbar and the inline packing header).
- add member-accessible GET /api/trips/:tripId/packing/templates so the
apply dropdown lists templates for any trip member; client fetches
from it instead of the admin endpoint.
Closes#1120Closes#1121
* fix(packing): show bag tracking to non-admin members
The global Bag Tracking toggle was only readable via the admin-gated
GET /api/admin/bag-tracking, so non-admin trip members got 403 and the
weight fields, bag circles, and BAGS sidebar never rendered (#1124).
Surface the flag through the already-authenticated GET /api/addons
(loaded into the client addon store on app start for every user); the
packing hook reads it from the store instead of the admin endpoint. The
admin write path stays admin-gated and unchanged.
* Fix a batch of reported bugs (#1145)
* fix(maps): fall back to OSM/Wikipedia for place photos and normalize non-standard language codes (#1137)
* fix(auth): refuse password reset for OIDC/SSO-linked accounts (#1129)
* fix(docker): ship server/assets (airports + atlas geo) in the runtime image (#1133, #1119)
* fix(unraid): point the template at a PNG icon Unraid can render (#1073)
* fix(offline): serve cached file blobs when offline or on network failure (#1046, #1069)
* fix(map): centre the selected pin in the visible map area above the bottom panel (#1125)
* fix(pdf): render persisted place-photo proxy URLs as images (#1130)
* fix(planner): show the selected place category in the edit form (#1134)
* fix(dashboard): collapse list-view trip cards to a compact row on mobile (#1132)
* Support multi-leg (layover) flights (#1146)
* feat(transport): support multi-leg (layover) flights in the booking form
A flight booking can now hold an ordered chain of airports (e.g. FRA -> BER ->
HND) instead of a single departure/arrival pair. The route is entered as a list
of waypoints with a '+ add stop' button; each stop carries its own arrival and
departure time plus the airline/flight number of the segment leaving it, while
the whole booking keeps one price.
Stored without a schema change: the existing reservation_endpoints rows carry the
ordered waypoints (from/stop/to by sequence) and a metadata.legs array holds the
per-leg detail. Top-level metadata (departure_airport/arrival_airport/airline/
flight_number) mirrors the first and last leg, so a single-leg flight persists
exactly as before and legacy readers keep working.
* feat(planner): show each flight leg as its own day-plan entry, ordered by time
A multi-leg flight now expands into one entry per leg (BER -> FRA, then FRA ->
HND), each on its own day with its own times, instead of a single span. Each leg
is an addressable slot (reservation id + leg index) so places and notes can be
dropped into the layover gap between legs; the per-leg position is persisted in
metadata.legs[i].day_positions and survives a reload.
Day-plan items are now ordered chronologically: anything with a time (a place's
time, a flight leg, a timed note) sorts by that time, and untimed items inherit
the time of the item before them so they stay where they were placed.
* feat(planner): show the full multi-stop route in the bookings panel
The route row now lists every waypoint (FRA -> BER -> HND) by sequence instead of
just the first and last airport.
* feat(map): draw multi-leg flights as connected legs with a marker per airport
Both the Leaflet and Mapbox overlays now render a flight over all its waypoints:
one great-circle arc per leg and a marker at every airport, with the label
showing the full route and the summed distance. A single-leg flight is unchanged.
Also drops the floating stats badge that was drawn on transport arcs.
* fix(map): centre a clicked place above the bottom inspector panel
Selecting a place panned/flew it to the dead centre of the screen, where it sat
behind the detail card. Both overlays now bias the target into the visible area
above the bottom panel (Leaflet offsets the pan by the inspector inset; Mapbox
passes the padding to flyTo).
* feat: show the full multi-stop flight route in PDF and calendar export
The PDF day list and the ICS export now render the whole route (FRA → BER → HND)
for a multi-leg flight instead of just the first and last airport, falling back to
the flat metadata for single-leg flights. The ICS keeps a single event per booking.
* feat(import): group connecting flight legs into one multi-leg booking
When a booking confirmation contains several flight legs sharing a PNR that
connect at the same airport with a short layover (under 24h), they are now
imported as a single multi-leg booking (from/stop/to endpoints + metadata.legs)
instead of one booking per leg. A round trip (same PNR, multi-day gap) stays two
separate bookings, and a single flight is unchanged.
* i18n: translate the new flight-route strings into all languages
* i18n: translate the Costs page into every language
The Budget → Costs rework left the new costs.* strings untranslated in every
non-English locale (they fell back to English). Translate them across all
supported languages.
* Revert "fix(map): centre a clicked place above the bottom inspector panel"
This reverts commit 0936103f04.
* Explore places on the map, planner route fixes, and instance-wide Mapbox (#1147)
* feat(maps): add an OSM POI search endpoint (category within a viewport)
New /api/maps/pois queries OpenStreetMap via Overpass for places of a category
(restaurants, cafes, hotels, sights, …) inside a bounding box. OSM-only by design
— it never calls Google, even when a Google key is configured.
* feat(map): explore nearby places on the trip map (OSM category pill)
A floating, icon-only pill over the planner map lets you toggle a POI category and
see those OpenStreetMap places in the current view; clicking a marker opens the
add-place form pre-filled (name, address, website, phone). Single-select with a
'search this area' action after the map moves. Renders on both the Leaflet and
Mapbox maps, and can be turned off in settings (discussion #841).
* fix(planner): anchor timed places when optimising and route transports by location
- The day optimiser no longer reshuffles places that have a set time — they stay
anchored to their time, like locked places.
- The route now uses a transport's departure/arrival location as a waypoint when it
has one (e.g. a flight's airport), instead of breaking the route at every booking;
transports without a location are ignored for routing but still show their leg's
distance/duration under the booking.
* feat(admin): instance-wide Mapbox defaults in default user settings
Admins can set a shared Mapbox token (plus style, 3D and quality) as instance
defaults, so the whole instance can use Mapbox without each user pasting their own
key. Users without their own value inherit it via the existing admin-defaults
merge; the shared token is stored encrypted (discussion #920).
* Reorder whole days and insert a day (#589) (#1148)
* feat(days): reorder whole days and insert a day at a position
Adds reorderDays + insertDay to the day service and a PUT /days/reorder route
(plus an optional position on create). Day rows stay stable so a day's
assignments, notes, bookings and accommodations ride along by id; on a dated
trip the calendar dates stay pinned to their slots while the content moves
across them, and each booking's date is re-stamped onto its day's new date
(time-of-day preserved) so day_id stays consistent. Renumbering uses the
two-phase write to avoid the UNIQUE(trip_id, day_number) collision, and a move
that would invert an accommodation's check-in/out span is rejected.
* feat(planner): reorder days from a toolbar popup, and add days
A new toolbar button opens a popup listing the days; drag a row by its grip or
use the up/down arrows to reorder, and add a day from there. Reorders apply
optimistically with rollback and sync over WebSocket; the day headers are left
untouched, so the existing place drop-targets are unaffected.
* i18n: add day-reorder strings across all languages
* Map/planner/dashboard polish and small community features (#1155)
* feat(planner): reorder days in a modal instead of a dropdown
The day-reorder control opened a small anchored dropdown; move it into the shared Modal (portal, dimmed backdrop, Esc/backdrop close) so it matches the Add activity dialog. Drag handles, up/down arrows and the day badges are unchanged.
* feat(map): explore reliability, Mapbox popups + compass, region-biased search
POI explore: clamp oversized viewports, query the Overpass mirrors in parallel (first valid response wins) with a per-request timeout and a short-lived cache, and surface a retry when every mirror fails - so it returns results at any zoom instead of timing out.
Mapbox renderer: add the place/POI hover popups (name, category, address, photo) the Leaflet map already had, plus a compass pill next to the explore pill that resets the view to north.
/api/maps/search: accept an optional locationBias to fix foreign-region bias and expose Google's place types in the result.
* feat(dashboard): list-view and mobile polish
Use the Archived status label for the filter and show Open dates for trips without dates; drop the unused settings button next to the view toggle. Desktop list view renders the date as a stat-style block separated from the counts.
Mobile list rows are stacked (slim cover banner + centred date), trip actions stay visible (touch has no hover), and the hero card's hover lift is disabled on touch; small spacing fix under the sidebar.
* feat: small community-requested options
Raise the plan-note subtitle limit to 250 characters and add more note icons. Expose is_archived and cover_image on the update_trip MCP tool. Add place coordinates to the PDF export. Allow creating a category from an existing to-do, and add a show/hide toggle on the admin password fields.
* test(shared): bump day-note subtitle limit assertion to 250
* test: align specs with the new search param order and archive label
Keep lang as the 3rd positional arg of the maps search controller so the existing unit test stays valid, and forward locationBias as the 4th. Add the now-used Popup to the MapViewGL mapbox mock, switch the dashboard archive-filter query to the Archived label, and expect the 4-arg search call.
* fix(packing): add more bag colors so sub-bags stop repeating (#1156)
The auto-assigned bag palette only had 8 colors, so the 9th bag reused the first one. Double it to 16 (keeping the existing 8 and their order) and keep the server and client lists in sync - both cycle BAG_COLORS[count % length].
* fix(packing): respect per-item quantity in bulk import (#1157)
* AirTrail integration: import flights & two-way sync (#214) (#1158)
* feat(admin): register AirTrail as an integration addon
Off by default; toggle lives in Admin -> Addons with a Plane icon. The
per-user connection (URL + API key) follows in integration settings.
* feat(integrations): add per-user AirTrail connection
Settings -> Integrations gains an AirTrail section: instance URL + Bearer
API key (encrypted at rest via apiKeyCrypto), a self-signed-TLS opt-in and
a test-connection check. Served by a small Nest controller under
/api/integrations/airtrail, gated on the airtrail addon and SSRF-guarded.
The key is per-user, so it only ever returns that user's own flights.
* feat(transport): import flights from AirTrail
Adds an AirTrail Import button next to Manual Transport that lists the
user's AirTrail flights and highlights the ones inside the trip dates.
Selected flights become reservations linked to their AirTrail origin
(external_* columns), deduped against flights already in the trip, then
broadcast to every member. The mapping resolves airports, airport-local
times and flight metadata; the linkage is what the two-way sync rides on.
* feat(transport): badge AirTrail-linked flights as synced
Linked reservations show an 'AirTrail synced' badge, or 'no longer
synced' once the flight is gone from AirTrail.
* feat(transport): keep TREK and AirTrail flights in sync both ways
A scheduled poll reconciles each connected owner's flights: field edits
(detected by snapshot hash, since AirTrail has no updated_at) flow into
the linked reservation and broadcast live; a flight deleted in AirTrail
keeps the TREK row but stops syncing. Editing a linked flight in TREK
pushes back to AirTrail under the importer's credentials, preserving the
existing seat manifest; if the owner disconnected the link detaches so the
poll can't revert the local edit. Deleting in TREK never touches AirTrail.
* i18n(airtrail): add AirTrail strings across all locales
* test(airtrail): cover flight mapping, timezones and snapshot hashing
* fix(airtrail): reduce airline/aircraft objects to codes
The flight list/get response returns airline and aircraft as joined
objects ({icao, iata, name, ...}), not bare codes. Mapping them straight
through produced '[object Object]' titles and stored objects in metadata,
which crashed reservation rendering. Extract the ICAO/IATA code instead,
and title flights by their flight number.
* fix(airtrail): clear error on non-JSON responses, tolerate /api in URL
A misconfigured instance URL made AirTrail serve its SPA/login HTML, and
the raw JSON.parse failure surfaced as 'Unexpected token <'. Surface an
actionable message instead, and strip a pasted trailing /api so the base
URL still resolves.
* feat(transport): sync AirTrail edits on trip open, not just on the poll
Add a per-user on-demand sync (POST /integrations/airtrail/sync) triggered
when a connected user opens a trip, so AirTrail-side edits appear right away
instead of waiting up to a full poll cycle. Lower the background poll from 15
to 5 minutes as a safety net.
* fix(transport): refresh imported AirTrail flights without a reload
loadTrip doesn't fetch reservations, so a freshly imported flight only
appeared after a full page reload — use loadReservations instead. Also show
flight dates in the user's locale format (e.g. 13.06.2026) rather than the
raw ISO string.
* style(settings): align AirTrail connection with the photo-provider layout
Match the Immich section: stacked URL/key fields, a ToggleSwitch for
self-signed TLS, and a Save / Test-connection row with a status badge.
* feat(transport): add a seat field when editing flights
The transport editor only offered a seat field for trains; flights had
none even though imports store metadata.seat. Show and persist a seat for
flights too.
* style(transport): match the AirTrail button height to Manual Transport
* feat(transport): put the flight seat next to flight number and sync it to AirTrail
Move the seat from a standalone row to the per-leg flight details (beside
the flight number), stored per leg in metadata.legs[].seat with the first
leg mirrored to metadata.seat. On push, set the seat number on the user's
own AirTrail seat (the one with a userId), leaving co-passengers untouched;
import/poll read that same seat back.
* refactor(planner): move the AirTrail trip-open sync into useTripPlanner
Page containers must not own state/effects (lint:pages). Same logic,
relocated from the page into its data hook.
* test(db): pin the region-reconciliation test to its schema version
The test re-ran 'the last migration' assuming the reconciliation is last;
it no longer is once later migrations are appended. Pin to version 135 and
re-run from there (the appended migrations are idempotent).
* Various fixes: 2FA autofocus, viewer-timezone times, duplicate place guard (#1159)
* fix(auth): autofocus the 2FA code input when the MFA step appears (#767)
* fix(notifications): show notification and admin times in the viewer timezone (#1149)
SQLite CURRENT_TIMESTAMP is UTC but the string has no Z, so the client parsed
it as local time. Normalize in-app notification created_at to ISO-UTC, and stop
forcing the admin user table to render in the server timezone.
* fix(places): warn before adding a duplicate place (#1152)
Manually adding a place did not check the existing pool, so the same POI could
land in Unplanned twice. Flag a likely duplicate by Google Place ID, name or
near-identical coordinates and require a confirming second click to add anyway.
* fix(planner): make route tools reachable in mobile day plan sheet (#1142)
* wiki: update dev env
* wiki: small precision in dev env
* fix(planner): make route tools reachable in mobile day plan sheet
On mobile, selecting a day closes the plan sheet immediately, so the
route tools footer (Route toggle / Optimize / routing profile) - gated
on the selected day - was never reachable. Desktop was unaffected.
- Add showRouteToolsWhenExpanded prop to DayPlanSidebar: when set,
route tools render on any expanded day with 2+ assigned places
- Make handleOptimize accept an explicit dayId (defaulting to
selectedDayId, preserving desktop behavior)
- Keep the distance/duration pill gated on the selected day, since
routeInfo belongs to the selected day's calculated route
- Enable the prop on the mobile plan sheet in TripPlannerPage
* fix(planner): correct route-tools prop doc and dev-environment wiki
- Reword the showRouteToolsWhenExpanded JSDoc to list the controls the
footer actually renders (Route toggle / Optimize / travel profile);
there is no "Open in Google Maps" action in that block.
- Wiki: drop the non-existent server test:parity script, document the
real shared i18n:parity checks, and fix the i18n note (the translation
layer already lives in @trek/shared, it is not "upcoming").
---------
Co-authored-by: jubnl <jgunther021@gmail.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
* feat(places): enrich list-imported places via the Places API (#886) (#1161)
* feat(places): enrich list-imported places via the Places API (#886)
Google/Naver list imports only carry a name and coordinates, so the places open
as bare pins — the Maps tab jumps to coordinates, with no photo, address or
open/closed. Add an opt-in "Enrich places via Google" toggle to the list-import
dialog, shown only when a Google Maps key is configured.
When enabled, after the (fast, unchanged) import the server runs a background
pass that re-resolves each place by name — biased to and validated against the
imported coordinates so a common-name search cannot overwrite the wrong place —
and fills the empty address/website/phone/photo columns plus the resolved
google_place_id, pushing each row over the live sync. Opening hours and the
proper Maps link then work on demand from the stored id.
Enrichment only fills empty fields, runs detached so a long list never blocks
the import, and no-ops when no key is configured.
* fix(places): use the ToggleSwitch component for the enrich toggle
Match the rest of the app — the import-enrichment opt-in used a raw checkbox;
swap it for the shared ToggleSwitch (text left, switch right) like the settings
toggles.
* fix(maps): bound place-photo cache growth (Wikimedia + Google) (#1174)
The place-photo cache (uploads/photos/google) grew unbounded: a Wikimedia
geosearch path cached full-res originals despite requesting a 400px thumb,
the writer applied no size guard, nothing reclaimed orphaned files, and
backups archived the whole re-derivable cache verbatim.
- Prefer the scaled `thumburl` over the full-res `info.url` in the Commons
geosearch fallback.
- Downscale any cached image to <=800px JPEG via the existing jimp dep,
with a safe fallback to the original bytes on decode failure.
- Add sweepOrphans() (orphaned meta rows + stray files) wired into the
scheduler (startup + nightly), and removeIfUnreferenced() called on
place delete for prompt reclamation.
- Exclude the re-derivable photo/trek caches from backups; restores
self-heal as the cache dirs are recreated at startup.
* fix(sync): remap temp ids, prevent id collisions, surface failed mutations (#1175)
Closes three offline BLOCKERs from the PWA audit:
- B1: offline edits/deletes of an offline-created entity were lost. The
negative temp id was baked into the PUT/DELETE url and never rewritten
after the CREATE returned a real id, so dependents 404'd and were dropped.
Dependents now carry a {id} placeholder + tempEntityId; flush builds a
tempId->realId map and durably rewrites still-queued dependents on CREATE
success (survives flush boundaries / reloads).
- B2: tempId = -(Date.now()) collided within a millisecond, overwriting an
optimistic row. Replaced with a monotonic nextTempId() minter.
- B3: any 4xx marked the mutation failed with no rollback and no signal, and
the badge ignored failed rows. Terminal failures now roll back the phantom
optimistic CREATE; 401/408/425/429 are treated as retryable; failedCount()
is surfaced in OfflineBanner (red pill) and OfflineTab.
* fix(maps): make offline tiles cover real trips (cap coherence + zoom-clamp) (#1177)
Closes BLOCKER B5 — the offline map was blank for most real trips:
- The Workbox 'map-tiles' cache held only 1000 entries while the prefetcher
budgeted ~3413, so prefetched tiles were evicted on arrival. Both caps are
now a coherent 12288 (~180 MB), kept in sync with cross-referencing comments.
- prefetchTilesForTrip skipped a trip entirely when its all-zooms estimate
exceeded the cap, so region/road-trip bboxes got no tiles. Removed the
all-or-nothing guard; prefetchTiles already fills zooms low→high and stops at
the budget, so large trips now cache the zooms that fit instead of nothing.
* fix(security): stop cross-user offline data leak on shared devices (#1176)
Closes BLOCKER B4 — three reinforcing paths could serve one account's
cached data to the next user on a shared device:
- The Workbox 'api-data' cache keyed trip/user-scoped GETs by URL only
(cookie-blind). Changed to NetworkOnly; offline reads come from the
per-user IndexedDB cache via the repo layer instead.
- IndexedDB had no per-user scoping. The Dexie connection is now scoped
per user (trek-offline-u<id>) behind a Proxy so the ~19 importers keep a
stable binding; login opens the user DB, logout deletes it and returns
to the anonymous DB.
- logout() was fire-and-forget and racy: background flush/syncAll could
re-seed the DB after the wipe. It is now async and ordered — close an
auth gate, unregister sync triggers, disconnect, clear caches, delete
the user DB — and flush()/syncAll() bail when the gate is closed.
* fix(db): scope, evict, and cap the offline blob cache (H3) (#1178)
Blob cache previously leaked forever: clearTripData omitted it, entries had
no trip discriminator, and there was no size/count bound, so file blobs
survived trip eviction and could starve the map-tile cache for quota.
- BlobCacheEntry gains tripId + bytes; Dexie v3 adds a tripId index with a
backfill upgrade (legacy rows -> tripId -1, bytes from blob.size)
- clearTripData purges the trip's blobs in-transaction
- enforceBlobBudget() evicts oldest-by-cachedAt past 200 entries / 100 MB
- tripSyncManager threads tripId/bytes into puts and enforces the budget
* fix(repo): fall back to Dexie when a network read fails (H2) (#1179)
Repos gated reads on raw navigator.onLine and the online branch had no
try/catch, so a captive portal or connected-but-no-internet (navigator.onLine
lying "true") threw a network error instead of serving the good cached copy —
blanking the trip even though Dexie held it.
- new onlineThenCache(onlineFn, cacheFn) helper: reads the cache when offline,
and on a network-level failure (Axios error with no HTTP response). A genuine
HTTP error (4xx/5xx — the server responded) is rethrown so callers still set
error state / navigate, not masked by a stale cache.
- gates only on navigator.onLine, NOT the connectivity probe: the probe is a
coarse global flag and one failed health check would otherwise divert every
read to the (possibly empty) cache even when the request would succeed.
- every repo list/get read path routed through it (reads only — writes still
go through the mutation queue so failures surface)
- tests: captive-portal fallback, HTTP-error rethrow, non-Axios rethrow
* fix(store): reset and uniformly hydrate trip-scoped slices in loadTrip (H4, H5) (#1180)
loadTrip only replaced the first slice group, so budget/reservations/files
from a previous trip stayed visible after switching trips (data exposure on a
shared screen). Those three also loaded via separate tab-gated effects, so they
never hydrated offline for an unopened tab.
- resetTrip() clears every trip-scoped slice (keeps global tags/categories) and
runs at the top of loadTrip, so a switch can't leak the prior trip's data
- loadTrip now hydrates budget/reservations/files through their repos alongside
the rest (non-fatal catches), making offline hydration uniform
- useTripPlanner drops the redundant loadFiles + reservations/budget effects;
tab-gated lazy reloads stay as on-demand refresh
- tests: cross-trip no-leak, uniform hydration, resetTrip
* fix(sync): re-hydrate active trip store on reconnect/online (H1) (#1181)
setRefetchCallback was dead code, so on reconnect the queue flushed and Dexie
re-seeded but the open trip's Zustand store was never refreshed — a
collaborator's edits made while we were offline didn't appear until navigating
away and back.
- new tripStore.hydrateActiveTrip(): silent refresh of the active trip's
collaborative state (days/places/packing/todo/budget/reservations/files),
no resetTrip and no isLoading toggle so there's no splash on reconnect
- syncTriggers wires setRefetchCallback to it (WS layer awaits the flush hook
first) and re-hydrates open trips after the online-event syncAll; cleared on
unregister
- websocket exposes getActiveTrips() for the online-event path
- tests: refetch wiring + ordering, silent hydrate without reset/splash
* fix(server): lengthen idempotency key TTL to survive multi-day offline (H6) (#1182)
The nightly cleanup deleted idempotency keys older than 24h. The TREK client
replays queued mutations with their X-Idempotency-Key on reconnect, so a device
offline longer than a day had its keys GC'd before it returned — the replayed
POST was then treated as new and created a duplicate.
- raise the TTL to 30 days (DEFAULT_IDEMPOTENCY_TTL_SECONDS), overridable via
IDEMPOTENCY_TTL_SECONDS
- extract purgeExpiredIdempotencyKeys(now, ttl, db) (mirrors cleanupOldBackups)
with an injectable db, and have the cron job call it
- tests: 30-day default eviction, 25-day key retained (was dropped at 24h),
env override
H7 (exactly-once across the lost-response window) is deferred: a correct fix
must store the response in the same DB transaction as the entity write. Doing
it in the generic interceptor (reserve-before-handler) cannot store the real
response body for the crash case, which would break the client's temp->real id
remapping on replay (mutationQueue.flush relies on the entity in the body). It
needs a per-service change and is tracked separately.
* fix(realtime): correct assignment:created echo dedup (H11) (#1183)
When X-Idempotency/X-Socket-Id let an own-echo through, the assignment:created
dedup had two bugs: it keyed on place id, so (1) a legitimate second assignment
of a place already on the day was silently dropped, and (2) the temp-version
reconciliation matched place?.id === placeId, letting undefined === undefined
collapse place-less rows onto each other.
- dedup now keys on assignment id (exact-id duplicate -> no-op)
- temp (negative-id) optimistic rows are reconciled only when a real placeId
matches, replacing just that row; a sibling temp of another place is untouched
- everything else appends, including a genuine 2nd assignment of the same place
- tests: 2nd-of-same-place kept, correct temp picked among siblings, place-less
rows don't collapse
Note: the broader own-echo suppression relies on X-Socket-Id being sent; this
fixes the client-side fallback when an echo slips through.
* fix(pwa): persist offline storage + Mapbox offline policy (H8, H9) (#1184)
H8: prefetched tiles and file blobs could be evicted under storage pressure
(worsened by opaque tile responses inflating the quota ~7MB each), blanking the
offline map right when a traveler needs it. Request persistent storage at app
init so the browser exempts our caches from eviction. We deliberately keep tile
requests no-cors (a cors switch would break self-hosted/custom tile providers
without CORS headers), so persistence is the safe mitigation rather than
de-opaquing responses.
H9: Mapbox GL users had no offline map at all — no runtimeCaching matched the
Mapbox hosts. Add a StaleWhileRevalidate rule for api.mapbox.com /
*.tiles.mapbox.com so visited areas are available offline (best-effort; full
pre-download still requires the Leaflet renderer, now documented).
- new sync/persistentStorage.ts requestPersistentStorage(), called from main.tsx
- vite.config: mapbox-tiles SW cache rule
- MapViewAuto / tilePrefetcher comments document the offline-maps policy
- tests for the persist helper (granted / already-persisted / absent / rejects)
* ci(security): only fail Docker Scout on fixable CVEs
Add only-fixed so the scan no longer fails on vulnerabilities with no
upstream fix available (e.g. base-image OS packages), and only flags
actionable, fixable findings.
* build(docker): rebuild gosu with a current Go toolchain
Debian's apt gosu ships an old Go stdlib that the image CVE scan flags
(1 critical + several high, all in golang/stdlib). Build gosu from source
with a current Go toolchain and copy the static binary in instead; the
runtime behaviour is unchanged — gosu still drops root to node at startup.
* build(deps): bump tsx's esbuild to 0.28.1 (GHSA-gv7w-rqvm-qjhr)
The production image's last image-scan finding was esbuild 0.28.0, pulled
in transitively by tsx. Pin tsx's esbuild to 0.28.1 (within tsx's ~0.28.0
range) to clear GHSA-gv7w-rqvm-qjhr. Lockfile-only; no runtime change.
* feat(auth): add "Remember me" checkbox to extend session lifetime (#1189)
Adds a "Remember me" checkbox to the login form (single responsive page,
covers mobile + desktop). Unchecked (default) issues the existing
SESSION_DURATION JWT with a browser-session cookie (no maxAge); checked
issues a longer-lived JWT plus a persistent cookie sized by the new
SESSION_DURATION_REMEMBER env var (default 30d). The choice is threaded
through the MFA verify leg so it survives the step-up.
Register/demo logins keep their current persistent behaviour.
* chore(ssrf): include lookup error code in error message
* fix(backup): restore from Docker, fail-fast on shadowed /app, bundle encryption key (#1193) (#1197)
* fix(backup): restore uploads through symlinked dir and bundle encryption key (#1193)
Restoring a backup inside Docker threw ERR_FS_CP_DIR_TO_NON_DIR because
/app/server/uploads is a symlink to the mounted /app/uploads volume and
cpSync (dereference:false) refuses to overwrite the symlink node with a
directory. The DB was swapped before this failing copy, so users saw
restored data but missing upload files (trip covers). Resolve the symlink
with realpathSync before copying so the merge targets the real directory;
no-op on a plain dir, so non-Docker behavior is unchanged.
Also bundle the at-rest encryption key (data/.encryption_key) into the
backup so a restore onto a different install can decrypt stored secrets
(API keys, MFA, SMTP/OIDC). Skipped when ENCRYPTION_KEY is provided via
env (the file is not the source of truth then). On restore the key is
swapped back if the archive carries one; a restart is required for the
in-memory key to take effect.
* fix(docker): fail fast when a volume shadows /app (#1193)
Mounting an old volume at /app hides the image's node_modules and dist,
so startup crashed with a cryptic "Cannot find module
'tsconfig-paths/register'". Add a CMD preflight that detects the missing
app files and exits with actionable guidance. Document in the README that
only /app/data and /app/uploads should be mounted, never /app.
* fix: ssrf test
* fix(places): fall back to search when autocomplete details lookup fails (#1192) (#1198)
Clicking an auto-suggest dropdown item did a second /maps/details lookup
that could fail (details kill-switch off, an overloaded OSM Overpass mirror
behind a proxy, or any upstream error), dead-ending on "Place search failed"
while the search button stayed reliable.
handleSelectSuggestion now treats a missing or coordinate-less details result
(or a thrown error) as a miss and falls back to the text-search path the search
button uses, applying the first result. The error toast only fires if the
fallback also returns nothing. Adds tests for the previously untested
suggestion-click path.
* fix(planner): scroll long place description/notes on mobile (#1195) (#1199)
The place details card (PlaceInspector) clipped long description/notes
with no way to scroll. The content area is a flex column whose children
(description/notes) had the default flex-shrink: 1, so once the card hit
its maxHeight cap they compressed to fit and their overflow:hidden clipped
the text instead of overflowing into a scroll region.
- Make the content area a bounded scroll region (flex: 1 1 auto,
minHeight: 0, overflowY: auto, momentum + overscroll containment).
- Pin description/notes with flexShrink: 0 so they keep natural height and
the card overflows into the scroll instead of clipping.
- Pin header/footer with flexShrink: 0 so they stay fixed while scrolling.
- Add wordBreak/overflowWrap to the description div to fix horizontal clip.
* Day plan: hotel travel times at start/end + login toggle polish (#1206)
* fix(login): use the shared toggle for the stay-signed-in option
* feat(planner): show hotel travel times at the start and end of a day
* fix(login): give the stay-signed-in toggle an accessible name and fix its test
* fix(trips): keep the day-count field empty when cleared and validate it (#1204) (#1207)
* docs(readme): refresh dashboard, costs and trip screenshots (#1208)
* docs(readme): refresh dashboard, costs and trip screenshots
* docs(readme): correct outdated info (React 19, NestJS, 20 languages, Costs rename, passkeys, AirTrail, notifications)
* chore: update all dependencies (#1209)
* chore: update all dependencies
* chore: remove lint errors
* fix(client): restore typecheck after dependency bump
vitest 4 types vi.fn() as Mock<Procedure | Constructable>, which no
longer assigns to the strictly-typed onUpdate prop; type the mock
explicitly. TS6 + the new transitive @types/node 25 stopped auto-
including node builtin module types, so import('node:buffer') failed;
add @types/node as a direct client devDependency and a scoped node
type reference in the one test that needs it.
* test: fix constructor mocks for vitest 4 Reflect.construct semantics
vitest 4 resolves new-invoked mocks via Reflect.construct, which rejects
arrow-function implementations (including mockReturnValue sugar) as
non-constructable. Convert mapbox-gl and better-sqlite3 mocks that the
code instantiates with new to regular function implementations.
* fix(planner): only route to multi-day transport endpoints on their pickup/drop-off days (#1210) (#1212)
* chore: move to Frankfurter API for exchange rate (#1214)
* Restore nest coverage to >=80% after the #1209 dep bump (istanbul provider + branch tests) (#1213)
* fix(server): set oxc:false in vitest so the SWC transform survives the Vite 8 bump
* fix(server): switch coverage to the istanbul provider (v8 under-reports branches on Vite 8 + Vitest 4)
* test(nest): cover controller/service branches to clear the 80% coverage gate
* fix(planner): correct transfer-day hotel legs and connect them to transports (#1215)
When you change hotels on a day, the morning bookend leg showed the hotel
you check into instead of the one you slept in whenever the morning stay
didn't end exactly on that day — both bookends collapsed onto the arriving
hotel. The morning hotel is now picked by "checked in earlier and still in
range" rather than "checks out today", which also fixes the route
optimizer's start anchor for the same case.
The bookend legs now connect to the first/last located waypoint of the day
— a place or a transport endpoint (a car return, a taxi or train arrival) —
so the hotel-to-transport drives are included too.
* feat(transports): add kitinerary import-from-file button to Transports tab
* docs(config): document SESSION_DURATION_REMEMBER across deployment artifacts
Add SESSION_DURATION_REMEMBER to docker-compose, .env.example, README env
table, Helm chart (values + configmap passthrough), the Unraid template, and
the Unraid install guide. Where the base SESSION_DURATION was also absent
(README, charts, Unraid) add the pair so the Remember-me variable has context.
---------
Co-authored-by: gzor <risenbrowser@web.de>
Co-authored-by: ppuassi <34529179+ppuassi@users.noreply.github.com>
Co-authored-by: sss3978 <106522699+soma3978@users.noreply.github.com>
Co-authored-by: SkyLostTR <onurluerin@gmail.com>
Co-authored-by: Julien G. <66769052+jubnl@users.noreply.github.com>
Co-authored-by: Dimitris Kafetzis <39215021+Dkafetzis@users.noreply.github.com>
Co-authored-by: Ahmet Yılmaz <70577707+sharkpaw@users.noreply.github.com>
Co-authored-by: jubnl <jgunther021@gmail.com>
Co-authored-by: jufy111 <40817638+jufy111@users.noreply.github.com>
Co-authored-by: Larinel <bodink7@gmail.com>
Co-authored-by: rossanorbr <48014819+rossanorbr@users.noreply.github.com>
2026-06-16 22:22:45 +02:00
1758 changed files with 123652 additions and 18952 deletions
@@ -202,7 +205,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
</div>
</div>
Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + Passkeys (WebAuthn) + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
<br />
<br />
@@ -263,7 +266,7 @@ Then:
docker compose up -d
docker compose up -d
```
```
**HTTPS notes:**`FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
**HTTPS notes:**`FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells the server how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
</details>
</details>
@@ -400,12 +403,14 @@ Caddy handles TLS and WebSockets automatically.
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `en` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
| `SESSION_DURATION` | How long a login session stays valid when **"Remember me" is unchecked** (the default): sets the `trek_session` JWT `exp` and issues a browser-session cookie (cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. | `24h` |
| `SESSION_DURATION_REMEMBER` | Session length when **"Remember me" is ticked** at login: a longer-lived JWT plus a persistent `trek_session` cookie that survives browser restarts. Same format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
| **OIDC / SSO** | | |
| **OIDC / SSO** | | |
@@ -423,6 +428,7 @@ Caddy handles TLS and WebSockets automatically.
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
| `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
@@ -39,7 +39,9 @@ See `values.yaml` for more options.
## Notes
## Notes
- Ingress is off by default. Enable and configure hosts for your domain.
- Ingress is off by default. Enable and configure hosts for your domain.
- PVCs require a default StorageClass or specify one as needed.
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
- To use your own PVCs, set `persistence.data.existingClaim` and/or `persistence.uploads.existingClaim`. The other values for that volume (size, storageClassName, annotations) are then ignored.
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
-`JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
-`JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
-`ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
-`ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
# Deployment update strategy. Recreate is the safe default for the single-writer SQLite
# DB on a ReadWriteOnce volume (the old pod is torn down before the new one starts).
# Set to RollingUpdate only if you back the data volume with ReadWriteMany storage.
updateStrategy:Recreate
# Optional image pull secrets for private registries
# Optional image pull secrets for private registries
imagePullSecrets:[]
imagePullSecrets:[]
# - name: my-registry-secret
# - name: my-registry-secret
@@ -34,6 +39,10 @@ env:
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
# COOKIE_SECURE: "true"
# COOKIE_SECURE: "true"
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
# SESSION_DURATION: "24h"
# How long a login session stays valid when "Remember me" is unchecked (the default): trek_session JWT exp + a browser-session cookie. Accepts 1h, 12h, 7d, 30d, 90d. Defaults to 24h.
# SESSION_DURATION_REMEMBER: "30d"
# Session length when "Remember me" is ticked: a longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Defaults to 30d.
# TRUST_PROXY: "1"
# TRUST_PROXY: "1"
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
# ALLOW_INTERNAL_NETWORK: "false"
# ALLOW_INTERNAL_NETWORK: "false"
@@ -63,6 +72,12 @@ env:
# Max MCP API requests per user per minute. Defaults to 300.
# Max MCP API requests per user per minute. Defaults to 300.
# MCP_MAX_SESSION_PER_USER: "20"
# MCP_MAX_SESSION_PER_USER: "20"
# Max concurrent MCP sessions per user. Defaults to 20.
# Max concurrent MCP sessions per user. Defaults to 20.
# OVERPASS_URL: ""
# Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled
# public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable
# from the cluster (e.g. locked-down egress). Non-http(s) entries are ignored.
# OVERPASS_TIMEOUT_MS: "12000"
# Per-endpoint timeout (ms) for Overpass POI requests. Raise it for a slow self-hosted Overpass instance. Defaults to 12000.
# Secret environment variables stored in a Kubernetes Secret.
# Secret environment variables stored in a Kubernetes Secret.
@@ -82,6 +97,12 @@ secretEnv:
ADMIN_PASSWORD:""
ADMIN_PASSWORD:""
# OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID.
# OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID.
OIDC_CLIENT_SECRET:""
OIDC_CLIENT_SECRET:""
# Optional Unsplash Access Key for trip-cover and place-image search.
# Without one, TREK uses Unsplash's unauthenticated endpoint, which some
# datacenter/VPS IPs (including many Kubernetes clusters) are blocked from.
# Get a free key at https://unsplash.com/developers. Can also be set per-admin
# in Admin > Settings; this value overrides that. Leave empty to disable.
UNSPLASH_ACCESS_KEY:""
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
generateEncryptionKey:false
generateEncryptionKey:false
@@ -91,11 +112,21 @@ existingSecret: ""
existingSecretKey:ENCRYPTION_KEY
existingSecretKey:ENCRYPTION_KEY
persistence:
persistence:
# When disabled, volumes fall back to an ephemeral emptyDir (data lost on pod restart).
enabled:true
enabled:true
data:
data:
size:1Gi
size:1Gi
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
storageClassName:""
# Bind an existing PVC. The other values (size, storageClassName, annotations) are then ignored.
existingClaim:""
annotations:{}
uploads:
uploads:
size:1Gi
size:1Gi
storageClassName:""
# Specify an existing PVC to bind. The other values are then ignored.
<inputtype="password"className={fieldCls}value={apiKey}onChange={e=>setApiKey(e.target.value)}placeholder={apiKey===MASKED?MASKED : provider==='local'?'(often not required)':'sk-…'}/>
<inputautoComplete="off"className={fieldCls}value={model}onChange={e=>setModel(e.target.value)}placeholder={provider==='anthropic'?'claude-opus-4-8':provider==='openai'?'gpt-4o':'select or pull below'}/>
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.