Commit Graph

171 Commits

Author SHA1 Message Date
Maurice 48f0f29273 feat(admin): let an operator switch the place index off
trek_places_enabled was read by six call paths — search, autocomplete, the POI
pills, booking-import geocoding, the offline area and details resolution — and
written by nothing. No admin route, no MCP tool, no switch anywhere in the UI,
and the key is not in the settings allow-lists either, so the only way to set it
was an INSERT against app_settings by hand on the server.

That matters more than the four switches beside it, which trade away quota. This
one decides whether every search and every keystroke in the place box leaves the
instance at all. The flag reads fail-open, so an install that upgrades without
visiting the admin panel starts sending them, which is the right default and
exactly why the operator needs somewhere to say no.

Adds the pair symmetrically to place-shadow from the same branch: getTrekPlaces
/updateTrekPlaces on AddonsService, GET/PUT /api/admin/trek-places with an audit
row, and a real switch on the card. The read stays fail-open so it keeps
agreeing with MapsService.trekPlacesEnabled(), and a test pins that agreement
across every stored value — if those two ever diverge the panel shows "off"
while the queries keep leaving, which is worse than either default.

The card said this itself: `available` was a display-only prop whose comment
read "False until the client is wired to the service; the card then says so
instead of offering a switch that would do nothing." It is wired now, so the
prop is a switch. Its two badge strings had been shipped to all 23 locales and
rendered nowhere; they make way for the switch label and a line naming what
answers while the index is off.
2026-09-07 14:05:33 +02:00
Maurice 715993feaf feat(places): descriptions from the place's own website
Overture has no descriptions, and the encyclopaedias only describe the
places that have an article, which is a fraction of a percent. What
Overture does have is the address of the page the description is on, for
43 percent of places, and those pages publish a summary in JSON-LD or
og:description precisely so machines can read it.

The API fetches each page once and keeps the summary; every instance
reads it from there. TREK never opens a business's website itself, which
would mean one request per instance per place against small servers.

In the ladder it sits below the encyclopaedias and above Google: an
article about this exact place beats the operator's own copy, but for a
restaurant or a shop the operator's summary is the only one that exists.
Credited by host and linked back, with no licence claimed.
2026-09-07 14:05:33 +02:00
Maurice b3d69aa760 feat(places): record which search result was picked, on this instance only
The corpus behind "is our own index good enough to replace Nominatim".
When somebody searches and then picks a result, the client posts the query,
the rank and the pick. Nothing else: no user, no trip, no session, and
coordinates only to three decimals.

Deliberately decoupled from the search endpoint. Correlating a response
with a later pick would mean handing out a ticket on POST /api/maps/search
and threading it through all fifteen call sites; the client already holds
everything the row needs at the moment of the pick.

Evaluation happens offline afterwards: export the corpus, run it against a
candidate index, count how often the pick came back in the top five. That
is why collection can start before such an index exists, which is the whole
point, because the corpus needs weeks of real use and the index needs a
server, and those two waits should not be serial.

Off unless an admin switches it on, and it reads === 'true' rather than
!== 'false'. The three older places switches fail open because a migration
backfilled a row for installs already using them; nothing uses this one, so
an instance that upgrades and never visits the admin panel logs nothing.

Reading is admin only, retention is 180 days, and the switch state is on the
unauthenticated app-config endpoint on purpose: whether an instance records
what its users search for is something a visitor may know before logging in.
2026-09-07 14:05:33 +02:00
Maurice 0a2fa32fc1 feat(admin): rebuild the API keys card around the TREK index
The card was a flat list: two key fields, four Google switches and a large
weather panel, all at the same level. That gave a switch you set once the
same weight as the key it depends on, and gave the loudest treatment on the
page to the one thing with nothing to configure.

Now three comparable provider blocks. The TREK index carries the accent and
the recommendation, because a key should be the exception rather than the
expected setup. Google carries no border badge, so it does not stand out,
and says plainly what it costs beyond money. Weather has left the card: it
needs no key and had nothing to set.

The "recommended" badge moved from Google to the index, which is the whole
point of the change.

Also here, and both are their own thing:

- The settings tab is two columns from xl up. Nearly every card is "label
  left, switch right", so the middle stayed empty while the page scrolled
  for ages. Explicit columns rather than a grid over the flat list, because
  a grid pairs cards row by row and leaves a hole under the shorter one,
  and these differ by hundreds of pixels.
- Pictures: the nearby fallback drops from 300m to 60m and is skipped
  entirely for shops and gastronomy. Measured across 600 places in six
  cities, 2 percent of ordinary businesses have a picture on Wikimedia
  against 70 percent of churches, so for a cafe the curated rungs never
  fire and the fallback always does. That is how the town hall ended up
  over the doner shop. It fails open on an unknown category, so landmarks
  keep the fallback that works for them.

vite.config reads the dev port and the API target from the environment,
defaulting to what they were. Several worktrees could not run side by side.
2026-09-07 14:05:33 +02:00
Konstantinos Thermos 2a0ceb2687 feat(transit): let an admin route transit search through Google
Transitous has no GTFS coverage for much of Asia, so a search between two
Osaka subway stops finds nothing — the stops are not in the feed at all.
Add Google as an optional backend behind an admin setting, reusing the
install's existing Places key, and fall back to Transitous whenever no
key resolves.

Uses the Routes API rather than the legacy Directions endpoint the
request named: every other Google call here already speaks the
v1/X-Goog-Api-Key family, and Google closed the legacy endpoint to Cloud
projects created after March 2025.

Google returns walking as turn-by-turn navigation steps where MOTIS
returns one leg per walking segment, so consecutive walk steps are
coalesced into a single leg — otherwise a finely-sliced walk pushed a
real journey past the 20-leg cap and the whole itinerary was dropped.

Both responses now name the backend that answered. The fallback is
silent by design, which left an empty result ambiguous between "no data
here" and "the provider you picked never ran", and the admin panel warns
when Google is selected but no key resolves — or when only the admin's
own key is set, which serves them and nobody else.

Cost shaped the implementation, since Google bills where Transitous does
not: both field masks are cut to the cheapest SKU tier that still
answers the question, cache TTLs are longer than the Transitous path's,
and route alternatives ride along inside the already-billed request.

Addresses #1699
2026-09-07 09:01:56 +02:00
Maurice 0a35ce83a5 fix: the follow-ups the v4.2.1 audit turned up
Five findings from auditing the release branch, none of them a blocker for it,
all of them things it would have shipped with.

