Commit Graph

2245 Commits

Author SHA1 Message Date
Maurice bc85ec1eea 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 16:11:52 +02:00
Maurice 04c22b34af fix(test): assert the untouched GET properly
toMatchObject({ method: undefined }) does not say what it looks like it says.
2026-09-06 16:11:52 +02:00
Maurice 56a23cb809 test(ssrf): pin what a redirect hop may carry across an origin
The credential strip and the 303/302 body downgrade are new behaviour in a
security helper, and src/utils sits on a 96% line ratchet that the untested
branches took under.
2026-09-06 16:11:52 +02:00
Maurice 251378be1d fix(security): keep the operator's ntfy token in and re-check every redirect hop
Two reported advisories, and a third path neither of them covers.

GHSA-7pqc-fj3c-9346 — the ntfy token fell back to the operator's whenever a
request did not carry one, so an authenticated user who named their own server
was handed the operator's decrypted token as `Authorization: Bearer`. The report
covers `test-ntfy`, but the live send path in channels/builtins.ts does the same
thing off a per-user `ntfy_server` setting: no test route needed, just wait for
the next notification. Gating the route on `role === 'admin'` would have closed
one of the two and broken the documented shared-server setup, where the operator
supplies server plus token and users only set a topic. The invariant is the
target, not the role: the operator's token travels to the operator's server and
nowhere else, and that rule fits both paths unchanged. Scheme is part of the
comparison — an http twin of an https server would put it on the wire in clear.

GHSA-8mw6-xphx-886m — both transports checked only the first URL and then let
the platform follow redirects on a dispatcher pinned to that first IP. Pinning
does not help: Node skips the pinned lookup for an IP-literal host, so a 307 to
127.0.0.1 or 169.254.169.254 connected straight through. They go through
safeFetchFollow now, which re-checks and re-pins per hop. safeFetch itself had
the same shape and carried it to sixteen more callers, several with a URL out of
a per-user setting, and one (pipeAsset) streams the response back — which would
have made a redirect into an internal service readable rather than blind. It
now routes through the same follower.

The third: following by hand opts out of the platform's own rules, and both
manual followers replayed the caller's init verbatim. undici drops
`Authorization` across origins itself, so converting ntfy to safeFetchFollow
would have re-opened the first advisory through a redirect. Credential headers
are now dropped on an origin change (same-host http to https stays, self-hosted
setups depend on it), and a 303 — or a 301/302 on a non-idempotent method —
drops the body, so a plugin's client_secret is not re-POSTed at a host the
first one merely pointed at. plugin-oauth pins maxRedirects to 0 like its OIDC
twin, and the Google Places calls get the timeout none of their nine call sites
passed.
2026-09-06 16:11:52 +02:00
Maurice 973ce5f8ff 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 16:11:52 +02:00
Maurice bbc32310e9 test(journey): pin the viewless-map window the photo layer fell into
The Leaflet mock had no getCenter, so the new guard bailed out of every
draw and FE-COMP-JOURNEYMAP-046/047 went red. Give the mock a loaded map
by default and add FE-COMP-JOURNEYMAP-050, which takes the view away for
exactly the window #2254 hit: render must survive it drawing nothing, and
the moveend listener must still put the thumbnail up once fitBounds lands.
2026-09-06 16:11:52 +02:00
xvilo ad6e51c574 fix(journey): guard photo layer draw against map with no view yet
Public journey pages ≥1024px render the sticky sidebar map immediately with photos, before the deferred fitBounds/setView lands, causing latLngToContainerPoint to throw "Set map center and zoom first."
2026-09-06 16:11:52 +02:00
xvilo 6da11994d9 fix(app): stop mounting authed-only widgets on public visitor routes
SystemNoticeHost, BackgroundTasksWidget, and save-to-collection were gated only on login/register/password routes, so anonymous visitors to /shared/:token and /public/journey/:token still mounted them and hit system-notices/active with no session, logging a 401 on every visit.
2026-09-06 16:11:52 +02:00
Maurice eef1f16836 test(e2e): locate the panel tabs by aria-label, the tab bar has a Plan button too 2026-09-06 16:11:52 +02:00
Maurice 4a8d77c59c fix(planner): give narrow screens one panel at a time, and unclip the public language picker
Three reports from the same week, all "the layout is in my way".

