* 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.

* fix(plugin-sdk): serve dev /ui frame at /ui/index.html so relative assets resolve (#1526)

The dev server embedded the plugin UI as <iframe src="/ui"> (no trailing
slash), so a multi-file build's relative asset URLs (./assets/x.js from Vite
base:'./') resolved against the origin root -> /assets/x.js -> 404, even though
the files are served at /ui/assets/*. The real host loads the frame at
/plugin-frame/<id>/index.html where the same relative URLs resolve correctly, so
dev now matches it by loading /ui/index.html (and also serves /ui/ as index.html).

* i18n: improve Russian translations (#1539)

* v3.4.0 (#1527)

* fix(plugins): unknown column

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

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

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

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

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

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

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

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

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

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

Closes #1523

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(plugins): enforce compatibility range

* chore: bump sdk version

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

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

Each failure was masking the next:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(sdk): support for plugin icons

* fix(planner): keep the places filter applied and visible across tab switches

The category and all/unplanned/tracks filters lived twice: a local copy in
the sidebar driving the checkboxes and the list, and a page-level copy
driving the map markers, synced only when a checkbox was clicked. Switching
planner tabs unmounts the sidebar, so the local copy reset to "All
Categories" while the markers stayed filtered — and the only way out was
toggling any category on and off again (#1541).

The filter now lives once in the trip store, next to selectedDayId: it
survives the Plan tab unmounting (and the mobile places sheet closing),
keeps both sidebar instances in agreement, and resets when another trip
loads.

* feat(airtrail): import connecting flights as one multi-leg booking

AirTrail flights always imported as separate single-leg reservations, so a
layover could not be expressed and the connection country ended up counted
as visited in Atlas (#1535).

The import picker now detects connection chains among the listed flights —
each leg departing from the airport the previous one landed at, onward
within 24 hours, and never back to the origin (an out-and-back is a return,
not a connection) — and offers to import each chain as one flight with
layover stops, on by default. The joined booking keeps per-leg airline,
flight number, times and seat in metadata.legs, files every leg on its own
trip day, and mirrors the first/last leg flat, exactly like the manual
multi-leg form. With the connection stored as a stop endpoint, the existing
Atlas role filter excludes the layover country on its own.

AirTrail has no multi-leg flight a joined booking could round-trip to, so
it imports detached from live sync, with every source flight id recorded in
metadata.airtrail_ids — the picker and the server-side dedupe both treat
those legs as imported, per leg, even across trip members. The server
re-validates each requested chain and falls back to individual imports when
it does not actually connect.

* fix(airtrail): stop syncing a booking once it grows extra stops

A linked flight that becomes multi-leg locally no longer matches the single
AirTrail flight it was imported from: pushing would rewrite that flight to
span the whole route, and the next pull would flatten the layover chain
back to a plain from/to. Both sync directions now detach the link instead —
the same state a joined import starts in, surfaced by the existing "Not
synced" badge.

TransportModal also carries metadata.airtrail_ids through re-saves (like it
already does for transit itineraries and day positions), so editing a
joined booking cannot cost it its import dedupe and get its legs re-offered
in the picker.

* fix(planner): default a new accommodation to checking out the next day

The hotel picker pre-filled "Apply to days" with the same day for check-in
and check-out — a stay that ends the day it begins. New accommodations now
default to the following day for check-out; the last trip day keeps the
same-day range, and editing still seeds from the stored range.

* docs(wiki): document the AirTrail import and connection joining

* fix(plugins): fold resolvePluginIcon into PluginIcon

pluginIcon.ts and PluginIcon.tsx resolve to the same file on the
case-insensitive filesystems dev checkouts commonly sit on (Windows,
macOS) — tsc sees both casings of one module and fails with TS1149 in
every importer. Keep the resolver and the component in one module.

* feat(sdk): add update verification

* chore: remove test files

* fix(sdk) rework the sdk helpers and DX/UX

* fix(sdk) harden dev environment

* fix(budget): add back the multi payer selection

* fix(map): stop MapLibre mouse rotation from reversing near mid-screen

Since MapLibre 5's camera rewrite, the right-button rotate handler flips
its sign whenever the cursor sits above a mid-screen line it derives by
re-projecting the map center. That line drifts with the bearing by a
fraction of a pixel, so inside the 100px band around the screen center a
steady horizontal drag lands alternately above and below it — every
processed movement reverses the previous one and the camera ping-pongs in
place instead of rotating (#1545). A real hand crossing the line mid-drag
flips the rotation direction outright. maplibre-gl 4.x rotated from plain
horizontal movement and had none of this.

Passing aroundCenter: false opts the handler out of the around-center
mode and restores the 4.x/mapbox-gl behaviour: horizontal drag rotates,
vertical drag pitches, in one continuous motion. Applied to all three GL
map builds (planner, journey, settings preview); mapbox-gl keeps its
options untouched.

* fix(budget): settle in the trip's real currency, not always EUR

The settlement route read `currency` off the row returned by canAccessTrip,
whose SELECT never included the column. A cast hid the mistake, so trip.currency
was always undefined and the settlement was told every trip is in EUR.

Balances are netted in the trip currency and converted to the display currency
once. With the trip mislabelled EUR, expenses in the trip's own currency still
cancelled out, but an expense booked in a foreign currency was divided by its
frozen rate into trip-currency units, then converted again as if those were
euros — inflating balances by the EUR/trip rate (~27x for a RUB trip with a USD
expense, #1543). MCP was unaffected: it reads the currency with its own SELECT.

Select the currency in canAccessTrip so the read is real. The settlement maths
was correct all along; it was simply being lied to about the base currency.

Fixes #1543

* feat(trips): let users set the trip currency, and rebase the budget when it changes

The trip currency is the base every expense and settle-up is netted against, but
the only picker for it lived in the legacy Budget addon panel — so on the Costs
panel a trip was stuck with whatever it was created as. Add the field to the trip
form, on create and on edit, gated on trip_edit. The REST and MCP write paths
already accepted `currency`; only the form was missing.

Changing it is not a rename, though: an expense's frozen `exchange_rate` is
"units of its currency per 1 trip currency", and `currency = NULL` means "the
trip's own", so both are relative to the outgoing base. Swapping it out from
under them redenominates the implicit rows (9 000 RUB becoming 9 000 EUR) and
leaves the frozen rates pointing at a currency the trip no longer uses — the same
mismatch that inflated #1543.

So rebaseTripCurrency() runs first, while the old currency is still on the row:
it pins the implicit rows to the outgoing currency and re-freezes every rate
against the incoming one, for expenses and settle-up transfers alike. No stored
amount is rewritten — each keeps the figure the user typed, in the currency they
typed it in, and its real-world value survives the switch.

Also covers the #1543 data as a settlement regression test.

* feat(budget): give settle-up payments their own currency

A transfer settling a shared bill can be made in any currency — paying a rouble
debt in euros is normal — and the server has stored `currency` + a frozen
`exchange_rate` on every transfer since #1445, re-freezing it on edit. The UI
just never let anyone choose one, so a payment silently inherited whatever the
viewer's display currency happened to be.

Add the picker to the payment modal, mirroring the expense modal, and reopen an
existing payment in the currency it was actually recorded in. The ledger row now
shows a foreign payment as `$30.00 -> 27,00 EUR` like a foreign expense does,
instead of stamping the display currency's symbol onto the raw stored number.

The Settle buttons on the suggested flows keep sending the display currency:
those amounts are computed in it.

* feat(settings): make the display currency optional, falling back to the trip's

Costs already resolved `default_currency || trip.currency || 'EUR'`, but the
setting could never actually be empty: the store seeded 'USD', so a user who had
never touched it silently had every trip converted into dollars, and the picker
offered no way to unset it.

Seed it empty and lead the picker with a "Trip currency" option, so an unset
preference means "show each trip in its own currency" instead of forcing them all
through one code. An explicit empty value persists and beats the admin-set
instance default — it is a deliberate choice, not an absence.

This also brings the public share's fallback to life: the share payload has
resolved sharer's currency -> trip currency since #1361, but the trip-currency
branch was unreachable while every owner had a currency forced on them.

Plugins are handed `formats.currency` as a concrete code, so PluginFrame now
resolves the same chain rather than passing an empty string through the bridge.

* docs(wiki): explain the three currencies and how they relate

Trip currency, expense currency and display currency answer three different
questions, and nothing said so: the trip currency wasn't documented at all (it
had no picker until now), and Budget-Tracking conflated the other two while
still claiming 47 currencies and a display currency that always came from
Settings.

Add a Currencies page as the one place they're defined together — the trip
currency as the accounting base, the expense currency as the receipt with its
rate frozen at entry, the display currency as presentation-only — plus what
happens when a trip's currency changes, which currency a public share renders
in, and what belongs to the Costs addon versus the trip itself.

Rewrite Budget-Tracking's currency section against it, document the currency
field in Creating-a-Trip and the display currency in Display-Settings (which
never mentioned it), and note the sharer-or-trip fallback in Public-Share-Links.

* feat(help): serve the in-app wiki from disk instead of fetching GitHub

The /help pages fetched their markdown from raw.githubusercontent.com at
runtime, so a self-hosted install was served docs from main rather than the
version it was actually running, and help was unusable without network access.
The wiki/ directory was in the repo the whole time; the bundled-snapshot
fallback the code reached for was gitignored and never populated by any build
step, so it was dead code.

Read wiki/ straight from disk instead. server/{src,dist}/services both sit
three levels under the repo root, so a single __dirname anchor resolves in dev,
a built source install, vitest and Docker with no copy or build step. GitHub is
kept strictly as a fallback for when the directory cannot be resolved, decided
once at load by probing for _Sidebar.md: a page missing from a present wiki is
a genuine 404, since falling back per-file would reintroduce the version skew
this removes.

Ship wiki/ in the image: .dockerignore excluded it outright, so the COPY alone
would have produced an image with no wiki, and the GitHub fallback would have
masked that at runtime. Add a real path-containment check on assets now that
the path becomes a filesystem read rather than a URL, and document
TREK_WIKI_DIR as an off-by-default escape hatch across the deployment configs.

* fix(plugins): serve frame assets root-relative and cross-origin loadable

res.sendFile(absolutePath) resolves against the rewritten req.url under
the Nest ExpressAdapter and 404s spuriously (files-download already
works around the same trap), which broke every plugin frame document.
And helmet's CORP: same-origin made the browser drop the opaque-origin
frame's own script/style subresources, so a multi-file plugin client
could never boot. Serve root-relative and mark frame responses
cross-origin — sandbox + per-plugin CSP stay the isolation boundary.

* feat(plugins): let a plugin ship its own settings page

A plugin that declares capabilities.settingsUi: true gets its
client/settings.html framed as a card under Settings -> Plugins — same
opaque-origin sandbox and postMessage bridge as its widget, sized via
trek:resize. Hosts that predate the flag strip it at install, so old
instances keep working and simply don't show the card.

* feat(map): open maps framed on their places (builds on #1393) (#1556)

* fix(map): fit MapLibre routes reliably

* fix(map): default planner map to world view

* fix(map): only await route geometry when a route is actually pending

The fit armed a pending route-refit slot on every fitKey change, even with no
route drawn, and only ever cleared it when a route arrived. So a route toggled
on much later — after the user had panned elsewhere — was mistaken for the
awaited geometry and yanked the camera back, and only on the first toggle.

Arm the slot only when a route is already on screen: updateRouteForDay lays down
straight lines in the same batch as the fit and upgrades them to real geometry a
moment later, so an empty route at fit time means none is coming.

* fix(collections): open the empty collection map on the world view

A collection with no mappable places centred on Paris, the same hardcoded
default this branch removes everywhere else.

* feat(map): open the map framed on its places

A trip in Japan opened on the world view at 0,0 and only then animated a fitBounds
flight across the planet — the hardcoded default was the map's answer to a question
its own places already answer.

Each renderer now derives its opening camera from the places it receives, at
construction: MapView for Leaflet, MapViewGL for MapLibre and Mapbox (whose zoom
runs one level below Leaflet's, measuring against a 512px world tile rather than
256px). Doing it at construction is what confines it to load — the map is built
once, and by then the trip's places are in hand. Nothing recomputes it afterwards,
so the camera stays where the user leaves it, and the opening fit stands down
rather than overruling the gentler zoom a lone place opens at.

A trip with no coordinates still falls back to the world view. Collections and the
public shared-trip page frame themselves the same way.

* refactor(settings): drop the default map centre and zoom

Nothing reads them now that every map frames itself on its own places, and a
home-city default was the wrong answer for the next trip on the other side of the
world. The style preview keeps a fixed location of its own: it needs a city to show
label density and 3D buildings, which open ocean cannot.

* fix(map): frame the map the way each renderer can actually draw

Two defects the unit tests missed and running the app exposed.

MapLibre and Mapbox opened on Null Island at zoom 2 regardless of the places: the
effect that mirrors an external centre prop onto the camera also ran on mount, so
it jumped straight to the default nobody passed and threw away the camera the map
had just been built with. It now only responds to actual changes, which is what it
was for. Leaflet was unaffected — its controller already guarded on the centre
changing.

A trip spanning Sydney, Reykjavik and Santiago lost Sydney's marker entirely. The
narrowest arc containing all three crosses the antimeridian, and framing there is
only sound on a renderer that repeats the world: MapLibre and Mapbox draw a marker
on whichever copy is nearest the camera, Leaflet draws one world and puts the
marker at its absolute position — off-screen. Leaflet now spans the long way round,
as L.latLngBounds would. The test that should have caught this wrapped the x-offset
in its own projection helper, quietly assuming behaviour Leaflet does not have; it
now models each renderer's real wrapping.

---------

Co-authored-by: Azalea <noreply@aza.moe>

* feat(mcp): expose public transit planning tools (#1558)

* feat(mcp): add public transit planning tools

* refactor(transit): reuse local time conversion

* fix(mcp): harden transit journey validation

* refactor(transit): centralize itinerary processing

* fix(mcp): return the transit itineraries the provider actually offers

search_transit_routes validated each itinerary leg's mode against
SCHEDULED_TRANSIT_MODES, but that constant is the request-side filter
whitelist — the modes a caller may ask for — not the modes MOTIS can return.
Its default TRANSIT mode expands to TRAM,FERRY,AIRPLANE,BUS,COACH,RAIL,ODM,
RIDE_SHARING,FUNICULAR,AERIAL_LIFT,OTHER, and street legs can be BIKE/CAR/
RENTAL. Any itinerary carrying one of those failed the parse and was dropped
by the flatMap, so the tool reported fewer routes than exist — or none at all.
Against the live provider, Trondheim → Ålesund returns 5 itineraries and 3 of
them contain an AIRPLANE leg, so the tool silently discarded them; the web app
shows all 5, because it treats mode as a free string and renders anything
non-WALK as a transit leg.

Accept any mode token on a leg and keep the existing "at least one non-WALK
leg" rule as the real gate, which restores parity with the web app. Everything
downstream keeps its mode !== 'WALK' semantics, so a journey created over MCP
is identical to one created in the app.

Dropping an itinerary is still possible when provider data fails the remaining
consistency rules, but it is indistinguishable from "no routes exist" — so
search_transit_routes now reports a `dropped` count alongside the results.

---------

Co-authored-by: Uzini <43294422+Uziniii@users.noreply.github.com>

* fix: show map poi search controls on mobile (#1555)

* fix(pdf): use the trip's actual currency instead of hardcoded EUR (#1519)

* fix(pdf): use the trip's actual currency instead of hardcoded EUR

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(i18n): drop hardcoded currency text from pdf.costLabel across locales

Remove redundant EUR/currency references from pdf.costLabel translations
across 21 locales now that the PDF export correctly renders amounts
with their actual trip currency via formatMoney().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(pdf): drop hardcoded euro-shaped icon from place price chip

svgEuro rendered a fixed € glyph next to the price chip regardless of
the trip's actual currency, undermining the currency fix. Swap for a
currency-neutral coin icon.

---------

Co-authored-by: Nguyen Trong Binh <nguytb15@VN1N07HO1CD1015.local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix(trips): pin place prices when the trip currency changes

A place price with no currency of its own means "the trip's own currency" —
that is how the PDF export and the place chips read it since #1519. But
rebaseTripCurrency() only pinned budget_items and budget_settlements, so
switching a trip's currency silently redenominated every implicit place
price: a €15 museum on a trip moved to JPY started reading as ¥15. Same
class of mismatch as #1543, on the places surface.

Pin priced, currency-less places to the outgoing currency inside the same
transaction. No amount is rewritten — the figure the user typed keeps its
real-world value, it just stops being ambiguous about which unit it is in.
Bump updated_at as well: it doubles as the optimistic-concurrency token
(#1135), so a client holding the pre-switch row can no longer write the
pin away.

Document place prices in the wiki's currency model, and drop the now-stale
"in EUR" from the PDF export's estimated-cost stat.

* fix(budget): re-split expenses when a member is removed

* fix(plugins): derive plugin req.user.isAdmin from role, not is_admin

* chore(test): make jsdom Storage work under Node's native Web Storage globals

* fix: recompute vacay calendar when week start changes (#1554)

* fix(websocket): handle socket 'error' events to prevent crash on malformed frames (#1584)

* fix(packing): apply templates into the active list tab (#1581)

* Fix packing list readability on mobile

* Localize packing quantity label in overflow menu

* fix(atlas): stream boundary GeoJSON instead of caching parsed bundles (#1585)

* fix(atlas): stream boundary GeoJSON instead of caching parsed bundles

* fix(atlas): scan admin1 features in one pass to avoid O(n^2) build

createFeatureSplitter re-scanned each partial Feature from the start on
every gunzip chunk, so a large feature (Canada ~5.5MB, ~340 chunks) made
the one-time admin1 store build O(n^2). That pushed the streaming build
(~4.2s) past the 15s test timeout under CI coverage/fork contention and
added the same latency to the first live region request.

Carry scan state (position, brace depth, string state) across chunks so
each character is examined once. Output is byte-identical (3228 features,
197 countries) and peak build memory is unchanged (~192MB RSS under a hard
512MB cap); build time drops to ~1.7s.

* fix(i18n): add packing.quantity to all locales

The packing.quantity key existed only in en (Qty) and de (Menge), so
i18n:parity:strict and the client parity test failed for the other 20
locales. Add the key everywhere; en/de are unchanged.

---------

Co-authored-by: jubnl <jgunther021@gmail.com>

* fix(trips): keep accommodations on their dates when the trip range shifts (#1288)

The v3.1.3 fix re-anchored dated bookings after a trip date change but
explicitly excluded hotels: day_accommodations has no absolute date columns,
so stays remained glued to positionally re-dated day rows and shifted with
the range. updateTrip now snapshots day dates before generateDays and a new
resyncAccommodationDays re-anchors each stay (and its linked hotel
reservation, restamping its stale reservation_time) to the days holding its
pre-change dates; out-of-range stays stay glued so whole-trip moves still
shift together. Unlinked dated hotels resync like any booking, and the
date-change block is wrapped in a transaction.

Changing the start date now also asks how plans should follow via a new
date_shift_mode field ('keep_bookings' default / 'shift_all', which reuses
the reorder/insert restamp path to glue everything), exposed in the trip
edit modal (all 22 locales), the shared contract, and the MCP update_trip
tool. Clients no longer show stale state: the initiator reloads
reservations + accommodations after saving, collaborators refetch on a
date-changing trip:updated, and reconnect hydration nudges the planner's
accommodations too.

* fix(extract): retry with json_object and surface AI import failures (#1546)

OpenAI-compatible providers that only support json_object (DeepSeek,
Mistral, some vLLM/llama.cpp) reject the json_schema response_format
with a 400, and the resulting error was swallowed silently: not logged
server-side and never rendered by the background-task widget, leaving
only a generic "no reservations" message.

- Retry the chat/completions request once with response_format
  json_object when the json_schema attempt returns 400 (non-NuExtract
  only); the system prompt already dictates the output shape
- Log swallowed llm-parse errors with an [llm-parse] tag so failures
  show up in server logs
- Render task warnings under the empty-preview note in the background
  tasks widget so the actual provider error reaches the user

* fix(costs): keep ticket item amounts visible on narrow screens (#1568)

* chore: update repo url

* chore: update repo url

* chore: update repo url

* chore: update repo url

* fix(security): block IPv6 transition addresses (NAT64/6to4/Teredo) in SSRF guard

An attacker-controlled DNS record pointing at a NAT64 (64:ff9b::/96),
6to4 (2002::/16), or Teredo (2001:0000::/32) address that embeds a
private IPv4 (e.g. 64:ff9b::a9fe:a9fe = 169.254.169.254) bypassed the
SSRF guard: none of the guard functions recognised these ranges, so on a
host that routes the transition prefix the connection reached the
embedded private target (cloud metadata / internal SSRF).

Add a shared embeddedTransitionIpv4() detector and re-apply each guard's
own blocklist to the extracted IPv4 in isAlwaysBlocked, isPrivateNetwork,
isLinkLocal (ssrfGuard.ts) and isBlockedIp (egress-policy.ts). A
transition address to a public IPv4 stays allowed so legitimate
IPv6-only egress is unaffected.

egress-policy.ts keeps the detector inline to preserve its dependency-free
contract for the isolated plugin subprocess.

* chore: Add star history

* Revert "chore: Add star history"

This reverts commit f9d5f75837.

* fix(map): draw transit routes even without other places on the day

The reservation overlays hide any transport whose from/to endpoints
project closer than a per-type pixel threshold (200px for transit) to
declutter tiny no-op straight connectors. A transit journey, though,
draws its real rail/bus alignment rather than a straight endpoint line,
so on a zoomed-out day — one with no other places to tighten the map
onto — its stations fall under the threshold and the whole route
vanishes.

Exempt a transit booking that carries real per-leg geometry from the
proximity gate in both renderers (Leaflet + MapLibre); a geometry-less
transit keeps the straight-arc declutter.

* fix(atlas): resolve region AND country by coordinates against the bundled polygons

Rebased onto dev's streaming atlas index (#1576): region resolution now
resolves a place's lat/lng directly against the same bundled admin1
polygons the client renders — offline, deterministic, and guaranteed to
match a bundle feature — rather than trusting Nominatim's address level,
which can name a subdivision the bundle doesn't carry (Barcelona's ES-B
province vs the bundle's ES-CT autonomous community) and never highlight.
Country resolution moves to the same coordinates-first order, so a place
stored "..., San Francisco, CA" no longer resolves to Canada.

admin1 is held as per-country GeoJSON text (never parsed whole, #1576),
so a country's regions are flattened to the compact Float64Array form and
cached on first use — only visited countries pay the parse. The stale GB
constituent-country rescue is removed, and a one-time migration clears the
re-derivable place_regions cache so every place re-resolves under the new
logic.

Closes #1547

* fix(client): convert mixed-currency day/trip cost totals instead of mislabeling raw sums (#1561)

Day headers, the plan sidebar footer and the PDF day/cover totals summed
raw place prices across currencies and labeled the result with a single
currency — a $2,730.27 hotel on a NOK trip read as "2730 NOK".

Totals now convert every amount into a base currency via the existing
frankfurter rates (sidebar: the user's display currency, falling back to
the trip's; PDF: the trip currency, resolved once before rendering so the
document is consistent), marked with "≈" when a conversion happened. When
a rate is unavailable (offline, blocked egress, unknown code) they fall
back to an honest per-currency breakdown ("2 500 kr + $2,730.27") instead
of folding foreign amounts into a mislabeled number. All-same-currency
trips make no FX request, so offline PDF export keeps working.

Also swaps the place inspector's hardcoded € chip icon for a neutral one
and formats the price via formatMoney in the place's own currency.

Note: day-header totals move from "50 EUR" to Intl formatting ("50 €").

* feat(atlas): let a visited region be hidden, cascading to the country when none remain (server)

Countries already have a hide/tombstone mechanism (hidden_countries, #1490):
a zero-count derived country can be dismissed and stays gone across reloads.
Regions had no equivalent — unmarkRegionVisited only ever deleted a
manually-marked visited_regions row, a no-op for the common case of a region
derived fresh from place_regions on every request, so there was no way to
dismiss one at all (place-derived or otherwise).

Adds the region-level counterpart:
- New hidden_regions table (user_id, region_code, country_code), mirroring
  hidden_countries.
- getVisitedRegions() filters its result through it, the same way getStats()
  already filters through getHiddenCountries().
- unmarkRegionVisited() now tombstones unconditionally (not just for a
  manually-marked region — a region with a real place attached, e.g. one
  misassigned by a border-simplification gap, is exactly the case this
  exists for) and derives the country code from the region code's
  "<country>-<rest>" prefix when there's no visited_regions row to read it
  from.
- Cascade: after hiding a region, if the country has no other visible region
  left (checked against place_regions + visited_regions, minus hidden_regions),
  the country is hidden too via the existing unmarkCountryVisited.
- markRegionVisited() clears both tombstones on re-mark, so a region (and its
  cascade-hidden country) can come back.

Note the cascade only has a visible effect on a country with no real place
attached to it — getStats' places-derived country entries are never
suppressed by hidden_countries (#1490's deliberate "reappears with a real
place" rule), so hiding every region of a country that DOES have real places
leaves the country visible, by the same existing design. Test coverage
reflects this.

Server-side only — client wiring (a way to trigger this from the map) is a
separate commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(atlas): let a visited region be hidden, cascading to the country when none remain (client)

Client wiring for the server-side hide-region feature (previous commit).

Clicking a visited region on the map used to open the hide/unmark
confirmation only when it was manually marked (visitedRegions[...].manuallyMarked);
a region derived from real place data instead opened the country-detail
view, with no way to dismiss it at all. Now any visited region offers the
same "Remove this region from your visited list?" confirmation regardless of
how it was derived — country details remain reachable via the country
search/sidebar, which was never gated on this in the first place.

The confirm handler's optimistic country-removal check dropped its
`&& r.manuallyMarked` filter on the remaining-regions count, matching the
server's unconditional cascade — but keeps the existing "only when the
country has zero real places/trips" guard, since a country backed by real
data is never actually hidden server-side (#1490) and removing it from the
UI early would just flash and reappear on the next reload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(settings): make fresh-instance unit defaults internally consistent

A brand-new instance — no admin-set defaults, no saved value — rendered
temperatures in Fahrenheit and times on the 12-hour clock while distances
defaulted to metric, a mix that matches no locale. The store also seeded
'fahrenheit'/'12h' while DisplaySettingsTab's fallback said 'celsius', so the
two paths disagreed about the intended default in the first place.

Default to one system — celsius / metric / 24h — matching the already-metric
distance_unit. The unit defaults now live in a single exported DEFAULT_SETTINGS
that both the store and DisplaySettingsTab's fallback read, so they can't drift
apart again. Admin-set user defaults (getAdminUserDefaults) and any value a user
has already saved still take precedence; only the code-level default changed.

* feat(i18n): complete Catalan (ca) translation

Adds Catalan as a supported language — the full shared/src/i18n/ca locale
(all domain files plus the notification texts), registered in
SUPPORTED_LANGUAGES and the client locale loader. Rebuilt onto current dev so
the locale is at full key parity with en (i18n:parity:strict clean).

* fix(map): stop real road-route fetches from dying under StrictMode

useTransportRoutes cached its AbortController in a ref that was
created once and aborted on unmount. React StrictMode's dev-only
mount->cleanup->remount cycle ran that abort during the *simulated*
cleanup, permanently poisoning the controller before the real mount's
fetch ever started — every road-routed booking (car/bus/taxi/bicycle)
silently fell back to a straight line in local dev, while production
builds (no StrictMode double-invoke) worked fine.

Fix: create a fresh AbortController per effect run instead of a
ref-cached singleton, and synchronously un-mark a job as "attempted"
in that same run's cleanup if it didn't settle before the cleanup
fired — so a StrictMode remount (or any other pre-completion
cancellation) retries instead of being skipped forever. Verified
against the real OSRM endpoint in a running dev server: all four
road-routed legs on a live trip now resolve with real road geometry
instead of straight lines.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(map): add reservation route-visibility util

Extracts the route-visibility filter that was hand-copied, identically,
in both MapView.tsx and MapViewGL.tsx into one pure, unit-tested
function: a reservation's route shows on the map when it's a transit
booking with the day-route toggle on, or its id is in the caller's
visible-ids set. isRoutableReservation (>= 2 endpoints) is exported
separately since callers besides the map filter need the same check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(map): add per-trip connections-visibility resolution util

Pure storage/resolution logic for a trip's booking-route visibility
preference, keyed trek:visible-connections:<tripId>. Two modes:
'only' (nothing shown except the listed ids — today's existing
behavior; a legacy bare-array localStorage value parses as this mode
for backward compatibility) and 'all-except' (everything routable
shown except the listed ids). A trip with no stored preference falls
back to the account-wide default (all or nothing) without writing
anything, so flipping the account setting later never silently
overrides a trip with an explicit per-trip choice already recorded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(settings): add always-show-booking-routes account setting

New map_always_show_routes account setting, defaulting to off, i18n'd
across all 22 supported locales. Lives in Display > Travel & map,
directly under Booking route labels — its closest sibling — using
that section's immediate-save On/Off pattern rather than a separate
toggle+Save flow, since it's a booking-display preference, not a map
render-config option.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(map): per-trip booking-route visibility with account default and bulk toggle

Lets a user see a booking's route on the map without manually
toggling it per item, two ways:

- The account setting from the previous commit sets the default for
  any trip that's never had its routes touched before.
- A new bulk "show all / hide all" button in the day-plan toolbar
  flips a trip explicitly between the two connectionsVisibility modes,
  independent of the per-item toggle (which still edits whichever
  mode's id list is active, in both directions, including while the
  account default is on).

useTripPlanner.ts resolves a trip's effective visible-connection ids
from connectionsVisibility.ts + the account setting + the trip's
routable reservations, and exposes it through the same
visibleConnections/toggleConnection contract MapView, MapViewGL and
DayPlanSidebar already had, plus allConnectionsShown/
toggleAllConnections for the new bulk control. MapView/MapViewGL
consume it via the shared reservationRoutes util instead of each
carrying their own copy of the filter.

The bulk toggle's tooltip gets its own map.showAllConnections/
hideAllConnections i18n keys (all 22 locales) distinct from the
per-item toggle's text, and matches the per-item toggle's active
(solid blue) styling rather than the toolbar's generic hover tint.

Manually verified end-to-end in a running dev server: the account
default seeding an untouched trip, the bulk toggle flipping a trip
between all-shown/all-hidden, and a single per-leg override while the
trip is in all-shown mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Update documentation for booking visibility change

* fix(i18n): add Catalan keys for the booking-route toggle + fix the language-count test

#1483 added map.showAllConnections/hideAllConnections and
settings.alwaysShowRoutes/Hint to every locale that existed when it was
written; Catalan (#1418) landed just after and was missing them, which
broke i18n parity. Also bumps the SUPPORTED_LANGUAGES test to 23 entries
and asserts the Catalan one — the ca addition left that assertion stale.

* test(e2e): add a reproducible documentation screenshot run

The wiki screenshots had drifted three months and two releases behind the UI:
39 of 46 assets came from a single commit in April, and pages such as
Budget-Tracking carry a disclaimer that their own images are out of date.
Retaking them by hand is what created that drift in the first place.

This adds a `screenshots` Playwright project that captures them from a dev
build instead:

- seed.ts populates a demo trip over the REST API — deliberately in JPY, so the
  3.4.0 currency work (per-trip currency, frozen FX rates, foreign-currency
  settle-up) is actually visible rather than hidden behind EUR.
- shot.ts settles the page before capturing (fonts, images, transitions) so
  captures don't catch skeleton loaders, and rewrites /auth/app-config to clear
  dev_mode — the E2E backend runs NODE_ENV=development, which would otherwise
  put a "Dev: Notifications" tab no real deployment has into every admin shot.
- promote.mjs downscales 2880px captures to 1600px on the way into
  wiki/assets/, which is 70% smaller for detail the wiki never renders.

Tabs are located by their visible label, so a rename like Budget → Costs fails
the run loudly instead of quietly capturing the wrong panel.

npm run shots && npm run shots:promote

* docs(wiki): retake screenshots against 3.4.0

Regenerated with `npm run shots` from the dev build. Replaces 21 assets and
adds 13 surfaces that had no screenshot at all.

Notable corrections:

- Collections.md referenced assets/Collections.png, which was never committed —
  the only broken image in the wiki. It now exists.
- The Budget panel became Costs in 3.3.0 (#1464); every budget image still
  showed the old label.
- The trip-create dialog gained a Currency field in 3.4.0, absent from the old
  TripCreate.png.
- Settings grew a Plugins tab and the admin sidebar a Plugins entry; neither
  appeared in the old sidebar shots.
- Weather now renders in Celsius. A fresh instance defaults to Fahrenheit while
  distance defaults to metric, so the seed pins both units — the mismatched
  defaults are a separate bug, not fixed here.

Koffi is installed from the community registry rather than dev-linked, because
dev-link and sideload both stamp a badge on the plugin card that an ordinary
install never shows.

Total 3.6 MB for 34 images, against 26 MB for the 46 assets already in the
directory.

No wiki page text is touched here — pages still point at the old filenames
where those were kept, and the newly added images are not referenced yet.

* fix(help): make wiki links and anchors work in the in-app reader

The wiki pages are written in GitHub-wiki style, and 455 of their links across
81 pages use the bare relative form — `[Currencies](Currencies)` — against only
114 `[[..]]` links. processMarkdown only rewrote the latter, so in-app every one
of those 455 fell through to HelpPage's external-link branch, opened a new tab
and 404'd. Since 6c87bf2f serves the wiki from disk, that is the primary way
users read these docs.

Rewriting them in the renderer fixes every page at once and keeps the sources
GitHub-compatible, so contributors can keep writing either form and neither
target breaks. Rewriting the 81 files instead would have fixed today's pages and
left the trap open for tomorrow's.

Also:

- Code is protected from the rewrite. Plugin-Development.md documents
  `actions[key](ctx)`, which reads as a markdown link and would otherwise be
  corrupted into `actions[key](/help/ctx)` inside a verbatim snippet.
- `[[Page#anchor|Slug]]` no longer renders its anchor as visible link text.
- Headings carry GitHub-compatible ids, so the 22 in-page `](#anchor)` links
  scroll instead of doing nothing.

* docs(wiki): correct settings, map and addon docs against 3.4.0

The wiki described settings that no longer exist and pointed at UI labels that
had been renamed. Each correction below was checked against the code.

- The Settings tab is labelled **General**, not "Display"
  (shared/src/i18n/en/settings.ts:6). Every "Settings → Display" path was wrong.
  The *display currency* setting keeps its name — only the tab was renamed.
- Colour mode is on the **Appearance** tab, not Display.
- The "Route calculation" setting does not exist: no hits for route_calc /
  routeCalc / auto_route / calculateRoutes anywhere in client or server.
- Default map centre and zoom were removed in 3.4.0 (0f4766e1). Replaced with
  what actually happens now, written from client/src/utils/mapViewport.ts:
  every map frames itself on its own places, world view when a trip has no
  coordinates.
- A third map provider, MapLibre GL / OpenFreeMap, was undocumented. It needs no
  access token, which is the reason a reader would choose it over Mapbox.
- Budget-Tracking.md said the feature is called Costs everywhere and then told
  the reader to open the "Budget" tab. The screenshot disclaimer is gone too —
  the images now show Costs.
- The admin tab table was missing Plugins.
- The `packing` addon is seeded as "Lists", not "Packing list management".
- Install docs pinned `mauriceboe/TREK:3.0.15` as the exact-release example,
  four minor versions stale.

* docs(wiki): document passkeys, calendar feeds, appearance, plugins and help

Five shipped features had no user documentation at all:

- **Passkeys** — WebAuthn enrolment and sign-in, admin policy, RP ID/origins.
  Previously mentioned only in passing in Environment-Variables.
- **Calendar Feeds** — the subscribable per-trip and per-user ICS feeds. Note
  Day-Plans-and-Notes documents only the one-off .ics *export*; the two are
  cross-referenced so it is clear which is which.
- **Appearance Settings** — the whole tab, including the custom accent colour
  and its contrast check.
- **Admin: Plugins** — installing from the registry, the pre-install permission
  review, egress hosts, and what Reviewed/Signed/Unsigned actually guarantee.
- **In-App Help** — that the wiki ships in the image and is served from disk
  since 3.4.0, with the GitHub fallback and TREK_WIKI_DIR.

One deliberate deviation from the brief: the Appearance settings are documented
as account-level, not per-device. They persist to /api/settings on the user
account with nothing in localStorage, so the dashboard widget picker's
desktop/mobile split still changes both from either device.

All five are listed in _Sidebar.md.

* docs(wiki): add screenshots for the remaining surfaces

Second capture pass, bringing the run to 42 screenshots. Adds the surfaces that
need more than a navigation to reach: collection and journey detail, MCP access,
two-factor setup, the settle-up payment dialog, and the trip file manager.

Two fixes to the harness itself, both of which had produced misleading images:

- The admin captures showed a "Dev: Notifications" tab that only exists when the
  server runs NODE_ENV=development. The run now clears dev_mode in the
  /auth/app-config response; switching the server to production instead would
  have enabled HSTS and broken the run over http://localhost.
- The settle-up capture clicked "Settle up", which does not open a view — it
  records the transfers. It zeroed every balance and photographed "Everyone's
  square", and because the specs share one database it poisoned Costs.png in the
  same run. Screenshot specs must not mutate state; it now captures the
  "Add payment" dialog instead.

Koffi is installed from the community registry rather than dev-linked, since
dev-link and sideload both badge the plugin card in a way no ordinary install
does.

* test(e2e): capture the detail pages and dialogs

Adds the second wave of screenshot specs (collection/journey detail, MCP access,
2FA, settle-up dialog, files) and enables the mcp, documents and collab addons
in the seed so their surfaces render instead of 404ing.

* docs(wiki): point plugin authors at the agent skill and the registry

Plugin-Development jumped straight into scaffolding without saying that two
supporting resources exist. TREK-Plugins was referenced only in passing far down
the page, and Plugin-Skill — an agent skill that teaches Claude Code and other
SKILL.md-compatible agents to build and publish a plugin — was not mentioned
anywhere in the wiki.

Both are called out up front, with a note that neither is required: the registry
only matters once you want other instances to find your plugin.

* test(e2e): capture the four collab surfaces separately

One Collab.png illustrated chat, notes, polls and the What's Next widget, so at
most one of those four wiki pages showed the feature it described. Each now has
its own capture.

Two things the collab seed needed:

- The conversation is posted by three different people. Every collab write is
  attributed to the acting user, and a single-voice chat log would misrepresent
  the feature outright.
- Each member therefore gets its OWN request context, created with an explicit
  `storageState: undefined`. Without that, newContext inherits the project's
  storageState — the admin's trek_session cookie — and extractToken reads the
  cookie BEFORE the Authorization header (server/src/middleware/auth.ts:9). The
  posts still return 200; they are just all recorded as the admin. That is
  exactly what happened on the first attempt, and the DB was the only place it
  showed.

The collab view is not tabbed — CollabPanel renders all panels at once — so the
captures target cards by seeded content rather than clicking tabs or matching
headings, whose DOM text is 'Notes'/'Polls' while CSS renders them uppercase.

* docs(wiki): show every screenshot on the page it belongs to

Finishes the wiring the screenshot commits deliberately left out.

- Embeds the 14 images that were committed but displayed nowhere: the Costs
  panel and settle-up dialog, the trip planner, transports, documents,
  collection and journey detail, the notifications inbox, the Offline and
  Account settings tabs, appearance, admin user defaults, registration and
  password reset.
- Splits the four collab pages onto their own images. Chat, Notes, Polls and
  What's Next each showed the same Collab.png until now; the overview shot moves
  to Real-Time-Collaboration, which had no image at all.
- Day-Plans-and-Notes pointed at TripPlaner.png — one 'n'. It now uses the
  correctly spelled file, and the misspelled one is deleted since nothing else
  referenced it.
- Removes 45 dead '<!-- TODO: screenshot -->' markers whose screenshot had long
  since been added. 9 remain, each on a page that genuinely still lacks the
  image it asks for — so the marker means something again and the gap is
  greppable, which is how this drifted unnoticed for three months.

Every asset in wiki/assets/ is now referenced by a page, and every image
reference resolves to a file.

* docs(wiki): add the four collab screenshots

Chat now shows a real three-person conversation rather than one voice talking to
itself, and the poll shows three separate votes across two options.

44 images, 4.6 MB total.

* test(help): point the asset test at the correctly spelled screenshot

The integration test hard-coded assets/TripPlaner.png — one 'n' — so deleting
the misspelled file broke it. It was the only thing keeping that filename
alive.

* docs(wiki): regenerate the screenshots on top of the rebased dev

ba3733da changed the fresh-instance defaults to celsius/metric/24h. Every
capture showing a clock — chat timestamps, bookings, day plans — and the General
settings tab itself were still on the 12-hour clock, so 39 of 44 images needed a
new run. Regenerating them is one command, which is the point.

The seed keeps pinning the units explicitly: it now matches the new defaults, but
stating them keeps the captures reproducible if a default moves again.

* docs(wiki): regenerate screenshots after rebasing onto dev

dev added a Catalan translation, an always-show-booking-routes account setting
and a bulk route toggle in the day-plan toolbar since the last run — all visible
on captures we ship. 15 of 44 images changed.

Also resolves the Map-Features conflict from 41d12e89: upstream's new bulk-options
section is kept, with the two 'Settings → Display' paths corrected to 'General'.
The tab is labelled General (shared/src/i18n/en/settings.ts:6), and upstream's own
i18n key for that section is settings.general.travelMap.

* chore: only allow manual trigger for the build&push

---------

Co-authored-by: Maurice <61554723+mauriceboe@users.noreply.github.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>
Co-authored-by: Maurice <mauriceboe@icloud.com>
Co-authored-by: Pavel Zolotarevskiy <code@fxgn.dev>
Co-authored-by: Azalea <noreply@aza.moe>
Co-authored-by: Uzini <43294422+Uziniii@users.noreply.github.com>
Co-authored-by: Daniel <drmoreno271@gmail.com>
Co-authored-by: trongbinhnguyen <43725147+trongbinh15@users.noreply.github.com>
Co-authored-by: Konstantinos Thermos <info@subdee.org>
Co-authored-by: Konstantinos Thermos <subdee@users.noreply.github.com>
Co-authored-by: Lucas Español <lucas.espanol@tutanota.com>
Co-authored-by: fbnlrz <frlrnzn@gmail.com>
This commit is contained in:
jubnl
2026-07-18 22:13:52 +02:00
committed by GitHub
parent f9c992ec93
commit a2bd9be184
661 changed files with 33186 additions and 4459 deletions
+2 -1
View File
@@ -129,11 +129,12 @@ function getPlaceWithTags(placeId: number | string): PlaceWithTags | null {
interface TripAccess {
id: number;
user_id: number;
currency: string | null;
}
function canAccessTrip(tripId: number | string, userId: number): TripAccess | undefined {
return db.prepare(`
SELECT t.id, t.user_id FROM trips t
SELECT t.id, t.user_id, t.currency FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
`).get(userId, tripId, userId) as TripAccess | undefined;
+75
View File
@@ -3626,6 +3626,81 @@ function runMigrations(db: Database.Database): void {
);
`);
},
// Why a plugin's update was REFUSED by the signature check (#plugins). A refused
// update leaves a working plugin pinned at its old version — previously the reason
// lived only in a transient toast, so the plugin quietly stopped updating and the
// admin had to re-attempt an update to rediscover why. Record it instead.
//
// `update_block_version` is the registry version that was refused: once the registry
// offers something NEWER, the block describes an artifact nobody is being offered
// anymore, so it reads as stale and the admin can just re-attempt (the next install
// re-verifies and either succeeds or re-blocks with fresh values). Deliberately no
// `status = 'error'` — the plugin still runs fine on its old code.
() => {
for (const col of ['update_block_code TEXT', 'update_block_detail TEXT', 'update_block_version TEXT']) {
try {
db.exec(`ALTER TABLE plugins ADD COLUMN ${col};`);
} catch (err) {
console.warn('[migrations] Non-fatal migration step failed:', err);
}
}
},
// The semver RANGE of TREK versions a plugin declares it supports (its manifest's
// `trek`, e.g. ">=3.2.0 <4.0.0"). The existing `min_trek_version` only carries the
// lower bound, so it cannot express "stops working at 4.0" — which is precisely the
// case the activation gate has to catch after a TREK upgrade. Kept nullable: a plugin
// installed before this column existed has no range recorded, and the gate refuses to
// activate it rather than guessing (see TREK_VERSION_UNKNOWN).
() => {
try {
db.exec('ALTER TABLE plugins ADD COLUMN trek_range TEXT;');
} catch (err) {
console.warn('[migrations] Non-fatal migration step failed:', err);
}
},
// `place_regions` is a re-derivable Nominatim cache, only ever populated for a place ID
// that isn't already cached — so a wrong row, once written, was permanent. Region
// resolution now resolves a place's lat/lng directly against the bundled admin1 polygons
// (the same ones the client renders) instead of trusting Nominatim's address level, which
// could name a subdivision level the bundle doesn't carry (Barcelona's ES-B province vs
// the bundle's ES-CT autonomous community) and never highlight. That fix only helps
// places re-resolved after it, so clear the cache once and let every place re-resolve on
// the next Atlas load. The country_code stored alongside is cleared too, which also drops
// the old wrong-country rows a US-state-abbreviation address used to produce.
() => {
try {
db.exec('DELETE FROM place_regions');
} catch (err) {
// place_regions is created by an earlier migration; tolerate its absence on an
// unusual partial DB rather than aborting startup.
if (!(err instanceof Error) || !err.message.includes('no such table')) throw err;
}
},
// Tombstones for Atlas regions the user has explicitly removed — the region-level
// counterpart to hidden_countries above (#1490). A visited region is normally derived
// fresh from place_regions/visited_regions on every request, so "removing" it has
// nothing to delete; recording it here lets getVisitedRegions suppress a derived region
// the same way getStats already suppresses a derived country. Unlike the country-level
// tombstone (originally only reachable for a manually-marked or zero-count country),
// this also covers a region derived from real place data — e.g. one that ended up on
// the wrong side of a border-simplification gap and the user just wants gone.
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS hidden_regions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
region_code TEXT NOT NULL,
country_code TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, region_code)
);
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_hidden_regions_user ON hidden_regions (user_id);');
},
];
if (currentVersion < migrations.length) {
+38 -3
View File
@@ -9,7 +9,7 @@ import { isAddonEnabled } from '../services/adminService';
import { ADDON_IDS } from '../addons';
import { registerResources } from './resources';
import { registerTools } from './tools';
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient, evictOldestSessionForUser } from './sessionManager';
import { resolveSessionTtlMs, resolveKeepaliveMs } from './config';
import { writeAudit, getClientIp } from '../services/auditLog';
import { getMcpSafeUrl } from '../services/notifications';
@@ -183,6 +183,12 @@ const sessionSweepInterval = setInterval(() => {
// Prevent the interval from keeping the process alive if nothing else is running
sessionSweepInterval.unref();
/** JSON-RPC 2.0 error body. MCP clients parse this and show `message`; a bare `{ error }`
* envelope isn't valid JSON-RPC, so they fall back to a generic "tool execution failed". */
function jsonRpcError(message: string, code = -32000): object {
return { jsonrpc: '2.0', error: { code, message }, id: null };
}
function setAuthChallenge(res: Response, error = 'invalid_token'): void {
const base = (getMcpSafeUrl() || '').replace(/\/+$/, '');
// RFC 9728 §5: resource with path component /mcp → PRM URL must include the path
@@ -304,9 +310,25 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
return;
}
// A client that reuses its session sends the header on every call after initialize, so a
// steady stream of session-less POSTs means the id isn't making it back to the client —
// usually a reverse proxy dropping Mcp-Session-Id, or a client that ignores it. Say so,
// because the visible symptom (sessions piling up to the cap) points nowhere near the cause.
console.warn(
`[MCP] POST without mcp-session-id for user ${user.id} — starting a new session. ` +
'If this repeats on every tool call, the Mcp-Session-Id response header is not reaching ' +
'the client (check that your reverse proxy forwards it).',
);
// At the cap, evict this user's coldest session rather than refusing the request: a client
// stuck re-initializing would otherwise be locked out until the process restarted.
if (countSessionsForUser(user.id) >= MAX_SESSIONS_PER_USER) {
res.status(429).json({ error: 'Session limit reached. Close an existing session before opening a new one.' });
return;
const evicted = evictOldestSessionForUser(user.id);
if (!evicted) {
res.status(429).json(jsonRpcError('Session limit reached. Close an existing session before opening a new one.'));
return;
}
console.log(`[MCP] Session limit (${MAX_SESSIONS_PER_USER}) reached for user ${user.id} — evicted idle session ${evicted}`);
}
// Create a new per-user MCP server and session
@@ -348,6 +370,10 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
sessions.delete(sid);
},
});
// Belt-and-braces: whenever the SDK closes the transport for any reason, drop the map entry.
transport.onclose = () => {
if (transport.sessionId) sessions.delete(transport.sessionId);
};
logToolCallAudit(req, user.id, clientId);
armSseKeepalive(res);
@@ -359,6 +385,15 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
if (!res.headersSent) {
res.status(500).json({ error: 'Internal MCP error' });
}
} finally {
// Only an `initialize` request assigns a sessionId (via onsessioninitialized). Any other
// session-less POST is rejected by the SDK with "Server not initialized" — and this server,
// with its ~200 registered tools, would otherwise be orphaned: never in `sessions`, never
// swept, never closed. Reap it here.
if (!transport.sessionId) {
try { server.close(); } catch { /* ignore */ }
try { transport.close(); } catch { /* ignore */ }
}
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ export const SCOPE_INFO: Record<Scope, ScopeInfo> = {
'notifications:write': { label: 'Manage notifications', description: 'Mark notifications as read and respond to them', group: 'Notifications' },
'vacay:read': { label: 'View vacation plans', description: 'Read vacation planning data, entries, and stats', group: 'Vacation' },
'vacay:write': { label: 'Manage vacation plans', description: 'Create and manage vacation entries, holidays, and team plans', group: 'Vacation' },
'geo:read': { label: 'Maps & geocoding', description: 'Search locations, resolve map URLs, and reverse geocode coordinates', group: 'Geo' },
'geo:read': { label: 'Maps, geocoding & transit', description: 'Search locations and public transit routes, resolve map URLs, and reverse geocode coordinates', group: 'Geo' },
'weather:read': { label: 'Weather forecasts', description: 'Fetch weather forecasts for trip locations and dates', group: 'Weather' },
'journey:read': { label: 'View journeys', description: 'Read journeys, entries, and contributor list', group: 'Journey' },
'journey:write': { label: 'Manage journeys', description: 'Create, update, and delete journeys and their entries', group: 'Journey' },
+35 -10
View File
@@ -16,14 +16,18 @@ export interface McpSession {
export const sessions = new Map<string, McpSession>();
/** Close both halves of a session and drop it from the map. Close errors are non-fatal:
* a transport whose socket is already gone must not block eviction of the map entry. */
function closeSession(sid: string, session: McpSession): void {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
sessions.delete(sid);
}
/** Terminate all active MCP sessions for a specific user (e.g. on token revocation). */
export function revokeUserSessions(userId: number): void {
for (const [sid, session] of sessions) {
if (session.userId === userId) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
sessions.delete(sid);
}
if (session.userId === userId) closeSession(sid, session);
}
}
@@ -32,10 +36,31 @@ export function revokeUserSessions(userId: number): void {
* sessions are closed, not sessions from other clients for the same user. */
export function revokeUserSessionsForClient(userId: number, clientId: string): void {
for (const [sid, session] of sessions) {
if (session.userId === userId && session.clientId === clientId) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
sessions.delete(sid);
}
if (session.userId === userId && session.clientId === clientId) closeSession(sid, session);
}
}
/**
* Close the least-recently-active session for a user so a new one can take its slot,
* and return its id (null when the user has no sessions).
*
* This is what keeps the per-user cap from becoming a dead end. A client that cannot
* persist its Mcp-Session-Id — a proxy stripping the header, a non-conformant client —
* re-initializes on every call, and a hard cap would leave the user permanently unable
* to open a session until the process restarted. Evicting the coldest session instead
* means the worst case is a bounded ring of sessions, not a wedged integration.
*/
export function evictOldestSessionForUser(userId: number): string | null {
let oldestSid: string | null = null;
let oldestSession: McpSession | null = null;
for (const [sid, session] of sessions) {
if (session.userId !== userId) continue;
if (!oldestSession || session.lastActivity < oldestSession.lastActivity) {
oldestSid = sid;
oldestSession = session;
}
}
if (!oldestSid || !oldestSession) return null;
closeSession(oldestSid, oldestSession);
return oldestSid;
}
+3
View File
@@ -14,6 +14,7 @@ import { registerPackingTools } from './tools/packing';
import { registerCollabTools } from './tools/collab';
import { registerTripTools } from './tools/trips';
import { registerTransportTools } from './tools/transports';
import { registerTransitTools } from './tools/transit';
import { registerVacayTools } from './tools/vacay';
import { registerMcpPrompts } from './tools/prompts';
@@ -44,6 +45,8 @@ export function registerTools(server: McpServer, userId: number, scopes: string[
registerTransportTools(server, userId, scopes);
registerTransitTools(server, userId, scopes);
registerJourneyTools(server, userId, scopes);
registerVacayTools(server, userId, scopes);
+15 -2
View File
@@ -1,6 +1,6 @@
import { broadcast } from '../../websocket';
import { db } from '../../db/database';
import { checkPermission } from '../../services/permissions';
import { broadcast } from '../../websocket';
export function safeBroadcast(tripId: number, event: string, payload: Record<string, unknown>): void {
try {
@@ -19,6 +19,11 @@ export const TOOL_ANNOTATIONS_READONLY = {
openWorldHint: false,
} as const;
export const TOOL_ANNOTATIONS_OPEN_WORLD_READONLY = {
...TOOL_ANNOTATIONS_READONLY,
openWorldHint: true,
} as const;
export const TOOL_ANNOTATIONS_WRITE = {
readOnlyHint: false,
destructiveHint: false,
@@ -40,6 +45,11 @@ export const TOOL_ANNOTATIONS_NON_IDEMPOTENT = {
openWorldHint: false,
} as const;
export const TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT = {
...TOOL_ANNOTATIONS_NON_IDEMPOTENT,
openWorldHint: true,
} as const;
export function demoDenied() {
return { content: [{ type: 'text' as const, text: 'Write operations are disabled in demo mode.' }], isError: true };
}
@@ -49,7 +59,10 @@ export function noAccess() {
}
export function permissionDenied() {
return { content: [{ type: 'text' as const, text: 'You do not have permission to perform this action on this trip.' }], isError: true };
return {
content: [{ type: 'text' as const, text: 'You do not have permission to perform this action on this trip.' }],
isError: true,
};
}
/**
+217
View File
@@ -0,0 +1,217 @@
import { canAccessTrip } from '../../db/database';
import { RateLimitService } from '../../nest/auth/rate-limit.service';
import { isDemoUser } from '../../services/authService';
import { getDay, listDays } from '../../services/dayService';
import { createReservation, notifyBookingChange } from '../../services/reservationService';
import {
buildTransitReservationParts,
cleanTransitItineraryNames,
transitCoordinatesMatch,
transitCoordinatesSchema,
transitItinerarySchema,
transitPlaceSchema,
} from '../../services/transitItineraryService';
import { geocode, plan, SCHEDULED_TRANSIT_MODES } from '../../services/transitService';
import { canRead, canWrite } from '../scopes';
import {
demoDenied,
hasTripPermission,
noAccess,
ok,
permissionDenied,
safeBroadcast,
TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
} from './_shared';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
const TRANSIT_RATE_WINDOW = 15 * 60 * 1000;
const transitRateLimiter = new RateLimitService();
const transitModes = z.enum(['TRANSIT', ...SCHEDULED_TRANSIT_MODES]);
function errorResult(err: unknown, fallback: string) {
return {
content: [{ type: 'text' as const, text: err instanceof Error ? err.message : fallback }],
isError: true,
};
}
function rateLimit(userId: number, bucket: string, max: number) {
if (transitRateLimiter.check(bucket, String(userId), max, TRANSIT_RATE_WINDOW, Date.now())) return null;
return {
content: [{ type: 'text' as const, text: 'Too many transit requests. Please try again later.' }],
isError: true,
};
}
export function registerTransitTools(server: McpServer, userId: number, scopes: string[] | null): void {
if (canRead(scopes, 'geo')) {
server.registerTool(
'search_transit_stops',
{
description:
'Search real public-transit stops and stations via Transitous. Use the returned coordinates with search_transit_routes.',
inputSchema: {
query: z.string().min(2).max(200),
language: z.string().min(2).max(5).optional(),
near: z
.object(transitCoordinatesSchema.shape)
.optional()
.describe('Optional coordinates used to bias nearby results'),
},
annotations: TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
},
async ({ query, language, near }) => {
const limited = rateLimit(userId, 'mcp_transit_geocode', 300);
if (limited) return limited;
try {
return ok(await geocode(query, language, near ? `${near.lat},${near.lng}` : undefined));
} catch (err) {
return errorResult(err, 'Transit stop search failed.');
}
},
);
server.registerTool(
'search_transit_routes',
{
description:
'Search scheduled public-transit routes via Transitous between two coordinates. Returns itineraries that can be passed unchanged to create_transit_journey. `dropped` counts provider itineraries that failed validation and are therefore absent from the results — a non-zero value means the provider offered routes this tool could not represent.',
inputSchema: {
from: transitPlaceSchema,
to: transitPlaceSchema,
time: z
.string()
.datetime({ offset: true })
.optional()
.describe('ISO 8601 departure or arrival time with timezone offset'),
arriveBy: z.boolean().optional().default(false),
modes: z.array(transitModes).max(14).optional(),
maxTransfers: z.number().int().min(0).max(10).optional(),
},
annotations: TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
},
async ({ from, to, time, arriveBy, modes, maxTransfers }) => {
const limited = rateLimit(userId, 'mcp_transit_plan', 60);
if (limited) return limited;
try {
const result = await plan({
from: `${from.lat},${from.lng}`,
to: `${to.lat},${to.lng}`,
time,
arriveBy,
modes: modes?.join(','),
maxTransfers,
});
const itineraries = result.itineraries.flatMap((itinerary) => {
const parsed = transitItinerarySchema.safeParse(cleanTransitItineraryNames(itinerary, from.name, to.name));
if (!parsed.success) return [];
const firstStop = parsed.data.legs[0].from;
const lastStop = parsed.data.legs[parsed.data.legs.length - 1].to;
return transitCoordinatesMatch(from, firstStop) && transitCoordinatesMatch(to, lastStop)
? [parsed.data]
: [];
});
// A rejected itinerary is provider data we could not vouch for, but dropping it
// silently is indistinguishable from "no routes exist" — report the count so the
// caller knows the difference.
return ok({ itineraries, dropped: result.itineraries.length - itineraries.length });
} catch (err) {
return errorResult(err, 'Transit route search failed.');
}
},
);
}
if (!canWrite(scopes, 'reservations')) return;
server.registerTool(
'create_transit_journey',
{
description:
'Add one itinerary returned by search_transit_routes to a trip day as a first-class automated public-transit journey.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive().describe('Trip day on which the journey departs'),
from: transitPlaceSchema,
to: transitPlaceSchema,
itinerary: transitItinerarySchema,
notes: z.string().max(1000).optional(),
},
annotations: TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT,
},
async ({ tripId, dayId, from, to, itinerary, notes }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
const day = getDay(dayId, tripId);
if (!day) {
return { content: [{ type: 'text' as const, text: 'dayId does not belong to this trip.' }], isError: true };
}
const cleaned = cleanTransitItineraryNames(itinerary, from.name, to.name);
const firstStop = cleaned.legs[0].from;
const lastStop = cleaned.legs[cleaned.legs.length - 1].to;
if (!transitCoordinatesMatch(from, firstStop) || !transitCoordinatesMatch(to, lastStop)) {
return {
content: [
{ type: 'text' as const, text: 'The itinerary does not match the requested origin and destination.' },
],
isError: true,
};
}
let reservationParts: ReturnType<typeof buildTransitReservationParts>;
try {
reservationParts = buildTransitReservationParts(from, to, cleaned);
} catch (err) {
return errorResult(err, 'Unable to resolve the transit journey timezones.');
}
const { endpoints, metadata } = reservationParts;
const departure = endpoints[0];
const arrival = endpoints[endpoints.length - 1];
if (!day.date) {
return {
content: [{ type: 'text' as const, text: 'Automated transit requires a dated trip day.' }],
isError: true,
};
}
if (departure.local_date !== day.date) {
return {
content: [
{
type: 'text' as const,
text: `The journey departs on ${departure.local_date}, but dayId is ${day.date}.`,
},
],
isError: true,
};
}
const endDay = listDays(tripId).days.find((day) => day.date === arrival.local_date);
if (!endDay) {
return {
content: [{ type: 'text' as const, text: `No trip day exists for the arrival date ${arrival.local_date}.` }],
isError: true,
};
}
const { reservation } = createReservation(tripId, {
title: `${from.name} → ${to.name}`,
type: 'transit',
status: 'confirmed',
day_id: dayId,
end_day_id: endDay.id,
reservation_time: `${departure.local_date}T${departure.local_time}`,
reservation_end_time: `${arrival.local_date}T${arrival.local_time}`,
notes,
metadata,
endpoints,
needs_review: false,
});
safeBroadcast(tripId, 'reservation:created', { reservation });
notifyBookingChange(tripId, userId, reservation.title, reservation.type || '');
return ok({ reservation });
},
);
}
+7 -2
View File
@@ -18,6 +18,7 @@ import { countMessages, listPolls } from '../../services/collabService';
import {
listItems as listTodoItems,
} from '../../services/todoService';
import { rebaseTripCurrency } from '../../services/budgetService';
import {
safeBroadcast, MAX_MCP_TRIP_DAYS,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
@@ -80,10 +81,12 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
currency: z.string().length(3).optional(),
is_archived: z.boolean().optional().describe('Archive (true) or unarchive (false) the trip'),
cover_image: z.string().optional().describe('Cover image path, e.g. /uploads/covers/abc.jpg'),
date_shift_mode: z.enum(['keep_bookings', 'shift_all']).optional().describe(
'When changing dates: keep_bookings (default) keeps dated reservations/accommodations on their dates while day plans move; shift_all moves the whole itinerary, bookings included'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, title, description, start_date, end_date, currency, is_archived, cover_image }) => {
async ({ tripId, title, description, start_date, end_date, currency, is_archived, cover_image, date_shift_mode }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('trip_edit', tripId, userId)) return permissionDenied();
@@ -97,7 +100,9 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
}
const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency, is_archived, cover_image }, 'user');
// Re-anchor the budget before the trip row moves off the old currency (#1543).
await rebaseTripCurrency(tripId, currency);
const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency, is_archived, cover_image, date_shift_mode }, 'user');
safeBroadcast(tripId, 'trip:updated', { trip: updatedTrip });
return ok({ trip: updatedTrip });
}
+13 -1
View File
@@ -82,6 +82,14 @@ export function applyGlobalMiddleware(
// answer OPTIONS without Access-Control-Allow-Origin before the SDK's own cors() runs.
// All /.well-known/* paths get open CORS so clients probing openid-configuration or the
// RFC 8414 path-suffixed AS metadata form don't get CORS-blocked (they get 404 JSON instead).
//
// `exposedHeaders` is load-bearing, not cosmetic. Without Access-Control-Expose-Headers the
// Fetch spec forbids a browser-context client (Claude Desktop connectors, Claude.ai, MCP
// Inspector) from *reading* Mcp-Session-Id off the initialize response — so it can never echo
// the header back, every request looks like a fresh initialize, and the server mints a new
// session per tool call until the per-user cap wedges the connection. Same reasoning for
// WWW-Authenticate, which carries the RFC 9728 resource-metadata challenge that drives
// OAuth discovery.
app.use(
(req: Request, _res: Response, next: NextFunction) => {
if (
@@ -91,7 +99,11 @@ export function applyGlobalMiddleware(
req.path === '/oauth/userinfo' ||
req.path === '/mcp'
) {
cors({ origin: '*', credentials: false })(req, _res, next);
cors({
origin: '*',
credentials: false,
exposedHeaders: ['Mcp-Session-Id', 'MCP-Protocol-Version', 'WWW-Authenticate'],
})(req, _res, next);
} else {
next();
}
+15 -3
View File
@@ -63,9 +63,21 @@ export class AtlasController {
}
@Get('countries/geo')
@Header('Cache-Control', 'public, max-age=86400')
countryGeo(): RegionGeo {
return this.atlas.countryGeo();
countryGeo(@Res() res: Response): void {
// Serve the pre-gzipped admin-0 bundle straight from disk. The browser decompresses
// transparently, so the wire shape is identical to before, but the server never parses
// or holds the ~145MB FeatureCollection (#1576). Content-Encoding is set explicitly, so
// the compression middleware leaves the body untouched.
const gz = this.atlas.countryGeoGz();
if (!gz) {
res.setHeader('Cache-Control', 'no-store');
res.json({ type: 'FeatureCollection', features: [] });
return;
}
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Encoding', 'gzip');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.send(gz);
}
@Get('country/:code')
+3 -3
View File
@@ -8,7 +8,7 @@ import {
unmarkRegionVisited,
getVisitedRegions,
getRegionGeo,
getCountryGeo,
getCountryGeoGz,
listBucketList,
createBucketItem,
updateBucketItem,
@@ -38,8 +38,8 @@ export class AtlasService {
return getRegionGeo(countries);
}
countryGeo() {
return getCountryGeo();
countryGeoGz(): Buffer | null {
return getCountryGeoGz();
}
countryPlaces(userId: number, code: string) {
+1 -1
View File
@@ -64,7 +64,7 @@ export class BudgetController {
@Query('base') base?: string,
) {
const trip = this.requireTrip(tripId, user);
return this.budget.settlement(tripId, base, (trip as { currency?: string }).currency || 'EUR');
return this.budget.settlement(tripId, base, trip.currency || 'EUR');
}
@Get('settlements')
+8 -5
View File
@@ -4,16 +4,17 @@ import {
getWikiIndex,
getWikiPage,
getWikiAsset,
isLocalWiki,
WikiNotFound,
type WikiPage,
type WikiNavSection,
} from '../../services/wikiService';
/**
* /api/help — embedded TREK wiki. Content is public docs (the wiki is public on
* GitHub), so these endpoints are unauthenticated; that also lets <img> tags load
* the proxied assets without sending credentials. All upstream calls go to the
* fixed TREK wiki path and are cached server-side.
* /api/help — embedded TREK wiki, served from the `wiki/` directory that ships
* with the app (see wikiService for the GitHub fallback). Content is public docs,
* so these endpoints are unauthenticated; that also lets <img> tags load the
* proxied assets without sending credentials.
*/
@Controller('api/help')
export class HelpController {
@@ -41,7 +42,9 @@ export class HelpController {
try {
const { buf, type } = await getWikiAsset(assetPath);
res.setHeader('Content-Type', type);
res.setHeader('Cache-Control', 'public, max-age=3600');
// Bundled assets are pinned to this build, so they can be cached hard; the
// GitHub fallback refreshes hourly, so match that TTL instead.
res.setHeader('Cache-Control', isLocalWiki() ? 'public, max-age=86400' : 'public, max-age=3600');
res.end(buf);
} catch {
res.status(404).end();
+1 -1
View File
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { HelpController } from './help.controller';
/** /api/help — embedded GitHub wiki (fetched + cached in wikiService). */
/** /api/help — the bundled `wiki/` directory, read via wikiService. */
@Module({
controllers: [HelpController],
})
@@ -34,7 +34,7 @@ export class AirtrailImportController {
): Promise<AirtrailImportResult> {
this.requireEdit(tripId, user);
try {
return await importAirtrailFlights(tripId, user.id, body.flightIds, socketId);
return await importAirtrailFlights(tripId, user.id, body.flightIds, socketId, body.connections ?? []);
} catch (err: any) {
throw new HttpException({ error: err?.message || 'AirTrail import failed' }, err?.status === 400 ? 400 : 502);
}
@@ -18,6 +18,10 @@ const MAX_TOKENS = 4096;
* template inlined in a single user message, no system prompt and no
* `response_format` (see ./nuextract.ts) — that's how the fine-tune expects to
* be driven; the generic instruct path applies to every other model.
*
* Structured output is requested as `json_schema` first; servers that only
* support `json_object` (DeepSeek, Mistral, some vLLM/llama.cpp) reject that
* with a 400, so the request is retried once in `json_object` mode.
*/
export class OpenAiCompatibleClient implements LlmExtractionClient {
async extract(input: LlmExtractionInput): Promise<Record<string, unknown>[]> {
@@ -39,7 +43,7 @@ export class OpenAiCompatibleClient implements LlmExtractionClient {
});
}
const body = {
const baseBody = {
model: input.model,
max_tokens: MAX_TOKENS,
// Extraction is a deterministic task — Ollama defaults to 0.7, which makes
@@ -53,33 +57,24 @@ export class OpenAiCompatibleClient implements LlmExtractionClient {
{ role: 'system', content: input.prompt },
{ role: 'user', content: userContent },
],
...(nuextract
? {}
: {
response_format: {
type: 'json_schema' as const,
json_schema: { name: 'reservations', schema: input.jsonSchema, strict: false },
},
}),
};
const body = nuextract
? baseBody
: {
...baseBody,
response_format: {
type: 'json_schema' as const,
json_schema: { name: 'reservations', schema: input.jsonSchema, strict: false },
},
};
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
let res: Response;
try {
// baseUrl is user-configurable — guard it against pointing at the cloud
// metadata endpoint, while still allowing a local/LAN Ollama.
res = await safeFetchLlm(url, {
method: 'POST',
signal: controller.signal,
headers: {
'content-type': 'application/json',
...(input.apiKey ? { authorization: `Bearer ${input.apiKey}` } : {}),
},
body: JSON.stringify(body),
});
} finally {
clearTimeout(timer);
let res = await this.send(url, body, input.apiKey);
// Servers that only support `json_object` (DeepSeek, Mistral, some
// vLLM/llama.cpp) reject `json_schema` with a 400 — retry once in
// `json_object` mode. The system prompt already dictates the exact output
// shape (and mentions JSON, which json_object mode requires).
if (!res.ok && res.status === 400 && !nuextract) {
res = await this.send(url, { ...baseBody, response_format: { type: 'json_object' as const } }, input.apiKey);
}
if (!res.ok) {
@@ -93,6 +88,26 @@ export class OpenAiCompatibleClient implements LlmExtractionClient {
const content = data.choices?.[0]?.message?.content;
return nuextract ? parseNuExtract(content) : parseReservations(content);
}
private async send(url: string, body: unknown, apiKey?: string): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
// baseUrl is user-configurable — guard it against pointing at the cloud
// metadata endpoint, while still allowing a local/LAN Ollama.
return await safeFetchLlm(url, {
method: 'POST',
signal: controller.signal,
headers: {
'content-type': 'application/json',
...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify(body),
});
} finally {
clearTimeout(timer);
}
}
}
/** Strip code fences and JSON.parse; `null` on failure. */
@@ -71,6 +71,7 @@ export class LlmParseService {
}
}
} catch (err) {
console.error(`[llm-parse] Could not read "${file.originalName}":`, err instanceof Error ? err.message : err);
return {
kiItems: [],
warnings: [`${file.originalName}: could not read file — ${err instanceof Error ? err.message : String(err)}`],
@@ -90,6 +91,7 @@ export class LlmParseService {
});
return { kiItems: routed.kiItems, warnings: [...warnings, ...routed.warnings] };
} catch (err) {
console.error(`[llm-parse] AI parsing failed for "${file.originalName}" (provider=${config.provider}):`, err instanceof Error ? err.message : err);
return {
kiItems: [],
warnings: [`${file.originalName}: AI parsing failed — ${err instanceof Error ? err.message : String(err)}`],
@@ -102,6 +104,7 @@ export class LlmParseService {
raw = await createLlmClient(config).extract(input);
console.debug('[DEBUG] Raw LLM Response: ', raw);
} catch (err) {
console.error(`[llm-parse] AI parsing failed for "${file.originalName}" (provider=${config.provider}):`, err instanceof Error ? err.message : err);
return {
kiItems: [],
warnings: [`${file.originalName}: AI parsing failed — ${err instanceof Error ? err.message : String(err)}`],
@@ -348,15 +348,17 @@ export class PackingController {
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('templateId') templateId: string,
@Body() body: { visibility?: 'common' | 'personal' },
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
const added = this.packing.applyTemplate(tripId, templateId);
const visibility = body?.visibility === 'personal' ? 'personal' : 'common';
const added = this.packing.applyTemplate(tripId, templateId, visibility, user.id);
if (!added) {
throw new HttpException({ error: 'Template not found or empty' }, 404);
}
this.packing.broadcast(tripId, 'packing:template-applied', { items: added }, socketId);
this.packing.broadcastItem(tripId, 'packing:template-applied', { items: added }, added[0], socketId);
return { items: added, count: added.length };
}
+2 -2
View File
@@ -126,8 +126,8 @@ export class PackingService {
return svc.listTemplates();
}
applyTemplate(tripId: string, templateId: string) {
return svc.applyTemplate(tripId, templateId);
applyTemplate(tripId: string, templateId: string, visibility: 'common' | 'personal', ownerId: number) {
return svc.applyTemplate(tripId, templateId, visibility, ownerId);
}
saveAsTemplate(tripId: string, userId: number, name: string) {
@@ -34,7 +34,7 @@ import { listNotes, createNote, getNote, updateNote, deleteNote, dayExists as da
import { listCollections, getCollection, createCollection, updateCollection, savePlace as saveCollectionPlaceSvc, copyToTrip as copyCollectionToTripSvc, deletePlace as deleteCollectionPlaceSvc } from '../../../services/collectionsService';
import { BudgetService } from '../../budget/budget.service';
import { ReservationsService } from '../../reservations/reservations.service';
import type { User } from '../../../types';
import { notifyBookingChange } from '../../../services/reservationService';
import { PluginDataDb } from './plugin-data.service';
import { DailyBudget, DEFAULT_DAILY_BUDGET } from './daily-budget';
import { PluginRpcHost, ForbiddenResource, BadParams } from './rpc-host';
@@ -57,16 +57,11 @@ function canEditTripAs(action: string, tripId: number, userId: number): boolean
// Reused for costs.create so a plugin write frozen-FX and members/payers logic
// matches a normal web-app budget write exactly (it has no injected deps).
const budgetSvc = new BudgetService();
// Same idea for reservations: the Nest service (no injected deps) encapsulates the
// accommodation/budget-sync/notification side effects 1:1 with the REST controller.
const reservationsSvc = new ReservationsService();
// The booking notification the REST controller sends after a create/update/delete
// (services/notificationService via reservationsSvc). Needs the acting User; a plugin
// only holds the id, so hydrate it host-side. Fire-and-forget — never blocks the write.
// is fire-and-forget, so it never blocks the plugin write.
function notifyBooking(actingUserId: number, tripId: number, booking: string, type: string): void {
const actor = db.prepare('SELECT * FROM users WHERE id = ?').get(actingUserId) as User | undefined;
if (actor) reservationsSvc.notifyBookingChange(String(tripId), actor, booking, type);
notifyBookingChange(tripId, actingUserId, booking, type);
}
// A subsystem read is refused when its addon is off — parity with the app, where a
+4 -1
View File
@@ -303,8 +303,11 @@ export class PluginRpcHost {
this.methods.set('trips.getById', (p, uid) =>
this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM trips WHERE id = ?').get(num(p.tripId, 'tripId'))),
);
// The trip's place POOL — places carry no itinerary position of their own
// (day_id/order_index live on day_assignments), so order by created_at like
// the REST list does. Use trips.getDays for the day-ordered itinerary.
this.methods.set('trips.getPlaces', (p, uid) =>
this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM places WHERE trip_id = ? ORDER BY day_id, position').all(num(p.tripId, 'tripId'))),
this.tripRead(p, uid, () => deps.db.prepare('SELECT * FROM places WHERE trip_id = ? ORDER BY created_at DESC').all(num(p.tripId, 'tripId'))),
);
// Hydrated like the REST list (endpoints, day_positions, joins, normalized
// accommodation_id) — a strict superset of the raw row, so older callers
+5 -5
View File
@@ -67,15 +67,15 @@ function upsert(db: BetterSqlite3.Database, m: PluginManifest): void {
if (existing) {
db.prepare(
`UPDATE plugins SET name = ?, description = ?, type = ?, icon = ?, version = ?, api_version = ?,
min_trek_version = ?, permissions = ?, capabilities = ?, dependencies = ?, operator_egress = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0, m.id);
min_trek_version = ?, trek_range = ?, permissions = ?, capabilities = ?, dependencies = ?, operator_egress = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, m.trekRange, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0, m.id);
} else {
db.prepare(
// granted_permissions '' (empty, not '[]') marks "never consented" so the
// first activation is distinguishable from a plugin consented to zero perms.
`INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, permissions, capabilities, dependencies, operator_egress, granted_permissions, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'inactive')`,
).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0);
`INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, trek_range, permissions, capabilities, dependencies, operator_egress, granted_permissions, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 'inactive')`,
).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, m.trekRange, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), dependencies, m.operatorEgress ? 1 : 0);
}
// Refresh the settings-page action descriptors from the manifest.
@@ -0,0 +1,106 @@
import semver from 'semver';
/**
* Host-version compatibility for plugins (#plugins). A manifest declares the TREK
* versions it supports as a semver RANGE (`"trek": ">=3.2.0 <4.0.0"`); this module
* is the single place that answers "what TREK is running" and "does that range admit
* it". Every install front door (registry, sideload, dev-link) and the activation
* gate go through here, so a plugin can never run against a host it says it doesn't
* support — and, just as importantly, they all answer the question the same way.
*/
/** The running TREK version (same source as the rest of the app). */
export function hostVersion(): string {
return process.env.APP_VERSION || (require('../../../../package.json') as { version: string }).version;
}
let warnedUnparseable = false;
/**
* The running version as a plain release triple, or null when it can't be parsed at all.
*
* Prereleases are deliberately COERCED to their target release: 3.4.0-rc.1 compares as
* 3.4.0, and 4.0.0-rc.1 as 4.0.0. Both halves of that matter, and semver's own ordering
* gets both wrong for this purpose:
*
* - Plain `satisfies()` excludes a prerelease from any range that doesn't name one, so
* 3.4.0-rc.1 would fail ">=3.2.0 <4.0.0" and EVERY plugin on the instance would go
* incompatible the moment TREK shipped a release candidate.
* - `includePrerelease` fixes that but orders 4.0.0-rc.1 BEFORE 4.0.0, so it satisfies
* "<4.0.0" — letting a plugin that disclaims TREK 4 load on a build that already
* carries TREK 4's breaking changes. That is the precise failure this gate exists to
* prevent.
*
* Coercing to the release says the useful thing instead: for compatibility purposes, an
* rc of 4.0 IS 4.0.
*
* A null host NEVER blocks anything (see {@link hostSatisfies}). This is deliberate:
* APP_VERSION is a Docker build ARG that defaults to the literal string `dev`, and an
* unversioned build must stay fully usable. The warning exists so that a MISCONFIGURED
* APP_VERSION in production — which silently switches this whole gate off — is visible
* in the logs instead of being discovered when an incompatible plugin misbehaves.
*/
export function normalizedHost(): string | null {
const raw = hostVersion();
const coerced = semver.coerce(raw)?.version ?? null;
if (!coerced && !warnedUnparseable) {
warnedUnparseable = true;
console.warn(`[plugins] APP_VERSION "${raw}" is not a semver version — plugin TREK-compatibility checks are disabled`);
}
return coerced;
}
/** Test hook: re-arm the one-time unparseable-host warning. */
export function __resetHostWarningForTests(): void {
warnedUnparseable = false;
}
/**
* Whether `r` is a semver range a plugin may declare.
*
* `semver.validRange` alone is not enough: ">=4.0.0 <3.0.0" is a VALID range that no
* version can ever satisfy, and a plugin that declares it would be uninstallable with
* no way to tell the author why. minVersion() returns null for exactly that case (and
* throws outright on garbage like "latest"), so it is the real satisfiability test.
*/
export function isValidTrekRange(r: unknown): r is string {
if (typeof r !== 'string' || !r.trim()) return false;
if (semver.validRange(r) === null) return false;
try {
return semver.minVersion(r) !== null;
} catch {
return false;
}
}
/**
* The lowest TREK version a range admits — what the UI shows as "Requires TREK x+".
* Undefined for a range with no real lower bound (`*`, `<4.0.0`), because "requires
* TREK 0.0.0+" is noise rather than information.
*/
export function minTrekOf(range: string): string | undefined {
if (!isValidTrekRange(range)) return undefined;
const min = semver.minVersion(range);
if (!min || min.version === '0.0.0') return undefined;
return min.version;
}
/**
* Whether the running TREK satisfies `range`. The host arrives here already coerced to a
* release triple (see {@link normalizedHost}), which is what makes a plain satisfies()
* correct on both prerelease edges.
*
* A null/empty range is not a decision this function can make — the caller knows whether
* an undeclared range means "reject" (an install) or "unknown" (an already-installed
* plugin), and they surface different codes. It returns false so neither can pass by
* accident.
*/
export function hostSatisfies(range: string | null | undefined, host: string | null = normalizedHost()): boolean {
// Coerce again rather than trusting the argument: callers that pass a host explicitly
// (the registry, tests) would otherwise skip the prerelease normalisation and get a
// different answer than the default path for the same TREK.
const release = host === null ? null : (semver.coerce(host)?.version ?? null);
if (release === null) return true; // unversioned build — never block
if (!isValidTrekRange(range)) return false;
return semver.satisfies(release, range);
}
+37 -2
View File
@@ -1,5 +1,6 @@
import semver from 'semver';
import { isKnownPermission } from '../protocol/envelope';
import { isValidTrekRange, minTrekOf } from './host-compat';
import type { NotifEventType } from '../../../services/notificationPreferencesService';
/**
@@ -69,6 +70,8 @@ export interface PluginCapabilities {
provides?: string[];
/** Event names this plugin publishes to its dependents via ctx.events.emit. */
emits?: string[];
/** The plugin ships client/settings.html; the host frames it on the user's settings page. */
settingsUi?: boolean;
}
/** A declared dependency on another plugin, pinned by a semver range. */
@@ -87,7 +90,11 @@ export interface PluginManifest {
homepage?: string;
icon?: string;
type: 'integration' | 'page' | 'widget' | 'trip-page';
/** The raw semver RANGE of TREK versions this plugin supports (">=3.2.0 <4.0.0"). */
trek?: string;
/** Same range, normalized: a valid+satisfiable one, or null. What the host gates on. */
trekRange: string | null;
/** The range's lower bound, for display ("Requires TREK 3.2.0+"). Derived, never authored. */
minTrekVersion?: string;
nativeModules: boolean;
permissions: string[];
@@ -134,7 +141,19 @@ export function parseJsonText(text: string): unknown {
return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
}
export function parseManifest(raw: unknown): PluginManifest {
/**
* Validate a manifest.
*
* `requireTrek` is the INSTALL front doors (registry, sideload, dev-link): a plugin
* that doesn't say which TREK versions it supports may not be installed at all.
*
* It is deliberately OFF for discovery, which is a reconciler, not a gate. Discovery's
* failure path only logs and skips — it never touches the plugins row — so a plugin it
* rejects keeps its stale `enabled = 1` and gets spawned by the next boot anyway. Making
* this strict there would produce a plugin that is invisible to discovery and still
* running. Activation is where an undeclared range is actually refused.
*/
export function parseManifest(raw: unknown, opts?: { requireTrek?: boolean }): PluginManifest {
if (!raw || typeof raw !== 'object') throw new ManifestError('manifest is not an object');
const m = raw as Record<string, unknown>;
@@ -183,6 +202,14 @@ export function parseManifest(raw: unknown): PluginManifest {
if (badEgress !== undefined) throw new ManifestError(`invalid egress host "${badEgress}"`);
const trek = optStr(m.trek);
const trekRange = isValidTrekRange(trek) ? trek : null;
if (opts?.requireTrek && !trekRange) {
throw new ManifestError(
trek
? `invalid "trek" version range "${trek}" (expected a satisfiable semver range, e.g. ">=3.2.0 <4.0.0")`
: 'missing "trek" version range — declare the TREK versions this plugin supports, e.g. ">=3.2.0 <4.0.0"',
);
}
return {
id,
name: str(m.name, 'name'),
@@ -194,7 +221,11 @@ export function parseManifest(raw: unknown): PluginManifest {
icon: optStr(m.icon) ?? 'Blocks',
type: type as PluginManifest['type'],
trek,
minTrekVersion: trek ? (trek.match(/(\d+\.\d+\.\d+)/)?.[1] ?? undefined) : undefined,
trekRange,
// Derived from the range itself, never from the first number that looks like a
// version in it: a range of "<4.0.0" has a *first* semver of 4.0.0 but a lower
// bound of 0.0.0, so reading it off the text produced the exact inverse of the truth.
minTrekVersion: trekRange ? minTrekOf(trekRange) : undefined,
nativeModules: false,
permissions,
egress,
@@ -279,6 +310,10 @@ function parseCapabilities(raw: unknown): PluginCapabilities {
}
if (Object.keys(page).length) out.tripPage = page;
}
if (c.settingsUi !== undefined) {
if (typeof c.settingsUi !== 'boolean') throw new ManifestError('capabilities.settingsUi must be a boolean');
if (c.settingsUi) out.settingsUi = true;
}
if (c.notificationChannel && typeof c.notificationChannel === 'object') {
const nc = c.notificationChannel as Record<string, unknown>;
const channel: NotificationChannelCapability = {};
-1
View File
@@ -119,7 +119,6 @@ export function pluginPermissionArgs(pluginId: string): string[] {
// off by accident.
if (!warnedPermissionsOff) {
warnedPermissionsOff = true;
// eslint-disable-next-line no-console
console.warn('[plugins] TREK_PLUGIN_PERMISSIONS=off — the OS permission jail is DISABLED; installed plugins run with full Node access to this process. Only use this on a machine you fully trust.');
}
return [];
-2
View File
@@ -111,7 +111,6 @@ export function applyStagedPluginTrees(): string[] {
swapContents(live, staged);
applied.push(label);
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[plugins] failed to apply staged ${label} restore:`, err);
}
}
@@ -135,7 +134,6 @@ export async function applyStagedRestoreNow(): Promise<boolean> {
await applier();
return true;
} catch (err) {
// eslint-disable-next-line no-console
console.error('[plugins] immediate staged-restore apply failed; will retry on next boot:', err);
return false;
}
@@ -61,18 +61,36 @@ export class PluginFrameController {
const ext = path.extname(resolved).toLowerCase();
res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream');
res.setHeader('X-Content-Type-Options', 'nosniff');
// The frame document runs at an OPAQUE origin (sandbox without
// allow-same-origin), so its own <script src>/<link> subresource loads are
// cross-origin — helmet's default CORP: same-origin makes the browser drop
// them (ERR_BLOCKED_BY_RESPONSE) and a multi-file client dies on boot.
// The sandbox + per-plugin CSP are the isolation boundary here, not CORP:
// these files are the plugin's own public client code.
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('Content-Security-Policy', this.frameCsp(pluginId, req.get('host')));
res.sendFile(resolved);
// Explicit { root } + basename, not the absolute path: under the Nest
// ExpressAdapter, res.sendFile(absolutePath) resolves against the rewritten
// req.url and 404s spuriously (same trap as files-download.controller.ts).
res.sendFile(path.basename(resolved), { root: path.dirname(resolved) });
}
/** Per-plugin, locked-down CSP for the sandboxed frame document. */
private frameCsp(pluginId: string, host: string | undefined): string {
// Defense in depth: the manifest validator already constrains these hosts, but
// never interpolate anything that isn't a clean host/wildcard into connect-src
// (a stray space or `*` would inject an extra CSP source token).
const outbound = this.runtime
.outboundHostsOf(pluginId)
.filter((h) => /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i.test(h));
// The frame must be able to reach exactly what the CHILD may reach. The child's egress
// guard is the union of the manifest's http:outbound:<host> grants AND the hosts an admin
// added post-install for an operatorEgress plugin (plugin-runtime.service: activate()).
// Building connect-src from the grants alone left an operatorEgress plugin WITH A UI able
// to call the operator's host from its server but CSP-blocked in its own iframe. The admin
// consented to these hosts at install and the child already reaches them, so matching the
// frame to the child widens no trust boundary that isn't already crossed.
//
// Defense in depth: both sources are validated upstream (the manifest validator; the
// admin writer's EGRESS_HOST_RE), but never interpolate anything that isn't a clean
// host/wildcard into connect-src — a stray space or `*` would inject an extra CSP source.
const outbound = [
...new Set([...this.runtime.outboundHostsOf(pluginId), ...this.runtime.operatorEgressHosts(pluginId)]),
].filter((h) => /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i.test(h));
// The frame runs at an OPAQUE origin (sandbox without allow-same-origin), so
// 'self' matches nothing and the plugin's own <script src>/<link> files would
// be blocked — a multi-file client build (Vite/React output) only worked
+132 -16
View File
@@ -22,7 +22,10 @@ import { parseJsonText, parseManifest } from './install/manifest';
import { scanForNativeBinaries } from './install/native-scan';
import { devLinkEnabled, DEV_LINK_SOURCE } from './dev-link';
import { pluginCodeDir, pluginDataDir } from './paths';
import { PluginRegistryService } from './registry/registry.service';
import { assertHostCompatible, PluginRegistryService, RegistryError } from './registry/registry.service';
import { hostSatisfies, hostVersion } from './install/host-compat';
import { keyFingerprint } from './signature-status';
import { writeAudit } from '../../services/auditLog';
import { isAddonEnabled } from '../../services/adminService';
import type { PluginDependency } from './install/manifest';
import type { VersionMismatch, PluginDepRow } from './dependencies';
@@ -83,18 +86,31 @@ export class PluginConsentRequired extends Error {
}
}
export type PluginDependencyCode = 'ADDON_DISABLED' | 'DEPENDENCY_MISSING';
export type PluginDependencyCode =
| 'ADDON_DISABLED'
| 'DEPENDENCY_MISSING'
/** The plugin's declared TREK range doesn't admit the running host. */
| 'TREK_VERSION_INCOMPATIBLE'
/** The plugin never declared a range, so we can't know that it does. */
| 'TREK_VERSION_UNKNOWN';
/**
* Thrown when a plugin can't activate because a required addon is disabled or a
* declared plugin dependency is missing / version-mismatched. The controller maps
* it to a 409 carrying `code` + `detail` so the admin UI can offer the right fix.
* Thrown when a plugin can't activate because a required addon is disabled, a declared
* plugin dependency is missing / version-mismatched, or it doesn't support this TREK.
* The controller maps it to a 409 carrying `code` + `detail` so the admin UI can offer
* the right fix.
*
* Host-incompatibility deliberately reuses this class rather than introducing its own.
* The boot loop only reconciles a failed activation's row back to `enabled = 0` for a
* PluginDependencyError / DependencyCycleError; any other error falls through to a branch
* that relies on the supervisor persisting a status — and the supervisor never ran. A
* separate class would therefore leave a plugin that never started still reading "active".
*/
export class PluginDependencyError extends Error {
constructor(
message: string,
readonly code: PluginDependencyCode,
readonly detail: { addons?: string[]; missing?: PluginDependency[]; versionMismatch?: VersionMismatch[] } = {},
readonly detail: { addons?: string[]; missing?: PluginDependency[]; versionMismatch?: VersionMismatch[]; trekRange?: string | null; hostVersion?: string } = {},
) {
super(message);
this.name = 'PluginDependencyError';
@@ -447,15 +463,35 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
/**
* Read-only activation gate for one plugin — throws (without mutating) if it may
* not activate. Checks run most- to least-severe: permission re-consent →
* required addon disabled → missing/mismatched plugin dependency.
* not activate. Checks run most- to least-severe: TREK-version compatibility →
* permission re-consent → required addon disabled → missing/mismatched plugin dependency.
*/
private assertActivatable(id: string, installed: Map<string, PluginDepRow>, consentWiden: boolean): void {
const row = db.prepare('SELECT permissions, granted_permissions, dependencies FROM plugins WHERE id = ?').get(id) as
| { permissions: string; granted_permissions: string; dependencies: string | null }
const row = db.prepare('SELECT permissions, granted_permissions, dependencies, trek_range FROM plugins WHERE id = ?').get(id) as
| { permissions: string; granted_permissions: string; dependencies: string | null; trek_range: string | null }
| undefined;
if (!row) throw new Error(`plugin ${id} not found`);
// This runs FIRST, and before the consent check in particular: a plugin that cannot
// run on this TREK must never be offered a permission dialog, because consenting to
// it would not make the plugin start. It is also the gate that catches the case
// install can't — TREK was upgraded PAST the plugin's declared upper bound, so code
// that was legitimately installed no longer supports the host it's sitting on.
if (!row.trek_range) {
throw new PluginDependencyError(
`plugin ${id} does not declare which TREK versions it supports`,
'TREK_VERSION_UNKNOWN',
{ trekRange: null, hostVersion: hostVersion() },
);
}
if (!hostSatisfies(row.trek_range)) {
throw new PluginDependencyError(
`plugin ${id} requires TREK ${row.trek_range} — this is TREK ${hostVersion()}`,
'TREK_VERSION_INCOMPATIBLE',
{ trekRange: row.trek_range, hostVersion: hostVersion() },
);
}
const declared = parseArray(row.permissions).filter(isKnownPermission);
const granted = parseArray(row.granted_permissions);
const newGrants = declared.filter((p) => !granted.includes(p));
@@ -586,16 +622,24 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
* Install runs first so a failed download/signature/integrity check leaves the
* currently-running child untouched (it keeps serving the old code from memory).
*/
async update(id: string): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }> {
const before = db.prepare('SELECT enabled, granted_permissions FROM plugins WHERE id = ?').get(id) as
| { enabled: number; granted_permissions: string }
async update(id: string, opts?: { version?: string; retrustKey?: string }): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }> {
const before = db.prepare('SELECT enabled, granted_permissions, version FROM plugins WHERE id = ?').get(id) as
| { enabled: number; granted_permissions: string; version: string | null }
| undefined;
if (!before) throw new Error(`plugin ${id} not found`);
if (!this.registry) throw new Error('registry service unavailable');
const wasEnabled = before.enabled === 1;
const granted = new Set(parseArray(before.granted_permissions));
const res = await this.registry.install(id); // swaps code + refreshes declared permissions; keeps granted
// Pick the newest version that this TREK can actually run, not simply the newest one
// published. Taking the latest blindly is how an update BREAKS a working plugin: the
// new release drops support for this host, the swap succeeds, and the activation gate
// then refuses to restart it — leaving the admin worse off than not updating. Refusing
// is the honest outcome; the plugin keeps running on the code it has.
const target = opts?.version ?? (await this.resolveUpdateTarget(id, before.version));
// swaps code + refreshes declared permissions; keeps granted
const res = await this.registry.install(id, { version: target, retrustKey: opts?.retrustKey });
const declared = parseArray(
(db.prepare('SELECT permissions FROM plugins WHERE id = ?').get(id) as { permissions: string }).permissions,
@@ -614,6 +658,72 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
return { version: res.version, activated: false, newPermissions, newEgress };
}
/**
* The version an "update" should install: the newest one compatible with this TREK.
* Refuses when that is not actually newer than what's installed — a compatible-but-older
* artifact is a DOWNGRADE, and silently rolling a plugin back under the word "update"
* would be worse than doing nothing.
*/
private async resolveUpdateTarget(id: string, installedVersion: string | null): Promise<string> {
if (!this.registry) throw new Error('registry service unavailable');
const target = await this.registry.resolveVersion(id); // throws TREK_VERSION_INCOMPATIBLE if nothing fits
if (installedVersion && semver.valid(installedVersion) && !semver.gt(target.version, installedVersion)) {
throw new RegistryError(
`no update available for ${id} on TREK ${hostVersion()} — ${installedVersion} is already the newest compatible version`,
'NO_COMPATIBLE_UPDATE',
);
}
return target.version;
}
/**
* Re-trust a plugin whose author signing key ROTATED, and update it — in ONE call.
*
* The single call is the point. A re-pin that returns and waits for the client to
* then call /update leaves a window where `author_pubkey` is pinned to a key no
* install has ever been verified against; if that second call never arrives (the
* admin closes the tab), the plugin sits pinned to an unverified key. So: either the
* new key verifies the artifact and the plugin lands on the new version with the new
* key pinned, or nothing changes at all.
*
* The safety properties live below this call, not here:
* - assertRetrustable re-derives the condition server-side, so this refuses anything
* other than a genuinely changed key (an invalid signature is NOT overridable),
* and refuses a key that changed again since the admin was shown it.
* - install() still verifies the artifact under the new key before pinning it, and
* only ever pins a key — never clears one to NULL.
*/
async retrust(
id: string,
version: string,
publicKey: string,
actor: { userId: number | null; ip?: string | null },
): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }> {
if (!this.registry) throw new Error('registry service unavailable');
const before = db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string | null } | undefined;
const entry = await this.registry.assertRetrustable(id, publicKey); // throws unless SIGNATURE_KEY_CHANGED
const res = await this.update(id, { version, retrustKey: publicKey });
// Audited because re-trusting a signing key is precisely the event that has to be
// reconstructible after an incident. This goes to the ADMIN audit log, not the
// plugin capability log — that one answers "what have plugins done in my name?"
// and is shown to end users; a lifecycle action by an admin does not belong there.
writeAudit({
userId: actor.userId,
action: 'admin.plugin_retrust',
resource: id,
details: {
plugin: id,
version: res.version,
oldKeyFingerprint: keyFingerprint(before?.author_pubkey),
newKeyFingerprint: keyFingerprint(entry.authorPublicKey),
},
ip: actor.ip ?? null,
});
return res;
}
/**
* Sideload a plugin from an uploaded archive (admin "Upload plugin"). Extracts +
* validates into staging first, stops any running child of the same id (its code
@@ -654,7 +764,8 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
if (!path.isAbsolute(sourceDir)) throw new Error('the dev-link path must be absolute');
const manifestPath = path.join(sourceDir, 'trek-plugin.json');
if (!fs.existsSync(manifestPath)) throw new Error(`no trek-plugin.json at ${sourceDir}`);
const manifest = parseManifest(parseJsonText(fs.readFileSync(manifestPath, 'utf8')));
const manifest = parseManifest(parseJsonText(fs.readFileSync(manifestPath, 'utf8')), { requireTrek: true });
assertHostCompatible(manifest.trekRange, manifest.id);
if (!fs.existsSync(path.join(sourceDir, 'server', 'index.js'))) {
throw new Error('no built server/index.js — build the plugin first (the loader runs the compiled artifact, not TS source)');
}
@@ -675,8 +786,13 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.symlinkSync(sourceDir, dest, 'junction'); // Windows junction (no elevation); POSIX ignores the type -> dir symlink
discoverPlugins(db); // registers/updates the row from the linked manifest, INACTIVE
// Same as a sideload: the plugin has left the registry trust model, so a block that
// described a refused REGISTRY update no longer describes the code that will run.
db.prepare(
`UPDATE plugins SET source_repo = ?, source_commit = NULL, sha256 = NULL, author_pubkey = NULL, status = 'inactive', enabled = 0 WHERE id = ?`,
`UPDATE plugins SET source_repo = ?, source_commit = NULL, sha256 = NULL, author_pubkey = NULL,
update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL,
status = 'inactive', enabled = 0
WHERE id = ?`,
).run(DEV_LINK_SOURCE, id);
this.watchLinked(id, sourceDir);
return { id, version: manifest.version, replaced };
@@ -18,6 +18,8 @@ interface ActivePlugin {
slot: 'sidebar' | 'hero' | 'place-detail' | 'day-detail' | 'reservation-detail';
/** How a trip-page plugin sits in the planner tab bar (replaced core tabs + position). */
tripPage?: { replaces?: string[]; position?: number };
/** The plugin ships a settings.html the user-settings page frames. */
settingsUi?: true;
}
@Controller('api/plugins')
@@ -31,7 +33,12 @@ export class PluginsFeedController {
.all() as Array<Omit<ActivePlugin, 'slot' | 'tripPage'> & { capabilities: string }>;
const plugins = rows.map(({ capabilities, ...p }) => {
const tripPage = p.type === 'trip-page' ? tripPageOf(capabilities) : undefined;
return { ...p, slot: slotOf(capabilities), ...(tripPage ? { tripPage } : {}) };
return {
...p,
slot: slotOf(capabilities),
...(tripPage ? { tripPage } : {}),
...(settingsUiOf(capabilities) ? { settingsUi: true as const } : {}),
};
});
return { plugins };
}
@@ -52,6 +59,15 @@ function slotOf(capabilities: string): ActivePlugin['slot'] {
// must never be steerable by a hand-edited row ('plan' stays unhideable).
const REPLACEABLE_TABS: ReadonlySet<string> = new Set(['transports', 'buchungen', 'listen', 'finanzplan', 'dateien', 'collab']);
function settingsUiOf(capabilities: string): boolean {
try {
const c = JSON.parse(capabilities || '{}') as { settingsUi?: unknown };
return c.settingsUi === true;
} catch {
return false;
}
}
function tripPageOf(capabilities: string): ActivePlugin['tripPage'] {
try {
const c = JSON.parse(capabilities || '{}') as { tripPage?: { replaces?: unknown; position?: unknown } };
@@ -90,7 +90,7 @@ export class PluginsProxyController {
}
// Per-route auth: default-on; `auth:false` routes are public (OAuth cb/webhook).
let user: { id: number; username: string; is_admin?: boolean } | null = null;
let user: { id: number; username: string; role?: 'admin' | 'user' } | null = null;
if (route.auth) {
const token = extractToken(req);
const loaded = token ? verifyJwtAndLoadUser(token) : null;
@@ -121,7 +121,7 @@ export class PluginsProxyController {
// allowlisted, credential-free subset — an authenticated route never
// needs them and must not see even the safe ones.
headers: route.auth === false ? pickInboundHeaders(req.headers as Record<string, unknown>) : {},
user: user ? { id: user.id, username: user.username, isAdmin: !!user.is_admin } : null,
user: user ? { id: user.id, username: user.username, isAdmin: user.role === 'admin' } : null,
},
},
// Bind the authenticated session user as the acting user for any trip
+59 -6
View File
@@ -1,14 +1,37 @@
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, Query, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, Query, Req, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Request } from 'express';
import { PluginsService } from './plugins.service';
import { PluginRuntimeService, PluginConsentRequired, PluginDependencyError } from './plugin-runtime.service';
import { DependencyCycleError } from './dependencies';
import { PluginRegistryService } from './registry/registry.service';
import { PluginRegistryService, RegistryError } from './registry/registry.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/admin.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { getClientIp } from '../../services/auditLog';
import { pluginsEnabled } from './kill-switch';
import { devLinkEnabled } from './dev-link';
/**
* Flatten a registry/install failure into the error envelope — CARRYING THE CODE.
*
* The code is the whole point: the admin UI offers the re-trust override only for
* SIGNATURE_KEY_CHANGED, and it must read that from a field, not by string-matching the
* message. Drop the code here and the client is left guessing from prose, which sooner
* or later means offering "re-trust" on a signature that simply doesn't verify.
* TrekExceptionFilter passes a `{ error, code }` body through verbatim.
*
* A missing plugin is a 404, not a 400 — the request was well-formed, the thing just
* isn't there. Only `assertRetrustable` raises NOT_FOUND, so this is scoped to /retrust:
* install's "not in registry" carries no code and update throws a plain Error, and both
* keep their existing 400.
*/
function registryFailure(e: unknown, fallback: string): HttpException {
const code = e instanceof RegistryError ? e.code : undefined;
const error = e instanceof Error ? e.message : fallback;
return new HttpException(code ? { error, code } : { error }, code === 'NOT_FOUND' ? 404 : 400);
}
/**
* /api/admin/plugins — admin-only plugin control surface (#plugins).
*
@@ -57,7 +80,7 @@ export class PluginsController {
if (body.withDependencies) return await this.registry.installWithDependencies(body.id, body.constraint);
return await this.registry.install(body.id, { version: body.version, constraint: body.constraint });
} catch (e) {
throw new HttpException({ error: e instanceof Error ? e.message : 'install failed' }, 400);
throw registryFailure(e, 'install failed');
}
}
@@ -71,7 +94,7 @@ export class PluginsController {
try {
return await this.runtime.sideload(file.buffer);
} catch (e) {
throw new HttpException({ error: e instanceof Error ? e.message : 'upload failed' }, 400);
throw registryFailure(e, 'upload failed'); // carries TREK_VERSION_INCOMPATIBLE
}
}
@@ -89,7 +112,7 @@ export class PluginsController {
try {
return await this.runtime.link(dir);
} catch (e) {
throw new HttpException({ error: e instanceof Error ? e.message : 'link failed' }, 400);
throw registryFailure(e, 'link failed'); // carries TREK_VERSION_INCOMPATIBLE
}
}
@@ -188,7 +211,37 @@ export class PluginsController {
try {
return await this.runtime.update(id);
} catch (e) {
throw new HttpException({ error: e instanceof Error ? e.message : 'update failed' }, 400);
throw registryFailure(e, 'update failed');
}
}
/**
* Re-trust a plugin whose author signing key ROTATED, and update it in the same call.
*
* This is the ONLY override of a signature refusal that exists, and it exists only for
* a changed key: a rotation has a benign explanation, a signature that doesn't verify
* does not. The scoping is enforced in the service (`assertRetrustable`), not by this
* route and not by the UI — calling this directly on a plugin with an INVALID signature
* is refused.
*
* `publicKey` is the full key the admin was SHOWN, echoed back so the server can refuse
* if the registry entry has been re-keyed again since the dialog rendered.
*/
@Post(':id/retrust')
@HttpCode(200)
async retrust(
@Param('id') id: string,
@Body() body: { version?: string; publicKey?: string },
@CurrentUser() user: { id: number },
@Req() req: Request,
) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
if (!body?.version) throw new HttpException({ error: 'version is required' }, 400);
if (!body?.publicKey) throw new HttpException({ error: 'publicKey is required' }, 400);
try {
return await this.runtime.retrust(id, body.version, body.publicKey, { userId: user?.id ?? null, ip: getClientIp(req) });
} catch (e) {
throw registryFailure(e, 'retrust failed');
}
}
+52 -8
View File
@@ -4,14 +4,16 @@ import { pluginsEnabled } from './kill-switch';
import { devLinkEnabled } from './dev-link';
import { maybe_encrypt_api_key, decrypt_api_key } from '../../services/apiKeyCrypto';
import { readAudit } from './host/plugin-audit';
import { keyFingerprint } from './signature-status';
import { pluginBudgetUsage } from './host/create-rpc-host';
import { isAddonEnabled } from '../../services/adminService';
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, type PluginDepRow, type PluginDependencies, type VersionMismatch } from './dependencies';
import { hostSatisfies, hostVersion } from './install/host-compat';
import type { PluginDependency } from './install/manifest';
const SECRET_MASK = '••••••••';
export type PluginDependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin';
export type PluginDependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin' | 'hostIncompatible';
/**
* Read side of the plugin system (#plugins), M0 scaffold. Lists installed
@@ -35,6 +37,11 @@ interface PluginRawRow {
permissions: string;
capabilities: string;
dependencies: string | null;
trek_range: string | null;
author_pubkey: string | null;
update_block_code: string | null;
update_block_detail: string | null;
update_block_version: string | null;
}
/** Hosts an admin has added for a plugin (0 unless it declared operatorEgress). */
@@ -70,8 +77,24 @@ export interface PluginListItem {
dependencies: PluginDependencies;
/** Whether this plugin can currently activate, and why not if it can't. */
dependencyStatus: PluginDependencyStatus;
/** The TREK range the plugin declares it supports; null if it declared none. */
trekRange: string | null;
/** The running TREK, so the UI can say "needs X, you have Y" without doing semver. */
hostVersion: string;
/** The concrete blockers, so the UI can render chips + the resolve dialog. */
dependencyIssues: { disabledAddons: string[]; missing: PluginDependency[]; versionMismatch: VersionMismatch[] };
/**
* The author's signature was verified and their key TOFU-pinned at install.
* False means the bytes matched the registry's sha256 pin and nothing more — one
* fewer guarantee, not "insecure". Meaningless for a sideloaded/dev-linked plugin,
* which sits outside the registry trust model entirely; the UI badges those instead.
*/
signed: boolean;
/** Short, human-comparable form of the pinned key — for reading out to the author. */
keyFingerprint: string | null;
/** Why an update was refused, if one was. `version` is the registry version that was
* refused, so a caller can treat the block as stale once a newer one is on offer. */
updateBlock: { code: string; detail: string | null; version: string | null } | null;
}
@Injectable()
@@ -80,7 +103,8 @@ export class PluginsService {
const rows = db
.prepare(
`SELECT id, name, description, type, icon, version, status, enabled, last_error, reviewed_at, source_repo,
permissions, capabilities, dependencies, operator_egress
permissions, capabilities, dependencies, operator_egress, trek_range,
author_pubkey, update_block_code, update_block_detail, update_block_version
FROM plugins
ORDER BY sort_order, name`,
)
@@ -92,19 +116,39 @@ export class PluginsService {
const deps = parseDependencies(r.dependencies);
const disabledAddons = disabledRequiredAddons(deps, isAddonEnabled);
const state = resolveDependencyState(deps, installed);
const dependencyStatus: PluginDependencyStatus = disabledAddons.length
? 'addonDisabled'
: state.missing.length || state.versionMismatch.length
? 'missingPlugin'
: 'ok';
const { dependencies: _raw, operator_egress: _oe, ...rest } = r as PluginRawRow & { operator_egress?: number };
// Mirrors the order of assertActivatable's gate, so the card explains the same
// blocker the activate call would hit rather than a second, lesser one.
const dependencyStatus: PluginDependencyStatus = !hostSatisfies(r.trek_range)
? 'hostIncompatible'
: disabledAddons.length
? 'addonDisabled'
: state.missing.length || state.versionMismatch.length
? 'missingPlugin'
: 'ok';
const {
dependencies: _raw,
operator_egress: _oe,
trek_range,
author_pubkey,
update_block_code,
update_block_detail,
update_block_version,
...rest
} = r as PluginRawRow & { operator_egress?: number };
return {
...rest,
operatorEgress: _oe === 1,
egressHostCount: egressHostCount(r.id),
dependencies: deps,
dependencyStatus,
trekRange: trek_range,
hostVersion: hostVersion(),
dependencyIssues: { disabledAddons, missing: state.missing, versionMismatch: state.versionMismatch },
signed: !!author_pubkey,
keyFingerprint: keyFingerprint(author_pubkey),
updateBlock: update_block_code
? { code: update_block_code, detail: update_block_detail, version: update_block_version }
: null,
};
});
return { enabled: pluginsEnabled(), devLink: devLinkEnabled(), plugins };
@@ -1,16 +1,19 @@
import { db } from '../../../db/database';
import { discoverPlugins } from '../install/discovery';
import { hostSatisfies, hostVersion, normalizedHost } from '../install/host-compat';
import type { PluginDependency } from '../install/manifest';
import { parseJsonText, parseManifest } from '../install/manifest';
import { scanForNativeBinaries } from '../install/native-scan';
import { extractArchive } from '../install/safe-extract';
import { safeDownload, sha256Matches } from '../install/safe-fetch';
import { verifyAuthorSignature, SignatureError } from '../install/verify-signature';
import { pluginCodeDir, pluginsCodeRoot, pluginsDataRoot } from '../paths';
import { clearUpdateBlock, isSignatureCode, setUpdateBlock, RETRUSTABLE_CODE } from '../signature-status';
import { Injectable } from '@nestjs/common';
import semver from 'semver';
import fs from 'node:fs';
import path from 'node:path';
import { db } from '../../../db/database';
import type { PluginDependency } from '../install/manifest';
import { pluginCodeDir, pluginsCodeRoot, pluginsDataRoot } from '../paths';
import { safeDownload, sha256Matches } from '../install/safe-fetch';
import { verifyAuthorSignature } from '../install/verify-signature';
import { extractArchive } from '../install/safe-extract';
import { scanForNativeBinaries } from '../install/native-scan';
import { parseJsonText, parseManifest } from '../install/manifest';
import { discoverPlugins } from '../install/discovery';
import semver from 'semver';
/**
* TREK-side of the plugin registry (#plugins, M5). Fetches the single aggregated
@@ -23,7 +26,7 @@ import { discoverPlugins } from '../install/discovery';
const REGISTRY_URL =
process.env.TREK_PLUGIN_REGISTRY_URL ||
'https://raw.githubusercontent.com/mauriceboe/TREK-Plugins/main/dist/index.json';
'https://raw.githubusercontent.com/liketrek/TREK-Plugins/main/dist/index.json';
const CACHE_TTL = 30 * 60 * 1000;
const MANIFEST_MAX_BYTES = 256 * 1024;
// Sideload upload ceiling — matches the SDK `pack` limit (50 MB) plus zip overhead.
@@ -35,7 +38,16 @@ interface RegistryVersion {
commitSha: string;
downloadUrl: string;
sha256: string;
minTrekVersion: string;
/**
* The version's declared TREK range, mirroring its manifest's `trek`. Authoritative
* when present. `minTrekVersion`/`maxTrekVersion` are the legacy shape — a lower bound
* plus an optional INCLUSIVE upper bound, which cannot express the exclusive `<4.0.0`
* that a manifest actually declares — and stay only so entries published before this
* field existed still gate.
*/
trek?: string;
/** Nullable: an entry may carry no lower bound at all (and a legacy one carries only this). */
minTrekVersion?: string | null;
maxTrekVersion?: string | null;
size?: number;
apiVersion?: number;
@@ -57,6 +69,8 @@ export interface RegistryEntry {
homepage?: string;
tags?: string[];
type: string;
/** A lucide icon name for the store tile; absent means the client's default (Blocks). */
icon?: string;
reviewedAt?: string | null;
/** base64 minisign author public key — stable across versions, TOFU-pinned. */
authorPublicKey?: string;
@@ -70,6 +84,17 @@ interface Registry {
plugins: RegistryEntry[];
}
/** A registry entry's standing against the running TREK, resolved server-side. */
export interface HostCompat {
/** The latest version's declared TREK range, or null for a legacy (min/max-only) entry. */
trek: string | null;
hostVersion: string;
/** Whether the LATEST version can be installed on this TREK. */
compatible: boolean;
/** Newest installable version — the latest one, or an older fallback, or null if none fits. */
latestCompatible: string | null;
}
/** Anzeige-only preview of a plugin's live manifest (fetched at the reviewed commit). */
export interface ManifestPreview {
permissions: string[];
@@ -97,7 +122,21 @@ export function __clearRegistryCacheForTests(): void {
_detailCache.clear();
}
export class RegistryError extends Error {}
/**
* A registry/install failure. `code` is machine-readable and MUST survive out to
* the client: the admin UI decides whether to offer a re-trust override from the
* code, never by string-matching the message. A client that matches on prose will
* eventually offer the override on the wrong condition — which is the single worst
* outcome this surface could produce.
*/
export class RegistryError extends Error {
constructor(
message: string,
readonly code?: string,
) {
super(message);
}
}
@Injectable()
export class PluginRegistryService {
@@ -113,7 +152,10 @@ export class PluginRegistryService {
try {
const url = force ? `${REGISTRY_URL}${REGISTRY_URL.includes('?') ? '&' : '?'}_=${Date.now()}` : REGISTRY_URL;
const headers: Record<string, string> = { 'User-Agent': 'TREK-Server' };
if (force) { headers['Cache-Control'] = 'no-cache'; headers.Pragma = 'no-cache'; }
if (force) {
headers['Cache-Control'] = 'no-cache';
headers.Pragma = 'no-cache';
}
const resp = await fetch(url, { headers });
if (!resp.ok) throw new Error(`registry ${resp.status}`);
const data = (await resp.json()) as Registry;
@@ -125,22 +167,75 @@ export class PluginRegistryService {
}
}
/** The browse list the admin UI renders (metadata only, no code). */
async browse(force = false): Promise<Array<Omit<RegistryEntry, 'versions'> & { latest: string | null; minTrekVersion: string | null; requiredAddons: string[]; pluginDependencies: PluginDependency[]; screenshotUrl: string | null }>> {
/**
* The browse list the admin UI renders (metadata only, no code).
*
* `signed` describes the LATEST version (browse already treats versions[0] as
* latest), so on a version-constrained install the badge can describe a version
* other than the artifact actually fetched. Accepted for a Discover card.
* `authorPublicKey` is exposed in full — it is a public key, and the re-trust
* round-trip compares it exactly (a truncated fingerprint would be a weak check).
*/
async browse(force = false): Promise<
Array<
Omit<RegistryEntry, 'versions'> & {
latest: string | null;
minTrekVersion: string | null;
requiredAddons: string[];
pluginDependencies: PluginDependency[];
screenshotUrl: string | null;
signed: boolean;
authorPublicKey: string | null;
} & HostCompat
>
> {
const reg = await this.fetchRegistry(force);
return reg.plugins.map((p) => {
const latest = p.versions[0] ?? null;
return {
id: p.id, name: p.name, author: p.author, description: p.description, repo: p.repo,
homepage: p.homepage, tags: p.tags, type: p.type, reviewedAt: p.reviewedAt ?? null,
id: p.id,
name: p.name,
author: p.author,
description: p.description,
repo: p.repo,
homepage: p.homepage,
tags: p.tags,
type: p.type,
icon: p.icon,
reviewedAt: p.reviewedAt ?? null,
downloadCount: p.downloadCount ?? null,
latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null,
requiredAddons: latest?.requiredAddons ?? [], pluginDependencies: latest?.pluginDependencies ?? [],
latest: latest?.version ?? null,
minTrekVersion: latest?.minTrekVersion ?? null,
requiredAddons: latest?.requiredAddons ?? [],
pluginDependencies: latest?.pluginDependencies ?? [],
screenshotUrl: latest ? rawFileUrl(p.repo, latest.commitSha, 'docs/screenshot.png') : null,
signed: !!p.authorPublicKey && !!latest?.signature,
authorPublicKey: p.authorPublicKey ?? null,
...this.hostCompat(p),
};
});
}
/**
* How this entry stands against the running TREK, computed SERVER-side. The client has
* no semver dependency and must never grow a second implementation of range logic — a
* UI that disagreed with the install gate would show an enabled button that 400s.
*
* `latestCompatible` is the useful half: when the newest version has outrun this TREK,
* an older one often still fits, so the UI can offer it instead of a dead grey button.
*/
private hostCompat(entry: RegistryEntry): HostCompat {
const latest = entry.versions[0] ?? null;
const compatible = latest ? hostCompatible(latest, normalizedHost()) : false;
const fallback = compatible ? null : this.latestCompatible(entry);
return {
trek: latest?.trek ?? null,
hostVersion: hostVersion(),
compatible,
latestCompatible: compatible ? (latest?.version ?? null) : (fallback?.version ?? null),
};
}
/**
* Everything the browse detail view shows for one plugin: the registry entry
* plus a preview of its live manifest (permissions, egress, settings) fetched
@@ -161,7 +256,10 @@ export class PluginRegistryService {
let manifest: ManifestPreview | null = null;
if (latest) {
try {
const { bytes } = await safeDownload(rawFileUrl(entry.repo, latest.commitSha, 'trek-plugin.json'), MANIFEST_MAX_BYTES);
const { bytes } = await safeDownload(
rawFileUrl(entry.repo, latest.commitSha, 'trek-plugin.json'),
MANIFEST_MAX_BYTES,
);
manifest = previewManifest(parseJsonText(bytes.toString('utf8')));
} catch {
// Soft-fail: the detail view still renders from registry metadata alone.
@@ -169,13 +267,26 @@ export class PluginRegistryService {
}
const data = {
id: entry.id, name: entry.name, author: entry.author, description: entry.description,
repo: entry.repo, homepage: entry.homepage ?? null, tags: entry.tags ?? [], type: entry.type,
reviewedAt: entry.reviewedAt ?? null, downloadCount: entry.downloadCount ?? null,
latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null,
size: latest?.size ?? null, publishedAt: latest?.publishedAt ?? null,
requiredAddons: latest?.requiredAddons ?? [], pluginDependencies: latest?.pluginDependencies ?? [],
id: entry.id,
name: entry.name,
author: entry.author,
description: entry.description,
repo: entry.repo,
homepage: entry.homepage ?? null,
tags: entry.tags ?? [],
type: entry.type,
reviewedAt: entry.reviewedAt ?? null,
downloadCount: entry.downloadCount ?? null,
latest: latest?.version ?? null,
minTrekVersion: latest?.minTrekVersion ?? null,
size: latest?.size ?? null,
publishedAt: latest?.publishedAt ?? null,
requiredAddons: latest?.requiredAddons ?? [],
pluginDependencies: latest?.pluginDependencies ?? [],
screenshotUrl: latest ? rawFileUrl(entry.repo, latest.commitSha, 'docs/screenshot.png') : null,
signed: !!entry.authorPublicKey && !!latest?.signature,
authorPublicKey: entry.authorPublicKey ?? null,
...this.hostCompat(entry),
manifest,
};
// Don't negative-cache a transiently failed manifest fetch — the next
@@ -194,32 +305,84 @@ export class PluginRegistryService {
const reg = await this.fetchRegistry();
const entry = reg.plugins.find((p) => p.id === id);
if (!entry) throw new RegistryError(`plugin ${id} not in registry`);
const host = hostVersion();
const v = this.latestCompatible(entry, constraint);
if (!v) {
throw new RegistryError(
constraint
? `no version of ${id} satisfies "${constraint}" and TREK ${hostVersion()}`
: `no version of ${id} is compatible with TREK ${hostVersion()}`,
'TREK_VERSION_INCOMPATIBLE',
);
}
return v;
}
/** The newest version of `entry` that satisfies `constraint` AND admits the running TREK. */
private latestCompatible(entry: RegistryEntry, constraint?: string): RegistryVersion | null {
const host = normalizedHost();
const candidates = entry.versions.filter((v) => {
if (constraint && !semver.satisfies(v.version, constraint, { includePrerelease: true })) return false;
return hostCompatible(v, host);
});
if (!candidates.length) {
throw new RegistryError(
constraint
? `no version of ${id} satisfies "${constraint}" and this TREK version`
: `no compatible version of ${id} for this TREK version`,
);
}
if (!candidates.length) return null;
return [...candidates].sort((a, b) => semver.rcompare(a.version, b.version))[0];
}
/** Install a version from the registry. Returns the installed plugin id + version. */
async install(id: string, opts?: { version?: string; constraint?: string }): Promise<{ id: string; version: string }> {
/**
* Pick the version `install()` will fetch, and refuse an incompatible one — on EVERY
* path, which is the whole point. An explicit `version` and the bare "install latest"
* both used to bypass the compat check entirely (only the `constraint` path went
* through resolveVersion), so the admin UI's own Install button — which sends neither —
* happily installed a plugin that declared it doesn't support this TREK.
*/
private selectVersion(entry: RegistryEntry, opts?: { version?: string; constraint?: string }): RegistryVersion {
if (opts?.version) {
const ver = entry.versions.find((v) => v.version === opts.version);
if (!ver) throw new RegistryError(`version ${opts.version} not found for ${entry.id}`);
if (!hostCompatible(ver, normalizedHost())) {
throw new RegistryError(
`${entry.id} ${ver.version} requires TREK ${trekRequirement(ver)} — this is TREK ${hostVersion()}`,
'TREK_VERSION_INCOMPATIBLE',
);
}
return ver;
}
const ver = this.latestCompatible(entry, opts?.constraint);
if (ver) return ver;
// Nothing compatible. Say WHY against the newest version the admin was actually
// shown, rather than a bare "not found" — the fix is a TREK upgrade, not a retry.
const latest = entry.versions[0];
if (latest && !opts?.constraint) {
throw new RegistryError(
`${entry.id} ${latest.version} requires TREK ${trekRequirement(latest)} — this is TREK ${hostVersion()}`,
'TREK_VERSION_INCOMPATIBLE',
);
}
throw new RegistryError(
opts?.constraint
? `no version of ${entry.id} satisfies "${opts.constraint}" and TREK ${hostVersion()}`
: `no version of ${entry.id} is compatible with TREK ${hostVersion()}`,
'TREK_VERSION_INCOMPATIBLE',
);
}
/**
* Install a version from the registry. Returns the installed plugin id + version.
*
* `retrustKey` is the admin's explicit "I have confirmed this new signing key with
* the author out-of-band" override, and it only lifts the TOFU key-change stop —
* the artifact must STILL verify under that key (see verifySignatureAndTofu), so a
* blessed key that doesn't actually sign the code is refused like any other bad
* signature. It is never a way to install something unverified.
*/
async install(
id: string,
opts?: { version?: string; constraint?: string; retrustKey?: string },
): Promise<{ id: string; version: string }> {
const reg = await this.fetchRegistry();
const entry = reg.plugins.find((p) => p.id === id);
if (!entry) throw new RegistryError(`plugin ${id} not in registry`);
const ver = opts?.version
? entry.versions.find((v) => v.version === opts.version)
: opts?.constraint
? await this.resolveVersion(id, opts.constraint)
: entry.versions[0];
if (!ver) throw new RegistryError(`version ${opts?.version ?? opts?.constraint ?? 'latest'} not found for ${id}`);
const ver = this.selectVersion(entry, opts); // throws TREK_VERSION_INCOMPATIBLE before we fetch a byte
// 1. SSRF-safe download + 2. sha256 verify
const { bytes, sha256 } = await safeDownload(ver.downloadUrl, (ver.size ?? 50 * 1024 * 1024) + 4096);
@@ -228,7 +391,14 @@ export class PluginRegistryService {
// 2b. author signature (opt-in) + TOFU key pin. Unsigned plugins skip this
// and install on sha256 alone (unchanged behaviour); a signed plugin must
// verify, and its author key must match the one pinned on first install.
this.verifySignatureAndTofu(id, bytes, entry, ver);
// A refusal is REMEMBERED (the plugin keeps running on its old code, so the
// reason must outlive the toast) and re-thrown untouched.
try {
this.verifySignatureAndTofu(id, bytes, entry, ver, opts?.retrustKey);
} catch (e) {
if (e instanceof RegistryError && isSignatureCode(e.code)) setUpdateBlock(id, e.code, e.message, ver.version);
throw e;
}
// 3. zip/tar-slip-safe extract to staging
const staging = path.join(pluginsDataRoot(), '.staging', `${id}-${ver.version}-${Date.now()}`);
@@ -238,8 +408,17 @@ export class PluginRegistryService {
if (!pluginRoot) throw new RegistryError('archive contains no trek-plugin.json');
// 4. re-validate the bundled manifest + 5. native re-scan
const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(pluginRoot, 'trek-plugin.json'), 'utf8')));
const manifest = parseManifest(
parseJsonText(fs.readFileSync(path.join(pluginRoot, 'trek-plugin.json'), 'utf8')),
{ requireTrek: true },
);
if (manifest.id !== id) throw new RegistryError(`manifest id "${manifest.id}" != "${id}"`);
// The artifact's OWN range is the authoritative compat check. The index metadata
// gated above is only what the registry says about this version, and it is weaker:
// published entries usually carry a lower bound and no upper one at all, so a
// plugin that declares "<4.0.0" passes the pre-download filter on TREK 4 and is
// caught only here.
assertHostCompatible(manifest.trekRange, id);
if (scanForNativeBinaries(pluginRoot).length) throw new RegistryError('artifact contains native binaries');
// 6. atomic move into place
@@ -251,12 +430,22 @@ export class PluginRegistryService {
// 7. register INACTIVE (record provenance)
discoverPlugins(db);
db.prepare('UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ? WHERE id = ?').run(
entry.repo, ver.commitSha, ver.sha256, entry.reviewedAt ?? null, id,
entry.repo,
ver.commitSha,
ver.sha256,
entry.reviewedAt ?? null,
id,
);
// Pin the author key on first successful install of a signed plugin (TOFU).
// Pin the author key on first successful install of a signed plugin (TOFU) —
// and, after a re-trust, re-pin to the new key the admin blessed. Only ever set
// to a key the artifact just verified under; NEVER cleared to NULL, because a
// NULL pin re-opens the "was never signed" path that accepts an unsigned update.
if (entry.authorPublicKey) {
db.prepare('UPDATE plugins SET author_pubkey = ? WHERE id = ?').run(entry.authorPublicKey, id);
}
// The plugin is now on new code that passed every check — whatever refusal was
// recorded before no longer describes reality.
clearUpdateBlock(id);
return { id, version: ver.version };
} finally {
fs.rmSync(staging, { recursive: true, force: true });
@@ -270,8 +459,13 @@ export class PluginRegistryService {
* can't be installed; they're collected and returned so the caller can prompt the
* admin to enable them. Cycle-safe. Already-installed plugins are left untouched.
*/
async installWithDependencies(id: string, constraint?: string): Promise<{ installed: string[]; requiredAddons: string[] }> {
const installedNow = new Set((db.prepare('SELECT id FROM plugins').all() as Array<{ id: string }>).map((r) => r.id));
async installWithDependencies(
id: string,
constraint?: string,
): Promise<{ installed: string[]; requiredAddons: string[] }> {
const installedNow = new Set(
(db.prepare('SELECT id FROM plugins').all() as Array<{ id: string }>).map((r) => r.id),
);
const done = new Set<string>();
const installed: string[] = [];
const requiredAddons = new Set<string>();
@@ -281,7 +475,10 @@ export class PluginRegistryService {
if (stack.includes(pid)) throw new RegistryError(`plugin dependency cycle: ${[...stack, pid].join(' -> ')}`);
const ver = await this.resolveVersion(pid, range); // throws if not in registry / unsatisfiable
if (!installedNow.has(pid)) {
await this.install(pid, range ? { constraint: range } : undefined);
// Install the version we just RESOLVED. Passing the range back (or nothing, for
// the root) made install() re-pick on its own and land on entry.versions[0] —
// so the compatible version resolveVersion had chosen was computed and discarded.
await this.install(pid, { version: ver.version });
installed.push(pid);
installedNow.add(pid);
}
@@ -308,7 +505,10 @@ export class PluginRegistryService {
extractArchive(bytes, stagingDir);
const root = locateManifestDir(stagingDir);
if (!root) throw new RegistryError('archive contains no trek-plugin.json');
const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(root, 'trek-plugin.json'), 'utf8')));
const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(root, 'trek-plugin.json'), 'utf8')), {
requireTrek: true,
});
assertHostCompatible(manifest.trekRange, manifest.id);
if (scanForNativeBinaries(root).length) throw new RegistryError('artifact contains native binaries');
return { id: manifest.id, version: manifest.version, root, stagingDir };
} catch (e) {
@@ -334,9 +534,17 @@ export class PluginRegistryService {
// an existing row's status, so replacing a plugin that was active must not
// leave the new code marked active — the admin re-activates (and re-consents
// to permissions) explicitly.
db.prepare("UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ?, author_pubkey = NULL, status = 'inactive', enabled = 0 WHERE id = ?").run(
'local:upload', null, null, null, staged.id,
);
//
// The update-block goes too. It described a REGISTRY update being refused, and this
// plugin has just left the registry trust model entirely — the code is now whatever
// the admin uploaded. Leaving the block would have the row insist an update was
// blocked over a signing key that no longer applies to the code that is running.
db.prepare(
`UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ?, author_pubkey = NULL,
update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL,
status = 'inactive', enabled = 0
WHERE id = ?`,
).run('local:upload', null, null, null, staged.id);
} finally {
fs.rmSync(staged.stagingDir, { recursive: true, force: true });
}
@@ -349,26 +557,105 @@ export class PluginRegistryService {
* - Neither key nor signature → unsigned plugin, skip (opt-in).
* - Key present but no signature (or vice versa) → hard stop; a half-signed
* entry is a misconfiguration we refuse rather than silently downgrade.
* - Signature invalid → hard stop.
* - Signature invalid (or malformed) → hard stop.
* - Registry key differs from the key pinned on a prior install → hard stop
* (author change / rotation / attack; needs an explicit admin re-trust).
*
* Each stop carries a machine-readable code, because only ONE of them
* (SIGNATURE_KEY_CHANGED) may ever be overridden and the UI must be able to tell
* them apart without reading prose.
*
* `retrustKey` lifts the key-change stop for exactly the key the admin confirmed.
* Note what it does NOT do: the signature check below still runs, against the NEW
* key, over the artifact bytes. A key an admin blesses must still sign the code it
* ships — a re-trust moves the pin from one VERIFIED key to another verified key.
*/
private verifySignatureAndTofu(id: string, bytes: Buffer, entry: RegistryEntry, ver: RegistryVersion): void {
const pinned = (db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string } | undefined)?.author_pubkey ?? null;
private verifySignatureAndTofu(
id: string,
bytes: Buffer,
entry: RegistryEntry,
ver: RegistryVersion,
retrustKey?: string,
): void {
const pinned =
(db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string } | undefined)
?.author_pubkey ?? null;
if (!entry.authorPublicKey && !ver.signature) {
if (pinned) throw new RegistryError('this plugin was signed before but the update is unsigned — refusing');
if (pinned) {
throw new RegistryError(
'this plugin was signed before but the update is unsigned — refusing',
'SIGNATURE_MISSING',
);
}
return; // unsigned throughout: sha256 is the only pin
}
if (!entry.authorPublicKey || !ver.signature) {
throw new RegistryError('incomplete signature: an author key and a version signature must both be present');
throw new RegistryError(
'incomplete signature: an author key and a version signature must both be present',
'SIGNATURE_INCOMPLETE',
);
}
if (pinned && pinned !== entry.authorPublicKey) {
throw new RegistryError("the plugin's author signing key changed since it was installed — re-trust it explicitly to continue");
if (pinned && pinned !== entry.authorPublicKey && retrustKey !== entry.authorPublicKey) {
throw new RegistryError(
"the plugin's author signing key changed since it was installed — re-trust it explicitly to continue",
RETRUSTABLE_CODE,
);
}
if (!verifyAuthorSignature(bytes, ver.signature, entry.authorPublicKey)) {
throw new RegistryError('author signature verification failed');
// verifyAuthorSignature returns false on a well-formed non-matching signature but
// THROWS SignatureError on a malformed key/signature. Both mean the same thing to
// an admin — the bytes aren't what the author signed — and both are non-overridable.
let ok: boolean;
try {
ok = verifyAuthorSignature(bytes, ver.signature, entry.authorPublicKey);
} catch (e) {
if (e instanceof SignatureError) {
throw new RegistryError(`author signature verification failed: ${e.message}`, 'SIGNATURE_INVALID');
}
throw e;
}
if (!ok) throw new RegistryError('author signature verification failed', 'SIGNATURE_INVALID');
}
/**
* Re-derive, SERVER-SIDE, that `id` is genuinely in the one overridable condition
* (its pinned key no longer matches the registry's) and that `publicKey` is the key
* the registry offers RIGHT NOW.
*
* Both halves matter. The first is the real enforcement of "re-trust is offered only
* for a changed key" — the UI hiding the button is a convenience, not the control, so
* an admin cannot re-trust their way past an invalid signature by calling the endpoint
* directly. The second closes the TOCTOU window: the client sends back the key it
* SHOWED the admin, and if the entry has been re-keyed again since the dialog
* rendered, the admin would be blessing a key they never saw.
*/
async assertRetrustable(id: string, publicKey: string): Promise<RegistryEntry> {
const row = db.prepare('SELECT source_repo, author_pubkey FROM plugins WHERE id = ?').get(id) as
| { source_repo?: string | null; author_pubkey?: string | null }
| undefined;
if (!row) throw new RegistryError(`plugin ${id} not found`, 'NOT_FOUND');
if (!row.source_repo || row.source_repo === 'local:upload' || row.source_repo === 'local:link') {
throw new RegistryError('only a registry-installed plugin can be re-trusted', 'RETRUST_NOT_APPLICABLE');
}
const reg = await this.fetchRegistry();
const entry = reg.plugins.find((p) => p.id === id);
if (!entry) throw new RegistryError(`plugin ${id} not in registry`, 'NOT_FOUND');
const pinned = row.author_pubkey ?? null;
if (!pinned || !entry.authorPublicKey || pinned === entry.authorPublicKey) {
throw new RegistryError(
"this plugin's signing key has not changed — there is nothing to re-trust",
'RETRUST_NOT_APPLICABLE',
);
}
if (entry.authorPublicKey !== publicKey) {
throw new RegistryError(
'the signing key changed again since you were shown it — review the new key before re-trusting',
'RETRUST_KEY_MISMATCH',
);
}
return entry;
}
}
@@ -376,18 +663,46 @@ function rawFileUrl(repo: string, commitSha: string, file: string): string {
return `https://raw.githubusercontent.com/${repo}/${commitSha}/${file}`;
}
/** The running TREK version (same source as the rest of the app). */
function hostVersion(): string {
return process.env.APP_VERSION || (require('../../../../package.json') as { version: string }).version;
/**
* Whether a registry version admits the running TREK.
*
* The version's `trek` range is authoritative when the entry carries one. Entries
* published before that field existed only carry min/max bounds, so those remain the
* fallback — but they are a strictly weaker check (maxTrekVersion is inclusive and is
* usually absent entirely, because the SDK never emitted it), which is why install
* re-gates on the artifact's own manifest after extraction. This is only the cheap
* pre-download filter.
*/
function hostCompatible(v: RegistryVersion, host: string | null): boolean {
if (host === null) return true; // unversioned build — don't block on compat
if (v.trek) return hostSatisfies(v.trek, host);
if (v.minTrekVersion && semver.valid(v.minTrekVersion) && semver.lt(host, v.minTrekVersion)) return false;
if (v.maxTrekVersion && semver.valid(v.maxTrekVersion) && semver.gt(host, v.maxTrekVersion)) return false;
return true;
}
/** Whether a registry version's host-version bounds admit the running TREK. */
function hostCompatible(v: RegistryVersion, host: string): boolean {
const h = semver.coerce(host)?.version ?? host;
if (!semver.valid(h)) return true; // unparseable host — don't block on compat
if (v.minTrekVersion && semver.valid(v.minTrekVersion) && semver.lt(h, v.minTrekVersion)) return false;
if (v.maxTrekVersion && semver.valid(v.maxTrekVersion) && semver.gt(h, v.maxTrekVersion)) return false;
return true;
/**
* Refuse an artifact whose declared TREK range doesn't admit the running host. Shared by
* every install front door (registry, sideload, dev-link) so they all fail the same way,
* with a code the admin UI can act on rather than prose it would have to string-match.
*/
export function assertHostCompatible(range: string | null, id: string): void {
if (hostSatisfies(range)) return;
throw new RegistryError(`${id} requires TREK ${range} — this is TREK ${hostVersion()}`, 'TREK_VERSION_INCOMPATIBLE');
}
/**
* How a version's TREK requirement reads in an error/UI string. Each bound is optional —
* an entry may declare only a ceiling, or (once `trek` is absent too) nothing at all — so
* this composes whatever bounds exist rather than interpolating a missing one as "null".
*/
function trekRequirement(v: RegistryVersion): string {
if (v.trek) return v.trek;
const bounds = [
v.minTrekVersion ? `>=${v.minTrekVersion}` : null,
v.maxTrekVersion ? `<=${v.maxTrekVersion}` : null,
].filter(Boolean);
return bounds.length ? bounds.join(' ') : 'any version';
}
/**
@@ -415,7 +730,10 @@ function previewManifest(raw: unknown): ManifestPreview {
const pluginDependencies = Array.isArray(m.pluginDependencies)
? m.pluginDependencies
.filter((d): d is Record<string, unknown> => !!d && typeof d === 'object')
.map((d) => ({ id: typeof d.id === 'string' ? d.id : '', version: typeof d.version === 'string' ? d.version : '' }))
.map((d) => ({
id: typeof d.id === 'string' ? d.id : '',
version: typeof d.version === 'string' ? d.version : '',
}))
.filter((d) => d.id && d.version)
: [];
return {
@@ -40,10 +40,33 @@ export function isBlockedIp(ip: string): boolean {
}
if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 ULA
// IPv6 transition addresses (NAT64/6to4/Teredo) embedding an IPv4 → re-check
// the embedded target, so a NAT64 spelling of 169.254.169.254 can't tunnel past.
const embedded = embeddedTransitionIpv4(g);
if (embedded) return isBlockedIp(embedded);
}
return false;
}
/**
* If the expanded hextets `g` are an IPv6 transition address that embeds an IPv4
* target, return that IPv4 in dotted form; otherwise null. Recognises the NAT64
* well-known prefix `64:ff9b::/96`, 6to4 `2002::/16`, and Teredo `2001:0000::/32`.
* Kept local so this module stays dependency-free for the isolated plugin child.
*/
function embeddedTransitionIpv4(g: number[]): string | null {
const v4 = (hi: number, lo: number) => `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
// NAT64 well-known 64:ff9b::/96 — first 96 bits fixed, last 32 = IPv4.
if (g[0] === 0x0064 && g[1] === 0xff9b && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0) {
return v4(g[6], g[7]);
}
// 6to4 2002::/16 — hextets 1,2 = IPv4.
if (g[0] === 0x2002) return v4(g[1], g[2]);
// Teredo 2001:0000::/32 — last 32 bits = client IPv4 XOR 0xffffffff.
if (g[0] === 0x2001 && g[1] === 0x0000) return v4(g[6] ^ 0xffff, g[7] ^ 0xffff);
return null;
}
/** Expand an IPv6 literal into its 8 numeric hextets, or null if not a valid IPv6. */
function expandV6(ip: string): number[] | null {
let h = ip.toLowerCase().replace(/%.*$/, '');
+13 -3
View File
@@ -788,9 +788,19 @@ export function createPluginContext(
},
events: {
// Fire-and-forget by contract, but the host CAN reject (undeclared event name,
// rate-limit) — swallow it, otherwise the detached rejection crashes the child
// and terminally disables the plugin over one bad emit.
emit: (name, payload) => { t.rpc('events.emit', { event: name, payload }).catch(() => {}); },
// rate-limit). The rejection must not escape — a detached rejection crashes the child
// and terminally disables the plugin over one bad emit — but swallowing it silently
// left an author with no way to discover that `emits` was missing from the manifest.
// Surface it on the plugin's own log stream instead.
emit: (name, payload) => {
t.rpc('events.emit', { event: name, payload }).catch((e: unknown) => {
t.emit('log', {
level: 'warn',
msg: `events.emit("${name}") was rejected by the host: ${e instanceof Error ? e.message : String(e)}`,
meta: { event: name },
});
});
},
},
};
}
@@ -0,0 +1,93 @@
import { db } from '../../db/database';
/**
* Signature/trust status shared between the registry installer, the read-side
* plugin service and the admin controller (#plugins).
*
* TREK verifies an author's Ed25519 signature and TOFU-pins the key on first
* install, but until now none of that was ever surfaced: a successfully-installed
* UNSIGNED plugin looked identical to a signed one, and a signature-refused update
* left the plugin quietly pinned at its old version with the reason in a toast.
* These helpers give the four failure conditions machine-readable codes and
* persist the refusal so the admin list can keep showing it.
*/
/**
* Machine-readable reason a signature check refused an install.
*
* Only SIGNATURE_KEY_CHANGED is overridable (an author can legitimately rotate a
* key). The other three mean the bytes are not what the author signed — there is
* no story where waving that through is the right answer, so no override exists
* for them, not even a disabled one.
*/
export type SignatureCode =
| 'SIGNATURE_MISSING'
| 'SIGNATURE_INCOMPLETE'
| 'SIGNATURE_KEY_CHANGED'
| 'SIGNATURE_INVALID';
/** The one code an admin may override, via POST /api/admin/plugins/:id/retrust. */
export const RETRUSTABLE_CODE: SignatureCode = 'SIGNATURE_KEY_CHANGED';
export function isSignatureCode(code: string | undefined): code is SignatureCode {
return (
code === 'SIGNATURE_MISSING' ||
code === 'SIGNATURE_INCOMPLETE' ||
code === 'SIGNATURE_KEY_CHANGED' ||
code === 'SIGNATURE_INVALID'
);
}
/**
* A short, human-comparable form of a minisign/base64 public key — the thing an
* admin reads out to the author over the phone to confirm a rotation ("pinned
* A1b2c3d4…w7x8y9z0, new …"). Display only: the re-trust round-trip carries the
* FULL key, because a truncated fingerprint is a weak equality check. There is
* nothing secret about either — it is a public key.
*/
export function keyFingerprint(pubkey: string | null | undefined): string | null {
if (!pubkey) return null;
const payload = pubkey
.trim()
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('untrusted comment'))
.pop();
if (!payload) return null;
return payload.length <= 20 ? payload : `${payload.slice(0, 8)}…${payload.slice(-8)}`;
}
/**
* Remember WHY an update was refused, so the Installed row can keep saying so
* instead of making the admin re-attempt the update to rediscover the reason.
* `version` is the registry version that was refused — the block is treated as
* stale (and the admin can just re-attempt) once the registry offers a newer one.
*
* Deliberately does NOT touch `status`: the plugin is still running fine on its
* old code. A blocked update is not a broken runtime, and conflating them would
* make the isolation-health dot lie.
*/
export function setUpdateBlock(id: string, code: SignatureCode, detail: string, version: string | null): void {
try {
db.prepare('UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?').run(
code,
detail,
version,
id,
);
} catch {
// Columns absent (a slimmed test app) — the block is a nicety, never a gate.
}
}
/** Clear a recorded block. Called on a successful install/update — NOT on activate:
* activating the plugin at its OLD version resolves nothing, and letting an off/on
* toggle erase the warning is exactly the silent-stops-updating failure this exists
* to prevent. (Uninstall drops the row entirely, so it needs no explicit clear.) */
export function clearUpdateBlock(id: string): void {
try {
db.prepare('UPDATE plugins SET update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL WHERE id = ?').run(id);
} catch {
// See setUpdateBlock.
}
}
@@ -128,15 +128,6 @@ export class ReservationsService {
/** Fire-and-forget booking-change notification, mirroring the legacy dynamic import. */
notifyBookingChange(tripId: string, actor: User, booking: string, type: string): void {
import('../../services/notificationService').then(({ send }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
send({
event: 'booking_change',
actorId: actor.id,
scope: 'trip',
targetId: Number(tripId),
params: { trip: tripInfo?.title || 'Untitled', actor: actor.email, booking, type: type || 'booking', tripId: String(tripId) },
}).catch(() => {});
});
svc.notifyBookingChange(tripId, actor.id, booking, type);
}
}
+1 -1
View File
@@ -153,7 +153,7 @@ export class TripsController {
? (this.trips.getRaw(id) as { cover_image: string | null } | undefined)?.cover_image
: undefined;
try {
const result = this.trips.update(id, user.id, body, user.role);
const result = await this.trips.update(id, user.id, body, user.role);
if (body.cover_image !== undefined && body.cover_image !== oldCover) {
this.trips.deleteOldCover(oldCover);
}
+6 -2
View File
@@ -8,7 +8,7 @@ import { listDays, listAccommodations } from '../../services/dayService';
import { listPlaces } from '../../services/placeService';
import { listItems as listPackingItems } from '../../services/packingService';
import { listItems as listTodoItems } from '../../services/todoService';
import { listBudgetItems } from '../../services/budgetService';
import { listBudgetItems, rebaseTripCurrency } from '../../services/budgetService';
import { listReservations } from '../../services/reservationService';
import { listFiles } from '../../services/fileService';
import { searchUnsplashPhotos, getUnsplashKey } from '../../services/unsplashService';
@@ -58,7 +58,11 @@ export class TripsService {
return tripSvc.getTripOwner(tripId);
}
update(tripId: string, userId: number, body: Parameters<typeof tripSvc.updateTrip>[2], role: string) {
async update(tripId: string, userId: number, body: Parameters<typeof tripSvc.updateTrip>[2], role: string) {
// Re-anchor the budget while the outgoing currency is still on the trip row,
// otherwise the frozen FX rates and the currency-less expenses that inherit the
// trip's base are left pointing at a currency that no longer exists (#1543).
await rebaseTripCurrency(tripId, body.currency);
return tripSvc.updateTrip(tripId, userId, body, role);
}
+330 -150
View File
@@ -1,22 +1,23 @@
import bcrypt from 'bcryptjs';
import { avatarUrl } from './avatarUrl';
import crypto from 'crypto';
import path from 'path';
import fs from 'fs';
import { db } from '../db/database';
import { User, Addon } from '../types';
import { updateJwtSecret } from '../config';
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp';
import { deleteUserCompletely } from './userCleanupService';
import { emitUserDeleted } from '../plugin-user-lifecycle';
import { validatePassword } from './passwordPolicy';
import { getPhotoProviderConfig } from './memories/helpersService';
import { ADDON_IDS } from '../addons';
import { prepareLlmAddonConfigForWrite, maskLlmAddonConfig } from './llmConfig';
import { send as sendNotification } from './notificationService';
import { updateJwtSecret } from '../config';
import { db } from '../db/database';
import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp';
import { emitUserDeleted } from '../plugin-user-lifecycle';
import { User, Addon } from '../types';
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
import { resolveAuthToggles } from './authService';
import { avatarUrl } from './avatarUrl';
import { prepareLlmAddonConfigForWrite, maskLlmAddonConfig } from './llmConfig';
import { getPhotoProviderConfig } from './memories/helpersService';
import { send as sendNotification } from './notificationService';
import { validatePassword } from './passwordPolicy';
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
import { deleteUserCompletely } from './userCleanupService';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -36,9 +37,11 @@ export function compareVersions(a: string, b: string): number {
const preN = n !== null && Number.isFinite(n) ? n : null;
return { parts, preN };
};
const pa = parse(a), pb = parse(b);
const pa = parse(a),
pb = parse(b);
for (let i = 0; i < Math.max(pa.parts.length, pb.parts.length); i++) {
const na = pa.parts[i] || 0, nb = pb.parts[i] || 0;
const na = pa.parts[i] || 0,
nb = pb.parts[i] || 0;
if (na > nb) return 1;
if (na < nb) return -1;
}
@@ -54,8 +57,13 @@ export function compareVersions(a: string, b: string): number {
export const isDocker = (() => {
try {
return fs.existsSync('/.dockerenv') || (fs.existsSync('/proc/1/cgroup') && fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker'));
} catch { return false; }
return (
fs.existsSync('/.dockerenv') ||
(fs.existsSync('/proc/1/cgroup') && fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker'))
);
} catch {
return false;
}
})();
// ── User CRUD ──────────────────────────────────────────────────────────────
@@ -63,15 +71,21 @@ export const isDocker = (() => {
export function listUsers() {
// Guests (#1362) are accountless trip participants, not real users — keep them out
// of admin user management entirely.
const users = db.prepare(
'SELECT id, username, email, role, avatar, created_at, updated_at, last_login FROM users WHERE COALESCE(is_guest, 0) = 0 ORDER BY created_at DESC'
).all() as (Pick<User, 'id' | 'username' | 'email' | 'role' | 'created_at' | 'updated_at' | 'last_login'> & { avatar?: string | null })[];
const users = db
.prepare(
'SELECT id, username, email, role, avatar, created_at, updated_at, last_login FROM users WHERE COALESCE(is_guest, 0) = 0 ORDER BY created_at DESC',
)
.all() as (Pick<User, 'id' | 'username' | 'email' | 'role' | 'created_at' | 'updated_at' | 'last_login'> & {
avatar?: string | null;
})[];
let onlineUserIds = new Set<number>();
try {
const { getOnlineUserIds } = require('../websocket');
onlineUserIds = getOnlineUserIds();
} catch { /* */ }
return users.map(u => ({
} catch {
/* */
}
return users.map((u) => ({
...u,
avatar_url: avatarUrl(u),
created_at: utcSuffix(u.created_at),
@@ -98,7 +112,9 @@ export function createUser(data: { username: string; email: string; password: st
}
// Guests (#1362) live in a reserved synthetic namespace; never let one block a real account.
const existingUsername = db.prepare('SELECT id FROM users WHERE username = ? AND COALESCE(is_guest, 0) = 0').get(username);
const existingUsername = db
.prepare('SELECT id FROM users WHERE username = ? AND COALESCE(is_guest, 0) = 0')
.get(username);
if (existingUsername) return { error: 'Username already taken', status: 409 };
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ? AND COALESCE(is_guest, 0) = 0').get(email);
@@ -106,13 +122,13 @@ export function createUser(data: { username: string; email: string; password: st
const passwordHash = bcrypt.hashSync(password, BCRYPT_COST);
const result = db.prepare(
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
).run(username, email, passwordHash, data.role || 'user');
const result = db
.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)')
.run(username, email, passwordHash, data.role || 'user');
const user = db.prepare(
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
).get(result.lastInsertRowid);
const user = db
.prepare('SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?')
.get(result.lastInsertRowid);
return {
user,
@@ -134,11 +150,15 @@ export function updateUser(id: string, data: { username?: string; email?: string
}
if (username && username !== user.username) {
const conflict = db.prepare('SELECT id FROM users WHERE username = ? AND id != ? AND COALESCE(is_guest, 0) = 0').get(username, id);
const conflict = db
.prepare('SELECT id FROM users WHERE username = ? AND id != ? AND COALESCE(is_guest, 0) = 0')
.get(username, id);
if (conflict) return { error: 'Username already taken', status: 409 };
}
if (email && email !== user.email) {
const conflict = db.prepare('SELECT id FROM users WHERE email = ? AND id != ? AND COALESCE(is_guest, 0) = 0').get(email, id);
const conflict = db
.prepare('SELECT id FROM users WHERE email = ? AND id != ? AND COALESCE(is_guest, 0) = 0')
.get(email, id);
if (conflict) return { error: 'Email already taken', status: 409 };
}
@@ -153,12 +173,15 @@ export function updateUser(id: string, data: { username?: string; email?: string
if (role && role !== 'admin') {
const current = db.prepare('SELECT role FROM users WHERE id = ?').get(id) as { role?: string } | undefined;
if (current?.role === 'admin') {
const adminCount = (db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }).count;
const adminCount = (
db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }
).count;
if (adminCount <= 1) return { error: 'Cannot remove the last admin', status: 400 };
}
}
db.prepare(`
db.prepare(
`
UPDATE users SET
username = COALESCE(?, username),
email = COALESCE(?, email),
@@ -166,11 +189,12 @@ export function updateUser(id: string, data: { username?: string; email?: string
password_hash = COALESCE(?, password_hash),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(username || null, email || null, role || null, passwordHash, id);
`,
).run(username || null, email || null, role || null, passwordHash, id);
const updated = db.prepare(
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
).get(id);
const updated = db
.prepare('SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?')
.get(id);
const changed: string[] = [];
if (username) changed.push('username');
@@ -190,7 +214,9 @@ export function deleteUser(id: string, currentUserId: number) {
return { error: 'Cannot delete own account', status: 400 };
}
const userToDel = db.prepare('SELECT id, email FROM users WHERE id = ?').get(id) as { id: number; email: string } | undefined;
const userToDel = db.prepare('SELECT id, email FROM users WHERE id = ?').get(id) as
| { id: number; email: string }
| undefined;
if (!userToDel) return { error: 'User not found', status: 404 };
deleteUserCompletely(userToDel.id);
@@ -201,7 +227,9 @@ export function deleteUser(id: string, currentUserId: number) {
// ── Stats ──────────────────────────────────────────────────────────────────
export function getStats() {
const totalUsers = (db.prepare('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0').get() as { count: number }).count;
const totalUsers = (
db.prepare('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0').get() as { count: number }
).count;
const totalTrips = (db.prepare('SELECT COUNT(*) as count FROM trips').get() as { count: number }).count;
const totalPlaces = (db.prepare('SELECT COUNT(*) as count FROM places').get() as { count: number }).count;
const totalFiles = (db.prepare('SELECT COUNT(*) as count FROM trip_files').get() as { count: number }).count;
@@ -212,7 +240,7 @@ export function getStats() {
export function getPermissions() {
const current = getAllPermissions();
const actions = PERMISSION_ACTIONS.map(a => ({
const actions = PERMISSION_ACTIONS.map((a) => ({
key: a.key,
level: current[a.key],
defaultLevel: a.defaultLevel,
@@ -246,13 +274,17 @@ export function getAuditLog(query: { limit?: string; offset?: string }) {
ip: string | null;
};
const rows = db.prepare(`
const rows = db
.prepare(
`
SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip
FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
ORDER BY a.id DESC
LIMIT ? OFFSET ?
`).all(limit, offset) as Row[];
`,
)
.all(limit, offset) as Row[];
const total = (db.prepare('SELECT COUNT(*) as c FROM audit_log').get() as { c: number }).c;
@@ -265,7 +297,8 @@ export function getAuditLog(query: { limit?: string; offset?: string }) {
details = { _parse_error: true };
}
}
const created_at = r.created_at && !r.created_at.endsWith('Z') ? r.created_at.replace(' ', 'T') + 'Z' : r.created_at;
const created_at =
r.created_at && !r.created_at.endsWith('Z') ? r.created_at.replace(' ', 'T') + 'Z' : r.created_at;
return { ...r, created_at, details };
});
@@ -275,7 +308,8 @@ export function getAuditLog(query: { limit?: string; offset?: string }) {
// ── OIDC Settings ──────────────────────────────────────────────────────────
export function getOidcSettings() {
const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || '';
const get = (key: string) =>
(db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value || '';
const secret = decrypt_api_key(get('oidc_client_secret'));
return {
issuer: get('oidc_issuer'),
@@ -296,10 +330,14 @@ export function updateOidcSettings(data: {
}): { error?: string; status?: number; success?: boolean } {
// Lockout prevention: can't remove OIDC config when password login is disabled
if ((data.issuer === '' || data.client_id === '') && !resolveAuthToggles().password_login) {
return { error: 'Cannot remove SSO configuration while password login is disabled. Enable password login first.', status: 400 };
return {
error: 'Cannot remove SSO configuration while password login is disabled. Enable password login first.',
status: 400,
};
}
const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || '');
const set = (key: string, val: string) =>
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, val || '');
set('oidc_issuer', data.issuer ?? '');
set('oidc_client_id', data.client_id ?? '');
if (data.client_secret !== undefined) set('oidc_client_secret', maybe_encrypt_api_key(data.client_secret) ?? '');
@@ -328,10 +366,9 @@ export function saveDemoBaseline(): { error?: string; status?: number; message?:
export async function getGithubReleases(perPage: string = '10', page: string = '1') {
try {
const resp = await fetch(
`https://api.github.com/repos/mauriceboe/TREK/releases?per_page=${perPage}&page=${page}`,
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
const resp = await fetch(`https://api.github.com/repos/liketrek/TREK/releases?per_page=${perPage}&page=${page}`, {
headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' },
});
if (!resp.ok) return [];
const data = await resp.json();
return Array.isArray(data) ? data : [];
@@ -364,41 +401,59 @@ export async function checkVersion(): Promise<VersionInfo> {
const currentVersion: string = process.env.APP_VERSION || require('../../package.json').version;
const isPrerelease = currentVersion.includes('-pre.');
const fallback: VersionInfo = { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker, is_prerelease: isPrerelease };
const fallback: VersionInfo = {
current: currentVersion,
latest: currentVersion,
update_available: false,
is_docker: isDocker,
is_prerelease: isPrerelease,
};
let result: VersionInfo;
try {
if (isPrerelease) {
// Fetch release list and find the newest prerelease
const resp = await fetch(
'https://api.github.com/repos/mauriceboe/TREK/releases?per_page=100',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
const resp = await fetch('https://api.github.com/repos/liketrek/TREK/releases?per_page=100', {
headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' },
});
if (!resp.ok) {
return fallback;
}
const data = await resp.json() as Array<{ tag_name?: string; html_url?: string; prerelease?: boolean }>;
const prereleases = Array.isArray(data) ? data.filter(r => r.prerelease) : [];
const data = (await resp.json()) as Array<{ tag_name?: string; html_url?: string; prerelease?: boolean }>;
const prereleases = Array.isArray(data) ? data.filter((r) => r.prerelease) : [];
if (!prereleases.length) {
return fallback;
}
// Pre-compute stripped versions, then sort descending
const tagged = prereleases.map(r => ({ r, v: (r.tag_name || '').replace(/^v/, '') }));
const tagged = prereleases.map((r) => ({ r, v: (r.tag_name || '').replace(/^v/, '') }));
tagged.sort((a, b) => compareVersions(b.v, a.v));
const latest = tagged[0].v;
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
result = { current: currentVersion, latest, update_available, release_url: tagged[0].r.html_url || '', is_docker: isDocker, is_prerelease: true };
result = {
current: currentVersion,
latest,
update_available,
release_url: tagged[0].r.html_url || '',
is_docker: isDocker,
is_prerelease: true,
};
} else {
const resp = await fetch(
'https://api.github.com/repos/mauriceboe/TREK/releases/latest',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
const resp = await fetch('https://api.github.com/repos/liketrek/TREK/releases/latest', {
headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' },
});
if (!resp.ok) {
return fallback;
}
const data = await resp.json() as { tag_name?: string; html_url?: string };
const data = (await resp.json()) as { tag_name?: string; html_url?: string };
const latest = (data.tag_name || '').replace(/^v/, '');
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
result = { current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker, is_prerelease: false };
result = {
current: currentVersion,
latest,
update_available,
release_url: data.html_url || '',
is_docker: isDocker,
is_prerelease: false,
};
}
} catch {
return fallback;
@@ -413,10 +468,17 @@ export async function checkAndNotifyVersion(): Promise<void> {
const result = await checkVersion();
if (!result.update_available) return;
const lastNotified = (db.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as { value: string } | undefined)?.value;
const lastNotified = (
db.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as
| { value: string }
| undefined
)?.value;
if (lastNotified === result.latest) return;
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('last_notified_version', result.latest);
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(
'last_notified_version',
result.latest,
);
await sendNotification({
event: 'version_available',
@@ -433,13 +495,17 @@ export async function checkAndNotifyVersion(): Promise<void> {
// ── Invite Tokens ──────────────────────────────────────────────────────────
export function listInvites() {
return db.prepare(`
return db
.prepare(
`
SELECT i.*, u.username as created_by_name, t.title as trip_title
FROM invite_tokens i
JOIN users u ON i.created_by = u.id
LEFT JOIN trips t ON i.trip_id = t.id
ORDER BY i.created_at DESC
`).all();
`,
)
.all();
}
/** Trips an admin can bind an invite to — id + title only, for the picker (#1402). */
@@ -447,7 +513,10 @@ export function listTripsForInvite() {
return db.prepare('SELECT id, title FROM trips ORDER BY title COLLATE NOCASE ASC').all();
}
export function createInvite(createdBy: number, data: { max_uses?: string | number; expires_in_days?: string | number; trip_id?: string | number | null }) {
export function createInvite(
createdBy: number,
data: { max_uses?: string | number; expires_in_days?: string | number; trip_id?: string | number | null },
) {
const rawUses = parseInt(String(data.max_uses));
const uses = rawUses === 0 ? 0 : Math.min(Math.max(rawUses || 1, 1), 5);
const token = crypto.randomBytes(16).toString('hex');
@@ -465,18 +534,22 @@ export function createInvite(createdBy: number, data: { max_uses?: string | numb
}
}
const ins = db.prepare(
'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by, trip_id) VALUES (?, ?, ?, ?, ?)'
).run(token, uses, expiresAt, createdBy, tripId);
const ins = db
.prepare('INSERT INTO invite_tokens (token, max_uses, expires_at, created_by, trip_id) VALUES (?, ?, ?, ?, ?)')
.run(token, uses, expiresAt, createdBy, tripId);
const inviteId = Number(ins.lastInsertRowid);
const invite = db.prepare(`
const invite = db
.prepare(
`
SELECT i.*, u.username as created_by_name, t.title as trip_title
FROM invite_tokens i
JOIN users u ON i.created_by = u.id
LEFT JOIN trips t ON i.trip_id = t.id
WHERE i.id = ?
`).get(inviteId);
`,
)
.get(inviteId);
return { invite, inviteId, uses, expiresInDays: data.expires_in_days ?? null, tripId };
}
@@ -491,57 +564,82 @@ export function deleteInvite(id: string) {
// ── Bag Tracking ───────────────────────────────────────────────────────────
export function getBagTracking() {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as { value: string } | undefined;
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as
| { value: string }
| undefined;
return { enabled: row?.value === 'true' };
}
export function updateBagTracking(enabled: boolean) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(enabled ? 'true' : 'false');
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(
enabled ? 'true' : 'false',
);
return { enabled: !!enabled };
}
// ── Places Photos ─────────────────────────────────────────────────────────
export function getPlacesPhotos() {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined;
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as
| { value: string }
| undefined;
return { enabled: row?.value !== 'false' };
}
export function updatePlacesPhotos(enabled: boolean) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_photos_enabled', ?)").run(enabled ? 'true' : 'false');
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_photos_enabled', ?)").run(
enabled ? 'true' : 'false',
);
return { enabled: !!enabled };
}
// ── Places Autocomplete ────────────────────────────────────────────────────
export function getPlacesAutocomplete() {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined;
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as
| { value: string }
| undefined;
return { enabled: row?.value !== 'false' };
}
export function updatePlacesAutocomplete(enabled: boolean) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_autocomplete_enabled', ?)").run(enabled ? 'true' : 'false');
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_autocomplete_enabled', ?)").run(
enabled ? 'true' : 'false',
);
return { enabled: !!enabled };
}
// ── Places Details ─────────────────────────────────────────────────────────
export function getPlacesDetails() {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined;
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as
| { value: string }
| undefined;
return { enabled: row?.value !== 'false' };
}
export function updatePlacesDetails(enabled: boolean) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_details_enabled', ?)").run(enabled ? 'true' : 'false');
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_details_enabled', ?)").run(
enabled ? 'true' : 'false',
);
return { enabled: !!enabled };
}
// ── Collab Features ───────────────────────────────────────────────────────
const COLLAB_FEATURE_KEYS = ['collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled'] as const;
const COLLAB_FEATURE_KEYS = [
'collab_chat_enabled',
'collab_notes_enabled',
'collab_polls_enabled',
'collab_whatsnext_enabled',
] as const;
export function getCollabFeatures() {
const rows = db.prepare("SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')").all() as { key: string; value: string }[];
const rows = db
.prepare(
"SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')",
)
.all() as { key: string; value: string }[];
const map: Record<string, string> = {};
for (const r of rows) map[r.key] = r.value;
return {
@@ -552,10 +650,20 @@ export function getCollabFeatures() {
};
}
export function updateCollabFeatures(features: { chat?: boolean; notes?: boolean; polls?: boolean; whatsnext?: boolean }) {
const mapping: Record<string, string> = { chat: 'collab_chat_enabled', notes: 'collab_notes_enabled', polls: 'collab_polls_enabled', whatsnext: 'collab_whatsnext_enabled' };
export function updateCollabFeatures(features: {
chat?: boolean;
notes?: boolean;
polls?: boolean;
whatsnext?: boolean;
}) {
const mapping: Record<string, string> = {
chat: 'collab_chat_enabled',
notes: 'collab_notes_enabled',
polls: 'collab_polls_enabled',
whatsnext: 'collab_whatsnext_enabled',
};
const before = getCollabFeatures();
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
const stmt = db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)');
for (const [feat, key] of Object.entries(mapping)) {
if (features[feat] !== undefined) stmt.run(key, features[feat] ? 'true' : 'false');
}
@@ -563,38 +671,50 @@ export function updateCollabFeatures(features: { chat?: boolean; notes?: boolean
// Collab flags gate MCP tool/resource registration, so callers must know
// whether anything actually flipped — a no-op save must not tear down every
// live MCP session (#1414).
const changed = (Object.keys(after) as Array<keyof typeof after>).some(k => after[k] !== before[k]);
const changed = (Object.keys(after) as Array<keyof typeof after>).some((k) => after[k] !== before[k]);
return { features: after, changed };
}
// ── Packing Templates ──────────────────────────────────────────────────────
export function listPackingTemplates() {
return db.prepare(`
return db
.prepare(
`
SELECT pt.*, u.username as created_by_name,
(SELECT COUNT(*) FROM packing_template_items ti JOIN packing_template_categories tc ON ti.category_id = tc.id WHERE tc.template_id = pt.id) as item_count,
(SELECT COUNT(*) FROM packing_template_categories WHERE template_id = pt.id) as category_count
FROM packing_templates pt
JOIN users u ON pt.created_by = u.id
ORDER BY pt.created_at DESC
`).all();
`,
)
.all();
}
export function getPackingTemplate(id: string) {
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(id);
if (!template) return { error: 'Template not found', status: 404 };
const categories = db.prepare('SELECT * FROM packing_template_categories WHERE template_id = ? ORDER BY sort_order, id').all(id) as any[];
const items = db.prepare(`
const categories = db
.prepare('SELECT * FROM packing_template_categories WHERE template_id = ? ORDER BY sort_order, id')
.all(id) as any[];
const items = db
.prepare(
`
SELECT ti.* FROM packing_template_items ti
JOIN packing_template_categories tc ON ti.category_id = tc.id
WHERE tc.template_id = ? ORDER BY ti.sort_order, ti.id
`).all(id);
`,
)
.all(id);
return { template, categories, items };
}
export function createPackingTemplate(name: string, createdBy: number) {
if (!name?.trim()) return { error: 'Name is required', status: 400 };
const result = db.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run(name.trim(), createdBy);
const result = db
.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)')
.run(name.trim(), createdBy);
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(result.lastInsertRowid);
return { template };
}
@@ -619,20 +739,29 @@ export function createTemplateCategory(templateId: string, name: string) {
if (!name?.trim()) return { error: 'Category name is required', status: 400 };
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(templateId);
if (!template) return { error: 'Template not found', status: 404 };
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_template_categories WHERE template_id = ?').get(templateId) as { max: number | null };
const result = db.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, ?)').run(templateId, name.trim(), (maxOrder.max ?? -1) + 1);
const maxOrder = db
.prepare('SELECT MAX(sort_order) as max FROM packing_template_categories WHERE template_id = ?')
.get(templateId) as { max: number | null };
const result = db
.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, ?)')
.run(templateId, name.trim(), (maxOrder.max ?? -1) + 1);
return { category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(result.lastInsertRowid) };
}
export function updateTemplateCategory(templateId: string, catId: string, data: { name?: string }) {
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(catId, templateId);
const cat = db
.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?')
.get(catId, templateId);
if (!cat) return { error: 'Category not found', status: 404 };
if (data.name?.trim()) db.prepare('UPDATE packing_template_categories SET name = ? WHERE id = ?').run(data.name.trim(), catId);
if (data.name?.trim())
db.prepare('UPDATE packing_template_categories SET name = ? WHERE id = ?').run(data.name.trim(), catId);
return { category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(catId) };
}
export function deleteTemplateCategory(templateId: string, catId: string) {
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(catId, templateId);
const cat = db
.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?')
.get(catId, templateId);
if (!cat) return { error: 'Category not found', status: 404 };
db.prepare('DELETE FROM packing_template_categories WHERE id = ?').run(catId);
return {};
@@ -642,17 +771,24 @@ export function deleteTemplateCategory(templateId: string, catId: string) {
export function createTemplateItem(templateId: string, catId: string, name: string) {
if (!name?.trim()) return { error: 'Item name is required', status: 400 };
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(catId, templateId);
const cat = db
.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?')
.get(catId, templateId);
if (!cat) return { error: 'Category not found', status: 404 };
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_template_items WHERE category_id = ?').get(catId) as { max: number | null };
const result = db.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(catId, name.trim(), (maxOrder.max ?? -1) + 1);
const maxOrder = db
.prepare('SELECT MAX(sort_order) as max FROM packing_template_items WHERE category_id = ?')
.get(catId) as { max: number | null };
const result = db
.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)')
.run(catId, name.trim(), (maxOrder.max ?? -1) + 1);
return { item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(result.lastInsertRowid) };
}
export function updateTemplateItem(itemId: string, data: { name?: string }) {
const item = db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(itemId);
if (!item) return { error: 'Item not found', status: 404 };
if (data.name?.trim()) db.prepare('UPDATE packing_template_items SET name = ? WHERE id = ?').run(data.name.trim(), itemId);
if (data.name?.trim())
db.prepare('UPDATE packing_template_items SET name = ? WHERE id = ?').run(data.name.trim(), itemId);
return { item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(itemId) };
}
@@ -672,16 +808,31 @@ export function isAddonEnabled(addonId: string): boolean {
export function listAddons() {
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
const providers = db.prepare(`
const providers = db
.prepare(
`
SELECT id, name, description, icon, enabled, sort_order
FROM photo_providers
ORDER BY sort_order, id
`).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number }>;
const fields = db.prepare(`
`,
)
.all() as Array<{
id: string;
name: string;
description?: string | null;
icon: string;
enabled: number;
sort_order: number;
}>;
const fields = db
.prepare(
`
SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order
FROM photo_provider_fields
ORDER BY sort_order, id
`).all() as Array<{
`,
)
.all() as Array<{
provider_id: string;
field_key: string;
label: string;
@@ -701,14 +852,15 @@ export function listAddons() {
}
return [
...addons.map(a => ({
...addons.map((a) => ({
...a,
enabled: !!a.enabled,
config: a.id === ADDON_IDS.LLM_PARSING
? maskLlmAddonConfig(JSON.parse(a.config || '{}'))
: JSON.parse(a.config || '{}'),
config:
a.id === ADDON_IDS.LLM_PARSING
? maskLlmAddonConfig(JSON.parse(a.config || '{}'))
: JSON.parse(a.config || '{}'),
})),
...providers.map(p => ({
...providers.map((p) => ({
id: p.id,
name: p.name,
description: p.description,
@@ -716,7 +868,7 @@ export function listAddons() {
icon: p.icon,
enabled: !!p.enabled,
config: getPhotoProviderConfig(p.id),
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
fields: (fieldsByProvider.get(p.id) || []).map((f) => ({
key: f.field_key,
label: f.label,
input_type: f.input_type,
@@ -734,44 +886,52 @@ export function listAddons() {
export function updateAddon(id: string, data: { enabled?: boolean; config?: Record<string, unknown> }) {
const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined;
const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number } | undefined;
const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as
| { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number }
| undefined;
if (!addon && !provider) return { error: 'Addon not found', status: 404 };
if (addon) {
if (data.enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
if (data.enabled !== undefined)
db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
if (data.config !== undefined) {
// The AI-parsing addon holds an API key — encrypt it at rest and preserve
// the stored key when the client echoes the mask sentinel (see llmConfig.ts).
const configToStore = id === ADDON_IDS.LLM_PARSING
? prepareLlmAddonConfigForWrite(data.config, JSON.parse(addon.config || '{}'))
: data.config;
const configToStore =
id === ADDON_IDS.LLM_PARSING
? prepareLlmAddonConfigForWrite(data.config, JSON.parse(addon.config || '{}'))
: data.config;
db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(configToStore), id);
}
} else {
if (data.enabled !== undefined) db.prepare('UPDATE photo_providers SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
if (data.enabled !== undefined)
db.prepare('UPDATE photo_providers SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
}
const updatedAddon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined;
const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number } | undefined;
const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as
| { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number }
| undefined;
const updated = updatedAddon
? {
...updatedAddon,
enabled: !!updatedAddon.enabled,
config: updatedAddon.id === ADDON_IDS.LLM_PARSING
? maskLlmAddonConfig(JSON.parse(updatedAddon.config || '{}'))
: JSON.parse(updatedAddon.config || '{}'),
}
...updatedAddon,
enabled: !!updatedAddon.enabled,
config:
updatedAddon.id === ADDON_IDS.LLM_PARSING
? maskLlmAddonConfig(JSON.parse(updatedAddon.config || '{}'))
: JSON.parse(updatedAddon.config || '{}'),
}
: updatedProvider
? {
id: updatedProvider.id,
name: updatedProvider.name,
description: updatedProvider.description,
type: 'photo_provider',
icon: updatedProvider.icon,
enabled: !!updatedProvider.enabled,
config: getPhotoProviderConfig(updatedProvider.id),
sort_order: updatedProvider.sort_order,
}
id: updatedProvider.id,
name: updatedProvider.name,
description: updatedProvider.description,
type: 'photo_provider',
icon: updatedProvider.icon,
enabled: !!updatedProvider.enabled,
config: getPhotoProviderConfig(updatedProvider.id),
sort_order: updatedProvider.sort_order,
}
: null;
// Only these addons gate MCP tool/resource/prompt registration (see
@@ -779,31 +939,45 @@ export function updateAddon(id: string, data: { enabled?: boolean; config?: Reco
// what a session would register. Config-only saves, photo providers and
// MCP-irrelevant addons must not tear down every live session (#1414).
const MCP_RELEVANT_ADDONS = new Set<string>([
ADDON_IDS.MCP, ADDON_IDS.PACKING, ADDON_IDS.BUDGET, ADDON_IDS.COLLAB,
ADDON_IDS.ATLAS, ADDON_IDS.VACAY, ADDON_IDS.JOURNEY,
ADDON_IDS.MCP,
ADDON_IDS.PACKING,
ADDON_IDS.BUDGET,
ADDON_IDS.COLLAB,
ADDON_IDS.ATLAS,
ADDON_IDS.VACAY,
ADDON_IDS.JOURNEY,
]);
const enabledChanged = !!addon && data.enabled !== undefined && (data.enabled ? 1 : 0) !== addon.enabled;
return {
addon: updated,
mcpAffected: enabledChanged && MCP_RELEVANT_ADDONS.has(id),
auditDetails: { enabled: data.enabled !== undefined ? !!data.enabled : undefined, config_changed: data.config !== undefined },
auditDetails: {
enabled: data.enabled !== undefined ? !!data.enabled : undefined,
config_changed: data.config !== undefined,
},
};
}
// ── MCP Tokens ─────────────────────────────────────────────────────────────
export function listMcpTokens() {
return db.prepare(`
return db
.prepare(
`
SELECT t.id, t.name, t.token_prefix, t.created_at, t.last_used_at, t.user_id, u.username
FROM mcp_tokens t
JOIN users u ON u.id = t.user_id
ORDER BY t.created_at DESC
`).all();
`,
)
.all();
}
export function deleteMcpToken(id: string) {
const token = db.prepare('SELECT id, user_id FROM mcp_tokens WHERE id = ?').get(id) as { id: number; user_id: number } | undefined;
const token = db.prepare('SELECT id, user_id FROM mcp_tokens WHERE id = ?').get(id) as
| { id: number; user_id: number }
| undefined;
if (!token) return { error: 'Token not found', status: 404 };
db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(id);
revokeUserSessions(token.user_id);
@@ -813,7 +987,9 @@ export function deleteMcpToken(id: string) {
// ── OAuth Sessions ─────────────────────────────────────────────────────────
export function listOAuthSessions() {
const rows = db.prepare(`
const rows = db
.prepare(
`
SELECT ot.id, ot.client_id, oc.name AS client_name, ot.user_id, u.username,
ot.scopes, ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at
FROM oauth_tokens ot
@@ -822,12 +998,16 @@ export function listOAuthSessions() {
WHERE ot.revoked_at IS NULL
AND ot.refresh_token_expires_at > CURRENT_TIMESTAMP
ORDER BY ot.created_at DESC
`).all() as (Record<string, unknown> & { scopes: string })[];
return rows.map(r => ({ ...r, scopes: JSON.parse(r.scopes) }));
`,
)
.all() as (Record<string, unknown> & { scopes: string })[];
return rows.map((r) => ({ ...r, scopes: JSON.parse(r.scopes) }));
}
export function revokeOAuthSession(id: string) {
const row = db.prepare('SELECT id, user_id, client_id FROM oauth_tokens WHERE id = ?').get(id) as { id: number; user_id: number; client_id: string } | undefined;
const row = db.prepare('SELECT id, user_id, client_id FROM oauth_tokens WHERE id = ?').get(id) as
| { id: number; user_id: number; client_id: string }
| undefined;
if (!row) return { error: 'Session not found', status: 404 };
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(id);
revokeUserSessionsForClient(row.user_id, row.client_id);
+186 -32
View File
@@ -4,14 +4,20 @@ import { broadcast } from '../../websocket';
import { createReservation } from '../reservationService';
import { getAirtrailCredentials } from './airtrailService';
import { AirtrailRequestError, listFlights } from './airtrailClient';
import { canonicalHash, mapFlightToReservation } from './airtrailMapper';
import type { AirtrailFlightRaw } from './airtrailClient';
import { canonicalHash, mapFlightToReservation, mapFlightsToMultiLegReservation, normalizeFlight } from './airtrailMapper';
interface ExistingFlightRow {
id: number;
reservation_time: string | null;
metadata: string | null;
from_code: string | null;
to_code: string | null;
}
interface EndpointRow {
reservation_id: number;
code: string | null;
local_date: string | null;
sequence: number;
}
function depDate(t: string | null): string | null {
@@ -31,10 +37,58 @@ function softSignature(
return null;
}
/** The signature a single AirTrail flight would get as its own reservation. */
function flightSignature(flight: AirtrailFlightRaw): string | null {
const mapped = mapFlightToReservation(flight);
return softSignature(
depDate(mapped.reservation_time),
(mapped.metadata.flight_number as string) ?? null,
mapped.endpoints.find(e => e.role === 'from')?.code ?? null,
mapped.endpoints.find(e => e.role === 'to')?.code ?? null,
);
}
/**
* Order a requested join group by departure and verify it forms a real
* connection chain: each flight leaves the airport the previous one arrived at,
* onward in time and within 24 h — the same window the booking import applies
* to same-PNR legs. A leg landing back at the chain's origin is a return
* flight, never a connection. Flights without usable instants can't prove any
* of this, so they don't chain. Returns the ordered chain, or null when the
* flights don't chain; the members then import individually, exactly as if no
* join had been requested.
*/
function orderConnectionChain(group: AirtrailFlightRaw[]): AirtrailFlightRaw[] | null {
if (group.length < 2) return null;
const norm = group.map(raw => ({ raw, n: normalizeFlight(raw) }));
const depMs = (n: (typeof norm)[number]['n']): number => (n.departure ? Date.parse(n.departure) : NaN);
if (norm.some(x => Number.isNaN(depMs(x.n)))) return null;
norm.sort((a, b) => depMs(a.n) - depMs(b.n));
const origin = norm[0].n.fromCode;
for (let i = 1; i < norm.length; i++) {
const prev = norm[i - 1].n;
const next = norm[i].n;
if (!prev.toCode || !next.fromCode || prev.toCode.toUpperCase() !== next.fromCode.toUpperCase()) return null;
if (origin && next.toCode && next.toCode.toUpperCase() === origin.toUpperCase()) return null;
const arrMs = prev.arrival ? Date.parse(prev.arrival) : NaN;
if (Number.isNaN(arrMs)) return null;
const gap = depMs(next) - arrMs;
if (gap < 0 || gap > 24 * 3600 * 1000) return null;
}
return norm.map(x => x.raw);
}
/**
* Import the given AirTrail flights into a trip as reservations (type:'flight'),
* recording the AirTrail linkage for two-way sync and broadcasting each one live.
*
* `connections` names chains of selected flights to import as ONE multi-leg
* reservation each, with the connection airports as layover stops (#1535). A
* joined booking is created detached from live sync (sync_enabled = 0): AirTrail
* has no multi-leg flight entity it could round-trip to, and a pull would
* flatten the layover again. All member ids are kept in metadata.airtrail_ids
* so none of the legs is offered for re-import.
*
* Dedup: a flight already linked to this trip is skipped ('already-imported'); a
* flight that looks like one already in the trip — e.g. the same flight another
* member already imported from their own AirTrail — is skipped ('already-in-trip').
@@ -46,58 +100,158 @@ export async function importAirtrailFlights(
userId: number,
flightIds: string[],
socketId: string | undefined,
connections: string[][] = [],
): Promise<AirtrailImportResult> {
const creds = getAirtrailCredentials(userId);
if (!creds) throw new AirtrailRequestError('AirTrail is not connected', 400);
const wanted = new Set(flightIds.map(String));
const selected = (await listFlights(creds)).filter(f => wanted.has(String(f.id)));
const byId = new Map(selected.map(f => [String(f.id), f]));
const result: AirtrailImportResult = { imported: [], skipped: [] };
const linkedIds = new Set(
(db.prepare("SELECT external_id FROM reservations WHERE trip_id = ? AND external_source = 'airtrail'").all(tripId) as {
external_id: string | null;
}[])
.map(r => r.external_id)
.filter((v): v is string => !!v),
);
const existing = db
.prepare(
`SELECT r.id, r.reservation_time, r.metadata,
(SELECT code FROM reservation_endpoints WHERE reservation_id = r.id AND role = 'from' LIMIT 1) AS from_code,
(SELECT code FROM reservation_endpoints WHERE reservation_id = r.id AND role = 'to' LIMIT 1) AS to_code
FROM reservations r WHERE r.trip_id = ? AND r.type = 'flight'`,
)
.all(tripId) as ExistingFlightRow[];
const existingSigs = new Set<string>();
for (const row of existing) {
let fn: string | null = null;
// Every AirTrail id already linked to this trip: the external_id column plus
// the metadata.airtrail_ids of joined multi-leg imports.
const linkedIds = new Set<string>();
const linkedRows = db
.prepare("SELECT external_id, metadata FROM reservations WHERE trip_id = ? AND external_source = 'airtrail'")
.all(tripId) as { external_id: string | null; metadata: string | null }[];
for (const row of linkedRows) {
if (row.external_id) linkedIds.add(row.external_id);
try {
fn = row.metadata ? (JSON.parse(row.metadata).flight_number ?? null) : null;
const ids = row.metadata ? JSON.parse(row.metadata).airtrail_ids : null;
if (Array.isArray(ids)) for (const id of ids) linkedIds.add(String(id));
} catch {
/* malformed metadata — ignore */
}
const sig = softSignature(depDate(row.reservation_time), fn, row.from_code, row.to_code);
if (sig) existingSigs.add(sig);
}
const existing = db
.prepare("SELECT r.id, r.reservation_time, r.metadata FROM reservations r WHERE r.trip_id = ? AND r.type = 'flight'")
.all(tripId) as ExistingFlightRow[];
const endpointsByReservation = new Map<number, EndpointRow[]>();
const endpointRows = db
.prepare(
`SELECT e.reservation_id, e.code, e.local_date, e.sequence
FROM reservation_endpoints e JOIN reservations r ON r.id = e.reservation_id
WHERE r.trip_id = ? AND r.type = 'flight' ORDER BY e.sequence`,
)
.all(tripId) as EndpointRow[];
for (const ep of endpointRows) {
const list = endpointsByReservation.get(ep.reservation_id);
if (list) list.push(ep);
else endpointsByReservation.set(ep.reservation_id, [ep]);
}
const days = db.prepare('SELECT id, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; date: string | null }[];
const dayIdByDate = new Map<string, number>();
const dayDateById = new Map<number, string>();
for (const day of days) {
if (!day.date) continue;
if (!dayIdByDate.has(day.date)) dayIdByDate.set(day.date, day.id);
dayDateById.set(day.id, day.date);
}
const resolveDayId = (date: string | null): number | null => (date ? (dayIdByDate.get(date) ?? null) : null);
const existingSigs = new Set<string>();
for (const row of existing) {
let meta: Record<string, any> = {};
try {
meta = row.metadata ? JSON.parse(row.metadata) : {};
} catch {
/* malformed metadata — ignore */
}
const eps = endpointsByReservation.get(row.id) ?? [];
const legs: any[] | null = Array.isArray(meta.legs) ? meta.legs : null;
if (legs && legs.length > 1) {
// One signature per leg. The leg's departure date comes from its own
// dep_day_id when set; the positional endpoint fallback is only sound
// while no endpoint was dropped (e.g. for missing coordinates) — a
// misaligned date would produce a WRONG signature, worse than none.
const aligned = eps.length === legs.length + 1;
legs.forEach((leg, i) => {
const legDate = (typeof leg?.dep_day_id === 'number' ? dayDateById.get(leg.dep_day_id) : null)
?? (aligned ? eps[i]?.local_date : null)
?? null;
const sig = softSignature(legDate, leg?.flight_number ?? null, leg?.from ?? null, leg?.to ?? null);
if (sig) existingSigs.add(sig);
});
} else {
const from = eps[0]?.code ?? null;
const to = eps.length > 1 ? eps[eps.length - 1].code : null;
const sig = softSignature(depDate(row.reservation_time), meta.flight_number ?? null, from, to);
if (sig) existingSigs.add(sig);
}
}
// Resolve the requested joins into validated chains. A group that doesn't
// chain, overlaps another, or contains an already-linked/duplicate flight
// degrades to individual imports rather than failing the whole request.
const groupedIds = new Set<string>();
const chains: AirtrailFlightRaw[][] = [];
for (const ids of connections) {
const unique = [...new Set(ids.map(String))];
const members = unique.map(id => byId.get(id)).filter((f): f is AirtrailFlightRaw => !!f);
const chain = members.length === unique.length && !unique.some(id => groupedIds.has(id))
? orderConnectionChain(members)
: null;
if (!chain) {
console.warn('[airtrail-import] join group is not a connection chain — importing flights individually');
continue;
}
if (unique.some(id => linkedIds.has(id)) || chain.some(f => {
const sig = flightSignature(f);
return !!sig && existingSigs.has(sig);
})) {
continue; // a member already exists in the trip — let the single path sort it out
}
chains.push(chain);
for (const id of unique) groupedIds.add(id);
}
for (const chain of chains) {
const ids = chain.map(f => String(f.id));
try {
const mapped = mapFlightsToMultiLegReservation(chain, resolveDayId);
const { reservation } = createReservation(tripId, mapped as any);
const now = new Date().toISOString();
db.prepare(
`UPDATE reservations SET external_source = 'airtrail', external_id = ?, external_owner_user_id = ?,
sync_enabled = 0, external_synced_at = ? WHERE id = ?`,
).run(ids[0], userId, now, reservation.id);
reservation.external_source = 'airtrail';
reservation.external_id = ids[0];
reservation.external_owner_user_id = userId;
reservation.sync_enabled = 0;
reservation.external_synced_at = now;
broadcast(tripId, 'reservation:created', { reservation }, socketId);
for (const f of chain) {
const sig = flightSignature(f);
if (sig) existingSigs.add(sig);
}
ids.forEach(id => linkedIds.add(id));
result.imported.push(...ids);
} catch (err) {
console.error('[airtrail-import] failed to import connection', ids.join('+'), err instanceof Error ? err.message : err);
for (const id of ids) {
result.skipped.push({ flightId: id, reason: 'invalid', detail: err instanceof Error ? err.message : undefined });
}
}
}
for (const flight of selected) {
const fid = String(flight.id);
if (groupedIds.has(fid)) continue;
if (linkedIds.has(fid)) {
result.skipped.push({ flightId: fid, reason: 'already-imported' });
continue;
}
const mapped = mapFlightToReservation(flight);
const sig = softSignature(
depDate(mapped.reservation_time),
(mapped.metadata.flight_number as string) ?? null,
mapped.endpoints.find(e => e.role === 'from')?.code ?? null,
mapped.endpoints.find(e => e.role === 'to')?.code ?? null,
);
const sig = flightSignature(flight);
if (sig && existingSigs.has(sig)) {
result.skipped.push({ flightId: fid, reason: 'already-in-trip', detail: mapped.title });
continue;
+133 -35
View File
@@ -1,7 +1,9 @@
import * as crypto from 'node:crypto';
import { localParts } from '../timezoneService';
import type { AirtrailAirport, AirtrailFlightRaw, AirtrailNamedCode } from './airtrailClient';
import type { AirtrailFlight } from '@trek/shared';
import * as crypto from 'node:crypto';
/** Preferred display/lookup code for an airport. */
function airportCode(a: AirtrailAirport | null): string | null {
return a?.iata || a?.icao || null;
@@ -24,37 +26,6 @@ export function entityName(e: AirtrailNamedCode | null | undefined): string | nu
return e?.name || e?.icao || e?.iata || null;
}
/**
* Local calendar date + clock time for an instant at a given IANA zone.
* AirTrail stores `departure`/`arrival` as instants (ISO w/ offset) plus a local
* `date`; the airport-local wall time is what TREK shows and files days by.
*/
function localParts(iso: string | null, tz: string | null): { date: string | null; time: string | null } {
if (!iso) return { date: null, time: null };
try {
const d = new Date(iso);
if (isNaN(d.getTime())) return { date: null, time: null };
const fmt = new Intl.DateTimeFormat('en-CA', {
timeZone: tz || 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
const parts = fmt.formatToParts(d);
const get = (t: string) => parts.find(p => p.type === t)?.value ?? '';
const date = `${get('year')}-${get('month')}-${get('day')}`;
let hh = get('hour');
if (hh === '24') hh = '00'; // some ICU builds emit 24:00 for midnight
const time = `${hh}:${get('minute')}`;
return { date: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null, time };
} catch {
return { date: null, time: null };
}
}
/** Raw AirTrail flight → the normalized shape the import picker consumes. */
export function normalizeFlight(raw: AirtrailFlightRaw): AirtrailFlight {
return {
@@ -69,7 +40,7 @@ export function normalizeFlight(raw: AirtrailFlightRaw): AirtrailFlight {
airline: entityName(raw.airline),
flightNumber: raw.flightNumber ?? null,
aircraft: entityCode(raw.aircraft),
seatClass: (raw.seats?.find(s => s.userId) ?? raw.seats?.[0])?.seatClass ?? null,
seatClass: (raw.seats?.find((s) => s.userId) ?? raw.seats?.[0])?.seatClass ?? null,
};
}
@@ -153,7 +124,7 @@ export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservatio
needsReview = 1;
}
const seat = raw.seats?.find(s => s.userId) ?? raw.seats?.[0];
const seat = raw.seats?.find((s) => s.userId) ?? raw.seats?.[0];
const airlineName = entityName(raw.airline);
const airlineCode = entityCode(raw.airline);
const aircraftCode = entityCode(raw.aircraft);
@@ -185,6 +156,133 @@ export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservatio
};
}
/**
* A chain of connecting AirTrail flights → ONE multi-leg reservation (#1535).
*
* Endpoints follow the manual multi-leg form: from → stop… → to, where a stop is
* the connection airport and carries the departure of the leg LEAVING it (the
* last endpoint carries the arrival). Per-leg airline/flight number/times/seat
* live in metadata.legs; the flat metadata mirrors the first/last leg so legacy
* readers keep working. Every source flight id rides along in
* metadata.airtrail_ids so the import dedupe recognizes each leg — the
* reservation itself is created detached from live sync, because AirTrail has no
* multi-leg flight entity a merged booking could round-trip to.
*
* `resolveDayId` maps a local calendar date to the trip's day id: the day
* planner files each leg by its own dep_day_id/arr_day_id, so an overnight
* connection must not inherit the whole booking's first day for every leg.
*/
export function mapFlightsToMultiLegReservation(
flights: AirtrailFlightRaw[],
resolveDayId: (date: string | null) => number | null = () => null,
): MappedReservation {
if (flights.length === 1) return mapFlightToReservation(flights[0]);
let needsReview = flights.some((f) => f.datePrecision && f.datePrecision !== 'day') ? 1 : 0;
const endpoints: MappedEndpoint[] = [];
const legs: Record<string, unknown>[] = [];
const first = flights[0];
const firstDep = localParts(first.departureScheduled ?? first.departure, first.from?.tz ?? null);
const firstDate = first.date || firstDep.date;
if (hasCoords(first.from)) {
endpoints.push({
role: 'from',
sequence: 0,
name: first.from.name || airportCode(first.from) || 'Departure',
code: airportCode(first.from),
lat: first.from.lat,
lng: first.from.lon,
timezone: first.from.tz,
local_time: firstDep.time,
local_date: firstDate,
});
} else {
needsReview = 1;
}
flights.forEach((f, i) => {
const isLast = i === flights.length - 1;
const dep = localParts(f.departureScheduled ?? f.departure, f.from?.tz ?? null);
const arr = localParts(f.arrivalScheduled ?? f.arrival, f.to?.tz ?? null);
if (hasCoords(f.to)) {
// The connection airport's endpoint carries the ONWARD departure, matching
// what the manual form stores for a stop; only the final arrival keeps its
// own arrival time.
const next = isLast ? null : flights[i + 1];
const nextDep = next ? localParts(next.departureScheduled ?? next.departure, next.from?.tz ?? null) : null;
endpoints.push({
role: isLast ? 'to' : 'stop',
sequence: 0,
name: f.to.name || airportCode(f.to) || (isLast ? 'Arrival' : 'Stop'),
code: airportCode(f.to),
lat: f.to.lat,
lng: f.to.lon,
timezone: f.to.tz,
local_time: isLast ? arr.time : (nextDep?.time ?? null),
local_date: isLast ? arr.date : next?.date || nextDep?.date || null,
});
} else {
needsReview = 1;
}
const seat = f.seats?.find((s) => s.userId) ?? f.seats?.[0];
const airline = entityName(f.airline);
legs.push({
from: airportCode(f.from),
to: airportCode(f.to),
...(airline ? { airline } : {}),
...(f.flightNumber ? { flight_number: f.flightNumber } : {}),
dep_day_id: resolveDayId(f.date || dep.date),
dep_time: dep.time,
arr_day_id: resolveDayId(arr.date),
arr_time: arr.time,
...(seat?.seatNumber ? { seat: seat.seatNumber } : {}),
});
});
endpoints.forEach((e, i) => {
e.sequence = i;
});
const last = flights[flights.length - 1];
const lastArr = localParts(last.arrivalScheduled ?? last.arrival, last.to?.tz ?? null);
const reservation_time = firstDep.date && firstDep.time ? `${firstDep.date}T${firstDep.time}` : (firstDate ?? null);
const reservation_end_time = lastArr.date && lastArr.time ? `${lastArr.date}T${lastArr.time}` : null;
const fromCode = airportCode(first.from);
const toCode = airportCode(last.to);
const airlineName = entityName(first.airline);
const airlineCode = entityCode(first.airline);
const aircraftCode = entityCode(first.aircraft);
const metadata: Record<string, unknown> = {
legs,
airtrail_ids: flights.map((f) => String(f.id)),
};
if (airlineName) metadata.airline = airlineName;
if (airlineCode) metadata.airline_code = airlineCode;
if (first.flightNumber) metadata.flight_number = first.flightNumber;
if (aircraftCode) metadata.aircraft = aircraftCode;
if (fromCode) metadata.departure_airport = fromCode;
if (toCode) metadata.arrival_airport = toCode;
if (legs[0]?.seat) metadata.seat = legs[0].seat;
const route = [fromCode, ...flights.map((f) => airportCode(f.to))].filter(Boolean).join(' → ');
const notes = [...new Set(flights.map((f) => f.note?.trim()).filter((n): n is string => !!n))].join('\n') || null;
return {
title: route || first.flightNumber?.trim() || 'Flight',
type: 'flight',
status: 'confirmed',
reservation_time,
reservation_end_time,
notes,
metadata,
endpoints,
needs_review: needsReview,
};
}
/**
* Stable snapshot hash of an AirTrail flight, used by the sync engine to detect
* remote changes (AirTrail exposes no updated_at/etag) and to suppress TREK's own
@@ -209,7 +307,7 @@ export function canonicalHash(raw: AirtrailFlightRaw): string {
flightReason: raw.flightReason ?? null,
note: raw.note ?? null,
seats: (raw.seats ?? [])
.map(s => ({
.map((s) => ({
userId: s.userId ?? null,
guestName: s.guestName ?? null,
seat: s.seat ?? null,
+38 -3
View File
@@ -39,6 +39,26 @@ function detach(tripId: number, reservationId: number): void {
broadcastUpdated(tripId, reservationId);
}
/**
* True when the reservation has grown into a multi-leg booking locally (extra
* stops / metadata.legs) — a shape the single AirTrail flight it is linked to
* cannot represent. Syncing such a row in either direction would corrupt one
* side: a pull flattens the layover chain back to from→to, a push rewrites the
* AirTrail flight to span the whole route (#1535).
*/
function hasLocalMultiLegShape(reservationId: number, metadataJson: string | null | undefined): boolean {
try {
const meta = metadataJson ? JSON.parse(metadataJson) : {};
if (Array.isArray(meta?.legs) && meta.legs.length > 1) return true;
} catch {
/* malformed metadata — fall through to the endpoint count */
}
const row = db.prepare('SELECT COUNT(*) AS n FROM reservation_endpoints WHERE reservation_id = ?').get(reservationId) as {
n: number;
};
return row.n > 2;
}
// ── AirTrail → TREK (poll) ───────────────────────────────────────────────────
/**
@@ -81,6 +101,13 @@ async function syncOwner(uid: number): Promise<number> {
const current = getReservation(row.id, row.trip_id);
if (!current) continue;
if (hasLocalMultiLegShape(row.id, (current as any).metadata)) {
// The user connected this flight into a multi-leg booking; applying the
// remote single-flight shape would flatten it. Stop syncing instead.
detach(row.trip_id, row.id);
changed++;
continue;
}
try {
updateReservation(row.id, row.trip_id, mapFlightToReservation(flight) as any, current as any);
db.prepare('UPDATE reservations SET external_hash = ?, external_synced_at = ? WHERE id = ?').run(
@@ -247,6 +274,17 @@ export async function pushReservationToAirtrail(reservationId: number, tripId: n
| undefined;
if (!row || !row.sync_enabled) return;
// An edit that turned this linked flight into a multi-leg booking severs the
// 1:1 mapping to the AirTrail flight: pushing would rewrite that flight to the
// full span, and the next pull would flatten the layover again. Detach — the
// merge is a deliberate local restructuring, like a joined import (#1535).
const reservation = getReservationWithJoins(row.id);
if (!reservation) return;
if (hasLocalMultiLegShape(row.id, reservation.metadata)) {
detach(tripId, row.id);
return;
}
// AirTrail is read-only by default (#1240). Only push when the flight's owner has
// explicitly opted in. A no-op skip (not a detach): the link stays active so the
// inbound, AirTrail-wins pull keeps the reservation up to date.
@@ -271,9 +309,6 @@ export async function pushReservationToAirtrail(reservationId: number, tripId: n
return;
}
const reservation = getReservationWithJoins(row.id);
if (!reservation) return;
const payload = buildSavePayload(reservation, existing);
if (!payload) return;
File diff suppressed because it is too large Load Diff
+1
View File
@@ -107,6 +107,7 @@ const ACTION_LABELS: Record<string, string> = {
'trip.delete': 'deleted trip',
'admin.user_role_change': 'changed user role',
'admin.user_delete': 'deleted user',
'admin.plugin_retrust': "re-trusted a plugin's author signing key",
'admin.invite_create': 'created invite',
'immich.private_ip_configured': 'configured Immich with private IP',
};
+121 -13
View File
@@ -30,13 +30,23 @@ function loadItemPayers(itemId: number | string) {
return rows.map(p => ({ ...p, avatar_url: avatarUrl(p) }));
}
function knownUserIds(userIds: number[]): Set<number> {
const unique = [...new Set(userIds)];
if (unique.length === 0) return new Set();
const rows = db.prepare(
`SELECT id FROM users WHERE id IN (${unique.map(() => '?').join(',')})`
).all(...unique) as { id: number }[];
return new Set(rows.map(r => r.id));
}
/** Replace the payer rows of an item and keep total_price = sum of payer amounts. */
function writeItemPayers(itemId: number | string, payers: { user_id: number; amount: number }[]) {
db.prepare('DELETE FROM budget_item_payers WHERE budget_item_id = ?').run(itemId);
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)');
const known = knownUserIds(payers.map(p => p.user_id));
let total = 0;
for (const p of payers) {
if (!(p.amount > 0)) continue;
if (!(p.amount > 0) || !known.has(p.user_id)) continue;
insert.run(itemId, p.user_id, p.amount);
total += p.amount;
}
@@ -141,6 +151,76 @@ export async function freezeForeignRate(
if (r && r > 0) data.exchange_rate = r;
}
/**
* Re-anchor a trip's money when its base currency changes (#1543).
*
* Every frozen `exchange_rate` is "units of the row's currency per 1 *trip*
* currency", and `currency = NULL` means "the trip's own currency" — both are
* relative to the trip currency, so swapping it out from under them silently
* corrupts the settlement: NULL rows redenominate (9 000 RUB becomes 9 000 EUR)
* and frozen rates keep pointing at the old base, which is exactly the mismatch
* that inflated #1543 by ~27x.
*
* So, before the switch: pin the implicit rows to the outgoing currency (their
* amounts really were in it) and re-freeze every row against the incoming one.
* No stored amount is rewritten — each expense keeps the figure the user typed,
* in the currency they typed it in, and its real-world value is preserved.
*
* Place prices follow the same NULL = "the trip's own currency" convention (that
* is how the PDF export and the place chips read them), so they are pinned too —
* otherwise a €15 museum on a trip switched to JPY starts reading as ¥15. They
* carry no frozen rate, so pinning the currency is all they need.
*
* Must run *before* the (synchronous) trip update, while the old currency is
* still in `trips`, and is a no-op when the currency isn't actually changing.
*/
export async function rebaseTripCurrency(
tripId: string | number,
newCurrency: string | null | undefined,
): Promise<void> {
const next = (newCurrency || '').toUpperCase();
if (!next) return;
const trip = db.prepare('SELECT currency FROM trips WHERE id = ?')
.get(tripId) as { currency?: string } | undefined;
if (!trip) return;
const prev = (trip.currency || 'EUR').toUpperCase();
if (prev === next) return;
const rates = await getRates(next);
// A row already denominated in the new base needs no conversion (rate 1). When no
// live rate is available we also store 1 rather than a stale one: rate 1 means "not
// frozen", so the settlement falls back to live rates instead of trusting a figure
// anchored to a currency this trip no longer uses.
const rateFor = (cur: string): number => {
if (cur === next) return 1;
const r = rates?.[cur];
return r && r > 0 ? r : 1;
};
const rebase = (table: 'budget_items' | 'budget_settlements') => {
db.prepare(`UPDATE ${table} SET currency = ? WHERE trip_id = ? AND (currency IS NULL OR currency = '')`)
.run(prev, tripId);
const rows = db.prepare(`SELECT DISTINCT currency AS cur FROM ${table} WHERE trip_id = ? AND currency IS NOT NULL`)
.all(tripId) as { cur: string }[];
for (const { cur } of rows) {
db.prepare(`UPDATE ${table} SET exchange_rate = ? WHERE trip_id = ? AND currency = ?`)
.run(rateFor(cur.toUpperCase()), tripId, cur);
}
};
// Only priced places have anything to denominate; a currency on a free place would
// just be noise. `updated_at` doubles as the optimistic-concurrency token (#1135),
// so bumping it stops a client holding the pre-switch row from writing the pin away.
const pinPlaces = () => {
db.prepare(`
UPDATE places SET currency = ?, updated_at = CURRENT_TIMESTAMP
WHERE trip_id = ? AND price IS NOT NULL AND (currency IS NULL OR currency = '')
`).run(prev, tripId);
};
db.transaction(() => { rebase('budget_items'); rebase('budget_settlements'); pinPlaces(); })();
}
export function createBudgetItem(
tripId: string | number,
data: {
@@ -172,6 +252,11 @@ export function createBudgetItem(
const payerTotal = (data.payers || []).reduce((a, p) => a + (p.amount > 0 ? p.amount : 0), 0);
const total = data.payers && data.payers.length > 0 ? payerTotal : (data.total_price || 0);
const knownMembers = data.members ? knownUserIds(data.members.map(m => m.user_id)) : null;
const members = data.members && knownMembers ? data.members.filter(m => knownMembers.has(m.user_id)) : undefined;
const knownIds = data.member_ids ? knownUserIds(data.member_ids) : null;
const memberIds = data.member_ids && knownIds ? data.member_ids.filter(uid => knownIds.has(uid)) : undefined;
const result = db.prepare(
'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, sort_order, expense_date, reservation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(
@@ -181,7 +266,7 @@ export function createBudgetItem(
total,
data.currency || null,
data.exchange_rate != null ? data.exchange_rate : 1,
data.member_ids ? data.member_ids.length : (data.persons != null ? data.persons : null),
memberIds ? memberIds.length : (data.persons != null ? data.persons : null),
data.days !== undefined && data.days !== null ? data.days : null,
data.note || null,
sortOrder,
@@ -191,12 +276,12 @@ export function createBudgetItem(
const itemId = result.lastInsertRowid as number;
if (data.payers && data.payers.length > 0) writeItemPayers(itemId, data.payers);
if (data.members && data.members.length > 0) {
if (members && members.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, ?)');
for (const m of data.members) insert.run(itemId, m.user_id, m.amount !== undefined && m.amount !== null ? m.amount : null);
} else if (data.member_ids && data.member_ids.length > 0) {
for (const m of members) insert.run(itemId, m.user_id, m.amount !== undefined && m.amount !== null ? m.amount : null);
} else if (memberIds && memberIds.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, NULL)');
for (const uid of data.member_ids) insert.run(itemId, uid);
for (const uid of memberIds) insert.run(itemId, uid);
}
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(itemId) as BudgetItem;
@@ -277,15 +362,19 @@ export function updateBudgetItem(
}
}
if (data.members !== undefined) {
const known = knownUserIds(data.members.map(m => m.user_id));
const members = data.members.filter(m => known.has(m.user_id));
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, ?)');
for (const m of data.members) insert.run(id, m.user_id, m.amount !== undefined && m.amount !== null ? m.amount : null);
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(data.members.length || null, id);
for (const m of members) insert.run(id, m.user_id, m.amount !== undefined && m.amount !== null ? m.amount : null);
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(members.length || null, id);
} else if (data.member_ids !== undefined) {
const known = knownUserIds(data.member_ids);
const memberIds = data.member_ids.filter(uid => known.has(uid));
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, NULL)');
for (const uid of data.member_ids) insert.run(id, uid);
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(data.member_ids.length || null, id);
for (const uid of memberIds) insert.run(id, uid);
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(memberIds.length || null, id);
}
// If category changed, update category order table
@@ -339,10 +428,12 @@ export function updateMembers(id: string | number, tripId: string | number, user
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
if (userIds.length > 0) {
const known = knownUserIds(userIds);
const memberIds = userIds.filter(uid => known.has(uid));
if (memberIds.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, ?)');
for (const userId of userIds) insert.run(id, userId, existingPaid[userId] || 0);
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(userIds.length, id);
for (const userId of memberIds) insert.run(id, userId, existingPaid[userId] || 0);
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(memberIds.length, id);
} else {
db.prepare('UPDATE budget_items SET persons = NULL WHERE id = ?').run(id);
}
@@ -352,6 +443,23 @@ export function updateMembers(id: string | number, tripId: string | number, user
return { members, item: updated };
}
export function removeUserFromBudgetItems(userId: number): void {
const itemIds = (db.prepare('SELECT DISTINCT budget_item_id FROM budget_item_members WHERE user_id = ?')
.all(userId) as { budget_item_id: number }[]).map(r => r.budget_item_id);
if (itemIds.length === 0) {
return;
}
db.prepare('DELETE FROM budget_item_members WHERE user_id = ?').run(userId);
const remaining = db.prepare('SELECT COUNT(*) AS count FROM budget_item_members WHERE budget_item_id = ?');
const setPersons = db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?');
for (const itemId of itemIds) {
const { count } = remaining.get(itemId) as { count: number };
setPersons.run(count || null, itemId);
}
}
export function toggleMemberPaid(id: string | number, tripId: string | number, userId: string | number, paid: boolean) {
// Resolve the item within the caller's trip before updating.
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
+51 -1
View File
@@ -207,7 +207,7 @@ function withDatePart(timestamp: string, date: string): string {
* date (time-of-day preserved). Transport endpoints (flight legs) shift by the
* same per-booking day delta so multi-leg timing stays internally consistent.
*/
function restampReservationDates(
export function restampReservationDates(
tripId: string | number,
oldDateById: Map<number, string | null>,
newDateById: Map<number, string | null>,
@@ -265,6 +265,56 @@ function assertNoInvertedAccommodation(tripId: string | number): void {
}
}
/**
* After a trip's date range changes, generateDays positionally re-dates the day rows
* (keeping their ids), so an accommodation — which has no absolute date, only
* start_day_id/end_day_id — visually shifts with the range (#1288). Re-anchor each
* stay to the days now holding its pre-change dates (from the snapshot taken before
* generateDays ran). A stay whose dates fall outside the new range is left glued to
* its day rows, mirroring resyncReservationDays' out-of-range semantics, so moving a
* whole trip still shifts everything together. The linked hotel reservation follows
* its accommodation's start day in both branches.
*/
export function resyncAccommodationDays(
tripId: string | number,
prevDateByDayId: Map<number, string | null>,
): void {
const stays = db.prepare(
'SELECT id, start_day_id, end_day_id FROM day_accommodations WHERE trip_id = ?'
).all(tripId) as { id: number; start_day_id: number; end_day_id: number }[];
if (stays.length === 0) return;
const dayByDate = db.prepare('SELECT id, day_number FROM days WHERE trip_id = ? AND date = ? LIMIT 1');
const updateStay = db.prepare('UPDATE day_accommodations SET start_day_id = ?, end_day_id = ? WHERE id = ?');
const restampLinkedRes = db.prepare(`
UPDATE reservations SET day_id = :dayId,
reservation_time = CASE WHEN reservation_time IS NULL THEN :date
ELSE :date || SUBSTR(reservation_time, 11) END
WHERE accommodation_id = :accId AND type = 'hotel'
`);
for (const stay of stays) {
const oldStartDate = prevDateByDayId.get(stay.start_day_id);
const oldEndDate = prevDateByDayId.get(stay.end_day_id);
if (oldStartDate && oldEndDate) {
const newStart = dayByDate.get(tripId, oldStartDate) as { id: number; day_number: number } | undefined;
const newEnd = dayByDate.get(tripId, oldEndDate) as { id: number; day_number: number } | undefined;
if (newStart && newEnd && newStart.day_number <= newEnd.day_number
&& (newStart.id !== stay.start_day_id || newEnd.id !== stay.end_day_id)) {
updateStay.run(newStart.id, newEnd.id, stay.id);
stay.start_day_id = newStart.id;
}
}
// Keep the linked reservation on the stay's (possibly re-dated) start day — its
// reservation_time is a snapshot of that day's date, stale after any range change.
const startDayDate = (db.prepare('SELECT date FROM days WHERE id = ?')
.get(stay.start_day_id) as { date: string | null } | undefined)?.date;
if (startDayDate) {
restampLinkedRes.run({ dayId: stay.start_day_id, date: startDayDate, accId: stay.id });
}
}
}
/** Thrown for invalid reorder/insert requests; mapped to HTTP 400 by the controller. */
export class DayReorderError extends Error {}
+1 -1
View File
@@ -1,7 +1,7 @@
import { db } from '../db/database';
// Great-circle distance between two points in kilometres.
function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
export function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6371;
const toRad = (deg: number) => (deg * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
+283 -161
View File
@@ -1,7 +1,9 @@
import { db } from '../db/database';
import { decrypt_api_key } from './apiKeyCrypto';
import { safeFetchFollow, SsrfBlockedError } from '../utils/ssrfGuard';
import { decrypt_api_key } from './apiKeyCrypto';
import { getAppUrl } from './notifications';
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
import * as placePhotoCache from './placePhotoCache';
// ── Google API call counter ───────────────────────────────────────────────────
@@ -13,7 +15,7 @@ function googleFetch(endpoint: string, label: string, init?: RequestInit): Promi
const referer = process.env.APP_URL ? getAppUrl() : undefined;
return fetch(endpoint, {
...init,
headers: { ...(referer ? { Referer: referer } : {}), ...(init?.headers as Record<string, string> ?? {}) },
headers: { ...(referer ? { Referer: referer } : {}), ...((init?.headers as Record<string, string>) ?? {}) },
});
}
@@ -62,7 +64,12 @@ interface GooglePlaceDetails extends GooglePlaceResult {
userRatingCount?: number;
regularOpeningHours?: { weekdayDescriptions?: string[]; openNow?: boolean };
editorialSummary?: { text: string };
reviews?: { authorAttribution?: { displayName?: string; photoUri?: string }; rating?: number; text?: { text?: string }; relativePublishTimeDescription?: string }[];
reviews?: {
authorAttribution?: { displayName?: string; photoUri?: string };
rating?: number;
text?: { text?: string };
relativePublishTimeDescription?: string;
}[];
photos?: { name: string; authorAttributions?: { displayName?: string }[] }[];
}
@@ -73,7 +80,7 @@ interface GooglePlaceDetails extends GooglePlaceResult {
// and throttled harder (see #1309). When the instance URL is configured we append it;
// getAppUrl()'s bare http://localhost fallback isn't a useful identifier, so we drop it.
export function buildUserAgent(instanceUrl: string | undefined): string {
const base = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
const base = 'TREK Travel Planner (https://github.com/liketrek/TREK)';
if (instanceUrl && !instanceUrl.startsWith('http://localhost')) return `${base}; ${instanceUrl}`;
return base;
}
@@ -115,9 +122,6 @@ export function googleFtidFromMapsUrl(url?: string | null): string | null {
}
}
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
import * as placePhotoCache from './placePhotoCache';
// ── Concurrency limiter for outbound photo fetches ───────────────────────────
// Caps simultaneous Wikimedia/Google photo requests so a bulk import of hundreds
// of places cannot monopolise the event loop or trigger external API rate limits.
@@ -130,7 +134,7 @@ function acquirePhotoFetchSlot(): Promise<void> {
photoFetchActive++;
return Promise.resolve();
}
return new Promise(resolve => photoFetchQueue.push(resolve));
return new Promise((resolve) => photoFetchQueue.push(resolve));
}
function releasePhotoFetchSlot(): void {
@@ -145,10 +149,16 @@ function releasePhotoFetchSlot(): void {
// ── API key retrieval ────────────────────────────────────────────────────────
export function getMapsKey(userId: number): string | null {
const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(userId) as { maps_api_key: string | null } | undefined;
const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(userId) as
| { maps_api_key: string | null }
| undefined;
const user_key = decrypt_api_key(user?.maps_api_key);
if (user_key) return user_key;
const admin = db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get() as { maps_api_key: string } | undefined;
const admin = db
.prepare(
"SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1",
)
.get() as { maps_api_key: string } | undefined;
return decrypt_api_key(admin?.maps_api_key) || null;
}
@@ -167,10 +177,12 @@ export async function searchNominatim(query: string, lang?: string) {
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`Nominatim API error: ${response.status} ${response.statusText}${text ? ' - ' + text.substring(0, 200) : ''}`);
throw new Error(
`Nominatim API error: ${response.status} ${response.statusText}${text ? ' - ' + text.substring(0, 200) : ''}`,
);
}
const data = await response.json() as NominatimResult[];
return data.map(item => ({
const data = (await response.json()) as NominatimResult[];
return data.map((item) => ({
google_place_id: null,
google_ftid: null,
osm_id: `${item.osm_type}:${item.osm_id}`,
@@ -187,8 +199,15 @@ export async function searchNominatim(query: string, lang?: string) {
// ── Nominatim lookup (by OSM ID) ────────────────────────────────────────────
export async function lookupNominatim(osmType: string, osmId: string, lang?: string): Promise<{
name: string; address: string; lat: number | null; lng: number | null;
export async function lookupNominatim(
osmType: string,
osmId: string,
lang?: string,
): Promise<{
name: string;
address: string;
lat: number | null;
lng: number | null;
} | null> {
const typePrefix = osmType.charAt(0).toUpperCase(); // N, W, R
const params = new URLSearchParams({
@@ -201,7 +220,7 @@ export async function lookupNominatim(osmType: string, osmId: string, lang?: str
headers: { 'User-Agent': UA },
});
if (!res.ok) return null;
const data = await res.json() as NominatimResult[];
const data = (await res.json()) as NominatimResult[];
const item = data[0];
if (!item) return null;
return {
@@ -210,7 +229,9 @@ export async function lookupNominatim(osmType: string, osmId: string, lang?: str
lat: parseFloat(item.lat) || null,
lng: parseFloat(item.lon) || null,
};
} catch { return null; }
} catch {
return null;
}
}
// ── Overpass API (OSM details) ───────────────────────────────────────────────
@@ -227,9 +248,11 @@ export async function fetchOverpassDetails(osmType: string, osmId: string): Prom
body: `data=${encodeURIComponent(query)}`,
});
if (!res.ok) return null;
const data = await res.json() as { elements?: OverpassElement[] };
const data = (await res.json()) as { elements?: OverpassElement[] };
return data.elements?.[0] || null;
} catch { return null; }
} catch {
return null;
}
}
// ── Overpass POI search (by category within a viewport bbox) ─────────────────
@@ -259,7 +282,14 @@ const CATEGORY_OSM_FILTERS: Record<string, string[]> = {
cafe: ['amenity=cafe'],
bar: ['amenity=bar', 'amenity=pub', 'amenity=nightclub'],
hotel: ['tourism=hotel', 'tourism=hostel', 'tourism=guest_house', 'tourism=apartment', 'tourism=motel'],
sights: ['tourism=attraction', 'tourism=viewpoint', 'historic=monument', 'historic=castle', 'historic=memorial', 'historic=ruins'],
sights: [
'tourism=attraction',
'tourism=viewpoint',
'historic=monument',
'historic=castle',
'historic=memorial',
'historic=ruins',
],
museum: ['tourism=museum', 'tourism=gallery', 'tourism=artwork', 'amenity=theatre'],
nature: ['leisure=park', 'leisure=garden', 'natural=beach', 'natural=peak'],
activity: ['tourism=theme_park', 'tourism=zoo', 'tourism=aquarium', 'leisure=water_park'],
@@ -306,10 +336,14 @@ const DEFAULT_OVERPASS_MIRRORS = [
export function resolveOverpassEndpoints(raw: string | undefined = process.env.OVERPASS_URL): string[] {
const custom = (raw ?? '')
.split(',')
.map(s => s.trim())
.filter(s => {
try { const u = new URL(s); return u.protocol === 'http:' || u.protocol === 'https:'; }
catch { return false; }
.map((s) => s.trim())
.filter((s) => {
try {
const u = new URL(s);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
});
return custom.length ? custom : DEFAULT_OVERPASS_MIRRORS;
}
@@ -358,7 +392,7 @@ async function overpassFetch(query: string): Promise<OverpassPoiElement[]> {
signal: ctrl.signal,
});
if (!res.ok) throw new Error(`Overpass ${res.status} @ ${url}`);
const data = await res.json() as { elements?: OverpassPoiElement[]; remark?: string };
const data = (await res.json()) as { elements?: OverpassPoiElement[]; remark?: string };
// Overpass signals an internal timeout / runtime error via `remark` while
// still answering HTTP 200 — often fast, with an empty or partial element
// set. Treat that as a failed attempt so a healthy mirror wins the race
@@ -380,14 +414,23 @@ async function overpassFetch(query: string): Promise<OverpassPoiElement[]> {
// Log WHY every endpoint failed (connection refused, aborted/timed out, non-OSM
// body, …) so an operator can tell blocked egress / a firewall from a transiently
// overloaded mirror — otherwise this is a bare 502 with no breadcrumb (see #1309).
const reasons = err instanceof AggregateError
? err.errors.map(e => (e instanceof Error ? e.message : String(e))).join(' | ')
: (err instanceof Error ? err.message : String(err));
const reasons =
err instanceof AggregateError
? err.errors.map((e) => (e instanceof Error ? e.message : String(e))).join(' | ')
: err instanceof Error
? err.message
: String(err);
console.error(`[Overpass] all ${OVERPASS_MIRRORS.length} endpoint(s) failed — ${reasons}`);
throw Object.assign(new Error('Could not reach any Overpass endpoint'), { status: 502 });
} finally {
// Cancel the slower/losing requests — we already have (or have given up on) a result.
controllers.forEach(c => { try { c.abort(); } catch { /* noop */ } });
controllers.forEach((c) => {
try {
c.abort();
} catch {
/* noop */
}
});
}
}
@@ -424,10 +467,12 @@ export async function searchOverpassPois(
// Overpass wants the box as (south,west,north,east) = (minLat,minLng,maxLat,maxLng).
const box = `(${south},${west},${north},${east})`;
const selectors = filters.map(f => {
const [k, v] = f.split('=');
return ` nwr["${k}"="${v}"]${box};`;
}).join('\n');
const selectors = filters
.map((f) => {
const [k, v] = f.split('=');
return ` nwr["${k}"="${v}"]${box};`;
})
.join('\n');
// `out center tags <n>` returns ways/relations with a computed center and caps
// the result count in one round-trip.
const query = `[out:json][timeout:20];\n(\n${selectors}\n);\nout center tags ${limit + 25};`;
@@ -442,8 +487,15 @@ export async function searchOverpassPois(
const lat = el.lat ?? el.center?.lat;
const lng = el.lon ?? el.center?.lon;
if (lat == null || lng == null) continue;
const matched = filters.find(f => { const [k, v] = f.split('='); return tags[k] === v; }) || filters[0];
const addr = [tags['addr:street'], tags['addr:housenumber'], tags['addr:postcode'], tags['addr:city']].filter(Boolean).join(' ') || null;
const matched =
filters.find((f) => {
const [k, v] = f.split('=');
return tags[k] === v;
}) || filters[0];
const addr =
[tags['addr:street'], tags['addr:housenumber'], tags['addr:postcode'], tags['addr:city']]
.filter(Boolean)
.join(' ') || null;
pois.push({
osm_id: `${el.type}:${el.id}`,
name,
@@ -472,18 +524,23 @@ export async function searchOverpassPois(
export function parseOpeningHours(ohString: string): { weekdayDescriptions: string[]; openNow: boolean | null } {
const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
const LONG = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const result: string[] = LONG.map(d => `${d}: ?`);
const result: string[] = LONG.map((d) => `${d}: ?`);
// Parse segments like "Mo-Fr 09:00-18:00; Sa 10:00-14:00"
for (const segment of ohString.split(';')) {
const trimmed = segment.trim();
if (!trimmed) continue;
const match = trimmed.match(/^((?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?(?:\s*,\s*(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?)*)\s+(.+)$/i);
const match = trimmed.match(
/^((?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?(?:\s*,\s*(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?)*)\s+(.+)$/i,
);
if (!match) continue;
const [, daysPart, timePart] = match;
const dayIndices = new Set<number>();
for (const range of daysPart.split(',')) {
const parts = range.trim().split('-').map(d => DAYS.indexOf(d.trim()));
const parts = range
.trim()
.split('-')
.map((d) => DAYS.indexOf(d.trim()));
if (parts.length === 2 && parts[0] >= 0 && parts[1] >= 0) {
for (let i = parts[0]; i !== (parts[1] + 1) % 7; i = (i + 1) % 7) dayIndices.add(i);
dayIndices.add(parts[1]);
@@ -506,13 +563,15 @@ export function parseOpeningHours(ohString: string): { weekdayDescriptions: stri
const timeRanges = [...todayLine.matchAll(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g)];
if (timeRanges.length > 0) {
const nowMins = now.getHours() * 60 + now.getMinutes();
openNow = timeRanges.some(m => {
openNow = timeRanges.some((m) => {
const start = parseInt(m[1]) * 60 + parseInt(m[2]);
const end = parseInt(m[3]) * 60 + parseInt(m[4]);
return end > start ? nowMins >= start && nowMins < end : nowMins >= start || nowMins < end;
});
}
} catch { /* best effort */ }
} catch {
/* best effort */
}
return { weekdayDescriptions: result, openNow };
}
@@ -524,7 +583,7 @@ export function buildOsmDetails(tags: Record<string, string>, osmType: string, o
let open_now: boolean | null = null;
if (tags.opening_hours) {
const parsed = parseOpeningHours(tags.opening_hours);
const hasData = parsed.weekdayDescriptions.some(line => !line.endsWith('?'));
const hasData = parsed.weekdayDescriptions.some((line) => !line.endsWith('?'));
if (hasData) {
opening_hours = parsed.weekdayDescriptions;
open_now = parsed.openNow;
@@ -543,12 +602,17 @@ export function buildOsmDetails(tags: Record<string, string>, osmType: string, o
// ── Wikimedia Commons photo lookup ───────────────────────────────────────────
export async function fetchWikimediaPhoto(lat: number, lng: number, name?: string): Promise<{ photoUrl: string; attribution: string | null } | null> {
export async function fetchWikimediaPhoto(
lat: number,
lng: number,
name?: string,
): Promise<{ photoUrl: string; attribution: string | null } | null> {
// Strategy 1: Search Wikipedia for the place name -> get the article image
if (name) {
try {
const searchParams = new URLSearchParams({
action: 'query', format: 'json',
action: 'query',
format: 'json',
titles: name,
prop: 'pageimages',
piprop: 'thumbnail',
@@ -558,7 +622,7 @@ export async function fetchWikimediaPhoto(lat: number, lng: number, name?: strin
});
const res = await fetch(`https://en.wikipedia.org/w/api.php?${searchParams}`, { headers: { 'User-Agent': UA } });
if (res.ok) {
const data = await res.json() as { query?: { pages?: Record<string, { thumbnail?: { source?: string } }> } };
const data = (await res.json()) as { query?: { pages?: Record<string, { thumbnail?: { source?: string } }> } };
const pages = data.query?.pages;
if (pages) {
for (const page of Object.values(pages)) {
@@ -568,12 +632,15 @@ export async function fetchWikimediaPhoto(lat: number, lng: number, name?: strin
}
}
}
} catch { /* fall through to geosearch */ }
} catch {
/* fall through to geosearch */
}
}
// Strategy 2: Wikimedia Commons geosearch by coordinates
const params = new URLSearchParams({
action: 'query', format: 'json',
action: 'query',
format: 'json',
generator: 'geosearch',
ggsprimary: 'all',
ggsnamespace: '6',
@@ -587,7 +654,9 @@ export async function fetchWikimediaPhoto(lat: number, lng: number, name?: strin
try {
const res = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, { headers: { 'User-Agent': UA } });
if (!res.ok) return null;
const data = await res.json() as { query?: { pages?: Record<string, WikiCommonsPage & { imageinfo?: { mime?: string }[] }> } };
const data = (await res.json()) as {
query?: { pages?: Record<string, WikiCommonsPage & { imageinfo?: { mime?: string }[] }> };
};
const pages = data.query?.pages;
if (!pages) return null;
for (const page of Object.values(pages)) {
@@ -602,12 +671,19 @@ export async function fetchWikimediaPhoto(lat: number, lng: number, name?: strin
}
}
return null;
} catch { return null; }
} catch {
return null;
}
}
// ── Search places (Google or Nominatim fallback) ─────────────────────────────
export async function searchPlaces(userId: number, query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }): Promise<{ places: Record<string, unknown>[]; source: string }> {
export async function searchPlaces(
userId: number,
query: string,
lang?: string,
locationBias?: { lat: number; lng: number; radius?: number },
): Promise<{ places: Record<string, unknown>[]; source: string }> {
const apiKey = getMapsKey(userId);
if (!apiKey) {
@@ -632,12 +708,13 @@ export async function searchPlaces(userId: number, query: string, lang?: string,
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri',
'X-Goog-FieldMask':
'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri',
},
body: JSON.stringify(searchBody),
});
const data = await response.json() as { places?: GooglePlaceResult[]; error?: { message?: string } };
const data = (await response.json()) as { places?: GooglePlaceResult[]; error?: { message?: string } };
if (!response.ok) {
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
@@ -698,7 +775,10 @@ export async function autocompletePlaces(
body: JSON.stringify(body),
});
const data = await response.json() as { suggestions?: GoogleAutocompleteSuggestion[]; error?: { message?: string } };
const data = (await response.json()) as {
suggestions?: GoogleAutocompleteSuggestion[];
error?: { message?: string };
};
if (!response.ok) {
const err = new Error(data.error?.message || 'Google Places Autocomplete error') as Error & { status: number };
@@ -744,7 +824,11 @@ async function autocompleteNominatim(
// ── Place details (Google or OSM) ────────────────────────────────────────────
export async function getPlaceDetails(userId: number, placeId: string, lang?: string): Promise<{ place: Record<string, unknown> }> {
export async function getPlaceDetails(
userId: number,
placeId: string,
lang?: string,
): Promise<{ place: Record<string, unknown> }> {
// OSM details: placeId is "node:123456" or "way:123456" etc.
if (placeId.includes(':')) {
const [osmType, osmId] = placeId.split(':');
@@ -777,20 +861,27 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
// Check DB cache first (lean mask, expanded=0) — 7-day TTL
const DETAILS_TTL = 7 * 24 * 60 * 60 * 1000;
const cached = db.prepare(
'SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 0'
).get(placeId, langKey) as { payload_json: string; fetched_at: number } | undefined;
const cached = db
.prepare(
'SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 0',
)
.get(placeId, langKey) as { payload_json: string; fetched_at: number } | undefined;
if (cached && Date.now() - cached.fetched_at < DETAILS_TTL) return { place: JSON.parse(cached.payload_json) };
const response = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, `getPlaceDetails(${placeId})`, {
method: 'GET',
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri',
const response = await googleFetch(
`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`,
`getPlaceDetails(${placeId})`,
{
method: 'GET',
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask':
'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri',
},
},
});
);
const data = await response.json() as GooglePlaceDetails & { error?: { message?: string } };
const data = (await response.json()) as GooglePlaceDetails & { error?: { message?: string } };
if (!response.ok) {
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
@@ -820,7 +911,7 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
try {
db.prepare(
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 0, ?, ?)'
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 0, ?, ?)',
).run(placeId, langKey, JSON.stringify(place), Date.now());
} catch (dbErr) {
console.error('Failed to cache place details:', dbErr);
@@ -829,28 +920,38 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
return { place };
}
export async function getPlaceDetailsExpanded(userId: number, placeId: string, lang?: string, refresh = false): Promise<{ place: Record<string, unknown> }> {
export async function getPlaceDetailsExpanded(
userId: number,
placeId: string,
lang?: string,
refresh = false,
): Promise<{ place: Record<string, unknown> }> {
const langKey = toApiLang(lang, 'de');
const apiKey = getMapsKey(userId);
if (!apiKey) throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
// Check DB cache for expanded result
if (!refresh) {
const cached = db.prepare(
'SELECT payload_json FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 1'
).get(placeId, langKey) as { payload_json: string } | undefined;
const cached = db
.prepare('SELECT payload_json FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 1')
.get(placeId, langKey) as { payload_json: string } | undefined;
if (cached) return { place: JSON.parse(cached.payload_json) };
}
const response = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, `getPlaceDetailsExpanded(${placeId})`, {
method: 'GET',
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri,reviews,editorialSummary',
const response = await googleFetch(
`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`,
`getPlaceDetailsExpanded(${placeId})`,
{
method: 'GET',
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask':
'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri,reviews,editorialSummary',
},
},
});
);
const data = await response.json() as GooglePlaceDetails & { error?: { message?: string } };
const data = (await response.json()) as GooglePlaceDetails & { error?: { message?: string } };
if (!response.ok) {
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
@@ -886,7 +987,7 @@ export async function getPlaceDetailsExpanded(userId: number, placeId: string, l
try {
db.prepare(
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 1, ?, ?)'
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 1, ?, ?)',
).run(placeId, langKey, JSON.stringify(place), Date.now());
} catch (dbErr) {
console.error('Failed to cache expanded place details:', dbErr);
@@ -924,96 +1025,103 @@ export async function getPlacePhoto(
const fetchPromise = (async (): Promise<{ filePath: string; attribution: string | null } | null> => {
await acquirePhotoFetchSlot();
try {
const apiKey = getMapsKey(userId);
const isCoordLookup = placeId.startsWith('coords:');
const apiKey = getMapsKey(userId);
const isCoordLookup = placeId.startsWith('coords:');
// Coordinate-based Wikipedia/Wikimedia lookup. Used for coordinate-only
// (right-click) places and as a fallback when a Google place yields no photo,
// so a place added via search still gets a marker image when Google returns
// nothing. Returns null (without marking an error) so the caller decides.
const fetchWikimediaFallback = async (): Promise<{ filePath: string; attribution: string | null } | null> => {
if (isNaN(lat) || isNaN(lng)) return null;
try {
const wiki = await fetchWikimediaPhoto(lat, lng, name);
if (!wiki) return null;
// Follow redirects manually so each hop (the image URL can 3xx to a CDN
// host) is re-validated against the SSRF guard, not just the first URL.
const imgRes = await safeFetchFollow(wiki.photoUrl, undefined, { bypassInternalIpAllowed: true });
if (!imgRes.ok) return null;
const bytes = Buffer.from(await imgRes.arrayBuffer());
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
return { filePath: cached.filePath, attribution: cached.attribution };
} catch {
return null;
}
};
// Coordinate-based Wikipedia/Wikimedia lookup. Used for coordinate-only
// (right-click) places and as a fallback when a Google place yields no photo,
// so a place added via search still gets a marker image when Google returns
// nothing. Returns null (without marking an error) so the caller decides.
const fetchWikimediaFallback = async (): Promise<{ filePath: string; attribution: string | null } | null> => {
if (isNaN(lat) || isNaN(lng)) return null;
try {
const wiki = await fetchWikimediaPhoto(lat, lng, name);
if (!wiki) return null;
// Follow redirects manually so each hop (the image URL can 3xx to a CDN
// host) is re-validated against the SSRF guard, not just the first URL.
const imgRes = await safeFetchFollow(wiki.photoUrl, undefined, { bypassInternalIpAllowed: true });
if (!imgRes.ok) return null;
const bytes = Buffer.from(await imgRes.arrayBuffer());
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
return { filePath: cached.filePath, attribution: cached.attribution };
} catch {
return null;
}
};
// Google Places photo for a Google place_id. Returns null (without marking an
// error) on any miss — no key, URL-shaped id, request rejected, no photos, or
// a failed media download — so the caller can fall back to Wikimedia.
const fetchGooglePhoto = async (): Promise<{ filePath: string; attribution: string | null } | null> => {
// URL-shaped placeIds aren't Google IDs — legacy DBs may store raw photo URLs in image_url
if (!apiKey || /^https?:\/\//i.test(placeId)) return null;
// Google Places photo for a Google place_id. Returns null (without marking an
// error) on any miss — no key, URL-shaped id, request rejected, no photos, or
// a failed media download — so the caller can fall back to Wikimedia.
const fetchGooglePhoto = async (): Promise<{ filePath: string; attribution: string | null } | null> => {
// URL-shaped placeIds aren't Google IDs — legacy DBs may store raw photo URLs in image_url
if (!apiKey || /^https?:\/\//i.test(placeId)) return null;
// Fetch details to get the photo name
const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'photos',
},
});
const body = await detailsRes.text();
if (!detailsRes.ok) {
console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200));
return null;
}
let details: GooglePlaceDetails & { error?: { message?: string } };
try { details = body ? JSON.parse(body) : { photos: [] }; }
catch { return null; }
if (!details.photos?.length) return null;
// Fetch details to get the photo name
const detailsRes = await googleFetch(
`https://places.googleapis.com/v1/places/${placeId}`,
`getPlacePhoto/details(${placeId})`,
{
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'photos',
},
},
);
const body = await detailsRes.text();
if (!detailsRes.ok) {
console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200));
return null;
}
let details: GooglePlaceDetails & { error?: { message?: string } };
try {
details = body ? JSON.parse(body) : { photos: [] };
} catch {
return null;
}
if (!details.photos?.length) return null;
const photo = details.photos[0];
const photoName = photo.name;
const attribution = photo.authorAttributions?.[0]?.displayName || null;
const photo = details.photos[0];
const photoName = photo.name;
const attribution = photo.authorAttributions?.[0]?.displayName || null;
// Fetch actual image bytes
const mediaRes = await googleFetch(
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
`getPlacePhoto/media(${placeId})`,
{ headers: { 'X-Goog-Api-Key': apiKey } }
);
if (!mediaRes.ok) return null;
// Fetch actual image bytes
const mediaRes = await googleFetch(
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
`getPlacePhoto/media(${placeId})`,
{ headers: { 'X-Goog-Api-Key': apiKey } },
);
if (!mediaRes.ok) return null;
const bytes = Buffer.from(await mediaRes.arrayBuffer());
if (!bytes.length) return null;
const bytes = Buffer.from(await mediaRes.arrayBuffer());
if (!bytes.length) return null;
const cached = await placePhotoCache.put(placeId, bytes, attribution);
const cached = await placePhotoCache.put(placeId, bytes, attribution);
// Persist stable proxy URL to database
try {
db.prepare(
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = \'\')'
).run(cached.photoUrl, placeId);
} catch (dbErr) {
console.error('Failed to persist photo URL to database:', dbErr);
// Persist stable proxy URL to database
try {
db.prepare(
"UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = '')",
).run(cached.photoUrl, placeId);
} catch (dbErr) {
console.error('Failed to persist photo URL to database:', dbErr);
}
return { filePath: cached.filePath, attribution };
};
// Prefer the Google photo (higher quality); if Google yields nothing, fall
// back to the same coordinate-based Wikipedia/OSM lookup that right-click
// places use. Coordinate-only ids skip Google entirely.
if (!isCoordLookup) {
const googlePhoto = await fetchGooglePhoto();
if (googlePhoto) return googlePhoto;
}
return { filePath: cached.filePath, attribution };
};
const fallback = await fetchWikimediaFallback();
if (fallback) return fallback;
// Prefer the Google photo (higher quality); if Google yields nothing, fall
// back to the same coordinate-based Wikipedia/OSM lookup that right-click
// places use. Coordinate-only ids skip Google entirely.
if (!isCoordLookup) {
const googlePhoto = await fetchGooglePhoto();
if (googlePhoto) return googlePhoto;
}
const fallback = await fetchWikimediaFallback();
if (fallback) return fallback;
placePhotoCache.markError(placeId);
return null;
placePhotoCache.markError(placeId);
return null;
} finally {
releasePhotoFetchSlot();
}
@@ -1028,16 +1136,24 @@ export async function getPlacePhoto(
// ── Reverse geocoding ────────────────────────────────────────────────────────
export async function reverseGeocode(lat: string, lng: string, lang?: string): Promise<{ name: string | null; address: string | null }> {
export async function reverseGeocode(
lat: string,
lng: string,
lang?: string,
): Promise<{ name: string | null; address: string | null }> {
const params = new URLSearchParams({
lat, lon: lng, format: 'json', addressdetails: '1', zoom: '18',
lat,
lon: lng,
format: 'json',
addressdetails: '1',
zoom: '18',
'accept-language': toApiLang(lang),
});
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, {
headers: { 'User-Agent': UA },
});
if (!response.ok) return { name: null, address: null };
const data = await response.json() as { name?: string; display_name?: string; address?: Record<string, string> };
const data = (await response.json()) as { name?: string; display_name?: string; address?: Record<string, string> };
const addr = data.address || {};
const name = data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null;
return { name, address: data.display_name || null };
@@ -1045,7 +1161,9 @@ export async function reverseGeocode(lat: string, lng: string, lang?: string): P
// ── Resolve Google Maps URL ──────────────────────────────────────────────────
export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; lng: number; name: string | null; address: string | null; google_ftid: string | null }> {
export async function resolveGoogleMapsUrl(
url: string,
): Promise<{ lat: number; lng: number; name: string | null; address: string | null; google_ftid: string | null }> {
let resolvedUrl = url;
// Extract coordinates from a string (URL or page body). Google Maps encodes
@@ -1119,9 +1237,13 @@ export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number;
// Reverse geocode to get address
const nominatimRes = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`,
{ headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, signal: AbortSignal.timeout(8000) }
{ headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, signal: AbortSignal.timeout(8000) },
);
const nominatim = await nominatimRes.json() as { display_name?: string; name?: string; address?: Record<string, string> };
const nominatim = (await nominatimRes.json()) as {
display_name?: string;
name?: string;
address?: Record<string, string>;
};
const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null;
const address = nominatim.display_name || null;
+123 -56
View File
@@ -1,19 +1,24 @@
import nodemailer from 'nodemailer';
import { db } from '../db/database';
import { checkSsrf, createPinnedDispatcher } from '../utils/ssrfGuard';
import { decrypt_api_key } from './apiKeyCrypto';
import { logInfo, logDebug, logError } from './auditLog';
import { checkSsrf, createPinnedDispatcher } from '../utils/ssrfGuard';
// ── Types ──────────────────────────────────────────────────────────────────
import type { NotifEventType } from './notificationPreferencesService';
import { EMAIL_I18N as I18N, EVENT_TEXTS, PASSWORD_RESET_I18N } from '@trek/shared/i18n/externalNotifications';
import type { EmailStrings, EventText, PasswordResetStrings, NotificationEventKey } from '@trek/shared/i18n/externalNotifications';
import type {
EmailStrings,
EventText,
PasswordResetStrings,
NotificationEventKey,
} from '@trek/shared/i18n/externalNotifications';
import nodemailer from 'nodemailer';
// Compile-time guard: shared NotificationEventKey and server NotifEventType must stay in sync.
type _EvtFwd = NotifEventType extends NotificationEventKey ? true : never
type _EvtBwd = NotificationEventKey extends NotifEventType ? true : never
const _eventKeyDriftGuard: [_EvtFwd, _EvtBwd] = [true, true]
type _EvtFwd = NotifEventType extends NotificationEventKey ? true : never;
type _EvtBwd = NotificationEventKey extends NotifEventType ? true : never;
const _eventKeyDriftGuard: [_EvtFwd, _EvtBwd] = [true, true];
interface SmtpConfig {
host: string;
@@ -38,7 +43,10 @@ function escapeHtml(str: string): string {
// ── Settings helpers ───────────────────────────────────────────────────────
function getAppSetting(key: string): string | null {
return (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null;
return (
(db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value ||
null
);
}
function getSmtpConfig(): SmtpConfig | null {
@@ -48,7 +56,14 @@ function getSmtpConfig(): SmtpConfig | null {
const pass = process.env.SMTP_PASS || decrypt_api_key(getAppSetting('smtp_pass')) || '';
const from = process.env.SMTP_FROM || getAppSetting('smtp_from');
if (!host || !port || !from) return null;
return { host, port: parseInt(port, 10), user: user || '', pass: pass || '', from, secure: parseInt(port, 10) === 465 };
return {
host,
port: parseInt(port, 10),
user: user || '',
pass: pass || '',
from,
secure: parseInt(port, 10) === 465,
};
}
// Exported for use by notificationService
@@ -57,8 +72,7 @@ export function getAppUrl(): string {
try {
const _ = new URL(process.env.APP_URL);
return process.env.APP_URL.replace(/\/+$/, '');
} catch (_ignored) {
}
} catch (_ignored) {}
}
const origins = process.env.ALLOWED_ORIGINS;
if (origins) {
@@ -67,8 +81,7 @@ export function getAppUrl(): string {
try {
const _ = new URL(first);
return first.replace(/\/+$/, '');
} catch (_ignored) {
}
} catch (_ignored) {}
}
}
const port = Number(process.env.PORT) || 3001;
@@ -99,15 +112,32 @@ export function isSmtpConfigured(): boolean {
export function getUserEmail(userId: number): string | null {
// Defense-in-depth (#1362): a guest's synthetic email must never be emailed.
return (db.prepare('SELECT email FROM users WHERE id = ? AND COALESCE(is_guest, 0) = 0').get(userId) as { email: string } | undefined)?.email || null;
return (
(
db.prepare('SELECT email FROM users WHERE id = ? AND COALESCE(is_guest, 0) = 0').get(userId) as
| { email: string }
| undefined
)?.email || null
);
}
export function getUserLanguage(userId: number): string {
return (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'language'").get(userId) as { value: string } | undefined)?.value || 'en';
return (
(
db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'language'").get(userId) as
| { value: string }
| undefined
)?.value || 'en'
);
}
export function getUserWebhookUrl(userId: number): string | null {
const value = (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(userId) as { value: string } | undefined)?.value || null;
const value =
(
db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(userId) as
| { value: string }
| undefined
)?.value || null;
return value ? decrypt_api_key(value) : null;
}
@@ -130,10 +160,16 @@ export function getEventText(lang: string, event: NotifEventType, params: Record
// ── Email HTML builder ─────────────────────────────────────────────────────
export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string, rawBody = false): string {
export function buildEmailHtml(
subject: string,
body: string,
lang: string,
navigateTarget?: string,
rawBody = false,
): string {
const s = I18N[lang] || I18N.en;
const appUrl = getAppUrl();
const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : (appUrl || ''));
const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : appUrl || '');
const safeSubject = escapeHtml(subject);
const safeBody = rawBody ? body : escapeHtml(body);
@@ -157,13 +193,17 @@ export function buildEmailHtml(subject: string, body: string, lang: string, navi
<p style="margin: 0; font-size: 14px; color: #4b5563; line-height: 1.7; white-space: pre-wrap;">${safeBody}</p>
</td></tr>
<!-- CTA -->
${appUrl ? `<tr><td style="padding: 8px 32px 32px; text-align: center;">
${
appUrl
? `<tr><td style="padding: 8px 32px 32px; text-align: center;">
<a href="${ctaHref}" style="display: inline-block; padding: 12px 28px; background: #111827; color: #ffffff; font-size: 13px; font-weight: 600; text-decoration: none; border-radius: 10px; letter-spacing: 0.2px;">${s.openTrek}</a>
</td></tr>` : ''}
</td></tr>`
: ''
}
<!-- Footer -->
<tr><td style="padding: 20px 32px; background: #f9fafb; border-top: 1px solid #f3f4f6; text-align: center;">
<p style="margin: 0 0 8px; font-size: 11px; color: #9ca3af; line-height: 1.5;">${s.footer}<br>${s.manage}</p>
<p style="margin: 0; font-size: 10px; color: #d1d5db;">${s.madeWith} <span style="color: #ef4444;">&hearts;</span> by Maurice &middot; <a href="https://github.com/mauriceboe/TREK" style="color: #9ca3af; text-decoration: none;">GitHub</a></p>
<p style="margin: 0; font-size: 10px; color: #d1d5db;">${s.madeWith} <span style="color: #ef4444;">&hearts;</span> by Maurice &middot; <a href="https://github.com/liketrek/TREK" style="color: #9ca3af; text-decoration: none;">GitHub</a></p>
</td></tr>
</table>
</td></tr>
@@ -178,7 +218,13 @@ export function buildEmailHtml(subject: string, body: string, lang: string, navi
// PASSWORD_RESET_I18N imported from @trek/shared/i18n/externalNotifications
function buildPasswordResetHtml(subject: string, strings: PasswordResetStrings, recipient: string, resetUrl: string, lang: string): string {
function buildPasswordResetHtml(
subject: string,
strings: PasswordResetStrings,
recipient: string,
resetUrl: string,
lang: string,
): string {
const safeGreeting = escapeHtml(`${strings.greeting}, ${recipient}`);
const safeBody = escapeHtml(strings.body);
const safeExpiry = escapeHtml(strings.expiry);
@@ -217,14 +263,14 @@ export async function sendPasswordResetEmail(
// No SMTP configured — log the link in a visually distinct block so
// the admin can relay it. Never log the associated user id/email
// content at a lower level, only what's needed.
console.log(
`\n===== PASSWORD RESET LINK =====\n` +
`to: ${to}\n` +
`url: ${resetUrl}\n` +
`expires: 60 minutes\n` +
`(SMTP is not configured — deliver this link to the user manually.)\n` +
`================================\n`,
`to: ${to}\n` +
`url: ${resetUrl}\n` +
`expires: 60 minutes\n` +
`(SMTP is not configured — deliver this link to the user manually.)\n` +
`================================\n`,
);
logInfo(`Password reset link issued (no SMTP) for=${to}`);
return { delivered: 'log' };
@@ -254,7 +300,13 @@ export async function sendPasswordResetEmail(
}
}
export async function sendEmail(to: string, subject: string, body: string, userId?: number, navigateTarget?: string): Promise<boolean> {
export async function sendEmail(
to: string,
subject: string,
body: string,
userId?: number,
navigateTarget?: string,
): Promise<boolean> {
const config = getSmtpConfig();
if (!config) return false;
@@ -286,20 +338,25 @@ export async function sendEmail(to: string, subject: string, body: string, userI
}
}
export function buildWebhookBody(url: string, payload: { event: string; title: string; body: string; tripName?: string; link?: string }): string {
export function buildWebhookBody(
url: string,
payload: { event: string; title: string; body: string; tripName?: string; link?: string },
): string {
const isDiscord = /discord(?:app)?\.com\/api\/webhooks\//.test(url);
const isSlack = /hooks\.slack\.com\//.test(url);
if (isDiscord) {
return JSON.stringify({
embeds: [{
title: `📍 ${payload.title}`,
description: payload.body,
url: payload.link,
color: 0x3b82f6,
footer: { text: payload.tripName ? `Trip: ${payload.tripName}` : 'TREK' },
timestamp: new Date().toISOString(),
}],
embeds: [
{
title: `📍 ${payload.title}`,
description: payload.body,
url: payload.link,
color: 0x3b82f6,
footer: { text: payload.tripName ? `Trip: ${payload.tripName}` : 'TREK' },
timestamp: new Date().toISOString(),
},
],
});
}
@@ -314,7 +371,10 @@ export function buildWebhookBody(url: string, payload: { event: string; title: s
return JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' });
}
export async function sendWebhook(url: string, payload: { event: string; title: string; body: string; tripName?: string; link?: string }): Promise<boolean> {
export async function sendWebhook(
url: string,
payload: { event: string; title: string; body: string; tripName?: string; link?: string },
): Promise<boolean> {
if (!url) return false;
const ssrf = await checkSsrf(url);
@@ -373,7 +433,11 @@ export async function testSmtp(to: string): Promise<{ success: boolean; error?:
export async function testWebhook(url: string): Promise<{ success: boolean; error?: string }> {
try {
const sent = await sendWebhook(url, { event: 'test', title: 'Test Notification', body: 'This is a test webhook from TREK. If you received this, your webhook configuration is working correctly.' });
const sent = await sendWebhook(url, {
event: 'test',
title: 'Test Notification',
body: 'This is a test webhook from TREK. If you received this, your webhook configuration is working correctly.',
});
return sent ? { success: true } : { success: false, error: 'Failed to send webhook' };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
@@ -390,22 +454,22 @@ export interface NtfyConfig {
/** Priority and tags mapped to each notification event type. */
const NTFY_EVENT_META: Partial<Record<NotifEventType, { priority: 1 | 2 | 3 | 4 | 5; tags: string[] }>> = {
trip_invite: { priority: 4, tags: ['loudspeaker'] },
booking_change: { priority: 3, tags: ['calendar'] },
trip_reminder: { priority: 4, tags: ['bell', 'alarm_clock'] },
vacay_invite: { priority: 4, tags: ['palm_tree'] },
photos_shared: { priority: 3, tags: ['camera'] },
collab_message: { priority: 3, tags: ['speech_balloon'] },
packing_tagged: { priority: 3, tags: ['luggage'] },
version_available: { priority: 4, tags: ['package'] },
trip_invite: { priority: 4, tags: ['loudspeaker'] },
booking_change: { priority: 3, tags: ['calendar'] },
trip_reminder: { priority: 4, tags: ['bell', 'alarm_clock'] },
vacay_invite: { priority: 4, tags: ['palm_tree'] },
photos_shared: { priority: 3, tags: ['camera'] },
collab_message: { priority: 3, tags: ['speech_balloon'] },
packing_tagged: { priority: 3, tags: ['luggage'] },
version_available: { priority: 4, tags: ['package'] },
synology_session_cleared: { priority: 3, tags: ['warning'] },
};
const NTFY_DEFAULT_META = { priority: 3 as const, tags: [] as string[] };
export function getUserNtfyConfig(userId: number): NtfyConfig | null {
const rows = db.prepare(
"SELECT key, value FROM settings WHERE user_id = ? AND key IN ('ntfy_topic', 'ntfy_server', 'ntfy_token')"
).all(userId) as { key: string; value: string }[];
const rows = db
.prepare("SELECT key, value FROM settings WHERE user_id = ? AND key IN ('ntfy_topic', 'ntfy_server', 'ntfy_token')")
.all(userId) as { key: string; value: string }[];
if (rows.length === 0) return null;
const map: Record<string, string> = {};
for (const r of rows) map[r.key] = r.value;
@@ -440,7 +504,7 @@ export function resolveNtfyUrl(adminCfg: NtfyConfig, userCfg: NtfyConfig | null)
function encodeHeaderValue(value: string): string {
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) > 0xFF) {
if (value.charCodeAt(i) > 0xff) {
return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`;
}
}
@@ -464,8 +528,8 @@ export async function sendNtfy(
// ntfy header-based API: POST to topic URL, body = plain text message, metadata in headers
const headers: Record<string, string> = {
'Title': encodeHeaderValue(payload.title),
'Priority': String(meta.priority),
Title: encodeHeaderValue(payload.title),
Priority: String(meta.priority),
};
if (meta.tags.length > 0) headers['Tags'] = meta.tags.join(',');
if (payload.link) headers['Click'] = encodeHeaderValue(payload.link);
@@ -495,7 +559,11 @@ export async function sendNtfy(
}
}
export async function testNtfy(cfg: { topic: string; server?: string | null; token?: string | null }): Promise<{ success: boolean; error?: string }> {
export async function testNtfy(cfg: {
topic: string;
server?: string | null;
token?: string | null;
}): Promise<{ success: boolean; error?: string }> {
const adminCfg = getAdminNtfyConfig();
const url = resolveNtfyUrl(adminCfg, { topic: cfg.topic, server: cfg.server ?? null, token: cfg.token ?? null });
if (!url) return { success: false, error: 'Could not resolve ntfy URL — missing topic' };
@@ -510,4 +578,3 @@ export async function testNtfy(cfg: { topic: string; server?: string | null; tok
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
}
}
+10 -3
View File
@@ -390,7 +390,12 @@ export function listTemplates() {
// ── Apply Template ─────────────────────────────────────────────────────────
export function applyTemplate(tripId: string | number, templateId: string | number) {
export function applyTemplate(
tripId: string | number,
templateId: string | number,
visibility: 'common' | 'personal' = 'common',
ownerId?: number,
) {
const templateItems = db.prepare(`
SELECT ti.name, tc.name as category
FROM packing_template_items ti
@@ -403,11 +408,13 @@ export function applyTemplate(tripId: string | number, templateId: string | numb
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null };
let sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const isPrivate = ownerId != null ? visibilityToPrivate(visibility) : 0;
const owner = isPrivate ? ownerId! : null;
const insert = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, sort_order, updated_at) VALUES (?, ?, 0, ?, ?, CURRENT_TIMESTAMP)');
const insert = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, sort_order, is_private, owner_id, updated_at) VALUES (?, ?, 0, ?, ?, ?, ?, CURRENT_TIMESTAMP)');
const added: any[] = [];
for (const ti of templateItems) {
const result = insert.run(tripId, ti.name, ti.category, sortOrder++);
const result = insert.run(tripId, ti.name, ti.category, sortOrder++, isPrivate, owner);
const item = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid);
added.push(item);
}
+36 -2
View File
@@ -19,6 +19,38 @@ export interface ReservationEndpoint {
export type EndpointInput = Omit<ReservationEndpoint, 'id' | 'reservation_id' | 'sequence'> & { sequence?: number };
export function notifyBookingChange(
tripId: string | number,
actorId: number,
booking: string,
type: string,
): void {
import('./notificationService')
.then(({ send }) => {
try {
const actor = db.prepare('SELECT email FROM users WHERE id = ?').get(actorId) as { email: string } | undefined;
if (!actor) return;
const trip = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
send({
event: 'booking_change',
actorId,
scope: 'trip',
targetId: Number(tripId),
params: {
trip: trip?.title || 'Untitled',
actor: actor.email,
booking,
type: type || 'booking',
tripId: String(tripId),
},
}).catch(() => {});
} catch {
// Notifications must never make the booking write fail.
}
})
.catch(() => {});
}
export function loadEndpointsByTrip(tripId: string | number): Map<number, ReservationEndpoint[]> {
const rows = db.prepare(`
SELECT e.* FROM reservation_endpoints e
@@ -74,12 +106,14 @@ function resolveDayIdFromTime(
// the booking visually shifts by the offset (#1288). Re-anchor non-hotel bookings to the
// day matching their absolute reservation_time — the same derivation create/updateReservation
// use. Only updates when a matching day exists, so a booking whose date now falls outside
// the new range is left untouched. Hotels keep their range on the linked day_accommodation.
// the new range is left untouched. Hotels linked to a day_accommodation are excluded here —
// resyncAccommodationDays re-anchors the accommodation span and its linked reservation;
// unlinked dated hotels (e.g. imported ones) re-anchor like any other booking.
export function resyncReservationDays(tripId: string | number): void {
const rows = db.prepare(
`SELECT id, reservation_time, reservation_end_time, day_id, end_day_id
FROM reservations
WHERE trip_id = ? AND type != 'hotel' AND reservation_time IS NOT NULL`,
WHERE trip_id = ? AND (type != 'hotel' OR accommodation_id IS NULL) AND reservation_time IS NOT NULL`,
).all(tripId) as {
id: number; reservation_time: string | null; reservation_end_time: string | null;
day_id: number | null; end_day_id: number | null;
+36
View File
@@ -0,0 +1,36 @@
import tzlookup from 'tz-lookup';
/** Local calendar date and clock time for an instant in an IANA timezone. */
export function localParts(iso: string | null, timezone: string | null): { date: string | null; time: string | null } {
if (!iso) return { date: null, time: null };
try {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return { date: null, time: null };
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: timezone || 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(date);
const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? '';
const localDate = `${value('year')}-${value('month')}-${value('day')}`;
const localTime = `${value('hour')}:${value('minute')}`;
return /^\d{4}-\d{2}-\d{2}$/.test(localDate) ? { date: localDate, time: localTime } : { date: null, time: null };
} catch {
return { date: null, time: null };
}
}
export function resolveTimeZone(lat: unknown, lng: unknown): string | null {
if (typeof lat !== 'number' || typeof lng !== 'number' || !Number.isFinite(lat) || !Number.isFinite(lng)) {
return null;
}
try {
return tzlookup(lat, lng);
} catch {
return null;
}
}
@@ -0,0 +1,287 @@
import { haversineKm } from './distanceService';
import type { EndpointInput } from './reservationService';
import { localParts, resolveTimeZone } from './timezoneService';
import { deriveTransitStats, type TransitItinerary, type TransitLeg } from './transitService';
import { z } from 'zod';
const MAX_ENDPOINT_DISTANCE_KM = 0.1;
const MAX_LEG_GAP_KM = 1;
const TIME_TOLERANCE_MS = 60_000;
export const transitCoordinatesSchema = z.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
});
export const transitPlaceSchema = transitCoordinatesSchema.extend({
name: z.string().min(1).max(300),
});
const transitStopSchema = transitPlaceSchema.extend({
time: z.string().datetime({ offset: true }).nullable(),
scheduledTime: z.string().datetime({ offset: true }).nullable(),
track: z.string().max(100).nullable(),
});
// SCHEDULED_TRANSIT_MODES is the *request* whitelist — what a caller may filter by. The
// provider's response taxonomy is wider: MOTIS's default TRANSIT expands to
// TRAM,FERRY,AIRPLANE,BUS,COACH,RAIL,ODM,RIDE_SHARING,FUNICULAR,AERIAL_LIFT,OTHER, and a leg
// can also be a street mode (BIKE/CAR/RENTAL). Validating legs against the request whitelist
// silently dropped every itinerary containing e.g. an AIRPLANE leg. transitService already
// uppercases the mode and defaults it to WALK, so accept any mode token and let the
// "at least one non-WALK leg" rule below do the gating — exactly as the web client does,
// which treats mode as a free string.
const transitLegModes = z
.string()
.min(1)
.max(40)
.regex(/^[A-Z_]+$/);
const colorSchema = z
.string()
.regex(/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/)
.nullable();
const transitLegSchema = z.object({
mode: transitLegModes,
from: transitStopSchema,
to: transitStopSchema,
duration: z.number().nonnegative(),
distance: z.number().nonnegative().nullable(),
headsign: z.string().max(300).nullable(),
line: z.string().max(100).nullable(),
lineColor: colorSchema,
lineTextColor: colorSchema,
agency: z.string().max(300).nullable(),
intermediateStops: z.number().int().nonnegative(),
geometry: z.string().max(60_000).nullable(),
geometryPrecision: z.number().int().min(0).max(10),
});
export function effectiveTransitStopTime(stop: { time?: string | null; scheduledTime?: string | null }): string | null {
return stop.time ?? stop.scheduledTime ?? null;
}
export const transitItinerarySchema = z
.object({
startTime: z.string().datetime({ offset: true }),
endTime: z.string().datetime({ offset: true }),
duration: z.number().nonnegative(),
transfers: z.number().int().nonnegative(),
walkSeconds: z.number().nonnegative(),
legs: z.array(transitLegSchema).min(1).max(20),
})
.superRefine((itinerary, context) => {
const startTime = new Date(itinerary.startTime).getTime();
const endTime = new Date(itinerary.endTime).getTime();
if (endTime <= startTime) {
context.addIssue({ code: 'custom', message: 'endTime must be after startTime', path: ['endTime'] });
}
if (!itinerary.legs.some((leg) => leg.mode !== 'WALK')) {
context.addIssue({ code: 'custom', message: 'At least one scheduled transit leg is required', path: ['legs'] });
}
const maximumTransfers = Math.max(0, itinerary.legs.filter((leg) => leg.mode !== 'WALK').length - 1);
if (itinerary.transfers > maximumTransfers) {
context.addIssue({
code: 'custom',
message: 'transfers exceeds the number of transit legs',
path: ['transfers'],
});
}
const geometrySize = itinerary.legs.reduce((total, leg) => total + (leg.geometry?.length ?? 0), 0);
if (geometrySize > 60_000) {
context.addIssue({ code: 'custom', message: 'Combined transit geometry is too large', path: ['legs'] });
}
itinerary.legs.forEach((leg, index) => {
const effectiveFromTime = effectiveTransitStopTime(leg.from);
const effectiveToTime = effectiveTransitStopTime(leg.to);
const fromTime = effectiveFromTime ? new Date(effectiveFromTime).getTime() : null;
const toTime = effectiveToTime ? new Date(effectiveToTime).getTime() : null;
if (fromTime === null || toTime === null) {
context.addIssue({
code: 'custom',
message: 'Every leg requires departure and arrival times',
path: ['legs', index],
});
return;
}
if (toTime < fromTime) {
context.addIssue({ code: 'custom', message: 'Leg arrival must not precede departure', path: ['legs', index] });
}
if (Math.abs(toTime - fromTime - leg.duration * 1000) > TIME_TOLERANCE_MS) {
context.addIssue({
code: 'custom',
message: 'Leg duration does not match its times',
path: ['legs', index, 'duration'],
});
}
if (fromTime < startTime || toTime > endTime) {
context.addIssue({
code: 'custom',
message: 'Leg times must stay within the itinerary',
path: ['legs', index],
});
}
if (index === 0) return;
const previous = itinerary.legs[index - 1];
if (haversineKm(previous.to.lat, previous.to.lng, leg.from.lat, leg.from.lng) > MAX_LEG_GAP_KM) {
context.addIssue({ code: 'custom', message: 'Adjacent legs are not connected', path: ['legs', index, 'from'] });
}
const previousTime = effectiveTransitStopTime(previous.to);
if (
previousTime &&
effectiveFromTime &&
new Date(effectiveFromTime).getTime() < new Date(previousTime).getTime()
) {
context.addIssue({
code: 'custom',
message: 'Adjacent legs are not chronological',
path: ['legs', index, 'from', 'time'],
});
}
});
const firstDeparture = effectiveTransitStopTime(itinerary.legs[0].from);
const lastArrival = effectiveTransitStopTime(itinerary.legs[itinerary.legs.length - 1].to);
if (!firstDeparture || Math.abs(new Date(firstDeparture).getTime() - startTime) > TIME_TOLERANCE_MS) {
context.addIssue({
code: 'custom',
message: 'First leg must start with the itinerary',
path: ['legs', 0, 'from', 'time'],
});
}
if (!lastArrival || Math.abs(new Date(lastArrival).getTime() - endTime) > TIME_TOLERANCE_MS) {
context.addIssue({
code: 'custom',
message: 'Last leg must end with the itinerary',
path: ['legs', itinerary.legs.length - 1, 'to', 'time'],
});
}
});
export type TransitPlaceInput = z.infer<typeof transitPlaceSchema>;
export function transitCoordinatesMatch(expected: TransitPlaceInput, actual: TransitPlaceInput): boolean {
return haversineKm(expected.lat, expected.lng, actual.lat, actual.lng) <= MAX_ENDPOINT_DISTANCE_KM;
}
export function cleanTransitItineraryNames(
itinerary: TransitItinerary,
fromName: string,
toName: string,
): TransitItinerary {
const clean = (name: string) => (name === 'START' ? fromName : name === 'END' ? toName : name);
return {
...itinerary,
legs: itinerary.legs.map((leg) => ({
...leg,
from: { ...leg.from, name: clean(leg.from.name) },
to: { ...leg.to, name: clean(leg.to.name) },
})),
};
}
function transitLocalParts(iso: string, timezone: string): { date: string; time: string } {
const parts = localParts(iso, timezone);
if (!parts.date || !parts.time) throw new Error(`Unable to convert ${iso} to local time in ${timezone}.`);
return { date: parts.date, time: parts.time };
}
function timezoneAt(lat: number, lng: number): string {
const timezone = resolveTimeZone(lat, lng);
if (!timezone) throw new Error(`Unable to resolve timezone for ${lat},${lng}.`);
return timezone;
}
export function buildTransitReservationParts(
from: TransitPlaceInput,
to: TransitPlaceInput,
itinerary: TransitItinerary,
) {
const fromTimezone = timezoneAt(from.lat, from.lng);
const toTimezone = timezoneAt(to.lat, to.lng);
const departure = transitLocalParts(itinerary.startTime, fromTimezone);
const arrival = transitLocalParts(itinerary.endTime, toTimezone);
const transitLegs = itinerary.legs.filter((leg) => leg.mode !== 'WALK');
const endpoints: EndpointInput[] = [
{
role: 'from',
sequence: 0,
name: from.name,
code: null,
lat: from.lat,
lng: from.lng,
timezone: fromTimezone,
local_date: departure.date,
local_time: departure.time,
},
];
transitLegs.slice(0, -1).forEach((leg, index) => {
const stop = leg.to;
const timezone = timezoneAt(stop.lat, stop.lng);
const stopTime = effectiveTransitStopTime(stop);
const local = stopTime ? transitLocalParts(stopTime, timezone) : null;
endpoints.push({
role: 'stop',
sequence: index + 1,
name: stop.name,
code: null,
lat: stop.lat,
lng: stop.lng,
timezone,
local_date: local?.date ?? null,
local_time: local?.time ?? null,
});
});
endpoints.push({
role: 'to',
sequence: endpoints.length,
name: to.name,
code: null,
lat: to.lat,
lng: to.lng,
timezone: toTimezone,
local_date: arrival.date,
local_time: arrival.time,
});
const stats = deriveTransitStats(itinerary.startTime, itinerary.endTime, itinerary.legs, itinerary.transfers);
const metadata = {
transit: {
provider: 'transitous',
duration: stats.duration,
transfers: stats.transfers,
walk_seconds: stats.walkSeconds,
legs: itinerary.legs.map((leg: TransitLeg) => {
const fromTime = effectiveTransitStopTime(leg.from);
const toTime = effectiveTransitStopTime(leg.to);
return {
mode: leg.mode,
line: leg.line,
line_color: leg.lineColor,
line_text_color: leg.lineTextColor,
headsign: leg.headsign,
agency: leg.agency,
duration: leg.duration,
stops: leg.intermediateStops,
from: {
name: leg.from.name,
time: fromTime ? transitLocalParts(fromTime, timezoneAt(leg.from.lat, leg.from.lng)).time : null,
track: leg.from.track,
},
to: {
name: leg.to.name,
time: toTime ? transitLocalParts(toTime, timezoneAt(leg.to.lat, leg.to.lng)).time : null,
track: leg.to.track,
},
geometry: leg.geometry,
geometry_precision: leg.geometryPrecision,
};
}),
},
};
return { endpoints, metadata };
}
+98 -31
View File
@@ -1,5 +1,5 @@
import { getAppUrl } from './notifications';
import { buildUserAgent } from './mapsService';
import { getAppUrl } from './notifications';
/**
* Public transit routing (#1065) backed by Transitous (api.transitous.org), the
@@ -14,17 +14,35 @@ import { buildUserAgent } from './mapsService';
*/
const TRANSIT_API_BASE = (process.env.TRANSIT_API_URL || 'https://api.transitous.org').replace(/\/+$/, '');
const UA = buildUserAgent(getAppUrl());
let userAgent: string | null = null;
function getUserAgent(): string {
userAgent ??= buildUserAgent(getAppUrl());
return userAgent;
}
// Modes the client may request — a strict whitelist so the proxy can't be used
// to smuggle arbitrary query values upstream. TRANSIT covers everything; the
// others let the user filter (RAIL already includes subway/suburban etc.).
const ALLOWED_MODES = new Set([
'TRANSIT', 'BUS', 'COACH', 'TRAM', 'SUBWAY', 'RAIL', 'FERRY', 'FUNICULAR', 'AERIAL_LIFT',
export const SCHEDULED_TRANSIT_MODES = [
'BUS',
'COACH',
'TRAM',
'SUBWAY',
'RAIL',
'FERRY',
'FUNICULAR',
'AERIAL_LIFT',
// Fine-grained rail modes so "train without subway" is expressible (RAIL
// itself includes SUBWAY per the MOTIS mode taxonomy).
'HIGHSPEED_RAIL', 'LONG_DISTANCE', 'NIGHT_RAIL', 'REGIONAL_RAIL', 'SUBURBAN',
]);
'HIGHSPEED_RAIL',
'LONG_DISTANCE',
'NIGHT_RAIL',
'REGIONAL_RAIL',
'SUBURBAN',
] as const;
const ALLOWED_MODES = new Set(['TRANSIT', ...SCHEDULED_TRANSIT_MODES]);
// Short-lived response cache: planning is the expensive call per the Transitous
// usage policy, and a user toggling filters re-requests identical plans.
@@ -35,7 +53,10 @@ const cache = new Map<string, { at: number; data: unknown }>();
function cacheGet(key: string): unknown | null {
const hit = cache.get(key);
if (!hit) return null;
if (Date.now() - hit.at > CACHE_TTL) { cache.delete(key); return null; }
if (Date.now() - hit.at > CACHE_TTL) {
cache.delete(key);
return null;
}
return hit.data;
}
@@ -57,7 +78,7 @@ function isCoord(v: string): boolean {
async function upstream(path: string, params: URLSearchParams): Promise<unknown> {
const url = `${TRANSIT_API_BASE}${path}?${params}`;
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
const res = await fetch(url, { headers: { 'User-Agent': getUserAgent(), Accept: 'application/json' } });
if (!res.ok) {
const err = new Error(`Transit provider error (HTTP ${res.status})`) as Error & { status: number };
err.status = res.status === 429 ? 429 : 502;
@@ -80,7 +101,11 @@ export interface TransitPlace {
export async function geocode(query: string, language?: string, near?: string): Promise<{ results: TransitPlace[] }> {
const text = (query || '').trim();
if (text.length < 2) return { results: [] };
if (text.length > 200) { const e = new Error('Query too long') as Error & { status: number }; e.status = 400; throw e; }
if (text.length > 200) {
const e = new Error('Query too long') as Error & { status: number };
e.status = 400;
throw e;
}
const params = new URLSearchParams({ text });
if (language) params.set('language', language.slice(0, 5));
@@ -91,7 +116,10 @@ export async function geocode(query: string, language?: string, near?: string):
if (cached) return cached as { results: TransitPlace[] };
const raw = (await upstream('/api/v1/geocode', params)) as Array<{
name?: string; lat?: number; lon?: number; type?: string;
name?: string;
lat?: number;
lon?: number;
type?: string;
areas?: Array<{ name?: string; matched?: boolean; default?: boolean }>;
}>;
@@ -152,6 +180,22 @@ export interface PlanQuery {
maxTransfers?: number;
}
export function deriveTransitStats(
startTime: string,
endTime: string,
legs: TransitLeg[],
reportedTransfers?: number,
): Pick<TransitItinerary, 'duration' | 'transfers' | 'walkSeconds'> {
return {
duration: Math.max(0, Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / 1000)),
transfers:
typeof reportedTransfers === 'number'
? reportedTransfers
: Math.max(0, legs.filter((leg) => leg.mode !== 'WALK').length - 1),
walkSeconds: legs.filter((leg) => leg.mode === 'WALK').reduce((total, leg) => total + leg.duration, 0),
};
}
// GTFS colors come as bare hex ("FF0000"), with hash, or empty — normalise to
// a #-prefixed value or null so the client can use them in CSS directly.
function safeColor(v: unknown): string | null {
@@ -161,10 +205,15 @@ function safeColor(v: unknown): string | null {
}
interface MotisPlaceRaw {
name?: string; lat?: number; lon?: number;
departure?: string; arrival?: string;
scheduledDeparture?: string; scheduledArrival?: string;
track?: string; scheduledTrack?: string;
name?: string;
lat?: number;
lon?: number;
departure?: string;
arrival?: string;
scheduledDeparture?: string;
scheduledArrival?: string;
track?: string;
scheduledTrack?: string;
}
function mapStop(p: MotisPlaceRaw | undefined, kind: 'departure' | 'arrival'): TransitLegStop {
@@ -180,7 +229,11 @@ function mapStop(p: MotisPlaceRaw | undefined, kind: 'departure' | 'arrival'): T
/** Route search between two coordinates. Returns compact itineraries for the picker. */
export async function plan(q: PlanQuery): Promise<{ itineraries: TransitItinerary[] }> {
const bad = (msg: string) => { const e = new Error(msg) as Error & { status: number }; e.status = 400; throw e; };
const bad = (msg: string) => {
const e = new Error(msg) as Error & { status: number };
e.status = 400;
throw e;
};
if (!q.from || !isCoord(q.from)) bad('from must be "lat,lng"');
if (!q.to || !isCoord(q.to)) bad('to must be "lat,lng"');
@@ -194,7 +247,10 @@ export async function plan(q: PlanQuery): Promise<{ itineraries: TransitItinerar
if (q.arriveBy) params.set('arriveBy', 'true');
if (q.modes) {
const modes = q.modes.split(',').map((m) => m.trim().toUpperCase()).filter(Boolean);
const modes = q.modes
.split(',')
.map((m) => m.trim().toUpperCase())
.filter(Boolean);
if (modes.some((m) => !ALLOWED_MODES.has(m))) bad('unsupported transit mode');
if (modes.length > 0) params.set('transitModes', modes.join(','));
}
@@ -213,11 +269,22 @@ export async function plan(q: PlanQuery): Promise<{ itineraries: TransitItinerar
const raw = (await upstream('/api/v6/plan', params)) as {
itineraries?: Array<{
duration?: number; startTime?: string; endTime?: string; transfers?: number;
duration?: number;
startTime?: string;
endTime?: string;
transfers?: number;
legs?: Array<{
mode?: string; duration?: number; distance?: number; headsign?: string;
routeShortName?: string; displayName?: string; routeColor?: string; routeTextColor?: string;
agencyName?: string; from?: MotisPlaceRaw; to?: MotisPlaceRaw;
mode?: string;
duration?: number;
distance?: number;
headsign?: string;
routeShortName?: string;
displayName?: string;
routeColor?: string;
routeTextColor?: string;
agencyName?: string;
from?: MotisPlaceRaw;
to?: MotisPlaceRaw;
intermediateStops?: unknown[];
legGeometry?: { points?: string; precision?: number };
}>;
@@ -241,17 +308,17 @@ export async function plan(q: PlanQuery): Promise<{ itineraries: TransitItinerar
geometry: leg.legGeometry?.points || null,
geometryPrecision: leg.legGeometry?.precision ?? 6,
}));
const walkSeconds = legs.filter((l) => l.mode === 'WALK').reduce((a, l) => a + l.duration, 0);
return [{
startTime: it.startTime,
endTime: it.endTime,
// Wall-clock duration (start→end) so waits/transfers count — summing leg
// run-times would understate the journey and mis-slot it in the timeline.
duration: Math.max(0, Math.round((new Date(it.endTime).getTime() - new Date(it.startTime).getTime()) / 1000)),
transfers: typeof it.transfers === 'number' ? it.transfers : Math.max(0, legs.filter((l) => l.mode !== 'WALK').length - 1),
walkSeconds,
legs,
}];
const stats = deriveTransitStats(it.startTime, it.endTime, legs, it.transfers);
return [
{
startTime: it.startTime,
endTime: it.endTime,
// Wall-clock duration (start→end) so waits/transfers count — summing leg
// run-times would understate the journey and mis-slot it in the timeline.
...stats,
legs,
},
];
});
const data = { itineraries };
+35 -30
View File
@@ -1,5 +1,4 @@
import path from 'path';
import tzlookup from 'tz-lookup';
import { avatarUrl } from './avatarUrl';
import fs from 'fs';
import { randomUUID } from 'crypto';
@@ -7,12 +6,13 @@ import { db, isOwner } from '../db/database';
import { erasePluginUserData } from './userCleanupService';
import { emitUserDeleted } from '../plugin-user-lifecycle';
import { Trip, User } from '../types';
import { listDays, listAccommodations, addDays } from './dayService';
import { listBudgetItems } from './budgetService';
import { listDays, listAccommodations, addDays, resyncAccommodationDays, restampReservationDates } from './dayService';
import { listBudgetItems, removeUserFromBudgetItems } from './budgetService';
import { listItems as listPackingItems } from './packingService';
import { listReservations, loadEndpointsByTrip, resyncReservationDays } from './reservationService';
import { listNotes as listCollabNotes } from './collabService';
import { shiftOwnerEntriesForTripWindow } from './vacayService';
import { resolveTimeZone } from './timezoneService';
export const MS_PER_DAY = 86400000;
export const MAX_TRIP_DAYS = 365;
@@ -218,6 +218,7 @@ interface UpdateTripData {
cover_image?: string;
reminder_days?: number;
day_count?: number;
date_shift_mode?: 'keep_bookings' | 'shift_all';
}
export interface UpdateTripResult {
@@ -262,10 +263,30 @@ export function updateTrip(tripId: string | number, userId: number, data: Update
const dayCount = data.day_count ? Math.min(Math.max(Number(data.day_count) || 7, 1), MAX_TRIP_DAYS) : undefined;
if (newStart !== trip.start_date || newEnd !== trip.end_date || dayCount) {
generateDays(tripId, newStart || null, newEnd || null, undefined, dayCount);
// generateDays re-dates day rows positionally; re-anchor dated bookings to the day
// matching their absolute reservation_time so they don't shift with it (#1288).
resyncReservationDays(tripId);
db.transaction(() => {
// Accommodations have no absolute date columns, so their pre-change dates must be
// snapshotted before generateDays re-dates the day rows in place.
const prevDateByDayId = new Map(
(db.prepare('SELECT id, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; date: string | null }[])
.map(d => [d.id, d.date]),
);
generateDays(tripId, newStart || null, newEnd || null, undefined, dayCount);
if (data.date_shift_mode === 'shift_all') {
// Explicit "shift everything": bookings stay glued to their (re-dated) day rows,
// so re-stamp reservation_time to follow — same rules as reorderDays/insertDay.
const newDateByDayId = new Map(
(db.prepare('SELECT id, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; date: string | null }[])
.map(d => [d.id, d.date]),
);
restampReservationDates(tripId, prevDateByDayId, newDateByDayId);
} else {
// Default: generateDays re-dates day rows positionally; re-anchor dated bookings to
// the day matching their absolute reservation_time, and accommodations (+ their
// linked hotel reservations) to the days now holding their pre-change dates (#1288).
resyncReservationDays(tripId);
resyncAccommodationDays(tripId, prevDateByDayId);
}
})();
}
const changes: Record<string, unknown> = {};
@@ -525,6 +546,9 @@ export function deleteGuest(tripId: string | number, guestUserId: number): boole
// host-side per-user tables + a durable own-db erasure per granted plugin — exactly
// like a full account deletion (otherwise a deleted guest's plugin data lingers).
erasePluginUserData(guestUserId);
// Re-split the expenses they were part of before the cascade takes their member
// rows away — the divisor is denormalized and cannot follow a foreign key (#1553).
removeUserFromBudgetItems(guestUserId);
// Deleting the guest's users row cascades its membership and every assignment join
// (trip_members, budget/packing/assignment links) via the ON DELETE foreign keys.
db.prepare('DELETE FROM users WHERE id = ? AND is_guest = 1').run(guestUserId);
@@ -563,25 +587,6 @@ function foldICS(ics: string): string {
// Timed events must carry an explicit IANA zone; a bare "YYYYMMDDTHHMMSS" is an
// RFC 5545 "floating" time that clients render in the *subscriber's* zone (#1453).
// Resolve an IANA zone (e.g. "Europe/Paris") from coordinates. Returns null for
// missing/invalid coords instead of throwing — tz-lookup throws RangeError when
// lat/lng are out of range.
function resolveZone(lat: unknown, lng: unknown): string | null {
if (
typeof lat !== 'number' ||
typeof lng !== 'number' ||
!Number.isFinite(lat) ||
!Number.isFinite(lng)
) {
return null;
}
try {
return tzlookup(lat, lng);
} catch {
return null;
}
}
// A stored/plugin-provided timezone (e.g. a transport endpoint's `timezone`) is a
// free string that need not be a real IANA zone. Intl.DateTimeFormat throws a
// RangeError on an unknown zone, which — via buildVTimezone → tzOffsetString —
@@ -737,7 +742,7 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
// Timed assignments → individual events
for (const a of timed) {
const zone = resolveZone(a.place_lat, a.place_lng);
const zone = resolveTimeZone(a.place_lat, a.place_lng);
ics += `BEGIN:VEVENT\r\nUID:${uid(a.id, 'assign')}\r\nDTSTAMP:${now}\r\n`;
ics += dtLine('DTSTART', a.effective_time, zone, day.date + 'T00:00');
if (a.effective_end_time) {
@@ -797,7 +802,7 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
if (!isDate(datePart)) return null; // time-only (relative "Day N" trips)
if (r.reservation_time.includes('T')) {
// Hotels/restaurants: derive the zone from the linked place, if any.
const zone = resolveZone(r.place_lat, r.place_lng);
const zone = resolveTimeZone(r.place_lat, r.place_lng);
let out = dtLine('DTSTART', r.reservation_time, zone);
if (r.reservation_end_time) {
const endDt = fmtDateTime(r.reservation_end_time, r.reservation_time);
@@ -817,10 +822,10 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
if (isTime(first.local_time)) {
// Transport: departure endpoint zone drives DTSTART, arrival drives DTEND.
// Prefer the stored IANA zone; fall back to the endpoint's coordinates.
const startZone = first.timezone || resolveZone(first.lat, first.lng);
const startZone = first.timezone || resolveTimeZone(first.lat, first.lng);
let out = dtLine('DTSTART', `${first.local_date}T${first.local_time}`, startZone);
if (last !== first && isDate(last.local_date) && isTime(last.local_time)) {
const endZone = last.timezone || resolveZone(last.lat, last.lng);
const endZone = last.timezone || resolveTimeZone(last.lat, last.lng);
out += dtLine('DTEND', `${last.local_date}T${last.local_time}`, endZone);
}
return out;
@@ -1,6 +1,7 @@
import fs from 'node:fs';
import { db } from '../db/database';
import { pluginsDataRoot } from '../nest/plugins/paths';
import { removeUserFromBudgetItems } from './budgetService';
/**
* Erase a user's PLUGIN-held data on account deletion. Two parts:
@@ -44,6 +45,7 @@ export function erasePluginUserData(userId: number): void {
function cleanupUserReferences(userId: number): void {
db.prepare('UPDATE trip_members SET invited_by = NULL WHERE invited_by = ?').run(userId);
db.prepare('UPDATE budget_items SET paid_by_user_id = NULL WHERE paid_by_user_id = ?').run(userId);
removeUserFromBudgetItems(userId);
db.prepare('DELETE FROM share_tokens WHERE created_by = ?').run(userId);
db.prepare('DELETE FROM journey_share_tokens WHERE created_by = ?').run(userId);
// Owned journeys cascade-delete their entries/contributors/share_tokens/photos via journey_id FKs
+109 -32
View File
@@ -1,21 +1,43 @@
import { existsSync, promises as fs } from 'fs';
import path from 'path';
import { promises as fs } from 'fs';
/**
* In-app Help/Wiki content, sourced from the TREK GitHub wiki (kept in the repo
* under `wiki/**` and mirrored to the GitHub wiki on push to main). The server
* fetches the markdown from GitHub and caches it, so the embedded help stays in
* sync with wiki edits without a redeploy — and the client never talks to GitHub
* directly (images are proxied too). A bundled snapshot under server/assets/wiki
* is the cold-start / offline fallback.
* In-app Help/Wiki content, sourced from the `wiki/**` directory that ships with
* the app — the same content that CI mirrors to the public GitHub wiki. Reading
* from disk keeps the help pages pinned to the running version (a v1.2 install
* shows v1.2 docs, not whatever `main` says) and works offline.
*
* If that directory can't be resolved — an unusual layout, an image built without
* it — we fall back to fetching from the GitHub wiki over the network and caching
* hourly, so help degrades instead of disappearing. The client never talks to
* GitHub directly either way; images are proxied through /api/help/asset.
*/
const REPO = 'mauriceboe/TREK';
const REPO = 'liketrek/TREK';
const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/main/wiki`;
const TTL_MS = 60 * 60 * 1000; // refresh from GitHub at most hourly
const SNAPSHOT_DIR = path.join(__dirname, '..', '..', 'assets', 'wiki');
const TTL_MS = 60 * 60 * 1000; // remote fallback only: refresh from GitHub at most hourly
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
/**
* `server/{src,dist}/services` both sit three levels under the repo root, so this
* one anchor resolves in dev, a built source install, vitest, and Docker (where
* the Dockerfile copies `wiki/` to /app/wiki). `process.cwd()` would not — Docker
* runs the server from /app/server.
*/
const WIKI_DIR = process.env.TREK_WIKI_DIR ?? path.join(__dirname, '..', '..', '..', 'wiki');
/**
* Probe for the sidebar rather than the bare directory: an empty or half-copied
* `wiki/` should fall back to GitHub, not serve an empty table of contents.
*/
const useLocalWiki = existsSync(path.join(WIKI_DIR, '_Sidebar.md'));
if (!useLocalWiki) {
console.warn(
`[help] wiki not found at ${WIKI_DIR} — falling back to the GitHub wiki (help may not match this version)`,
);
}
export class WikiNotFound extends Error {
status = 404;
}
@@ -29,16 +51,26 @@ const assetCache = new Map<string, { buf: Buffer; type: string; ts: number }>();
const fresh = (ts: number): boolean => Date.now() - ts < TTL_MS;
async function readSnapshot(file: string): Promise<string | null> {
try {
return await fs.readFile(path.join(SNAPSHOT_DIR, file), 'utf8');
} catch {
return null;
}
/** Resolve a path inside the wiki dir, refusing anything that escapes it. */
function resolveInWiki(rel: string): string {
const root = path.resolve(WIKI_DIR);
const full = path.resolve(root, rel);
if (full !== root && !full.startsWith(root + path.sep)) throw new WikiNotFound(rel);
return full;
}
/** Fetch a wiki text file with cache → stale-cache → bundled-snapshot fallback. */
/** Fetch a wiki text file: local disk, or GitHub with cache → stale-cache fallback. */
async function fetchText(file: string): Promise<string> {
if (useLocalWiki) {
try {
return await fs.readFile(resolveInWiki(file), 'utf8');
} catch (err) {
if (err instanceof WikiNotFound) throw err;
if ((err as NodeJS.ErrnoException).code === 'ENOENT') throw new WikiNotFound(file);
throw err;
}
}
const cached = textCache.get(file);
if (cached && fresh(cached.ts)) return cached.data;
try {
@@ -53,14 +85,9 @@ async function fetchText(file: string): Promise<string> {
if (res.status === 404) throw new WikiNotFound(file);
} catch (err) {
if (err instanceof WikiNotFound) throw err;
// network/parse error — fall through to stale cache or snapshot
// network/parse error — fall through to stale cache
}
if (cached) return cached.data; // serve stale rather than fail
const snap = await readSnapshot(file);
if (snap != null) {
textCache.set(file, { data: snap, ts: Date.now() });
return snap;
}
throw new WikiNotFound(file);
}
@@ -105,18 +132,58 @@ function processMarkdown(md: string): string {
// markdown renderer would otherwise surface them as raw text.
let out = md.replace(/<!--[\s\S]*?-->/g, '');
out = out.replace(/\[\[([^\]]+)\]\]/g, (_m, inner: string) => {
const [title, slugRaw] = inner.includes('|') ? inner.split('|') : [inner, inner];
const [titleRaw, slugRaw] = inner.includes('|') ? inner.split('|') : [inner, inner];
const slug = slugRaw.trim().replace(/\s+/g, '-');
return `[${title.trim()}](/help/${slug})`;
// `[[Plugin Development#talking-to-plugins|Plugin-Development]]` must not
// render its anchor as visible link text.
const [title, anchor] = titleRaw.includes('#') ? titleRaw.split('#') : [titleRaw, ''];
const hash = anchor ? `#${anchor.trim()}` : '';
return `[${title.trim()}](/help/${slug}${hash})`;
});
out = out.replace(/!\[([^\]]*)\]\(([^)\s]+)([^)]*)\)/g, (m, alt: string, url: string) => {
if (/^https?:\/\//i.test(url) || url.startsWith('/api/help/asset/')) return m;
const clean = url.replace(/^\.?\//, '').replace(/^wiki\//, '');
return `![${alt}](/api/help/asset/${clean})`;
});
// Bare relative links — `[Currencies](Currencies)`, the native GitHub-wiki
// spelling and by far the most common in these pages (455 of them across 81
// files, against 114 `[[..]]` links). GitHub resolves them against the wiki
// root; in-app they used to fall through to HelpPage's external-link branch
// and open a dead tab. Rewriting them here fixes every page at once and keeps
// the source GitHub-compatible, so contributors can keep writing either form.
//
// Runs last: `[[..]]` links and images have already become absolute paths by
// this point, so the leading-slash guard skips them.
out = outsideCode(out, (segment) =>
segment.replace(/(^|[^!])\[([^\]]+)\]\(([^)\s]+)\)/g, (m, prefix: string, text: string, url: string) => {
if (/^(https?:|mailto:|tel:|#|\/)/i.test(url)) return m;
const [pageRaw, anchor] = url.includes('#') ? url.split('#') : [url, ''];
const page = pageRaw.replace(/^\.?\//, '').replace(/\.md$/i, '').trim();
if (!page || !SLUG_RE.test(page)) return m;
return `${prefix}[${text}](/help/${page}${anchor ? `#${anchor}` : ''})`;
}),
);
return out;
}
/**
* Apply `fn` to the parts of the markdown that are NOT code, leaving fenced
* blocks and inline spans untouched.
*
* Without this the link rewriter corrupts code samples: Plugin-Development.md
* documents `actions[key](ctx)`, which reads as a markdown link and would be
* rewritten to `actions[key](/help/ctx)` inside what is supposed to be a
* verbatim snippet.
*/
function outsideCode(md: string, fn: (segment: string) => string): string {
// Alternation order matters: fenced blocks first, so a ``` fence containing
// backticks is consumed whole rather than being split by the inline rule.
const CODE = /(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`)/g;
const parts = md.split(CODE);
// split() with a capturing group yields [text, code, text, code, …].
return parts.map((part, i) => (i % 2 === 1 ? part : fn(part))).join('');
}
function extractTitle(md: string, fallback: string): string {
const h1 = md.match(/^#\s+(.+?)\s*$/m);
return h1 ? h1[1].replace(/[*_`]/g, '').trim() : fallback.replace(/-/g, ' ');
@@ -128,6 +195,9 @@ export interface WikiPage {
markdown: string;
}
/** True when help is served from the bundled wiki rather than fetched from GitHub. */
export const isLocalWiki = (): boolean => useLocalWiki;
export async function getWikiIndex(): Promise<{ sections: WikiNavSection[] }> {
const md = await fetchText('_Sidebar.md');
return { sections: parseSidebar(md) };
@@ -148,7 +218,7 @@ const ASSET_TYPES: Record<string, string> = {
'.svg': 'image/svg+xml',
};
/** Proxy a wiki image so the browser never calls GitHub directly. */
/** Read a wiki image from disk, or proxy it from GitHub so the browser never calls it directly. */
export async function getWikiAsset(assetPath: string): Promise<{ buf: Buffer; type: string }> {
// Defend against traversal; allow nested image folders.
if (assetPath.includes('..') || !/^[A-Za-z0-9/._-]+$/.test(assetPath)) throw new WikiNotFound(assetPath);
@@ -156,6 +226,18 @@ export async function getWikiAsset(assetPath: string): Promise<{ buf: Buffer; ty
const type = ASSET_TYPES[ext];
if (!type) throw new WikiNotFound(assetPath);
if (useLocalWiki) {
try {
// resolveInWiki re-checks containment: the regex above is a filter, this is the boundary.
const buf = await fs.readFile(resolveInWiki(assetPath));
return { buf, type };
} catch (err) {
if (err instanceof WikiNotFound) throw err;
if ((err as NodeJS.ErrnoException).code === 'ENOENT') throw new WikiNotFound(assetPath);
throw err;
}
}
const cached = assetCache.get(assetPath);
if (cached && fresh(cached.ts)) return { buf: cached.buf, type: cached.type };
try {
@@ -171,10 +253,5 @@ export async function getWikiAsset(assetPath: string): Promise<{ buf: Buffer; ty
/* fall through */
}
if (cached) return { buf: cached.buf, type: cached.type };
try {
const buf = await fs.readFile(path.join(SNAPSHOT_DIR, assetPath));
return { buf, type };
} catch {
throw new WikiNotFound(assetPath);
}
throw new WikiNotFound(assetPath);
}
+58
View File
@@ -0,0 +1,58 @@
/**
* IPv6 helpers shared by the SSRF guard (`utils/ssrfGuard.ts`) and the plugin
* egress policy (`nest/plugins/runtime/egress-policy.ts`).
*
* The headline job is detecting IPv6 *transition* addresses that embed an IPv4
* target. A hostname that resolves to e.g. `64:ff9b::a9fe:a9fe` (NAT64 spelling
* of 169.254.169.254) would otherwise sail past every IPv6 prefix check and,
* on a host with a NAT64/6to4 gateway, connect straight through to the embedded
* private IPv4 — an SSRF-guard bypass. Callers extract the embedded IPv4 and
* re-apply their own blocklist to it.
*/
/** Expand an IPv6 literal into its 8 numeric hextets, or null if not a valid IPv6. */
export function expandIpv6(ip: string): number[] | null {
let h = ip.toLowerCase().replace(/%.*$/, '');
if (!h.includes(':')) return null;
const dotted = h.match(/^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
if (dotted) {
const v4 = dotted[2].split('.').map(Number);
if (v4.some((n) => n > 255)) return null;
h = dotted[1] + ((v4[0] << 8) | v4[1]).toString(16) + ':' + ((v4[2] << 8) | v4[3]).toString(16);
}
const halves = h.split('::');
if (halves.length > 2) return null;
const head = halves[0] ? halves[0].split(':') : [];
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
const groups =
halves.length === 2 ? [...head, ...Array(8 - head.length - tail.length).fill('0'), ...tail] : head;
if (groups.length !== 8) return null;
const nums = groups.map((x) => (x === '' ? NaN : parseInt(x, 16)));
return nums.some((n) => !Number.isInteger(n) || n < 0 || n > 0xffff) ? null : nums;
}
/**
* If `ip` is an IPv6 transition address that embeds an IPv4 target, return the
* embedded IPv4 in dotted form; otherwise null. Recognised ranges:
*
* - NAT64 well-known prefix `64:ff9b::/96` — last 32 bits are the IPv4
* - 6to4 `2002::/16` — hextets 1–2 are the IPv4
* - Teredo `2001:0000::/32` — last 32 bits are the client IPv4, XOR 0xffffffff
*
* Only the *well-known* NAT64 prefix is detected; custom-prefix NAT64 (RFC 6052
* /32–/64, RFC 8215 `64:ff9b:1::/48`) can't be recognised without site config.
*/
export function embeddedTransitionIpv4(ip: string): string | null {
const g = expandIpv6(ip.replace(/^\[/, '').replace(/\]$/, ''));
if (!g) return null;
const v4 = (hi: number, lo: number) => `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
// NAT64 well-known 64:ff9b::/96 — first 96 bits fixed, last 32 = IPv4.
if (g[0] === 0x0064 && g[1] === 0xff9b && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0) {
return v4(g[6], g[7]);
}
// 6to4 2002::/16 — hextets 1,2 = IPv4.
if (g[0] === 0x2002) return v4(g[1], g[2]);
// Teredo 2001:0000::/32 — last 32 bits = client IPv4 XOR 0xffffffff.
if (g[0] === 0x2001 && g[1] === 0x0000) return v4(g[6] ^ 0xffff, g[7] ^ 0xffff);
return null;
}
+12
View File
@@ -1,5 +1,6 @@
import dns from 'node:dns/promises';
import { Agent } from 'undici';
import { embeddedTransitionIpv4 } from './ipv6';
const ALLOW_INTERNAL_NETWORK = process.env.ALLOW_INTERNAL_NETWORK?.toLowerCase() === 'true';
@@ -23,6 +24,9 @@ function isAlwaysBlocked(ip: string): boolean {
if (addr.startsWith('169.254.') || /^fe80:/i.test(addr)) return true;
// IPv4-mapped loopback / link-local: ::ffff:127.x.x.x, ::ffff:169.254.x.x
if (/^::ffff:127\./i.test(addr) || /^::ffff:169\.254\./i.test(addr)) return true;
// IPv6 transition addresses (NAT64/6to4/Teredo) embedding a hard-blocked IPv4.
const embedded = embeddedTransitionIpv4(addr);
if (embedded) return isAlwaysBlocked(embedded);
return false;
}
@@ -43,6 +47,9 @@ function isPrivateNetwork(ip: string): boolean {
if (/^::ffff:10\./i.test(addr)) return true;
if (/^::ffff:172\.(1[6-9]|2\d|3[01])\./i.test(addr)) return true;
if (/^::ffff:192\.168\./i.test(addr)) return true;
// IPv6 transition addresses (NAT64/6to4/Teredo) embedding a private IPv4.
const embedded = embeddedTransitionIpv4(addr);
if (embedded) return isPrivateNetwork(embedded);
return false;
}
@@ -117,6 +124,11 @@ function isLinkLocal(ip: string): boolean {
// (100.64.0.0/10), which safeFetchLlm otherwise allows for a LAN model server —
// so block the specific metadata IPs directly rather than the whole range.
if (addr === '100.100.100.200' || addr === '100.100.100.100') return true;
// NAT64/6to4/Teredo embedding one of the above link-local/metadata IPs — extract
// the embedded IPv4 and re-check. A transition address to a public or LAN IPv4
// stays allowed (safeFetchLlm deliberately permits private/loopback targets).
const embedded = embeddedTransitionIpv4(addr);
if (embedded) return isLinkLocal(embedded);
return false;
}
+7
View File
@@ -61,6 +61,13 @@ function setupWebSocket(server: http.Server): void {
wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => {
const nws = ws as NomadWebSocket;
// ws emits protocol violations (malformed frames, reserved close codes such as 1006)
// as an 'error' event on the socket; unhandled, Node rethrows it and the process dies.
// Must stay above the auth guards below: they close and return early, and a socket that
// never gets this listener can still crash the server before it finishes closing.
nws.on('error', () => nws.terminate());
// Extract token from query param
const url = new URL(req.url, 'http://localhost');
const token = url.searchParams.get('token');