The mirrored booking price only survived an edit when the form happened to fill
metadata of its own. Both reservation forms and the phone sheet sent
`metadata: null` whenever the form had no metadata key to send, and null clears
the column outright — so restaurant, event, tour, parking, other, a hotel with
no check-in times and a transport with no carrier details all lost the price
again on the next save. They send the object now: it still clears what the form
dropped, and the server carries the price across (#2233).

Your share counted expenses nobody has paid for, while the balances right below
it stopped counting them. The tile contradicted the panel it sits in (#2225).

App.tsx kept its own list of routes that must not mount authed-only widgets, and
it had already drifted from the one the response interceptor uses: this one
named /public/journey/, that one covers all of /public/. It reads
isAuthPublicPath now, so a route cannot be public to one and not the other.

LLM_TIMEOUT_MS and TREK_PLUGINS_IGNORE_TREK_RANGE shipped in .env.example but
not in the compose file most installs actually read.

And a typo in the Russian offline copy: Завершёна → Завершена.
2026-09-06 15:03:08 +02:00
Maurice 04b31ec2e5 fix(mobile): give the trip map a way back to every day
The phone map filters down to one day and had no way back out. The chip
rail can only swap one day for another, the second tap on the active chip
is the day sheet, and the seed guard deliberately does not re-seed — so a
day picked on the map stayed picked until you left the trip and came back
(#2257). Desktop has had the deselect since the day header got it.

Its own round control beside the rail, map view only: the plan timeline is
single-day, so there is nothing to widen there, and the day sheet keeps the
one route into it that also exists on the map. It toggles — the first press
parks the day it came from and frames the whole trip, the second puts that
day back with its route, or falls back to the focus day if a remote delete
retired it in between. Leaving the map restores it too, since an empty
timeline would be the worse answer.
2026-09-06 13:35:30 +02:00
Maurice 55f14c6a70 fix(offline): the PWA starts offline again and the download does what it says
Four separate defects sat behind "offline mode is broken".

The app shell. On the first online launch after a version bump, App.tsx
deleted EVERY Cache Storage bucket and unregistered EVERY service worker,
then reloaded. That is exactly wrong for a Workbox autoUpdate build, which
swaps its precache atomically on install and never leaves an empty one.
Between the purge and the completion of a fresh 22 MB precache the origin
had no shell at all; anyone who closed the app or lost signal inside that
window could no longer start the PWA offline. It also threw away the map
tiles and documents the user had downloaded on purpose. It now asks the
worker to fetch the new build and reloads once it has taken over. The
version marker also moves inside the guarded path, because it was written
after the purge and outside its try: a throwing setItem left the caches
gone, the marker unwritten and the reload unreached, repeating on every
launch.

The reported version is validated before it reaches this device's storage and
before it is compared against the stored marker, so a value that is not a
release tag can neither be persisted nor start an update on every launch.

The session. loadUser's offline branch kept the persisted session but
never called onAuthSuccess, the only caller of setAuthed(true), and the
mutation queue's flush, syncAll and prepareForOffline are all gated on it.
A session that launched without network could therefore never sync again
for the rest of its life, even after the signal returned: the offline
settings buttons spun and returned instantly having done nothing, and
queued edits never uploaded. It also left reads pointed at the anonymous
database instead of the user's own.

The date rule. A trip is only cached when it has not ended, and one that
ended over a week ago is actively evicted. The per-trip switch was an
opt-out list, so ticking a finished trip changed nothing at all. For
anyone whose trips are all in the past, "Download for offline use" stored
none of them, reported success, and the cache stayed at zero. Switching a
trip on now pins it, which overrides both the skip and the eviction, and
the switch shows the honest position rather than promising storage that
never happens.

What the screen says. A sync that never started (already running, offline,
signed out) was indistinguishable from one that stored everything: the
progress bar painted full and a green "Ready for offline use" appeared over
a run of nothing. Both syncs now report why they stopped and the screen
says so. A rejected Dexie read no longer renders as "No trips cached yet",
and the clear button stays reachable when the database holds data that no
bookkeeping row points at. A run that rejects outright says so too: the trip
list it starts from is a plain fetch, so a network blip took the whole thing
down and left the last progress frame on screen looking like a finished
download. The 401-with-HTML branch in the API client now
confirms a reachable proxy before tearing the worker down, since several of
TREK's own routes answer 401 with a text/html body.

Both settings shells move onto one shared hook rather than growing the same
logic twice, and the tile prefetch caps its depth when the browser refused
persistent storage. Opaque tiles are billed at about 7 MB each, and an
origin that is not exempt from eviction loses its whole bucket, precached
shell included, which Workbox then never refills because sw.js has not
changed.

Closes #2228
2026-09-04 14:32:44 +02:00
Maurice 6d54ac629e fix(oauth): accept private-use redirect schemes and loopback ports
POST /oauth/register refused every single-label private-use scheme, so an
MCP client registering workbuddy:// could not connect at all, while the
error it got back advertised "a private custom scheme" as supported.

The cause was two validators that had drifted apart, both of which run on
registration. The provider required a dot in the scheme name, which RFC
8252 section 7.1 only recommends and which protected nothing
(javascript.evil:// passed it). The service behind it had no
custom-scheme branch whatsoever and no [::1], which is what actually
answered the reporter's second symptom, and its localhost exemption was
not gated on http:. So javascript://localhost/%0aalert(1), blob:, about:,
ftp: and wss: were all accepted through the settings UI, and the consent
flow assigns the stored URI to window.location.href. Only the CSP stood
between that and a navigation. Both now decide through one policy in
oauth.helpers.ts.

Widening the scheme set means the deny-list has to carry its own weight:
it grows by the browser, extension and OS-handler schemes that were
previously blocked only as a side effect of the dot rule, including
ms-msdt (Follina) and the ms- namespace by prefix. intent: is denied,
because Chrome follows its embedded fallback URL, which turns it into an
open redirect the authorization server cannot inspect.

Fixing registration alone would have moved the reporter's [::1]:{port}
failure one step later: TREK matched redirect URIs byte for byte while
RFC 8252 section 7.3 frees the port for loopback, so a native client on
an OS-assigned port got a 302 to consent and then invalid_redirect_uri.
The matcher now relaxes the port, and only the port, for http loopback.
Stored URIs are never canonicalised, so the %3A round-trips untouched.

Note this tightens POST /api/oauth/clients for inputs it accepts today.
That is the security half of the change and applies at create time only,
so no already-registered client changes behaviour.

Closes #2227
2026-09-04 12:15:53 +02:00
jubnl bc10fba389 feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning (#2226)
* feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning

A plugin whose author has not updated its `trek` range for the running TREK can
now be installed, updated and activated when the operator sets
TREK_PLUGINS_IGNORE_TREK_RANGE to a truthy value. Every gate (registry picker,
post-extraction re-check, sideload, dev-link, activation) warns instead of
refusing; a missing range is tolerated the same way; "install latest" takes the
newest published version. The plugin-API version gate is not lifted and the
Discover compatibility verdict stays truthful.

The admin is warned at every step: each bypass is logged, install/upload/link/
update responses carry a `trekRangeBypassed` marker, and both admin panels show
a "Version checks off" pill, an "Install anyway" confirm dialog before a
registry install, the same notice after a path that could not ask first, and a
persistent chip on the row while the plugin runs outside its range.

Nine new i18n keys in every locale; wiki pages updated (Environment-Variables,
Admin-Plugins, Plugin-Development, Security-Hardening).

* refactor(plugins): one useRangeBypass hook behind both admin shells

SonarQube flagged PR 2226 at 10.8% duplicated new code: the range-bypass
logic had been added to the desktop and phone plugin panels as identical
copies. The marker/warning types, the row chip, the "Install anyway" offer,
the dialog copy and the confirm-or-notice state now live in
useRangeBypass (next to useInstanceSettings, the existing shared-logic
precedent); each panel keeps only its own dialog/sheet markup.

* docs(claude): record the SonarQube gate rules and the no-English-placeholder i18n rule

The CI SonarCloud gate (Sonar way, on new code: >=80% coverage, <=3%
duplication, A ratings, hotspots reviewed) was only discoverable by waiting
20-25 minutes for the workflow. The root CLAUDE.md now states the conditions,
the desktop/mobile shell-mirror duplication trap and its shared-hook fix, and
how to check a PR with the sonarqube MCP first; client and server CLAUDE.md
point at it. shared/CLAUDE.md says every locale needs a real translation —
parity cannot tell an English placeholder from a translation.

* docs(claude): keep CLAUDE.md to commands, invariants, gates and philosophy

Verified every concrete claim in the five CLAUDE.md files against the code
and fixed the stale ones: the websocket server is the Nest realtime gateway,
MCP tools live in <domain>.mcp.ts files, the client "native shell" section
described code that was never written, offline maps also prefetch vector
basemaps, plus several renamed files and wrong table names.

Then trimmed everything that drifts on its own: dates, migration history,
counts, and inventories of stores, slices, domains, sync files and CLI
commands. What stays is the commands, the invariants with the file that
enforces each one, what CI actually runs, the reference implementations to
copy, and the rules for new code. Each file now opens with that rule.
2026-09-04 00:43:06 +02:00
Maurice 6cbefd23e8 feat(studio): filter photos by entry, upload from Studio, and switch stops off
The content browser gets a filter over the journey's pictures: all of them,
the ones no entry holds, or one entry at a time, with the day and the place
under each name. An entry card in the Entries tab reaches its own pictures
the same way. Pictures can now come in without leaving Studio, into the
entry the filter names or into the gallery, from the panel or dropped
straight onto the page, and a photo element can fill its page or the whole
spread in one press.

Stops can be taken out of the arithmetic. A journal entry carries a
stats_excluded flag; an entry that has it is off the route, out of the
distance, out of the countries and not a step, while staying in the
journal. Studio lists every stop in the travel panel with a switch, the
entry editor carries the same switch on desktop and phone, and the entry
card says when a day is off the route.
2026-09-03 10:47:08 +02:00
jubnl b82962f95b Feat/plugin instance actions (#2209)
* feat(shared): scope plugin action descriptors

* feat(plugin-sdk): instance-scoped settings actions

* feat(plugins): run instance-scoped actions from the admin settings dialog

* feat(admin): instance-scoped plugin actions in the settings dialog

* fix(plugins): surface instance actions without instance fields and harden the action gate

* docs(plugin-sdk): note that hosts before 4.2.0 ignore action scope

* fix(admin): open the dialog for instance actions, explain inactive 404s, guard the save window

* docs(wiki): plugin pages for instance actions and the admin panel changes
2026-09-02 23:06:03 +02:00
Maurice 7c857173cf fix(costs): three-decimal currencies stay typable and refunds work on iOS (#2175, #2176)
The split and receipt fields rejected the third decimal a KWD or BHD amount seeds, on desktop and in the mobile sheet, which share the currency-aware guard now. Signed amounts get a sign toggle on the numeric input because the iOS decimal pad has no minus key, and the under/over hint no longer swaps direction for a negative total.
2026-09-02 21:06:51 +02:00
jubnl 1e6ab069cd Fix/sdk default (#2199)
* feat(plugin-sdk): settings-field default + unknown-attribute warning

* feat(shared): default on the plugin settings-field contract

* feat(server): persist and serve plugin settings-field defaults

* feat(client): pre-fill plugin settings defaults in the admin and user forms

* feat(server): enforce required plugin settings on both save paths

* feat(client): block plugin-settings save on an empty required field

* feat(server): resolve plugin settings defaults at runtime

A settings-field `default` was stored and served to the form but never
reached the plugin: the child's init envelope, `ctx.settings.get()`, the
channel dispatch's readAll and the OAuth provider config all read the
stored blob only. Fold `stored ?? default` in on every path that hands
config to a plugin (settings-defaults.ts), and let a default satisfy
`required` on both save gates — refusing a save the runtime would have
accepted was a contradiction.

Also: one `isFilled` rule shared by the save gate and `hasRequired`
(checkbox exempt, whitespace empty); the install parser drops a
non-boolean checkbox default or a select default outside its options,
matching what the SDK already refuses; export SETTING_FIELD_KEYS so the
SDK's mirror can be parity-tested.

* feat(client): share the plugin settings form rules across all three forms

The mobile user settings screen was a third consumer of the fields/config
contract that never got `default` pre-fill or the required check, and
the seed/patch loops lived in five hand copies. Extract
seedSettingsValues / findMissingRequired / settingsPatch into one module
and use it from the admin instance form, the desktop user tab and the
mobile screen. The user forms now surface a 4xx `{ error }` from the
server (the required-field refusal) instead of the generic toast.

* feat(plugin-sdk): seed dev fixtures from manifest defaults

`trek-plugin dev` handed the plugin only the fixture values, so a plugin
relying on its manifest defaults read undefined in dev and only in dev.
Seed ctx.config / ctx.settings from the declared defaults (a fixture
value still wins) via a new exported settingDefaults(manifest, scope).
SETTING_FIELD_KEYS is now parity-tested against the host's list.

* docs(plugins): document settings defaults as runtime fallbacks

A `default` is the field's effective value wherever nobody set one, not
just a form pre-fill; `required` is enforced on both ends; unknown
attributes are dropped at install and warned on by validate. Add a
Settings section with the attribute table to the SDK README and update
the ctx type comments, the shared schema, and the wiki guide.

* add missing exports
2026-09-02 18:42:16 +02:00
Claude 639c40f8de fix(packing): a bag weighs what everyone put in it, not just what you can see
A bag's weight was summed in the browser over the item list the viewer is
allowed to see. Personal items belonging to other members are filtered out of
that list by design (#858), so their weight vanished with them: two people
looking at the same shared bag saw two different totals, and neither saw the
real one.

That number is then measured against `weight_limit_grams`, an absolute airline
limit. A per-viewer subtotal there is not merely incomplete but wrong — a
shared bag could sit over its limit and warn nobody.

The sum now happens in SQL over every row and ships as `total_weight_grams` on
the bag, with `unassigned_weight_grams` alongside so the "no bag" pile and the
grand total follow the same rule; a screen mixing true totals with per-viewer
ones would be worse than either. Only integers cross the wire: a member learns
that a bag is heavier than the items they can see, never a name, category,
quantity or owner. That disclosure is deliberate and bounded, and it is what
the issue asks for.

The item lists stay viewer-scoped, so #1767 ("someone shared it with you, but
they are the one bringing it") is unchanged — it just no longer decides what a
bag weighs.

Totals also had to stay live, because the reported repro is two people looking
at the same screen. A private item's event reaches only its owner, which is the
very rule that made the totals wrong, so item writes now also ping the room
with a content-free `packing:bag-totals` and everyone re-reads the numbers.

All three surfaces — the bag sidebar, the bag modal and the mobile sheet — read
the server figure and fall back to the local sum only for a bag cached offline
before this change.

Closes #2191

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5VwmiBQ9sPCJwfmpv64r3
2026-09-02 16:21:50 +02:00
Claude b7f9c1e8ae feat(journey): make the linked trips' GPX tracks a switch, off by default
#1260 drew the GPX tracks of every trip behind a journey onto its map, with
nothing to switch off — the reasoning being that a route imported into a
linked trip is part of the journey's story. For a trip carrying a season of
recorded drives it is instead a map nobody asked for, drawn from places that
never became an entry.

So it becomes a journey-level setting, owner-only like the other Journey
Settings, and it starts off: a journal should show what its author put in it,
and #1260's set is everything the linked trips happen to contain. The switch
gates the request, not just the rendering — the endpoint ships full unthinned
geometry, which is megabytes fetched on every journey open for a map nobody
wanted.

`journeyTracks()` itself is deliberately untouched, so TREK Studio keeps the
set its saved books were laid out with.

Also fixes what the reporter's screenshot actually shows: JourneyMap fed track
coordinates into `fitBounds`, so one drive across a country zoomed the journey
map out until the entries were specks. Tracks now only frame the map when
there is nothing else to frame, which is what JourneyMapGL already did — the
same journey used to open at two different zooms depending on the renderer.

Closes #2194

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S5VwmiBQ9sPCJwfmpv64r3
2026-09-02 16:21:50 +02:00
Maurice 46a89e7a90 fix(map): surface geolocation failures as a localized toast 2026-09-01 19:24:38 +02:00
Maurice 03bd5c77af fix(weather): day-local named anchor, cache ttl and past-days forecast window (#2167) 2026-09-01 17:18:10 +02:00
Maurice c21e86e3bd feat(planner): surface and edit day-assignment notes across desktop, mobile and pdf (#2163) 2026-09-01 17:18:10 +02:00
Maurice 2e0c496a93 fix(planner): edit details handoff from the transit journey dialog (#2148) 2026-09-01 17:18:10 +02:00
Maurice 37d7365707 feat(collab): markdown poll questions, multiline inputs and wrapping options (#2177) 2026-09-01 17:18:10 +02:00
Maurice d5a3427cbb fix(packing): accept weight, bag and quantity on create across REST, MCP and plugin RPC (#2154) 2026-09-01 17:18:10 +02:00
Maurice 2834967919 fix(i18n): drop the self-hosted wording from the airtrail description 2026-09-01 16:19:21 +02:00
Maurice 3472d06e62 feat(admin): rebuild the addons page as a tile grid with sub-shelves
The three stacked sections become one grid of cards per group, with the
type shown on the tile itself. Bag tracking, the collab features and the
photo providers hang off their parent addon as compact shelf rows
instead of full-width siblings, and each group header counts how many of
its addons are on.

Along the way:
- naver_list_import, airtrail and llm_parsing get catalog entries, so
  their names and descriptions translate like everyone else
- dark detection moves into a shared useIsDark hook that watches the
  .dark class instead of re-deriving settings + matchMedia locally
- the ToggleSwitch knob uses --accent-text so a light accent in dark
  mode no longer renders white-on-white
2026-09-01 16:19:21 +02:00
jubnl 394201cd32 feat(plugins): instance-wide settings editor in the admin UI (#2170)
* chore: bump version to 4.1.0 [skip ci]

* fix(collections): stop findDuplicateCollectionPlace merging places by coordinates alone

Three places answered "is this a place the trip already has?", not two:
PlacesService.findDuplicatePlace and isPlaceDuplicate got the shared strategy
list in #2130; CollectionsService.findDuplicateCollectionPlace was missed and
kept its own hand-rolled copy of the pre-#2130 order.

Unlike findDuplicatePlace, this one had no isPlaceDuplicate() guard in front of
it — savePlace calls it directly — so the coordinate fallback for a NAMED
candidate was live, not latent: two distinct places sharing an address (a
restaurant and the bar upstairs) would report each other as duplicates the
moment their coordinates round to the same ~11 m box, regardless of name.

It also never read google_place_id/google_ftid/osm_id at all, even though
every collection_places row stores them, so a place renamed since it was
saved could be saved again instead of being recognised by its provider id.

Now walks placeMatchStrategies() from @trek/shared, same as
findMatchingPlaceId: provider id, then name, then coordinates only when there
is no name. savePlace passes the provider id fields through since they are
already on the request body; the two bulk call sites (importablePlaces,
saveFromTripPlaces) are unchanged, since their source rows don't carry
provider ids to pass.

Closes #2137

* fix(collections): ask the same question at all three call sites

savePlace was not the only caller of findDuplicateCollectionPlace. The bulk copy
already carries google_place_id/google_ftid/osm_id into the row it writes but
asked without them, and the import picker never selected them at all, so a
renamed place was recognised on one path and not on the other two. The picker
answering differently from the import is the drift the method exists to prevent:
a row shown as new would come back refused.

COLLECTIONS-SVC-102 and -103 cover the bulk copy and the picker.

* fix(map): give the Leaflet map its own zoom ceiling, so a vector basemap cannot take the planner down

Opening a trip on 4.1.0 could land on the error boundary with "Map has no maxZoom
specified", and so could the map settings tab, which left no screen from which to
pick a different basemap.

Leaflet answers getMaxZoom() from the map options first and otherwise from a
layer that brought one, and only a GridLayer ever contributes via its beforeAdd
hook. A vector basemap is a GL canvas on a plain L.Layer, so it contributes
nothing, and MapContainer set no ceiling of its own. MarkerClusterGroup.onAdd
refuses an infinite one by throwing, before any clustering, so an empty trip hit
it too.

That path opened when the default basemap became a MapLibre style. It reaches
further than an operator choosing OpenFreeMap: resolveTileUrl sends a keyless
CARTO template to the app default, which is now a vector style, so every
instance still carrying the old CARTO basemap fell into it on upgrade.

The ceiling belongs on the map rather than on the base layer, because it has to
hold whichever of the three branches renders. 19 matches what the raster and
satellite layers already carried, so nothing changes for the maps that worked.
SharedTripPage gets the same treatment: it has no cluster to throw, but it draws
the same vector basemap and its fitBounds asks for a ceiling.

mapZoomCeiling.test.ts pins Leaflet's own semantics against the real library
rather than a mock, since a mocked react-leaflet would have kept passing through
all of this.

* fix(map): put the CARTO key back on the template the settings preview draws

The map settings fields hold what the user is editing rather than what
useTileUrl already resolved, so the preview received a bare template with no key
on it. resolveTileUrl then read it as a keyless CARTO url and did what it is
meant to do with one: fall back to the app default. The preview drew
OpenFreeMap while the fields said CARTO, whether or not a key was saved.

Same reason the tab crashed for anyone on a CARTO basemap even after entering a
key, which is the loop that made this hard to escape: the key is entered on the
page the fallback had taken down.

FE-COMP-MAP-033b pins the template the preview is handed, with and without a key.

* fix(admin): put the CARTO key back on the default-settings map preview too

Same shape as the user-facing map tab, and reported alongside it: the field holds
what the admin is editing, so the preview resolved a keyless CARTO template and
drew the app default instead of the basemap being set.

* fix(mobile): put the CARTO key back on the phone map settings preview too

The third preview with the same shape, and the one that matters most while the
crash is live: the phone layout is where someone would go to change the basemap
when the desktop tab will not open.

* fix(calendar): a fully timed stay is its two hand-overs, not a week-long bar

Closes #2136

A stay was always exported as an all-day block across its whole range, and the
check-in and check-out markers came on top of it. When the stay records both
ends of its clock the markers already say everything the block does, and the
block is the half nobody can act on: it takes a week of calendar to repeat what
two one-hour events state precisely. Same shape as the parking and rental
windows in #2068.

Only where the markers actually stand in for the booking, though. They are
emitted once per stay and titled from its lowest-id reservation, so a second
room on the same stay would otherwise be left with nothing at all. A stay that
knows only one end keeps its block too, since nothing else carries the other
end's date.

CAL-025b covers the drop, -025c the one-sided stay, -025d the second room.

* fix(mobile): the phone admin map preview needs the CARTO key too

The fourth preview with the same shape, missed when the other three were fixed.
Sonar pointed at it: the desktop and phone admin pages are near-identical, so the
duplication report named the file the change had not reached.

* chore: bump version to 4.1.1 [skip ci]

* feat(plugins): admin editor for instance-wide plugin settings

The scope:'instance' settings a plugin declares had API endpoints but no UI.

- shared: plugins.schema.ts — the settings-field descriptor and the admin
  instance-config GET/PUT wire contracts, imported by both sides
- server: GET /api/admin/plugins/:id/config now returns the declared fields
  alongside the (masked) values; PUT re-spawns an ACTIVE plugin (its child
  reads config once, at init) and reports it via 'restarted'; the admin list
  carries instanceSettingsCount so the UI gates its menu item without a fetch
- client: 'Instance settings' row action opening a form modal (desktop) /
  MSheet (phone) in both admin shells — checkbox/select/secret rendering as
  in the user settings form; an untouched secret mask is never sent back
- i18n: three new admin.plugins.* keys in every locale

* refactor(plugins): move the admin body contracts to @trek/shared

The remaining plugin DTO schemas (install, link, activate, uninstall,
retrust, update, egress-hosts, user-settings update, route) now live in
shared/src/plugins/plugins.schema.ts with their inferred types, alongside
the instance-config contracts. The journey-contract doctrine notes (#1842)
moved with them — each body stays deliberately loose so the handlers keep
the rejections and fallbacks they already own, and new spec tests pin that
looseness so a well-meant tightening breaks a test that explains itself.

plugins.dto.ts is now thin createZodDto wrappers over the shared schemas
(same class names, no controller changes), and the client's pluginInstall
types its options and body against PluginInstallRequest.

* fix(admin): offer Allowed hosts only to plugins that declared operatorEgress

The row's egress chip was already gated on the declaration, but the kebab
menu (desktop) and row action sheet (phone) offered Allowed hosts on every
installed plugin — inviting the admin to widen egress for plugins that
never asked for it. Both entries now render only when the plugin declares
operatorEgress; the dialog's unsupported-notice fallback stays as the
truth-teller for stale list data.

* test(admin): close the Sonar gaps on the instance-settings form

The PR gate flagged new-code coverage (76.4%) and duplication (10.9%).

- The two identical 39-line handler blocks were logic, not markup — they
  move into one shared useInstanceSettings hook; each shell keeps its own
  markup (the deliberate desktop/phone divergence stays)
- New tests per shell cover the untested branches: every field type
  renders and round-trips into the save payload (select, checkbox/MToggle,
  number, hint, no-label key fallback), a failed fetch is a toast, a
  rejected save shows the server reason and keeps the edits, and the
  dialog/sheet closes without saving
- INS-009 covers the controller's no-body '|| {}' fallback

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: michael-bohr <263459669+michael-bohr@users.noreply.github.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-09-01 06:45:24 +02:00
Renzo Beux eb7d595203 feat(admin): show the MCP tools a plugin will advertise
The manifest declaration only becomes an admin-visible control if the
admin can actually read it. Nothing rendered capabilities.mcpTools, so
approving mcp:tools meant approving text you would first meet inside a
chat.

The pre-install dialog now lists the tool names and descriptions, beside
the existing access and egress sections, and the grant gets an
at-a-glance chip like every other capability.

Desktop and mobile both, since the panels are parallel copies.
2026-08-29 12:22:59 +02:00
Renzo Beux 21f4a1241b feat(plugins): mcp:tools permission and the mcpToolProvider hook
Modelled on the hook:* family rather than on geolocation:read. The host
dispatches INTO the child for every tool call, so it needs what
HOOK_PERMISSION buys: providersOf()'s active-and-holds-the-grant check,
the second gate in invokeHook, and the acting-user binding. That last one
is the point. The user is bound host-side from the supervisor's
invocation map, so a tool called by user A reaches A's data whatever the
model puts in args.

The permission keeps no hook: prefix because the surface it opens is MCP,
not a TREK render slot, and nothing keys off that prefix. It is the first
HOOK_PERMISSION value shaped that way; `satisfies` still holds.

One commit, because it cannot be split. requireTotalCoverage makes a
HOOK_PERMISSION key with no @PluginHook consumer a boot failure, not a
test failure, and PluginRpcRegistryService.onModuleInit runs in every
buildApp() — so a half-landed version breaks every integration test in the
repo. The generator is chained to it too: it refuses to run without both
the en admin label and the CLI hint.

15s is the longest budget of any read hook. The others back a render
someone is waiting on and fall back when they time out; this one backs a
chat turn, and the plugin is likely calling a third-party API of its own.
Still under the supervisor's unused 30s default, which is a hung client
rather than a budget.

One callTool fn for every tool, since tool names are runtime data and
hookContracts() is keyed by (hook, fn).

PluginDefinition exists in two copies with no generator between them, so
both get McpToolProvider. grantGaps and the dev mock host need no change:
both read HOOK_PERMISSION, which is generated.

PLUGHOOK-010, plus the CONTRACTS row.
2026-08-29 12:22:59 +02:00
Renzo Beux 092223c259 feat(mcp): plugins:use OAuth scope
One coarse scope rather than per-plugin granularity. Which plugins are
installed is runtime data, so a per-plugin scope cannot be statically
enumerated: it would break the ${group}:${mode} derivation and the
AssertExact guards in nest-mcp-policy.ts, and its consent copy could not
be written ahead of time for a third-party plugin name. Tokens issued
before a plugin existed could never carry its scope either. Per-plugin
granularity already exists one layer down as the admin's mcp:tools grant.

'use' is a fourth mode, which widens ScopeMode. Nothing else moves:
McpAccessGroup and McpAccessMode derive from Scope, so both AssertExact
guards self-heal, and the policy's fallback branch already covers a third
mode the way it covers files:content.

The presets needed an explicit exclusion. Five of the six are written as
"everything except deletes", so plugins:use would have joined Claude.ai,
Cursor, Windsurf and Zed silently and handed out third-party tool
execution without anyone choosing it. That filter was duplicated across
the desktop and mobile files with nothing pinning it, so both now use one
shared constant, and FE-OAUTH-SCOPES-012 holds the line for the next
scope like this one.

The consent copy says what the scope does not do: the RPC host authorises
from grants frozen at construction, so this scope plus weather:read still
reaches whatever the plugin itself was granted.

Two i18n gates, not one. The dot-only group key is tracked by
i18n-parity.mjs, whose regex has no colon in its character class; the
colon-bearing label/description pair is invisible to it but is compared by
client/tests/unit/i18n/parity.test.ts, which diffs merged bundles. All 23
locales get all three keys, including the three in neither gate.

The consent glyph gets a ':use' branch. Falling through to the eye would
tell the user a tool-running scope only looks at things.
2026-08-29 12:22:59 +02:00
michael-bohr 84b51a8af5 refactor(places): one door onto the place-matching rule
Two consumers decide whether a candidate place is one the trip already has,
and they spelled the rule out separately: the bulk importers ask an in-memory
set (isPlaceDuplicate), while findDuplicatePlace asks the database because its
caller needs the matched row's id. The two had drifted — the SQL copy falls
back to a coordinate match for a NAMED candidate, which the in-memory copy
deliberately never does, since that would merge the restaurant and the bar at
the same address.

The order now lives once in @trek/shared as placeMatchStrategies(), and each
consumer is a mechanical interpreter of it: provider id (the only part that
survives a rename, #1550), then name, then coordinates and only when there is
no name.

Adds PlacesService.findMatchingPlaceId as the public door for an importer that
has to link to the existing place rather than merely skip the candidate.
findDuplicatePlace stays private behind it — it also returns google_ftid for
the bulk importer's backfill, which is that caller's business and not part of
"which place is this?".

No behaviour change for either existing caller. The coordinate branch is
currently unreachable from the one call site, which sits inside an
isPlaceDuplicate() guard that has already matched on name and returned; the
drift only bites once something calls it standalone.

COORD_DEDUP_TOLERANCE and externalIdsOf move to shared and are re-exported from
places.helpers, so collections.service and places.service keep their imports.
2026-08-29 12:22:46 +02:00
Maurice b401da2264 fix(maps): default the Leaflet basemap to OpenFreeMap
CARTO started stamping "API KEY REQUIRED" across every keyless basemap tile
on 26.08.2026. #2060 added a key field as the immediate answer, but it leaves
a self-hosted instance needing an email exchange with CARTO before its maps
look right, and every default in the client still pointed at CARTO. Default
to OpenFreeMap Positron instead: no key, no account, no request limit,
commercial use allowed, attribution required.

OpenFreeMap serves vector tiles only, so the Leaflet basemap is a MapLibre
style document rather than an XYZ template. maplibre-gl-leaflet hangs a GL
canvas into Leaflet's own tile pane, which leaves everything above it alone -
markers, GeoJSON, clusters, plugin layers, the custom panes, and the click
handling, since Leaflet's CSS already puts pointer-events:none on that layer.
maplibre-gl still arrives only through a dynamic import, so no chunk that
draws a map grows by a megabyte.

Switched: the trip planner, the MapView fallback every preview goes through,
the journey map, collections, the atlas, the shared trip page and the offline
tile prefetch. OpenFreeMap Positron and Bright join the tile presets in
settings, the admin defaults and both mobile screens.

A CARTO template that is already saved keeps working when a key backs it -
that stays a supported choice. Without a key it is not drawn at all:
resolveTileUrl falls back to the default rather than to a watermark, so an
instance that upgrades with one in its settings sees the new basemap
immediately and needs no migration. The stored value is left as it is, so the
Map settings tab still shows what was chosen and the warning underneath it
explains why - one sentence appended, in all 23 locales.

OpenFreeMap asks for a credit of its own; OpenStreetMap alone under its tiles
is wrong and a licence problem. attributionForTile() derives it from the URL
rather than from a flag at each call site, and the shared trip map, which
carried no attribution at all, now gets one.

Offline maps keep working and get cheaper. A vector style is four things
instead of one, so sync/glPrefetcher.ts walks the style document, its sprite,
the Latin glyph ranges of each font stack it uses, the TileJSON - the planet
path is versioned, so guessing it answers 200 with an empty body - and then
the .pbf tiles. Over the Berlin bounding box TREK computes, raster z10-16 is
2237 requests and 34.6 MB and gives up above z16; vector z10-14 is 177
requests and 32.1 MB and stays sharp above that, because MapLibre scales the
z14 tile rather than asking for another. The Workbox rule for
tiles.openfreemap.org moves to CacheFirst and shares the prefetcher's cache,
so browsing tops a downloaded region up instead of filling a second copy
beside it - a versioned tile path can never be stale. Clearing offline maps
clears it too.

The atlas loses its second raster layer. It existed to warm the HTTP cache of
neighbouring zoom levels, which a vector basemap does not need; a second one
would only cost a second WebGL context. OpenFreeMap has no label-free style,
so its labels are switched off on the live map instead - they are all symbol
layers, while borders and coastlines are line and fill - re-armed on
style.load so a theme switch keeps them off.

Theme changes call setStyle in place rather than rebuilding the layer: a swap
per toggle would leak a WebGL context, and browsers cap those in the low
teens.
2026-08-29 00:51:43 +02:00
Konstantinos Thermos 9e0fb19e79 fix(studio): keep the auto layout inside the book contract
Auto layout wrote journey data straight into the document without holding
it to the caps the contract sets, and the save path sends the document
unvalidated - so the route refused the whole body with a 400 and the
editor reported "couldn't save" for the rest of the session, because
autosave kept offering the same rejected book.

The everyday way in was the itinerary list: thirteen stops a page over
two pages is eighty elements on a spread capped at sixty, so a run of
twenty bare stops was unsavable and a longer one put two spreads over.
Sixty was below what this app's own layouts draw, which makes it a bug
with a constant in front of it rather than a limit, so the cap goes to
ninety and the page keeps the design somebody chose. It stays a
serialisation limit: the largest document the contract now admits is
under four megabytes against the book route's eight.

A long journal entry, a long place name, a journey past eighty countries
and a long title each did the same thing on their own, and a bound story
did it on open without anyone touching Auto layout. The caps become named
exports on the contract and every producer builds against them, so the
two cannot drift apart again - which is also why the stations chunk is
derived from the cap and the salvage fixture counts off it.

Fixes #2085
2026-08-28 22:01:06 +02:00
Maurice 661777f81f feat(plugins): let a plugin attach a photo to a journal entry
A journal importer can create the journey, create its entries and write their
stories, and then it stops: there was no way to give an entry a picture. The
seven journal.* methods cover text and nothing else, so an importer reading an
export archive could only upload its photos as trip files, which live on a trip
and never reach the journey's gallery.

journal.addEntryPhoto takes the bytes. Deliberately not a link to an existing
gallery photo and not a provider asset: an importer holding an archive has
neither, it has a file it just unpacked.

The access decision stays where it already was. addPhoto answers null unless the
acting user may edit the journey the entry belongs to, and null becomes
RESOURCE_FORBIDDEN here, so a plugin reaches exactly the entries its acting user
does. Everything the REST upload refuses, this refuses too: images only and no
SVG, the operator's allowed-file-types setting, 10MB decoded, demo mode. The
stored filename is the host's, never the plugin's, because the gallery dedupes
on file_path. The object is written before any row points at it and removed
again when the link is refused.

JournalRpc moves into its own module for this. It now needs StorageService and
the EXIF backfill out of MemoriesModule, and JourneyDomainModule exists
precisely so places, assignments and the plugin host can reach a few journey
functions without dragging both photo providers into their graphs.

Discussion #1365
2026-08-28 18:04:25 +02:00
Maurice 5a7b8d75aa feat(api): add GET /api/v1/stats for dashboard widgets
Homepage's customapi widget and everything shaped like it want a handful
of numbers from one request; /api/v1/trips hands out the rows, but a
widget maps fields, it cannot aggregate a list.

The figures come from AtlasService.getTravelStats, the same call behind
the dashboard's passport card, rather than from a second count. Visited
countries carries years of rules — countries reached only by a flight,
layovers that do not count, countries hidden by hand — and a second
implementation would disagree with the card next to it within a release.

That is also why the controller sits in atlas/ and not in public-api/:
PublicApiModule stays a leaf, so the route comes to the data instead.
TravelStatsController next door already does this for
/api/auth/travel-stats. ApiTokenGuard is provided locally rather than
imported with PublicApiModule — @UseGuards instantiates a guard in the
declaring controller's module, so an export would still leave its
TokenService unresolvable here.

last_trip means the most recently started trip, not the most recently
created one, and is null while every trip is still ahead.

Closes #1367
2026-08-28 15:23:05 +02:00
Maurice 1e1a80c199 feat(api): expose shortlisted places, detached bookings and the bucket list
Reading the surface back against real data turned up three things it dropped
without saying so.

Places with no day were invisible, because the payload only reached places
through day_assignments. On a live instance that is half of them, and they
are the ones carrying a coordinate and no claim about when — exactly what an
integration can act on. They come back under `unplanned_places`, minus the
hotel, which is already reported as an accommodation.

Bookings with no day were dropped by the day filter. reservations.day_id is
nullable and deleting a day detaches its bookings rather than removing them,
so this is not a hypothetical.

The bucket list had no endpoint at all. It hangs off the user rather than a
trip, so it gets its own, and it answers whether or not the Atlas addon is
switched on: the addon decides what TREK shows, not what a key can read.

Also fixes ?include=notes returning the bare trip: places, notes and
reservations are reported on days, so asking for one now implies days
instead of quietly answering with nothing.

The module stays a leaf. Reaching for AtlasService pulled AuthModule and the
storage registry in behind it and broke the surface's own boot, so the one
SELECT lives here like every other query in the file.
2026-08-28 15:23:05 +02:00
Maurice d559fdda1d feat(settings): give API keys their own section
Minting a key for other software meant finding it under MCP, behind a
heading about AI assistants and gated on an addon it does not need. It now
sits on its own in Settings, above the MCP block, and works on an instance
with MCP switched off.

Deliberately separate state rather than another branch of the tab's shared
hook: the section has to stand on its own, and that hook is already carrying
forty fields through three levels of props.

Translated into all 23 languages.
2026-08-28 15:23:05 +02:00
Maurice 4b120d39c8 feat(api): separate API keys from MCP tokens, add travellers
An MCP token drives every assistant tool; an API key reads trips over HTTP.
Handing the same credential to a third-party integration gives it the whole
MCP surface, so the two are now separate kinds on `mcp_tokens` and the kind
sits in the WHERE clause — a token of the wrong kind is indistinguishable
from one that does not exist.

Also adds `travellers` to the trip payload: display name and an owner flag,
no ids and no email addresses. An integration key reads its owner's
itinerary; it is not a way to enumerate the people around them.

Documents the surface in the wiki, since integrators need something to read
that is not the source.
2026-08-28 15:23:05 +02:00
Maurice 9cddf683a5 feat(api): rate-limit /api/v1 and report when a trip last changed
Two gaps from the first pass, both about a client that polls.

Rate limiting is keyed by user rather than by IP. A self-hosted integration and
its owner's browser routinely leave the same address, and an IP bucket would let
one starve the other; 120 requests a minute is generous for a sync surface and
still bounded, so a runaway poll degrades its own integration instead of the
instance.

updated_at now ships with every trip, and its docblock says plainly what it is
not: a content timestamp. It moves when the trip's own fields change — title,
dates, currency, cover — and stays put when a place, a day or a note is edited,
because the child tables carry no updated_at of their own. That also rules out
the ?since= filter this was meant to enable: it would silently hide every trip
whose itinerary changed, which is worse than no filter at all. Making it honest
needs a schema change, not an API field, so the field is documented for what it
is and the filter is left out.
2026-08-28 15:23:05 +02:00
Maurice fd6381c814 feat(api): add a versioned read-only public API for integrations
TREK's REST surface is internal: it answers a session cookie, carries no
version, and changes with the client. That is fine for our own frontend and
useless for anyone building against us — the Dawarich integration people have
been asking for since April needs something it can rely on not moving.

So this adds a small one rather than opening the existing 549 endpoints:

  GET /api/v1/trips           the trips the caller can reach
  GET /api/v1/trips/:id       one trip, ?include= picks the sections

Auth reuses the trek_ tokens users already manage under Settings, via Bearer
or X-API-Key. No new credential type, no new table — the guard calls the same
hash lookup the MCP transport does. It deliberately refuses a session JWT and,
for now, OAuth bearers: a credential whose scopes this surface does not yet
interpret is worse than one it rejects outright.

Access is decided per trip against canAccessTrip, the same owner-or-member
predicate as everywhere else, and a trip the caller may not read answers 404
identically to one that does not exist — a 403 would confirm the id is real and
turn the endpoint into a way to count someone else's trips.

The payload is not the rows. Ids, foreign keys and order_index stay inside;
accommodation day ids resolve to ISO dates, places arrive in planned order, and
every dated thing carries its date so a consumer can join on days rather than
on ids it has no way to look up.
2026-08-28 15:23:05 +02:00
Maurice d091f4a815 fix(shared): read the regex groups defensively in the datetime helper
shared compiles with noUncheckedIndexedAccess, so a capture group is
string | undefined there even when the pattern guarantees it. Pull the groups
through optional access with explicit fallbacks instead of indexing into the
match, which also drops the two throwaway locals.

No behaviour change: same inputs, same outputs, the spec is untouched.
2026-08-28 14:25:08 +02:00
Maurice 016fdeec64 fix(booking-import): read a printed 12-hour clock instead of slicing it (#2094)
splitIso reached the time by taking characters 11..16, so "2026-06-11T01:11 PM"
became 01:11. The hour survived and the half of the day did not, which is why
AM and 24-hour times in the same document imported correctly. The arrival then
sorted before the departure and the router rolled it onto the next day, so a
three-hour hop was rendered as an overnight.

The client has done this properly since #1725; this is the server half, which
was never pulled across. normalizeLocalDateTime rewrites a value only when it
actually finds a meridiem, so a date-only value, a value carrying an offset and
anything the parser does not know come back byte for byte.

The mapper has only two exits for a date, toIsoString and splitIso, so this
covers all eight types and every emitted field at once. It also stops
sameConnection comparing a NaN timestamp, which silently merged unrelated legs
into one booking.
2026-08-28 14:25:08 +02:00
Maurice 15588d20c9 fix(feeds): keep a staged booking out of the public feed and the shared trip
The ICS feed is public with a token that never expires and prints the
confirmation number into the DESCRIPTION, and the shared-trip payload does a
SELECT * over reservations with share_bookings on by default. Neither filters
anything, so whatever lands on a trip is published the moment it is written.
That is fine while a person types every booking in, and it stops being fine as
soon as something writes on their behalf.

reservations.ingest_state is that gate. It defaults to live, SQLite backfills
every existing row on the ALTER, and no current writer names the column, so
every booking visible today stays visible. Nothing writes staged yet.

The predicate takes its table alias because share.service.ts queried
reservations without one. A stay is withheld only when bookings point at it and
all of them are staged: a hand-added hotel has no booking at all and has to keep
showing up.
2026-08-28 14:25:08 +02:00
Maurice ab3751761c feat(mcp): files and settings scope groups
Two domains an assistant cannot reach at all today need somewhere to hang,
so this is the credential half on its own, ahead of the tools.

files:content is a third mode rather than a second group. Listing the
documents on a trip and reading the bytes inside a booking PDF are different
privileges, and the plugin host already draws that line between
db:read:files and db:read:files:content. It is not implied by files:write,
the same way journey:share is not implied by journey:write; the policy's
fallback branch covers both.

settings:write is scoped to display preferences. Settings also holds the
webhook URL, the ntfy token and the Mapbox, Carto and LLM keys, so the tools
that ride this scope work off an allow-list rather than off the settings
row.

34 scopes across 16 groups, translated into all 23 languages.
2026-08-28 14:20:02 +02:00
Maurice 1f66ef1d32 fix(maps): let users and admins enter a CARTO API key (#2060)
CARTO stamps an "API KEY REQUIRED" watermark onto every basemap tile
fetched without a key since 26.08.2026, and both CartoDB presets plus the
built-in fallback are CARTO. The key is free and needs no account, so add
it as a setting rather than dropping the provider.

carto_api_key sits next to mapbox_access_token: per user in Settings/Map,
instance-wide under Admin/User Defaults, encrypted at rest, injected from
CARTO_API_KEY on managed instances. Unlike Mapbox it never switches the
map provider, since a raster key says nothing about the renderer.

The key is appended as ?key= at render time instead of being stored in the
template, so a saved URL stays portable and survives a rotation. That is
also what reaches the four maps that pinned CARTO and ignored the user's
tile setting: atlas, collections, the public trip share and the public
journey. The two share pages read it from their token-gated payload, the
same way baseCurrency already travels.

Also lifts the release notice above page chrome. Its overlay sat at
z-index 50, under the Vacay mode toolbar at 61, so the dialog was neither
on top nor modal. Adds a six-step layer scale to index.css and moves the
three notice shells onto it.

Closes #2054
Closes #2052
2026-08-27 12:06:55 +02:00
Maurice 800f97b8ce feat(mobile): swipe the day details to step through days (#2059)
* @
feat(mobile): swipe the day details to step through days

The phone shell hangs the days off a chip rail pinned to the top of the
screen, which on a large phone is a thin target a thumb cannot reach one
handed. The day details fill the rest of the screen and did nothing.

They now take a horizontal swipe: left for the next day, right for the
previous one, with the panel following the finger, rubber banding at the
first and last day and springing back when the swipe falls short. The
chip rail is untouched and still the accessible path; a committed swipe
announces the day it reached through a live region, and the active chip
scrolls itself back into view once the rail overflows.

The gesture never calls preventDefault or stops propagation on a touch
event and sets no touch-action, so the browser keeps sole ownership of
the card scroll and of the header chip strip, and the long press reorder
bridge keeps its own 10px self cancel. In edit mode the swipe also
refuses to claim a press held past the bridge window.
@

* @
fix(mobile): line the map controls up and widen the POI bar

The compass sat on its own under the POI bar, centre top, while the
locate button floated 42px above the dock on the other end of the
screen. Both are one handed controls, so they now share the band just
above the dock: compass bottom left, locate bottom right, off the same
--bottom-nav-h the map already reads, a dock gap lower than before.

That frees the row under the chip rail, so the nearby places bar takes
the full width between the screen margins and its segments end up the
size of everything else the thumb aims at there. Desktop keeps the
floating, content width bar.
@
2026-08-27 12:01:11 +02:00
jubnl 4ba407aead feat(plugins): install an older version of a plugin (#2035)
Adds a version picker to the plugin admin panel, on desktop and mobile: every
published version with its own server-computed compatibility verdict, so the
client never re-derives semver range logic.

A deliberate non-latest pick sets an update hold on the row, which keeps the
update banner and "Update all" from immediately nagging the rollback away. The
hold releases when an install lands back on the newest compatible version by any
path, or when the admin resumes updates explicitly. An unresolvable registry
never sets one: refusing to pin on missing information beats silently muting
future updates.
2026-08-25 17:30:27 +02:00
jubnl d110b3bb6d Fix/plugin audit findings (#2031)
* fix(plugins): enforce manifest apiVersion at install and activation

* test(plugins): exercise the absent-apiVersion default

* feat(plugins): generate the core-event catalog into host-facts

* docs(plugins): explain the packing.listBags write-permission binding

* test(plugins): cover apiVersion upload refusal; tidy docblocks and generator header

* fix(plugin-sdk): validate settings options and oauth like the host

* fix(plugin-sdk): model the full manifest shape and stop dropping fields

* fix(plugin-sdk): drop non-string routeProfiles icons like the host

* docs(plugin-sdk): document host limits, oauth contract, and ignored asUserId

* feat(plugin-sdk): enforce daily budgets and meta quotas in the mock host

* feat(plugin-sdk): expose the generated core-event catalog

* feat(plugin-sdk): scaffold trek range >=3.4.0 <5.0.0 ahead of TREK 4.0

* feat(plugin-sdk): scaffold the oauth broker settings for oauth:client plugins

* chore(plugin-sdk): fix stale mirror docs and the colliding permissions export

* docs(plugin-sdk): add the rate-limit string; restore trek docblock; match host meta sizing

* feat(plugin-sdk): raise the scaffold trek floor to 4.0.0

* docs(wiki): bring the plugin pages in line with the current runtime

* docs: bring the workspace CLAUDE.md guides up to date and make them committable

* feat(plugin-sdk): roll back failed publishes, add unrelease, sync the fork, retro-sign updates

* docs(wiki): document the publish rollback, unrelease, fork sync, and retro-signing

* chore(plugin-sdk): 1.6.0
2026-08-24 23:48:36 +02:00
Maurice 502a3fd4e8 fix(shared): express the URL scheme check without a control-character regex
Same behaviour, and the root lint job stops failing on no-control-regex. The
stripping is the point and has to stay: browsers drop everything at or below
U+0020 before they resolve a scheme, so a tab spliced into 'javascript:' is
still a javascript: URL to them.
2026-08-23 11:59:12 +02:00
Maurice e72338bbbc i18n: the English left in Costs, Studio and the mobile timeline
Three surfaces still had English written straight into JSX in a codebase that
gates on key parity: the reworked expense modal, the Studio quickbar and
rotation handles, and the leg-mode menu on the mobile day plan. Keyed, and
translated into all 23 locales rather than pasted.

Also reworded the plugin geolocation permission: it told the user their
browser would ask, and it never does - TREK answers the plugin from the
page's own location permission.
2026-08-23 11:59:12 +02:00
Maurice 61d4fd4d64 fix(planner): scheme-check the booking URL, and stop the serial routing calls
A booking's url is rendered as a plain href to every other member of the trip
and nothing ever checked its scheme, so a collaborator could store
javascript: there. The place website next to it has been going through
safeHttpUrl for exactly this reason, with a comment saying stored values
predate the server check - this is the surface that was left. Both ends now:
safeHttpUrl at the render site, and the scheme in the shared contract so
nothing new gets stored, additively so existing rows still load.

The per-leg routing rewrite turned one multi-waypoint request per run into
one serial request per leg. A twelve-stop day is eleven round trips to a
public OSRM in sequence before a single connector appears, and toggling km/mi
does it all again. Group consecutive legs that resolve to the same mode into
one request and fire the groups concurrently, keeping leg order on the join.

Transport reordering wrote to the store and persisted with a bare catch, so
a failed PUT left the sidebar showing an order the server never got; it
reconciles now. Plus the smaller ones: the OSM menu_url rendered unchecked,
expand/collapse-all persisting to a different storage than the day list reads,
the debounced address search with no ordering guard, the accommodation save
chain with no error path, and window.__trek_map holding a destroyed map.
2026-08-23 11:59:12 +02:00
Maurice eadb3703bc fix(shared,tests): bound the book contract and close the gaps that hid all this
Book page setup and element geometry were unbounded numbers, so a shared
book could be saved with zero, negative or astronomical dimensions and every
consumer downstream - the canvas, the PDF export - had to cope. Day-note caps
had drifted from the limits their own doc comment states, and icon had none.

On the test side: the path filter meant a PR touching only the root lockfile
ran no jobs at all, which is exactly the change class most likely to break a
native binding; the canAccessTrip mock is hand-copied into 72 files and had
already drifted from the real query; the path-traversal test asserted only
status !== 200, so a 500 from a broken guard passed it; and one unit test
shelled out to a real kitinerary binary if the host happened to have one.
2026-08-23 11:59:12 +02:00