#2247 — the Plan tab shows both side panels from 768px up, unconditionally.
A Pixel 10 Pro Fold unfolded is ~860 CSS px, so 340 + 300 of panel left the
map a 200px strip; those same widths became fit padding, shrinking the
framing window to 140px and opening the camera two zoom levels out over half
of East Asia. The floating POI/compass cluster is centred on the viewport at
z-25, which at that width put it on top of both collapse tabs (z-1 inside
z-20 containers) and on the Add Place/Activity button — and its pills set
pointer-events: auto, so it ate the taps meant for them. The resize handles
are mousedown-only, so a touch user could not narrow anything either.

Between 768 and 1023px only one panel is open now, the day plan first, the
places list one tap away — the Google Maps shape the reporter asked for. The
collapse flags stay the wide-layout state, so a fold back restores what was
there, and a marker tap reopening "both" no longer re-crowds the screen. The
control cluster centres on the corridor the panels leave (the formula
PlaceInspector and DayDetailPanel already use), the tabs finally have
accessible names, the dead resize handles go, and a panel dragged to 520px on
a desktop is clamped so the map keeps 360px without touching the stored
width. computeMapViewport refuses to frame for less than a third of the box,
so absurd padding can never pick the zoom again.

#2248 — the public journey hero clips its own overflow to keep its
decorative circles from widening the document, which also amputated the
language menu at the hero's bottom edge: 8 of 23 locales, the rest
unreachable. Both share pages inlined the same menu, so it is one component
now, portalled to the body and anchored to its trigger the way CustomSelect
is, with a height cap and its own scroll. It closes on Escape and on an
outside click, which it never did, and `zh-TW` stops labelling itself 简体中文.

#2249 — the note row's pencil/trash pill is absolutely positioned over the
row's own reorder chevrons, and a coarse-pointer `opacity: 1 !important`
pinned it permanently visible on tablets. On a mouse it was no better: the
chevrons are revealed on hover, which is exactly when the opaque pill covers
them, so note reordering has never been clickable on any pointer. The pill is
gone; the row opens the note the way the phone shell already does, and delete
lives in that dialog behind the same confirmation. A link in the note body
keeps its own tap, on both shells.

Closes #2247
Closes #2248
Closes #2249
2026-09-06 16:11:52 +02:00
Maurice a70c2eb9e0 fix(collections): read the created list off the response the server actually sends
POST /api/addons/collections answers with the collection itself — the
controller returns the service's `Collection` straight through, and the e2e
suite pins that shape. The client declared a `{ collection }` envelope and
read `.collection` off it, so `createCollection()` resolved `undefined` and
handed back `null` every single time.

The caller needs that id. In the list editor and its phone sheet the id is
what the cover upload and `onCreated` hang off, so creating a list with a
cover picked in the same dialog silently dropped the image, the new list was
never opened or selected afterwards, and the retry guard that exists to stop
a failed cover step from minting a duplicate was dead. The list itself still
appeared, because `loadAll()` runs first — which is why this went unnoticed.

Nothing caught it because both test layers stubbed the shape the client
believed in rather than the one the server sends: the api suite's MSW
handlers wrapped the collection in an envelope, and the store suite's mock
resolved one too. Both now answer the way the controller does, `update()`
included — its declared type was wrong for the same reason, though no caller
reads its result.

The fix is on the client. That wire shape has shipped for as long as
Collections has existed and the server e2e test pins it, so changing that
side would be the breaking one.
2026-09-06 16:11:52 +02:00
Maurice 99b040f684 fix(planner): never link an offline temp place id to a hotel booking
The envelope fix routes the created place through the store, which offline
mints a NEGATIVE temp id: reservations have no offline write path, the
mutation queue rewrites temp ids in a URL but never inside another entity's
body, and day_accommodations.place_id carries a foreign key. So a
forced-offline save (the Settings toggle sets the flag while the network is
still live) reached the server with place_id: -1758..., failed the FK inside
the insert transaction and rolled the whole booking back into a 500 — where
dev merely saved it unlinked. Worse, the temp place is in `places` by then,
so every retry matched the same negative id and the booking stayed unsavable
for the rest of the session.

Offline the function now links nothing at all and skips the geocode too: the
booking cannot be written either way, so minting a place would only leave an
orphan behind on the next flush. The name-match arm rejects temp ids for the
same reason. `as never` is gone from the addPlace argument, and
placesApi.create finally declares its `{ place }` return type — an untyped
`r.data` is what let the envelope be misread in the first place.
2026-09-06 16:11:52 +02:00
Xheldon 944e0a0970 fix(planner): link the place created for a hotel instead of minting a new one on every save
resolveImportedPlace created the hotel's place through placesApi.create and
read .id off the response, but that endpoint answers { place }, so the id was
undefined: the accommodation was saved without a place_id and the new place
was orphaned. Because the planner's places state never learned about it, the
name match failed on the next edit too, and every save of the same hotel
produced another copy (five in a row on one trip).

Create the place through the store's addPlace instead. It unwraps the
envelope, so the accommodation is linked, and it pushes the place into
`places`, so a later save of the same hotel reuses it. The mobile sheet goes
through the same handler and is fixed with it.

The hook tests mocked placesApi.create with a bare { id } and therefore
never saw the envelope; they now assert on addPlace, and a new case saves the
same hotel twice and expects one place.

Closes #2243
2026-09-06 16:11:52 +02:00
Maurice b9f69b0c76 test(login): stub the world animation so the takeoff assertions stop flaking
FE-PAGE-LOGIN-014 failed intermittently in CI while passing in isolation:
twice in a row on unrelated PRs, each time costing a full run, and once on
one of two runs of the identical commit.

The takeoff overlay renders LoginWorld, which starts three
requestAnimationFrame loops on mount. Under the load of the full suite that
was enough to push the overlay assertion past waitFor's default timeout.
Nothing in this file asserts on the animation, and LoginWorld has its own
tests, so the file stubs it.
2026-09-06 16:11:52 +02:00
Maurice fcefc7e771 test(maps): pin the RTL registration, and correct the precache figure
The guard in rtlText.ts had tests; the two lines that call it did not.
setRTLTextPlugin appears at exactly two places in the client and no test
touched either, so deleting a call, swapping an argument or dropping the
deferred flag left the whole suite green. The engines are mocked because
importing them for real builds a worker pool under jsdom, and the plugin
URL is mocked so the payload never loads.

The precache comment in vite.config.js was three releases stale, and it is
the only record in the repo of what a service-worker install costs. A real
build says 463 entries / 23,292 KiB, the plugin among them: it matches the
js glob, so it is fetched at install by every user rather than on first RTL
text as the branch description claims. That trade is the right one, since
the alternative is a CDN the production CSP refuses and an unshaped map for
anyone offline. Only the description of it was wrong.

Measured against a real client build, which no CI workflow performs: none
of them runs vite build or check:gl-split, so nothing here was covered
before.
2026-09-06 16:11:52 +02:00
Mohamed Elkmeshi 6f0a171c74 fix(maps): register the RTL text plugin so Arabic labels join
Vector basemaps rendered Arabic, Hebrew, Persian and Urdu place names as
isolated letters in logical order — "القاهرة" drawn as "ة ر ه ا ق ل ا" —
because neither GL engine loads its optional RTL text plugin by default and
TREK never registered one. Raster basemaps hid the problem, since their
labels are drawn server-side, so it only became visible for everyone when
the default basemap became a vector style.

Both engine entry points now register the plugin lazily, so the payload is
only fetched once a map actually paints RTL text and Latin-only users
download nothing. The call is left with each engine because the two
signatures genuinely differ: MapLibre takes (url, lazy) and returns a
promise, Mapbox takes (url, callback, deferred).

The plugin is bundled rather than loaded from a CDN: TREK is offline-first
and the production CSP is script-src 'self', so a remote plugin URL would be
refused by the worker that imports it. Bundling also puts it in the Workbox
precache, so shaping keeps working offline. It needs a resolve alias because
the package publishes only its ESM/WASM entry point through "exports" while
setRTLTextPlugin requires the classic script in dist/ — the alias is shared
by the build and test configs so the two cannot drift.
2026-09-06 16:11:52 +02:00
Maurice 3e1c06a0ee fix(reservations): keep a mirrored booking price without keying it on the expense
The carry-over was conditional on a budget_items row still being linked to
the booking, which drops a real price in two shipped cases. The booking
importer writes metadata.price unconditionally but only creates the linked
cost when the Costs addon is on and the price is above zero, so an instance
with that addon off has bookings carrying a price and no budget row at all;
an MCP booking created with a price of 0 is in the same position. Both lost
the price on the first edit.

The condition is gone: a payload that does not name the key keeps what is
stored, and naming it, null included, is taken at face value so a price can
still be removed on purpose.

What stops a price from outliving its expense is now the other end.
deleteBudgetItem is the single chokepoint for all four delete paths, and it
clears the mirror there, inside the same transaction as the delete. That
also removes an ordering hazard the linked-row check had: the controller
runs update() before it syncs the budget, so on an explicit clear the row
still existed and the price would have come back a moment before it went.

The predicate moves into reservation-metadata.ts so it can be read and
tested on its own, and the update_transport tool description no longer
promises that metadata is replaced wholesale.
2026-09-06 16:11:52 +02:00
Xheldon 1aafab83a5 fix(reservations): keep the linked expense price when a booking is edited
A booking's linked expense price lives in reservations.metadata.price
(written by BudgetService.syncReservationPrice). The booking form rebuilds
metadata from its fields on every save and only carries transit /
airtrail_ids over, so PUT /api/trips/:tripId/reservations/:id overwrote the
stored metadata and the price vanished from the card until the expense was
re-saved.

ReservationsService.update() now carries price / priceCurrency from the
stored metadata into an incoming metadata object that does not set them,
as long as a budget item is still linked to the booking. A payload that
sets price itself, a booking without a linked expense, and an explicit
metadata: null behave exactly as before.

Closes #2233
2026-09-06 16:11:52 +02:00
Maurice c1d0435480 fix(app-config): refuse an LLM ceiling that would become a zero timeout
LLM_TIMEOUT_MS was validated as "a positive number", which lets two shapes
through that then degrade into an immediate abort rather than a long one.
A fractional value passes the check and floors to 0 in derive, which makes
the dispatcher fall back to undici's default and setTimeout(abort, 0) kill
every model call on the next tick. Anything past 2^31-1 does the same,
because Node clamps the delay to 1 ms. Both now refuse at boot, which is
what the wiki row already promised.

Also pins the seam the three deleted constants used to occupy: the clients'
abort deadline has to come from the configured ceiling. Nothing else in the
suite would have noticed a literal coming back.
2026-09-06 16:11:52 +02:00
Maurice 798859517f docs(wiki): the AI parsing addon now reads one environment variable
Three shipped sentences became false with LLM_TIMEOUT_MS: two claiming the
addon needs no environment variables (one of them in the very file the
variable was added to), and one naming the old five-minute ceiling that the
deleted constant used to set.
2026-09-06 16:11:52 +02:00
RemEx d3fdf78db6 fix(llm-parse): one configurable ceiling for a model call, and undici honours it
The timeout for a model call was spelled three times and adjustable
nowhere: 120s in the Anthropic client, 300s in the OpenAI-compatible one,
300s in the Ollama one. None of them was the ceiling that actually
applied, either — every one of those calls goes through the pinned undici
dispatcher, and an undici Agent stops waiting for response headers after
five minutes by default, so a client constant set above that never got
the chance to fire.

- LLM_TIMEOUT_MS (default 900000) replaces the three constants and is
  passed to the dispatcher as headersTimeout/bodyTimeout, so the abort
  signal and undici are governed by the same setting. The default is
  deliberately generous: heavier parsing work should fit without a code
  change, and an admin who would rather fail fast can lower it.
- The ceiling stays on the model lane. safeFetchAdminConfigured is also
  the OIDC lane (discovery, token, userinfo, JWKS) and the plugin OAuth
  token exchange, and that one sends no AbortSignal of its own, so it
  keeps undici's five minutes rather than inheriting a model setting.
- The derived value is floored: the schema accepts any positive number
  and undici rejects a fractional headersTimeout.
2026-09-06 16:11:52 +02:00
Maurice 1b71b8633d 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-06 16:11:52 +02:00
Maurice d77f1c1758 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-06 16:11:52 +02:00
Maurice e09c0688ae fix(collab): notes show and keep their website on the phone
The note website round-trips correctly through the API and the desktop
grid renders it, but the mobile notes tab never carried the field: the
form had no input, the payload had no key, and neither the card nor the
read-only sheet showed a stored link. Since an editor only ever opens the
form and a viewer only ever opens the sheet, no role on a phone could see
or reach a website saved from a desktop.

Editing a note on mobile did not wipe an existing link. The key was
omitted rather than nulled, and the server's CASE WHEN guard kept the
stored value, so this was a visibility gap rather than a destructive one.

The form now always sends the field, null included, because the server
only clears on an explicit non-undefined value. Every render path goes
through safeExternalHref: the REST contract accepts any string, so an
unguarded href would execute a stored javascript: value in this origin.

The desktop expanded view had the same omission and is fixed alongside it,
so a member without collab_edit no longer loses the link there either.

Closes #2222
2026-09-06 16:11:52 +02:00
Maurice 0e66ae72ca fix(budget): an expense with no payer creates no debt
An expense saved with "No one paid yet" was still charging its split
members in calculateSettlement while crediting nobody, so the ledger
drifted off zero by the unpaid total. The greedy simplifier that turns
balances into payments has no memory of which expense produced which
debt, so it handed that phantom amount to whoever happened to be a
creditor from an unrelated expense. In the report, two people each owed
about 80 to someone who never paid that bill. Booking every payment it
offered then left one member permanently short, next to an "everyone
square" line.

Both cost panels already promise the opposite: they label such a row
"Unfinished, total only, not settled yet" and count it into the
Outstanding amount card instead. The server now agrees with them.

The guard deliberately does not also require a non-zero total, unlike the
panels' label predicate. PUT /budget/:id/payers with an empty array zeroes
total_price without re-applying it, which would otherwise leave a custom
split debiting its members against no credit at all.

The #1543 fixture and BUDGET-SVC-DB-004/005 were re-baselined: they pinned
the old arithmetic through a deliberately payer-less 9 000 RUB item. The
wiki said the old behaviour was intended, so it is updated too.

Closes #2225
2026-09-06 16:11:52 +02:00
jubnl 61bef334d5 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-06 16:11:52 +02:00
jubnl fa406b708f plugin-sdk-v1.7.0 plugin-sdk-v1.7.0 2026-09-03 17:30:31 +02:00
github-actions[bot] 09ce5cb733 chore: bump version to 4.2.0 [skip ci] v4.2.0 2026-09-03 14:24:43 +00:00
Maurice 74c47a6158 docs(wiki): add the feature-requests banner used by the release notes
Release notes link the banner from the repo rather than from a comment
attachment, so it keeps working when the notes are edited later.
2026-09-03 16:18:21 +02:00
Maurice 8b291a9a7e fix(maps): handing a place to a maps app leaves no blank window behind (#2218)
In an installed app, opening a maps target in a new context left an empty
window: the platform switches to the maps application before that context
paints anything, and coming back lands on a blank page the user has to
dismiss before TREK is usable again. The handover now happens from the page
the user is already on when TREK runs as an installed app, which is where
there is no tab strip to close a stray window from. A browser tab still opens
a tab, because there a link should.
2026-09-03 15:51:13 +02:00
Maurice 07e4a558e6 fix(bookings): the phone keeps a booking's stop and shows the files it already has (#2216, #2217)
Editing a booking on the phone erased the stop it was linked to. The sheet
seeded its assignment from the create-for-a-stop flag, which is null on an
edit, and saved that straight back over a link the user had made on the
desktop; the server writes the field whenever the key is present. It now
seeds from the booking being edited, and the picker the desktop dialog has
always had sits in the sheet too, so a stop can be seen, changed and cleared
from a phone. The option list moved to its own module rather than being
copied.

Two more of the same shape, found on the way. The endpoint set was sent as an
empty list on every save, and the server swaps the whole set when that key is
present, so editing a transit booking dropped its stations; neither form
edits endpoints, so an edit no longer sends the key at all. And the sheet's
Files block listed only what was picked in this session, so a file uploaded
from the desktop was invisible on the phone. It lists what is attached now,
resolved the way the desktop resolves it.

A place also shows the files of the bookings that sit on it, on the phone and
on the desktop: a confirmation lives on the booking, and the place card was
the one screen that could not reach it.
2026-09-03 15:51:13 +02:00
Maurice 9228ef2bce fix(notifications): a real send keeps its ten minutes, only the admin test is impatient (#2196)
The #2196 timeouts were applied to one shared transport, so the 30-second
inactivity bound landed on the password-reset and notification mails as well
as on the admin's test send. That bound starts counting after the greeting,
which means it bounds the transfer: a relay that scans the message can sit on
DATA for minutes, and a reset mail dropped for that reason fails where nobody
can see it. Nodemailer's ten minutes are back for a real send, the connect and
greeting cuts stay, and the test send keeps the 30 seconds it needs to answer
inside the client's 40-second budget. The source comment already described this
split; only the value disagreed.
2026-09-03 13:59:39 +02:00
Maurice 15f22058e9 refactor(studio): split the content panel and cover the new paths
Three things Sonar was right about. The content panel had grown a filter, a
menu and an upload into one function of thirty-two branches: the filtering
is four pure rules in photoFilter.ts, the row that drives it is its own
component, and what is left in the panel is the grid and the drop zone. The
gallery rows behind an entry upload move out of the store's setter into a
named function, since five levels of nesting is a place bugs hide. And the
ids for placed elements come from getRandomValues rather than Math.random,
in one module instead of four copies of the same line: two editors minting
the same id for two elements is one of them losing their work.

The new paths carry tests now, which is what the gate was really asking
for: the filter's rules, the ids, the upload helper, the store's derived
gallery rows, the canvas file drop, and the shell's upload, drop and stop
switch.
2026-09-03 10:47:08 +02:00
Maurice b383230928 fix(studio): fold the stops list and put a picture into the gallery it lands in
The stops list was the longest thing in the travel panel by a distance: a
fortnight is fourteen rows, and it pushed the maps and the country tiles
below the fold on every journey. It is a folding section now, closed to
begin with, with the count of what still counts in its head, so the closed
section answers the question anyone opens it for. The rows are one line
each, the day loses its weekday, the country appears only on a journey with
more than one, and the switch is a mark rather than a word, which is what
lets the place name fit.

A picture uploaded onto an entry also goes into the gallery, because that is
where the row lives. The entry upload only answers with the entry's view of
it, so the store derives the gallery row it did not get and the content
browser shows a new picture without the journey being fetched again.
2026-09-03 10:47:08 +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
dependabot[bot] 23401fcdb9 build(deps): bump fast-uri from 3.1.5 to 3.1.7
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.5 to 3.1.7.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.5...v3.1.7)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
(cherry picked from commit 56f7e1871f97a2059a47f8b57adb785568d3c861)
2026-09-03 10:07:27 +02:00
Maurice a2cb6a12d1 fix(mobile): a stay chip in the timeline header opens the stay, not the day (#2210)
The hotel chips next to the day pill shared its target, so tapping a stay
opened the day sheet. A chip now carries its stay and opens it the way the
stay card in the day sheet does: the accommodation editor for members who
may edit days, otherwise the hotel's place, and the day sheet only when
neither applies. Closing the editor returns to the timeline it was opened
from instead of surfacing the day sheet, and the chip's accessible name
says whether it is a check-in, a check-out or an ongoing stay.
2026-09-03 09:15:27 +02:00
Maurice ffb6546e51 fix(mcp): prompts take the trip id as the string every client sends (#2207)
Prompt arguments cross the wire as strings, but the three trip prompts
declared tripId as z.number(), so every prompts/get failed with -32602
"expected number, received string", MCP Inspector included. The id is now
parsed from the string through a shared tripIdPromptArg (still an integer
in the safe range), PromptOptions only admits string-input schemas so the
mistake cannot come back unnoticed, and a prompt declared with an empty
argsSchema no longer demands an empty arguments object from the client.
The prompt tests run through a real in-memory MCP client instead of
calling the callbacks directly.
2026-09-03 09:15:14 +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 b1ce7c303d feat(todo): sort the task list by due date (#2205)
The list held its manual order, so an item due tomorrow sat behind one created earlier but due later. The sort-by section gains a due-date toggle next to the priority one, on desktop and in the phone tab: nearest deadline first, undated tasks after all dated ones, ties keep the manual order. One sort at a time, and manual drag-reorder pauses while either is active, exactly like the priority sort.
2026-09-02 21:39:01 +02:00
Maurice c5d10dfd6f fix(client): settle the two findings Sonar raised on the audit branch
The farthest-stop reduce gets its explicit seed (the guard above already refuses an empty list, the seed makes that local), and the toast pill moves into its own component so the host stays below the complexity ceiling.
2026-09-02 21:06:51 +02:00
Maurice ed3216f65d fix(planner): an untimed hotel bookend only closes a local day-trip loop (#2157)
Without stay times the bookend leg drew unless a carrier or a 2000 km edge stop disproved it, which is exactly the reported car trip: home five hundred kilometres away, routed back to the hotel after check-out. The disproof threshold is a day-trip distance now, and the route optimizer anchors through the same rule on desktop and mobile instead of unconditionally, so drawing and optimizing agree.
2026-09-02 21:06:51 +02:00
Maurice 31fa8908fc fix(weather): one failed fetch no longer parks the badge on the dash (#2167)
failed was set once and never cleared, so a single hiccup kept the badge on its error dash while later fetches quietly delivered data. It resets with every new anchor.
2026-09-02 21:06:51 +02:00
Maurice 1428637694 fix(mobile): the filter pill hugs its chips and the list switcher clears the dock (#2104)
On wider phones the dashboard filter pill stretched across the leftover width instead of wrapping its chips. The collections list switcher could not scroll, so its lower entries and the new-list button hid behind the dock.
2026-09-02 21:06:51 +02:00
Maurice 3f49ef8d21 fix(plugins): readable save button, honest respawn, scrolling hosts dialog (#2170, #2159)
The instance-settings save button paired bg-accent with a hard text-white and vanished in dark mode; it uses the accent pair token now. A failed activate() after saving no longer reports success over a dead plugin: the config stays, the response says what happened. The allowed-hosts dialog scrolls like the ones e70d7397 fixed, and the discover cards get the press-scale hardening from #2158, on desktop and on the phone panel.
2026-09-02 21:06:51 +02:00
Maurice 348d584a02 fix(collab): sanitized markdown keeps angle-bracket text and working footnotes (#2177)
rehype-sanitize silently dropped anything that parsed as an unknown tag, eating text people had saved long before the sanitizer arrived; disallowed markup renders as literal text now. GFM footnotes pointed at a doubly prefixed anchor and never landed. One shared plugin set drives the polls, the notes, the note card and both mobile tabs.
2026-09-02 21:06:51 +02:00
Maurice 4cd25d3047 fix(admin): the addons page re-reads after a journey toggle instead of trusting itself (#2156)
Disabling Journey cascades the photo providers off on the server, but the panel kept its local rows, so after off and on the providers showed active while being disabled. The desktop and the mobile panel re-read the list after a journey toggle; the write itself still rolls back a single row on failure.
2026-09-02 21:06:51 +02:00
Maurice 293dc9e1f1 fix(ui): defuse the press-scale click trap on three more composite buttons (#2158)
Same trap the task-list fix hardened: the press animation shrinks the container mid-click and the release lands next to the target. The collections rows, the journey gallery tiles and the shared vacay calendar cards carry the established no-press attribute now.
2026-09-02 21:06:51 +02:00
Maurice c026c5b1c9 fix(auth): passkey_configured stops advertising the phantom localhost config (#2147)
Without any configuration getAppUrl() invents http://localhost as the relying party, and the UI advertised passkeys that every click answered with not configured. A localhost RP only counts as configured now when the operator declared one. The origin check also binds to the expected origins again instead of accepting every subdomain of the RP id; that widening never shipped in a release.
2026-09-02 21:06:51 +02:00
Maurice a5d87d295a fix(settings): the offline language mirror does not outlive the account (#2146)
The mirror of the account language stayed in localStorage past logout, so the next account on the device booted in the previous user's language forever. It is cleared on logout and rewritten on login, and the rate-limit message follows the same order the boot language does.
2026-09-02 21:06:51 +02:00