Commit Graph

1024 Commits

Author SHA1 Message Date
Maurice 1ad2e92142 fix: make the app keyboard-reachable, and close four DoS-able patterns
The bulk of what SonarQube still had open on the reliability side, read site by
site rather than swept. Two clusters are worth calling out because they are
defects, not lint.

**Four regexes could be made to hang the process.** The worst is
`/.+@.+\..+/` in the forgot-password path: three unbounded `.+` that all
overlap, unanchored, on an endpoint that needs no login and whose schema is a
bare z.string() under a 100kb body cap. A thousand '@' characters had not
finished after 100 seconds — one request pins the event loop, and Node has one.
EMAIL_REGEX in auth.helpers is the same shape on register and profile update
(310ms for 8kb). The two tag strippers over an uploaded KML description and over
Commons metadata took 0.52s on a 16k run of '<'. Each rewrite was proven
equivalent by brute force against the original — millions of strings for the
coordinate patterns, 671,876 for the tag stripper — because a subtly different
regex on a parsing path is worse than the finding.

`<[^>]+>` could not be fixed in the pattern: `[^>]` matches `<` too, so the only
unambiguous form changes what happens to malformed markup. It is now a scan in
common/stripHtmlTags.ts, quirks included — `<>` is left alone, `<a<b>` is one
tag — with the equivalence pinned by a test that compares against the old regex
over every string up to length six.

**And roughly 500 places could only be reached with a mouse.** Clickable divs
became real buttons where the markup allowed (52 of them), took
role + tabIndex + Enter/Space where they had to stay divs, and modal backdrops
took role="presentation" — the honest role, since a backdrop is a mouse shortcut
for a Cancel control that is already in the tab order, and making it a button
plants a nameless full-screen tab stop around its own contents.

Alongside those: `.sort()` calls that now say the order they always had, `{n &&
<X/>}` guards that rendered a literal "0" for a zero-byte file and for a place at
latitude 0, an optimistic budget write that stored a boolean where the contract
and both transports use 0/1, and the Number statics.

sonar-project.properties documents the six that were deliberately left: two wiki
patterns with no overlapping quantifier pair whose "fix" would stop parsing
`[see [1]](url)`, four tar header fields bounded at 12-155 bytes by the format,
two djb2 hashes where the 32-bit wrap is the algorithm, two `| 0` that are also
coercing undefined to 0 on a path where the API sends no user_id, and the MCP
session maps Sonar reads as empty because they are filled from another module.
2026-08-25 19:26:38 +02:00
Maurice 52d1a0720f fix: say out loud what every sort was already doing
Sonar's S2871 flags a bare .sort(), which orders by UTF-16 code unit rather than
by value. All 30 remaining sites turned out to sort ASCII strings — ISO dates,
currency and country codes, setting keys, route names, OAuth scopes, zip entry
names — where that order is already the intended one. Each says so explicitly
now, with a comparator that reproduces it exactly rather than a localeCompare
that would quietly reorder things.

That mattered more than it sounds. Several of these feed something other than a
display: useAtlas joins the sorted codes into the signature that decides whether
to rebuild the region layer, daysSlice re-pins sorted dates positionally onto
reordered days, and three timeline views pick a day's colour by indexOf into the
sorted array. Switching any of them to a locale collator would have been a silent
regression.

Two smaller ones came with it. The .map(placeBtn) calls in FileManagerAssignModal
pass the index as a second argument, which placeBtn declares and uses for its
React key — correct already, now written out as .map((p, i) => placeBtn(p, i)).

And budgetSlice's optimistic paid-toggle stored the raw boolean behind
`paid as unknown as number`, while the schema declares z.number() and both the
REST response and the WebSocket event carry 0/1. So the optimistic state and the
confirmed state disagreed on type until a refresh. It normalises now, and the two
tests that asserted `true` assert the contract.

Four `| 0` truncations are listed in sonar-project.properties instead of changed.
Two are a djb2 string hash where the 32-bit wrap is the algorithm. The other two
look like plain truncation of a row id, but `| 0` is also coercing undefined to
0 there — the settlement flows the API returns carry no user_id, so that is the
normal path, and Math.trunc returns NaN and crashes the lookup.
2026-08-25 19:26:38 +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
cartoone b5c8182410 feat(planner): clicking the selected day header again deselects it
Same effect as the day panel's close button: selection cleared and the
day detail panel closed. Discussed in #2024.
2026-08-25 17:26:09 +02:00
cartoone 1cb67d5e64 feat(planner): scope the planned places filter to the selected day
With a day selected, the 'planned' filter now shows only that day's stops,
matching how the other filters follow the selection; with no day selected
it keeps showing the whole plan. 'unplanned' still uses the whole-trip
assigned set. Discussed in #2024.
2026-08-25 17:26:09 +02:00
Maurice fe5c6f182e fix(ci): restore the peer dep the swc install drops, and close a test race (#2040)
Two separate reasons v4.0.0 (#1719) is red.

The SonarQube workflow killed all 616 client test files with "Cannot find module
'@testing-library/dom'". That package is a peer dependency of
@testing-library/react and not a direct dependency of the client, and the step
that installs @swc/core's Linux binary passes --legacy-peer-deps, which tells npm
not to put peer dependencies back when it re-resolves the tree. In test.yml that
step lives in the server job, which never runs client tests, so folding the jobs
into one (#2039) is what brought the two together. It now installs alongside the
swc binary, with its version read from the peer range rather than pinned, and a
one-line resolve check right after fails fast with the module name instead of six
hundred identical failures twenty minutes later.

MAdminStoragePanel's FE-MOB-MSTOR-015 was a genuine race in the test, not in the
panel. Its POST handler incremented postCount, then awaited request.json(), and
only then pushed to migrationPosts — so between the two there was a window where
one POST had been counted but migrationPosts was still empty. The GET handler
reports `migrations: []` for an empty migrationPosts, i.e. "nothing running", so
a poll landing in that window told the queue it was free to start the second
migration, and the waitFor(postCount === 1) above could resolve inside it too.
Parsing before counting closes it. Verified by running the whole mobile suite at
--maxWorkers=8, where it used to surface.
2026-08-25 17:09:29 +02:00
Maurice 2c2d008dc2 fix: clear every open Sonar security issue, and half the reliability ones (#2037)
* fix(security): mint idempotency keys properly, and resolve the extractor by path

Three findings out of a sweep of every open Sonar security issue. The other 49
turned out to be false positives and are documented in sonar-project.properties
rather than changed — each entry scoped to a file, never a whole rule, so a
future Math.random() on an auth path still fails.

The idempotency key is the one that can lose data. Both producers fell back to
Math.random when crypto.randomUUID was missing, and randomUUID needs a secure
context — TREK's own quickstart is http://localhost:3000 and LAN installs run on
http://192.168.x.x, so on the deployments the README documents the fallback is
what actually runs. The axios one was
`Math.random().toString(36).slice(2)`, which is not length-stable: 0.5 yields a
single character and 0 yields the empty string, which the interceptor reads as
"no key" and skips deduplication for entirely. Two writes sharing a key collapse
into one — the second gets the first one's cached response and the edit is gone
with no error anywhere. Both now come from utils/randomId, which has the
getRandomValues rung that works in insecure contexts too.

The extractor probe stored the bare binary name, so every extraction re-resolved
it through whatever PATH held at that moment. The official image sets
KITINERARY_EXTRACTOR_PATH and never reaches that branch, but a bare-metal install
without it ran an unqualified name from a PATH it does not control. It now walks
the search path itself and keeps the absolute path of the file it verified;
execFileSync also drops the /bin/sh hop the old probe needed. PATH comes through
app-config because process.env is walled off in src/.

* fix: four tests and sorts that were not doing what they claimed

Sonar reported 43 high-impact reliability issues. Reading all of them, four are
real; the rest are cosmetic or would be made worse by the suggested change.

budget.test.ts hand-built `paid: true`, but both emitters send `paid: body.paid
? 1 : 0` and the handler stores the value verbatim — the test asserted a payload
the server never sends. The line below it already used a number.

admin.controller.test.ts had a `.resolves` assertion with no await. The promise
was dropped, so that line could not fail the test.

The two to-do category lists sorted with a bare .sort(), which is byte order:
every accented name files behind the whole ASCII range, so in a German trip
"Übernachtung" lands after "Zelt". TREK ships 23 locales, so this is real data.
One side effect worth knowing: katColor picks a category's dot colour by its
index, so re-sorting can move which category shows which colour — it already
moves whenever a category is added, and the colour carries no meaning.

Deliberately not changed: the two `| 0` truncations (Math.trunc is not
equivalent — `| 0` wraps at 32 bits and these carry millisecond timestamps), the
two .map() callbacks whose second parameter is declared and used, and the sha1
in thumbnail.service (its own comment says a swap would orphan every existing
thumbnail).

* refactor: give every button a type, and prefer the Number statics

Two mechanical sweeps, no behaviour change intended in either.

A <button> with no type attribute is type="submit" per the HTML spec. Outside a
form that does nothing today, but it turns into a surprise the moment the markup
is wrapped in one, which is what Sonar's S9011 is warning about. 870 buttons get
an explicit type="button". Two things were left alone on purpose: buttons that
take a {...spread}, because the spread may carry a type and the merge order
decides which wins, and anything inside a <form> — there the answer is a
judgement call, and checking all eleven form files found every real submit button
already carrying type="submit".

The Number sweep covers only the three globals that are exactly equivalent to
their Number twin: parseInt, parseFloat and NaN. isNaN is NOT in it — the global
coerces its argument, so isNaN('abc') is true where Number.isNaN('abc') is false,
and renaming it would change what the code does. It reaches server and plugin-sdk
as well as the client; both copies of egress-policy.ts got the identical edit, so
the mirror the SDK's parity test guards stays intact.
2026-08-25 15:22:38 +02:00
Maurice e3c524dd02 test: give every test case an assertion of its own
SonarQube's S2699 flagged 196 test cases as assertion-less. Almost all of
them end on a bare `await screen.findByText(...)`, which does throw when the
text never shows up — the test is real, the analyser just cannot see it.
Spelling the check out keeps the behaviour identical and makes it readable.

Writing them out turned three tests red, each for a good reason:

- FE-COMP-NOTES-008 read the heading out of the loading panel, which renders
  its own copy and swaps the whole node out once the fetch settles. It now
  waits for the loaded state.
- FE-COMP-PHOTOPROVIDERS-014 matched /connected/i, which also matches the
  "Not connected" the badge starts as — the test passed whether or not the
  button did anything. Exact text now.
- FE-FILES-002 compared an empty list against the empty list it already was.
  It seeds a file first, so the 500 has something to leave alone.

JOURNEY-SVC-066 asserted nothing at all behind a "should not throw" comment;
it now says that out loud and checks no journey entry is left orphaned.
2026-08-25 11:02:39 +02:00
jubnl efcdbd94fb fix(client): compute "today" from the local calendar, not UTC 2026-08-25 00:44:42 +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 4bac447ac8 fix(planner): draw only the selected day's automated transports
Closes #2019

The route toggle belongs to one day; the reservation list it filtered is the
whole trip. So turning on a day's routing let every transit reservation in the
trip draw its geometry at once, including the ones belonging to other days —
on a day that has no transport of its own, which is what the reporter saw.

Only automated transports rode it, and that is the distinction they drew: a
manually entered train or flight is type 'train'/'flight' and reaches the map
only through its own per-booking toggle. Only the transit search stores
type 'transit'.

Gate the transit branch on the day, and gate only that one — the per-booking
toggle stays trip-wide, because switching a booking's route on is a deliberate
act about that booking. The predicate is the day-order range, not the departure
day: an overnight journey carries end_day_id and has to stay on both of its
days, the same rule the sidebar timeline already applies. Day order rather than
day id, since ids stop being monotonic once days are reordered.

The day context is optional throughout, so a map without one — collections —
keeps behaving exactly as it did. Not a 4.0.0 regression: it reproduces on
3.4.1 too.
2026-08-23 23:43:14 +02:00
Maurice 6779cd2dd1 fix(studio): fit an outline map to the country the trip was in
Closes #2021

A country is one path but not one landmass. France's is five rings, three of
them Kerguelen, French Guiana and New Caledonia, so fitting a map to its
bounding box asked for 221 degrees of longitude and printed a Paris-Lyon-Nice
route as a single dot. Britain does it through South Georgia, Norway through
Svalbard, Ecuador through the Galapagos — which is why a Netherlands journey
looks perfect and a France one does not.

Satellite only escaped by accident: imagery leaves the shape list empty, so the
fit fell through to the stops. Same control, silently doing nothing.

Fit to the rings the journey is actually near, and keep the whole shape as the
fallback for a map with no route yet. The margin is the route's own size with a
two-degree floor, which is the judgement in this fix: at two degrees a
Paris-Nice route still keeps Corsica, which is the picture that trip wants,
while Kerguelen stays out.

The outlines themselves are untouched — the overseas rings are still drawn,
they just run off the frame the way a coastline does.
2026-08-23 23:43:14 +02:00
Maurice 477e44ef50 fix(journey): keep the mobile timeline at the bottom of the screen
Closes #2022

The story preview carried both `block` and `line-clamp-2`. Tailwind emits the
display plugin after line-clamp, same specificity, so `display:block` won and
`-webkit-line-clamp` — which only does anything on a -webkit-box — was inert.
The whole entry text rendered, the card grew with it, and the timeline it sits
in is bottom-anchored with no height of its own, so it climbed up the screen.
Measured at 430x932: three words put its top edge at y=744, four hundred words
at y=24, two thousand at y=-2916. That is the reporter's "at the top in one
journey, in the middle in another" — it is not the install, it is the longest
comment in the journey being opened.

The same growth explains the other half. The timeline sits above the map and is
a horizontal scroller, so once it covers the screen it takes every drag Leaflet
would have got, and only the gap between it and the dock still reaches the map.

Dropping the display utility fixes today's cards; capping the card height means
no future one can do this again. 140px is what the older journey timeline pins
its active card to, so the two look like the same product.

Also lift the map attribution over the dock, which the reporter's screenshots
show sitting on top of it. Both engines, since a phone meets either.
2026-08-23 23:43:14 +02:00
Maurice 44ddfa510e fix(client): only give a scheme to something that looks like a host
safeExternalHref prefixed https:// to anything without a scheme, which turns
a note that reads "call the hotel" into a link to a host that does not exist.
A dot and no whitespace is what separates www.hotel.com from free text.
2026-08-23 11:59:12 +02:00
Maurice 7c6a17c020 fix(client): make the new rollbacks roll back only what they wrote
The rollbacks this branch added were snapshot-shaped: capture the whole
collection before the write, restore it on any failure. That is wrong in two
ways at once. It undoes work the server already accepted when a later step is
the one that fails, and it throws away whatever a collaborator's socket event
applied while the requests were in flight.

The day-plan reorder is the clearest case: five awaits, and a failing day-note
write reverted transport positions the server had already stored. It now
records only the fields it is about to overwrite, per booking, and drops each
one as its request lands - so a note failure reaches the catch with nothing to
undo, and a booking that arrived mid-write survives.

The notification toggle had the same shape underneath: the rollback was made
surgical but the request still shipped the whole matrix built from the
pre-toggle render, so two overlapping toggles overwrote each other anyway. It
sends the one cell that changed. The test pinned the disagreement as correct;
it asserts the payload now.

Photo reorder awaits allSettled rather than all, so the UI is not snapped back
while the remaining writes are still landing - a partial run really does leave
the server holding part of the new order, and only a total refusal justifies
restoring the old one. Both the desktop editor and the mobile sheet, which was
still swallowing every rejection.

Also: the geolocation start guard is a run generation now, because a start
issued after a stop re-sets a plain flag while the first run is still parked in
the iOS permission prompt; a category rename reports once for the batch and
rethrows, so the settings dialog stops closing as if it had worked; and a 404'd
import job stops telling the user their file was invalid when it had simply
expired.

The clipboard fallback for plain-HTTP installs was pasted into three files by
this branch. It is one helper now, and the two copy buttons in the members
modal that still called navigator.clipboard unguarded use it too. Same for the
fourth local-date helper written next to the three that already import the
existing one.
2026-08-23 11:59:12 +02:00
Maurice 818021908f fix(client): correct the second pass over this branch's own client fixes
Four of the fixes already on this branch were wrong, and an adversarial read
of the diff is what turned them up.

The shared merge helper claimed to implement the server's
COALESCE(assignment_time, place_time) and did not: it pinned the embedded
times to whatever the assignment already showed, ignoring the override
column entirely. Sharing that with the WS applier traded one bug for
another - a collaborator moving a place from 08:00 to 10:00 stopped reaching
the day card for every assignment WITHOUT an override, which is most of them.
It merges the way the server projects now, and the test that was supposed to
pin this seeded a fixture the server could never produce (embedded times that
differ from the pool row with no override to explain the difference).

The per-iteration auth gate went into every loop in tripSyncManager except
the one that matters most: the mutation-queue replay, which writes server
responses straight into Dexie. After a logout that is the shared anonymous
database. The fire-and-forget tags/categories writes two lines below a gate
added in the same commit were ungated for the same reason - they resolve
later, and the check has to happen then.

safeHttpUrl is backed by placeWebsiteSchema: http(s) only, capped at 500
characters. A booking url is deliberately free-form - shared's own contract
says so in as many words, because people paste bare hosts and long provider
deep links - so guarding it with the stricter schema turned a link somebody
saved last year into dead text. safeExternalHref refuses the schemes that
execute in this origin and gives a scheme-less value https://, which is what
an address bar does with the same input.

And the memories timeout was 20s while the server's own budget for a Synology
call is 30s, doubled again when the session has expired and it re-authenticates:
a slow NAS that answered before this branch would have started reading as
disconnected. Those calls had no client timeout at all before; they have none
again, and the ones that need to give up early carry an AbortSignal.

The notes thumbnail also loses its NUL byte cache separator - it made git treat
the whole file as binary, so a security fix landed in it unreviewable - and
gains the same cache cap its chat sibling got.
2026-08-23 11:59:12 +02:00
Maurice 3e0bfbbb29 fix(client): give the desktop halves the same fixes their mobile twins got
Several of the fixes in this branch landed on a mobile screen while the
desktop component with the identical bug sat in another package and was left
alone, which leaves the two disagreeing about the same handler.

So: the appearance tab flushes its debounced write on unmount, the journey
share copy guards navigator.clipboard (undefined on a plain-HTTP self-hosted
install), the admin collab toggle rolls back from a scoped snapshot instead
of a stale one, the reservation modal parses metadata through the guarded
helper rather than a bare JSON.parse that takes the route down, and the
notification toggle surfaces its failures and stops racing concurrent
toggles.

The Journey components reached the memories provider-status endpoint with a
raw fetch(), past every apiClient interceptor; they go through a small
memoriesApi now. And the server half of the OSM menu_url finding: the client
renders it through safeHttpUrl, so place-enrichment should not be handing a
javascript: value out in the first place.

The share section hides itself when the server says the link is not this
user's to manage, rather than offering a create button that 403s.
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 27be764164 test(client): the coverage that goes with the previous commits
Left out of the commits they belong to by hand, not by intent: the mobile
screen suites, the tripRepo cache-first read behind the startup-destination
fix, and the MSW handlers the stricter unhandled-request setting needs.
2026-08-23 11:59:12 +02:00
Maurice c75e958fc0 fix(client,sdk): the smaller silent failures, and a dead SDK test file
The SDK's parity suite had an unescaped apostrophe in an it() title, so the
file did not parse and vitest reported it as "no tests". Both guards in it
were dead - including the byte-identical egress-policy check the package's own
notes call the worst possible bug in that module to lose. No CI job runs the
SDK suite, so it landed quietly; the next npm publish would have failed on it,
since prepublishOnly runs the tests.

Also in the SDK: the dev preview answered trek:geolocation without checking
the geolocation:read grant, so a plugin whose manifest is missing the
permission works perfectly in dev and is dead once installed. That is the
lenient direction, which is the one that ships broken plugins.

Client side: the background-task poll never dismissed a 404'd import job and
spun forever, "Update All" collapsed its busy flag after the first plugin
settled, the lightbox had no cancellation so a slow signed URL rendered the
wrong image, the plugin geolocation watch was torn down by any unrelated
settings write, manual date entry accepted 31 February, cover previews were
never revoked, and check-all issued one request per packing item.
2026-08-23 11:59:12 +02:00
Maurice b3d8bf08f1 fix(mobile): local dates, revoked object URLs, and writes that said nothing
Four screens derived "today" from toISOString(), which is the UTC calendar
day: an expense filed at 17:00 in Los Angeles was dated tomorrow, a to-do went
overdue eight hours early in Tokyo, and a new journal entry got the wrong day
either side of midnight. All four take the viewer's own calendar day now,
through the helper the planner already had.

Three sheets built preview object URLs in render and never revoked them, so
scrolling a picker leaked one blob per pass. Photo reorder fired its PATCHes
from inside a setState updater, which StrictMode double-invokes.

And several writes failed in silence: the notification toggle, the collab
feature switch rolling back to a stale snapshot, the appearance change whose
debounced write was dropped on unmount so it reverted on the next reload. The
backup-code print page skipped the escaping its desktop twin does, the
clipboard copy threw on a non-HTTPS install, and reservation metadata was
JSON.parsed unguarded in render, which takes the whole route down.
2026-08-23 11:59:12 +02:00
Maurice 19745b7b0d fix(studio): keep "Keep mine" from throwing the document away
After a save conflict, Keep mine set the version and unblocked writes but
never re-queued the local document, and write() had already cleared the
queue before the request. So if the user picked Keep mine and then simply
closed the editor without typing anything else, saveNow() found nothing
pending and did nothing: their afternoon of edits existed only in memory,
the indicator said idle, and the server kept the other version. The exact
outcome the conflict dialog promises to prevent.

Ctrl+Z was intercepted while typing in a text field too. Studio commits text
on blur, so mid-edit the typing is not in the undo history yet: the browser's
own text undo was suppressed and a previous layout change was undone
underneath the open editor instead. Reads as random data loss.

Also: delete no longer removes locked elements, the auto-layout no longer
freezes an empty GPS path when tracks resolve after the one-shot build, the
back-cover tally cannot print "0 places", the map fetch aborts when Studio
closes, and a double Enter in the list-name field stops creating two lists.

The page-pattern gate had quietly stopped covering anything: the Desktop-shim
refactor moved every real container out of the default export the checker
reads, and its regex missed useRef<T>(null) anyway. Widened on both counts,
and the state it immediately found moved into the hooks.
2026-08-23 11:59:12 +02:00
Maurice 7959470802 fix(collab): scope the realtime handlers to the trip, and stop losing writes quietly
The notes and polls WebSocket handlers applied every event they saw without
checking which trip it belonged to, so an event for another trip the user is
a member of landed in the open one. The poll load effect had no cancellation
either, so switching trips fast let the old response win.

What's Next compared trip-local calendar dates against a UTC one while
building the time from the local clock. West of UTC that means the rest of
today disappears from the widget every evening and tomorrow's items are
labelled Today; east of UTC it happens after local midnight instead.

A note website was rendered as a raw href with no scheme check, the same
stored-javascript: shape the planner had. Category rename walked the notes in
a loop with no handling for a partial failure. And the school-holiday region
labels resolved German first for every locale.
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 47a4276e84 fix(client): make the offline core survive a logout and stop clobbering itself
logout() unregistered every sync trigger, and the only place that registers
them is App's mount effect - which never runs again, because logout navigates
through the router rather than reloading. Log out, log back in, go offline,
edit something: it queues and nothing ever flushes it. No online listener, no
visibilitychange, no 30s heartbeat, no WS reconnect hook. Register on auth
success instead; registerSyncTriggers is already idempotent.

The background sync loops only checked the auth gate on entry. Logout swaps
the Dexie handle to the shared anonymous database, so a trip loop or a blob
download still in flight afterwards wrote the logged-out account's itinerary
and documents into it - the database that survives logout and greets the next
person on a shared device. Check the gate per iteration.

And the remote place:updated applier replaced the whole embedded place, while
the local reducer right next to it preserves the assignment's own times. Same
event, two answers, and the remote one wipes a scheduled 09:00-10:30 off the
itinerary when someone else renames the place. One merge helper now, used by
both, so they cannot drift again. The test that should have caught it seeded
null times and asserted only the name.

Also: moving a day note deleted before it created, and restored the note only
locally when the create failed; t() interpolation was corrupted by a '$' in a
parameter value; a 429 upload was treated as permanently failed; and the
geolocation watch was started inside a state updater.
2026-08-23 11:59:12 +02:00
Maurice 39c8a22352 fix(offline): replay queued writes an interrupted flush left behind
A flush marks each row 'syncing' before it sends, and only ever picks up
rows that are 'pending'. Kill the tab mid-request - the OS evicting a PWA
does exactly this - and the row stays 'syncing' forever: it keeps counting
towards the pending badge, but nothing ever retries it, fails it or shows
it as a conflict. Anything queued behind it that depends on it later fails
with 'unresolved temp id'.

Stamp the row when it goes in flight and reclaim it on the next flush. The
axios instance times out at 8s, so a minute is well past anything that
could still be running, and the mutation id doubles as the idempotency key,
so replaying a write the server already applied is answered from its cache.
2026-08-23 11:59:12 +02:00
jubnl e91ad69c83 fix(atlas): visual empty stats state 2026-08-22 18:04:38 +02:00
Maurice bb7c484ca4 feat(notices): add the 4.0.0 release modal
The thank-you modal was one column of markdown for every notice. A release
needs more room than that: what shipped, and why it took what it took.

Adds a `release` block to the notice contract. A notice carrying it renders
through ReleaseNoticeModal instead of the generic body — the release on the
left, the note from the maintainer on the right, both halves driven entirely
by translation keys so the next release only edits the registry entry.

Sizing lives on one CSS variable (--rn-s), so the panel scales as a piece and
the design's proportions hold at every breakpoint.

The thank-you notice gets maxVersion 4.0.0: from there on the release modal
carries the same thank-you and the same two support links, and installs still
on 3.x keep the old one.

All 23 locales.
2026-08-22 15:48:13 +02:00
jubnl 305b113d5f Feat/storage driver (#1949)
* feat(storage): storage key validation, driver contract suite, LocalDriver

* feat(storage): MirrorDriver composite backend

* feat(server): add TREK_UPLOADS_DIR env var for the uploads root

* feat(storage): settings-backed registry, StorageService facade, module wiring

* test(storage): pin src/nest/storage coverage thresholds

* feat(storage): multer upload options factory

* refactor(files): spool trip-file uploads through storage

* refactor(collab): spool note attachments through storage; normalize files/ prefix

* refactor(journey): spool journey media uploads through storage

* refactor(trips): spool trip cover upload through storage

* refactor(places): spool place-image and collection uploads through storage

* refactor(auth): spool avatar upload through storage

* fix(server): consolidate allowed_file_types onto AllowedFileTypesService

* fix(storage): default defParamCharset to utf8 in the upload factory

* test(db): re-point the trailing-migration guard at the trip_files prefix strip

* fix(storage): age-gate the boot spool sweep

* test(files): key FILE-P02 residue checks on upload size

* test(storage): raise src/nest/storage coverage pin

* refactor(platform): serve /uploads routes through storage

* refactor(files): serve trip-file downloads through storage

* refactor(memories): serve photo bytes through storage

* refactor(maps): stream place-photo bytes through storage

* refactor(share): stream shared place-photo bytes through storage

* refactor(journey): serve public journey assets through storage

* fix(share): answer place-photo cache miss with 204 like the maps proxy

* test(server): close serving-swap gaps surfaced by the full tier

* test(storage): raise src/nest/storage branches pin

* refactor(place-photos): route place-photo cache through storage

* refactor(places): reclaim place images and collection covers through storage

* refactor(trips): trip covers through storage

* refactor(auth): avatar reclaim through storage

* refactor(collab): note-file deletion through storage

* refactor(files): trash deletion through storage

* refactor(memories): trek photo cache internals through storage

* refactor(memories): thumbnail service category-addressed

* refactor(memories): immich upload reads through storage; drop uploads-root

* refactor(journey): photo deletion through storage

* fix(memories): reclaim orphaned trek photo cache objects in the ttl sweep

* feat(journey): journey-thumbs orphan sweep and delete-site derived-artifact reclaim

* test(server): close slice-4 gaps surfaced by the full tier

* test(coverage): raise slice-4 domain pins

* refactor(backup): list, existence and delete through storage

* refactor(backup): download through storage sendToResponse

* refactor(backup): create backup through per-category storage enumeration

* refactor(backup): restore and upload-restore through storage

* refactor(backup): auto-backup retention through storage

* refactor(server): boot dir-creation to driver init

* test(server): close slice-5 gaps surfaced by the coverage tier

* test(coverage): raise slice-5 backup pin

* test(server): resolve async backupFileExists mocks for the tests typecheck

* test(server): pin ranged-read stat and zero-byte roundtrip in the storage driver contract

* feat(server): TREK_S3_* env wiring — schema, s3 namespace, all-or-nothing boot rule

* feat(server): S3 driver core — aws-lite client seam, stat/delete, driver-owned deadlines

* refactor(server): type the aws-lite S3 client via s3-types reference

* feat(server): S3 driver getStream and list — ranged full-object stat, iterator pagination

* feat(server): S3 driver put — bounded-peek routing, Upload inactivity deadline

* fix(server): S3 driver Upload monitoring — Transform interposition, stream cleanup

* feat(server): env-declared s3-main storage backend; settings-declared s3 rejected

* test(server): env-gated S3 driver contract suite against MinIO

* fix(server): S3 driver zero-byte put — work around aws4 duplicate Content-Length signing defect

* fix(server): scope the zero-byte put workaround to buffered bodies; bind test MinIO to localhost

* ci(server): MinIO-backed S3 driver contract job

* ci(server): align s3-contract shim comment and MinIO bind with references

* docs: S3 storage backend configuration and off-box backup mirroring

* docs: restore README formatting, keep S3 additions only

* ci(server): make live-MinIO contract runs manual-only (AIStor licensing)

MinIO's community line is discontinued entirely; the maintained successor, AIStor, requires a license key that can't safely live in a shared CI runner. The s3-contract CI job is removed; live-provider contract runs against MinIO/AIStor are now a manual, license-gated procedure documented in docker-compose.minio-test.yml's header.

* chore(server): final-review polish — stale CI refs, dockerignore key, boundary pins, zero-byte tempfile contract case

* expose admin ui

* feat(shared): storage backend type registry, wire schemas, category relocation

* feat(server): accept settings-declared s3 backends — shared-schema options, decrypt-at-build, registry preview()

* feat(server): remove TREK_S3_*/TREK_UPLOADS_DIR — tombstone boot rule, built-in override as the relocation path

* feat(server): deriveSecurity.encryptionKeySet and storage secret plumbing (gate, encrypt, unmask, mask, redact)

* chore(server): drop storage-secrets import that only Task 8's probe helper will use

* feat(server): seed-once storage-config.json boot import — fail closed on any invalid seed

* feat(server): registry snapshot() — effective storage config with provenance

* feat(server): api/admin/storage GET/PUT — masked secrets, preview-gated saves, wired reload, audits

* feat(server): api/admin/storage/test — per-target ephemeral probes, mirror targets probed individually

* test(server): e2e coverage for api/admin/storage — guards, managed refusal, envelope, probes

* test(server): give e2e harness DBs the app_settings table the storage registry reads at boot

* test(coverage): raise pins after the storage admin-config slice

* fix(server): final-review polish — encryption-gate wording, all-field sentinel guard, README/e2e notes

* test(server): storage slice-1 review follow-ups — mirror s3-replica probe decrypt, all-encrypted seed without key, test-route 401

* feat(i18n): storage admin domain + tab label in every locale

* feat(client): adminApi storage endpoints — getStorage, updateStorage, testStorageBackend

* feat(client): storage admin draft-document helpers + relative-time util

* feat(client): schema-driven storage BackendForm — fields by kind, mask echo, encryption banner

* feat(client): admin storage panel — backends list, category map, health strip, one-PUT save

* feat(client): storage tab on the admin page

* feat(client): storage admin panel and section on the phone shell

* fix(client): block renaming a storage backend onto an existing name

* feat(server): allow mirrors on any storage category — lift the backups-only rule

* feat(i18n): replicas-on-primary mirror strings in every locale

* feat(client): mirror fold/synthesize model helpers for replicas-on-primary

* feat(client): BackendForm mirror-targets composer; raw mirror type hidden

* feat(client): desktop storage panel folds mirrors into their primaries

* feat(client): phone storage panel folds mirrors into their primaries

* fix(client): strip removed backends from remaining mirror replicas — no phantom refs

* feat(storage): retire photos as a configurable category — served-only via the ServedCategory seam

* feat(i18n): descriptive storage category names and descriptions in every locale

* feat(client): descriptive category names, descriptions and id badges in the storage panel

* docs(storage): fix stale nine-category comments after the eight-category shrink

* docs: storage configuration via the admin UI and seed file — README + compose

* docs(wiki): Admin Storage page and storage cross-links

* docs(server): storage domain blueprint entry; TREK_PLACE_PHOTO_DIR tombstone clarifier

* refactor(server): drop the storage env-var tombstones — the variables never shipped

* docs: storage config is simply new in v4 — drop the removed-variables story

* feat(shared): backfill-status and usage wire schemas on the storage admin state

* feat(server): MirrorDriver.backfill — prefix-scoped, size-compared, cancellable replica catch-up

* feat(server): one-at-a-time storage backfill jobs + the storage events bridge

* feat(server): storage usage scan — nightly cron, on-demand service, persisted with computedAt

* feat(server): replica_failure admin notifications — events bridge, hourly debounce, channel registration

* feat(i18n): replica-failure notification text in every locale

* feat(server): storage backfill and stats-refresh admin endpoints

* feat(client): adminApi backfill and stats-refresh endpoints

* feat(i18n): storage sync and usage strings in every locale

* feat(client): desktop storage panel — sync now, usage display, backfill polling

* feat(client): phone storage panel — sync now, usage display, backfill polling

* fix(client): per-category usage line on the phone storage panel

* docs: sync now, usage stats and replica-failure notifications

* test(server): admin scope now includes replica_failure, not just version_available

Commit 6db135da added replica_failure to ADMIN_SCOPED_EVENTS but left
NPREF-005b asserting the admin scope contained only version_available.
Update the expectation to match.

* test(server): cover storage backfill/stats/jobs branches to hold the coverage pin

* fix(client): guard storage admin polling against stale responses racing a save

A poll GET issued before a save's PUT resolves could land after the save
applied its fresher response, overwriting it with the pre-save world and
re-deriving the draft from that stale snapshot. useStorageAdmin now tracks
a state-application sequence number and drops any refreshState() response
whose sequence has moved on, or that resolves while a save is in flight.
Also guard the fire-and-forget refreshState() calls in startBackfill/
cancelBackfill against unhandled rejections, matching the poll's style.

* fix(server): report 404 before 409 when a backfill start names an unknown mirror

startBackfill checked the global busy flag before validating that :name is
a mirror routed by some category, so POST .../backends/garbage/backfill
answered 409 instead of 404 whenever a sync was already running. Resolve
and validate the target first (BackfillTargetError -> 404), then check
busy (BackfillBusyError -> 409).

* feat(client): two-column category grid on the storage panel

* feat(server): plugin files.getContent reads through the storage layer

* feat(server): plugin files.create writes through the storage layer

* refactor(server): drop FilesService.resolveFilePath, last direct-fs seam in the plugin surface

* fix(server): map StorageInvalidKeyError to the plugin file accessibility envelope

* feat(storage): allow credentialed backends without an explicit ENCRYPTION_KEY

Secrets are still always encrypted at rest; without an explicit key the
implicit data-directory key is used. Removes the plaintext-secret refusal
from the admin PUT and the seed import, the encryptionReady GET field, the
save-blocking banner in both admin shells, and the now-consumerless
security.encryptionKeySet env derivation. Docs now recommend (not require)
an explicit key, since the implicit key rides inside backup archives.

* test(client): pin the stale-poll release to the pre-save GET in FE-ADMIN-STOR-024

The stub deferred EVERY poll GET and rebound the release handle to the
newest one, so a tick landing after the save was released with the
pre-save payload — a response no real backend can produce, which the seq
guard rightly applied. Only the first (genuinely stale) GET is held now;
later GETs answer with the world the mock server currently holds. Also
drops the encryptionReady fixture field removed from the schema in the
previous commit.

* fix(storage): backfill puts carry content-type, stat failures report op stat, progress clamps

* feat(storage): category migration job — copy, flip, delta sweep, reclaimable tally

* feat(storage): migration start/cancel endpoints and client API

* test(client): storage admin fixtures carry the new migrations field

* fix(storage): migration POST answers 200 and validates its body via the shared schema

* fix(storage): cancel running storage jobs when a config save removes their backend

* fix(notifications): in-app replica-failure row carries the suppressed count

* feat(client): migration prompt, queue, and progress on the storage panel

* fix(client): migration queue holds a synchronous single-flight lock

* feat(client): migration prompt and progress on the mobile storage panel

* fix(server): register category-migration routes in the managed-route allow list

StorageAdminController.migrationStart/migrationCancel were added under the
controller-level @ManagedForbidden() decorator but never added to
MANAGED_ROUTE_ALLOW_LIST, so validateManagedRoutes() threw "Managed surface
changed" on every buildApp() call, failing 46 test files (any test booting
the real app).

* test(server): cover category-migration copy/sweep branches to hold the storage coverage pin

Adds MIG-005 (a target object already matching size/key is skipped in the
copy phase itself, not just the delta sweep) and MIG-006 (a copy failure
during the delta sweep is counted without undoing the flip that already
happened). src/nest/storage/**'s branch coverage was 83.91%, just under the
84% pin.

* docs(storage): category migration — move existing objects on reassign

* fix(storage): close the pre-flip cancel window and cancel re-routed migrations

A cancel landing between the last copy iteration and the category flip was
silently ignored, and a config save that re-routed the migrating category to
a third, still-defined backend never stopped the in-flight migration. Also
guards assignCategory against writing an unknown backend name, and fixes a
missing `suppressed` notification param being treated as truthy suppression.

* fix(client): migrations keep mirrors, queue waits on backfills, sweep failures surface

Reassigning a category onto a mirrored primary now posts the migration to the
mirror's wire name instead of the bare primary, so replication survives the
move. The queued-migration effect also waits out a running backfill (the
server's one-job rule spans both), and a done migration with sweep failures
now surfaces a doneFailures line in both shells, translated into every locale.

* ci(server): run the S3 contract suite against MinIO

Resurrects the s3-contract job removed in 6fbff835, adapted for AIStor:
writes the MINIO_LICENSE_KEY secret to a license file, boots the licensed
container, and runs the contract suite hermetically (no --coverage) so the
storage coverage pin keeps holding from unit tests alone. Gated to same-repo
PRs since fork PRs have no access to the license secret.

* fix(server): backup archives uploads from a remote primary

createBackup() enumerated uploads through withLocalFile, which downloads a
remote object to a temp file, hands it to the callback, then deletes it
before archiver ever reads it — archiver reads entries lazily during
finalize(), so a remote (S3) primary silently backed up zero upload bytes.

Add StorageService.getLocalPathOrNull(category, name): a narrow locality
probe that returns a real path only when one exists on disk right now (the
fs-existence check is the fail-safe half of the contract — a driver-reported
path that vanished between list() and here falls back too, not just a
path-less remote driver). The enumeration loop uses it directly when
available (zero-copy, unchanged for a local primary) and otherwise streams
the object into a per-backup staging dir under the backups spool, cleaned up
in the existing finally alongside zipSpool/dbSnap.

Also wire archive.on('warning', reject): archiver treats a missing/unreadable
entry as a warning, not an error, and skips it by default — which would
silently drop the entry from an otherwise "successful" backup.

* fix(server): client aborts stop 500ing and leaking streams

storage.service.ts sendToResponse: the local res.sendFile branch now treats
ECONNABORTED/ECONNRESET/ERR_STREAM_PREMATURE_CLOSE/syscall=write as success,
mirroring res.sendFile's own default callback (the contract
storageStaticHandler already implements caller-side). The remote branch
replaces the hand-rolled pipe+Promise with pipeline(stream, res), which
destroys the source stream on any failure — fixing the leaked S3 keep-alive
socket on a client abort — while swallowing the same client-abort error set;
a real source error still rejects so callers keep their miss contracts.

This centralizes the fix for every caller that streams through
sendToResponse: files-download, journey-public, photo-resolver,
trek-photo-cache, backup downloads, and /uploads/photos.

trek-exception.filter.ts now no-ops (destroying the socket) when headers are
already sent, so no filter path ever writes onto a response in flight.

maps.controller.ts and share.controller.ts's place-photo bytes proxies
switch their direct getStream+pipe to pipeline() with the same abort-tolerant
catch, keeping the existing 204-empty fallback for pre-header failures.

* fix(storage): mirror read fallback consults replicas on primary miss

MirrorDriver.getStream/stat now fall back to replicas on a primary miss
(StorageNotFoundError / null stat), not just an infra error — binding
decision: a read succeeds if ANY member holds the object. Only a
deterministic StorageInvalidKeyError skips fallback.

stat() gets a dedicated fallback loop instead of reusing fromReplicas:
a replica returning null means "keep looking," which the generic
catch-and-continue helper would have short-circuited on.

StorageService.sendToResponse/withLocalFile stop treating composite
driver.stat() as proof the object is at localPath; both now fs-check
the path itself and fall through to the stream branch when it's
missing, which is what actually reaches a MirrorDriver's replicas.

* feat(storage): remote serving parity — content-type, validators, range

Local backends get content type, ETag/Last-Modified, conditional GET, Range
and HEAD for free from res.sendFile; the proxying branch every remote
backend (and a local primary whose file vanished) falls through to had none
of it — every request re-streamed the whole object with no cache validator
and no way to seek.

sendStreamed() reproduces the contract from ONE pre-flight driver.stat():
a miss throws before any header is set (the caller's 404/204/next()
contract), and that same stat then answers 304s, 416s, HEADs and suffix
ranges without ever opening a body. The decisions live in a new pure module,
http-serving.ts:

- validatorsFor: the driver's ETag, else express/send's weak
  W/"<size hex>-<mtime hex>". mtimeMs 0 (S3 can report it) means no
  Last-Modified and no derived tag — serving uncached beats a 1970 header
  that reads as fresh forever.
- isNotModified: If-None-Match (weak compare, *, list) wins over
  If-Modified-Since, per RFC 9110 §13.2.2.
- decideRange: single range only, inclusive clamp, suffix computed from the
  pre-stat size, unsatisfiable → 416, If-Range strong-exact or full entity.
  A multi-range or malformed spec is answered with the full object rather
  than multipart/byteranges. If-Match/If-Unmodified-Since stay out of scope.

Range math uses the pre-stat size as the single source of truth, the ranged
getStream stat is never re-consulted, and the pipeline abort semantics are
unchanged.

S3Driver now surfaces the bucket's ETag on stat()/getStream() (on a ranged
GET it is still the full object's tag). aws-lite strips the tag's
surrounding quotes — verified live against AIStor RELEASE.2026-08-07 — and
an unquoted entity-tag is not a valid HTTP ETag, so the driver restores
exactly the quotes the SDK removed and nothing else.

* fix(server): key rotation covers storage.backends

The S3 storage backend's secretAccessKey is encrypted at rest inside the
app_settings row keyed storage.backends, but scripts/migrate-encryption.ts
didn't know about it — after a key rotation that secret stayed encrypted
under the old key and silently failed to decrypt on the next read.

The script now re-encrypts secretAccessKey for every s3 backend inside the
storage.backends JSON array, using a pinned copy of the secret-field list
(it deliberately can't import @trek/shared). The parity test pins that copy
against storageSecretFields('s3') so a new secret field can't be missed
silently.

* fix(server): restore reloads the storage registry

restoreFromZip closed and reopened the DB but never reloaded the
storage registry, so uploads rehydration ran against the STALE
pre-restore driver/category map instead of the config the restored
archive's own travel.db carries (audit #4, major).

StorageService gains reloadConfig(), a narrow passthrough to the
(unexported) registry's reload() — same shape as health(). backup.impl's
restoreFromZip now calls it right after reinitialize() and right before
the uploads wipe/rehydrateUploads/plugin staging, so rehydrated bytes
land where the restored config says. When reopen fails (reinitFailed),
reload and rehydration are skipped entirely — already reported as
"restart required", and there is no live DB handle for the registry to
read anyway.

* fix(server): category migration rewrites keys across prefix changes

Category migration copied objects to the destination backend using the
SOURCE key verbatim. A mode-A ('' prefix) source migrating onto a
prefixed backend (or vice versa) landed objects at keys the registry
could never resolve post-flip (audit #8).

Extracts StorageRegistryService.keyPrefixFor(category, backendName) from
build()'s inline conditional so startMigration can compute the
destination prefix before the flip. runMigration now maps every
destination key (destPrefix + key.slice(sourcePrefix.length)) in the
copy skip-check, the put, and the delta sweep; the reclaimable tally
stays source-keyed. Equal-prefix migrations are unaffected (identity
mapping).

* fix(plugins): await the now-async place/collection services in RPCs

PlacesService.update/remove and CollectionsService.deletePlace became
async when they started deleting storage objects, but their plugin RPC
handlers still called them synchronously: the null/false refusal check
compared against an always-truthy Promise, and places.update broadcast
a Promise instead of the resolved place. Both handlers now await the
service call; mapCollectionError is async-aware (accepts a sync-or-
async thunk) so the sync callers (create/update/savePlace/copyToTrip)
keep working through the same path. Verified the RPC host already
awaits handler results, so no change was needed there.

* fix(storage): backfill Sync now gains a deletion sweep

The Sync now backfill copied primary objects to replicas but never removed
replica objects the primary no longer has, leaving mirror read-fallback able
to serve stale data indefinitely after a failed replica delete. backfill()
now sweeps each replica post-copy: any replica key absent from the primary
key-set is re-checked against primary.stat (a race gate against the primary
regaining the key) before being deleted. Adds a deleted counter end-to-end
(driver, jobs service, shared schema, both admin shells, all 23 locales) and
a new ReplicaFailure 'list' op for a replica whose listing itself fails
during the sweep.

* feat(storage): optimistic concurrency for admin config saves

A stale admin PUT of storage settings could silently undo a category
migration's just-flipped assignment. Adds a version counter
(app_settings row storage.config_version) bumped inside the same
transaction by both writers — applyConfig and the migration flip
(assignCategory). applyConfig compares the submitted version first
and throws StorageConflictError, mapped to 409 ahead of the blanket
400. The client carries the draft's loaded version through every
local edit and maps a 409 to a distinct "settings changed underneath
you" message, refreshing state while preserving the dirty draft for
review.

* fix(storage): surface a failed config load + redact secretAccessKey

Registry load() now records the exact parse/validate failure as
lastLoadError() instead of silently falling back to last-good config or
built-in defaults; StorageAdminService exposes it as configError on the
admin state (storageAdminStateSchema extended additively), and
AdminStoragePanel renders it as a warning banner naming the error. Save
stays enabled — it's the recovery path — but an admin can no longer save
over a broken stored config without knowing.

Also adds 'secretaccesskey' to globalMiddleware's SENSITIVE_KEYS request-log
redaction list, so storage admin PUT bodies never leak the S3 secret into
debug logs.

* fix(client): admin storage panel state races

Migration prompt now recomputes computeMigrationCandidates(draft, state)
on render and again at confirm time, so a stale open-time snapshot never
drives stripCategories or the migration queue; adds a Cancel button to
the dialog. A failed startMigration POST explicitly clears the remaining
queue with a toast naming the dropped categories and refreshes state,
instead of leaving it invisibly stranded; a "Queued: X, Y" line renders
while the queue is non-empty. Testing a mirrored row now expands the
draft primary + replicas client-side and probes each individually,
instead of sending the mirror stub to the server (which resolved
targets from its own live snapshot, silently testing saved options over
unsaved draft edits). A completed/cancelled/errored backfill now renders
its terminal status alongside the Sync now button instead of hiding it,
so it can be re-run without a page reload.

* fix(storage): refuse shared replica backends so the sync sweep cannot cross categories

A mirror's "Sync now" sweep makes each replica MATCH the primary: it lists the
replica under every prefix the mirror's categories occupy and deletes the keys
the primary doesn't hold. The backups category's prefix is '' — the replica's
entire root — so making a category-serving backend (say uploads-local) a
replica of a backups mirror meant the first sync deleted every user upload.
Nothing forbade the config: validateConfig only checked that mirror targets
existed and weren't nested, and the admin picker offered every backend.

The sweep is correct, so the fix is at the config level:

- validateConfig refuses a backend that is both a mirror replica and a
  category target (directly, or as the primary of a mirror a category routes
  to), and refuses a backend replicating two mirrors whose swept prefixes
  overlap — '' overlaps everything. Disjoint prefixes stay legal.
- assignCategory (the migration flip, the other writer of the settings rows)
  refuses routing a category onto a backend that is currently a replica.
- The admin mirror-target picker (both shells) only offers backends that serve
  no category; already-selected targets stay listed so they can be unchecked.
  A category assigned to a replica still surfaces the server's 400 verbatim.

Existing shared-replica fixtures were rewritten onto dedicated replicas.

* fix(client): a 409 conflict save is recoverable without a page reload

After a 409 the admin storage panel was a dead end. refreshState leaves a
dirty draft's version alone on purpose (audit #7: the background poll must
never float a mid-edit draft past a migration flip it never carried), setDraft
re-attaches that same version to every later edit, and save() resubmits it —
so every retry 409s again and only a full page reload got the operator out,
while the banner told them to "review and save again".

The version block stays. Instead the conflict banner now offers the one action
that can clear it: discard the local edits and reload the saved settings —
a fresh GET applied through applyState, re-deriving the draft (and its
version) and clearing dirty/saveError. Bumping the version in refreshState,
or only in the 409 branch, was rejected: either resurrects audit #7, since a
full-document save would then revert a flip the draft never carried.

storage.saveConflict now points at that action instead of implying a plain
re-save works (all 23 locales). The escape lives in the shared hook, so the
phone panel renders it too.

* fix(client): deflake the queued-migration-waits-out-backfill panel test

FE-ADMIN-STOR-037 seeds a running backfill, so the panel polls GET from
first render — but the stub served the post-save world (files: off-box)
before the PUT had landed, which no real server would do. A poll
resolving while the confirm dialog was open made the confirm-time
candidate recompute see from === to, so nothing was queued and the
migration never POSTed.

The GET stub now stays faithful (pre-save world until the PUT lands,
usage included so candidates keep their object counts), and the test
deterministically forces the once-flaky interleaving: it waits for
polls to land while the dialog is open and asserts the candidate
survives them.

* fix(storage): surface the underlying cause in probe target errors

The admin storage Test probe reported only the driver wrapper's outer
message ("put failed for '…' on 'probe-s3'"), dropping the cause that
says WHY — connection refused vs missing bucket. Flatten the error's
cause chain into the probe result, appending an S3-style code when it
isn't already in the message.

* fix(storage): surface the underlying cause in replica-failure reports

Same defect as the probe fix, one layer over: MirrorDriver's
reportReplicaFailure captured only the wrapper's message, so
replica-failure notifications said "put failed" without the why.
Move the probe's cause-chain flattener into storage.types.ts as
describeError and use it in both call sites.

* fix(storage): strip ContentType from multipart Upload calls (MalformedXML)

Every multipart S3 put carrying a contentType — every backup zip and
photo over the 10MB threshold — failed with 400 MalformedXML. Upstream
@aws-lite/s3's Upload leaks the ContentType param into its final
CompleteMultipartUpload headers ({ ...xml, ...paramHeaders }), clobbering
application/xml, which flips @aws-lite/client's payload serializer to
JSON — so the server receives a JSON parts list where XML belongs.
Confirmed live against MinIO AIStor; any S3 refuses the same.

uploadMonitored now drops ContentType before s3.Upload. Multipart
objects are stored without a content-type, which TREK never reads back
(serving derives Content-Type from the key/caller, never object
metadata); single-request PutObject paths keep sending it.

Also closes the coverage gap that let this ship: the live contract
suite never crossed MULTIPART_THRESHOLD, so s3.Upload had never run
against a real server — it now round-trips a >10MB put carrying a
contentType.

---------

Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-08-22 11:33:55 +02:00
Maurice Böe 942e1932c9 fix(budget): name the category parking and file parking bookings in it
The key was 'parkings', which is not a word and does not match the 'parking'
the reservation type uses everywhere else. The shared trip page prints the
stored category verbatim, so a share link would have shown "parkings" in every
language.

The category also had no relationship to the booking type of the same name:
RESERVATION_TYPE_TO_COST_CATEGORY still sent a parking booking's auto-expense
to Transport, so the new bucket sat empty next to the costs that belong in it
while a manually typed "parking" resolved into it. Both paths agree now.
2026-08-21 14:28:53 +02:00
MikeDabrowski 999236f944 feat(budget): add "Fuel" and "Parking" expense categories
- Adds two new fixed budget categories: fuel ("Fuel") and parkings
  ("Parking"), alongside the existing set (Accommodation, Food & drink,
  Groceries, Transport, Flights, Activities, Sightseeing, Shopping,
  Fees & tickets, Health, Tips, Other).
- Icon/color metadata added for both (Fuel and ParkingCircle from
  lucide-react), plus legacy free-text aliases (gas/petrol->fuel,
  parking->parkings) for existing/imported expense rows.
- Label strings added for all 23 supported locales.
- Adds test coverage for the new categories and legacy alias mapping.
2026-08-21 14:28:53 +02:00
Maurice 875f87b885 fix(planner): open the booking a place is linked to
The place inspector summarises the booking attached to the day assignment and
then went nowhere: the strip is a bare div with no handler, no role and no
tabIndex, and PlaceInspector was never handed an editor callback in the first
place. Reached from a map marker, that strip is the only route a restaurant or
tour booking has — only transports with two positioned endpoints get map pins of
their own — so its fields and its attachments were unreachable.

The identical lookup in the day sidebar has always carried a pencil that picks
the editor by type. The inspector now takes the same onEditTransport /
onEditReservation pair and does the same: a ferry, a flight, a train opens the
transport form, everything else the reservation modal. That split matters —
ReservationModal has no transport entry in its type list, so a ferry sent
through it would have its type re-picked on save.

Each handler arrives as undefined when the user lacks the matching right, which
is what keeps the strip a read-only summary instead of turning it into a button
that does nothing. Gating inside the handler would have re-shipped the reported
symptom with a label claiming otherwise.

The phone place sheet never rendered the strip at all, so the same booking was
just as unreachable there, only invisibly. It shows it now and opens the same
two editors, closing itself first — it hangs off the place selection rather than
the sheet stack, so closeSheet() would leave the editor stacked on top of it.

Closes #2012
2026-08-21 14:19:28 +02:00
Maurice a23a81f35b fix(studio): the spread import points inward too
Missed in the sweep: "Import spread" opens a JSON file picker and wore the
outgoing arrow like the rest of them.

Refs #2006
2026-08-21 13:27:27 +02:00
Maurice 331954c1b0 feat(mobile): drag to reorder the day plan, and reach the day sheet from any day
Reordering a day on the phone meant two 15px arrows, while the same list on the
web takes a drag. It takes one here too now, via the long-press bridge tablets
already use (#1616): it watches only draggable rows inside an opted-in
container, waits out a press before claiming the gesture — so a swipe stays a
scroll — and replays real drag events, which is why the rows only needed the
receiving half. Armed in edit mode and on coarse pointers alone, so go-mode taps
and map pans are untouched and the polyfill retired in #1432 stays retired. The
arrows stay: they are the accessible path.

Both paths go through one mover, so the chronology guard and the undo entry are
the same either way.

The card header was also the only route into the day sheet, and it rendered only
when the day happened to have an accommodation or a weather reading — with the
accommodation chip as the sole tap target. A day with neither had no way in.
A day pill now leads that row unconditionally, the weather reading is a target
too, and the active day chip carries a chevron so the second tap that opens the
sheet is visible in map view as well.

Closes #1997
Closes #2004
2026-08-21 13:27:27 +02:00
Maurice 2d6c8c5157 fix(mobile): open the stay when you tap the stay
Tapping the accommodation in the day sheet opened the place it is pinned to, so
check-in, check-out and the confirmation code had no way in at all. The sheet
that edits them has taken an accId since the mobile rewrite; nothing ever passed
one.

The row is a real button now and opens that editor, with the place kept one tap
to its right for anyone who wants it. Viewers without day_edit keep the place as
their target, since the editor is no use to them. The linked hotel booking below
opens too — the day's booking list filters hotels out, so that strip was the only
place it showed and it did nothing.

Closes #2001
2026-08-21 13:27:27 +02:00
Maurice da92581a16 fix(planner): let a route choice survive the map, and keep new items on their day
Two things the mobile planner forgot.

The day sheet's route toggle only ever lived in component state, and entering
the map — or tapping a day while in it — forced it back on. Turning the route
off therefore did nothing you could see and had reset itself by the time you
looked again. The choice is now remembered per trip; the map still switches it
on by default, but only while the user has never said otherwise.

Adding a place or a booking from inside a day did not connect it to that day.
The place dropped into the unplanned pool, which on mobile is a different screen
— it looks like the save was lost — and the booking opened with no date at all.
Both entry points now carry the day they were opened from, including a
long-press on the mobile map, where the chip rail names a day at all times. The
desktop map has no such context and passes nothing, so its pool behaviour stands.

While here: the import button on the bookings and transports headers only
appeared when kitinerary was installed, though the server also accepts an import
when the LLM parser is configured (#2007).

Closes #2003
Closes #1998
2026-08-21 13:27:27 +02:00
Maurice 8b88986ca8 fix(bookings): make the reservation import findable on the desktop panel
The import is not missing from the desktop — it sits at the far right of the
bookings toolbar, next to the accent "Manual Booking", above an empty page with
nothing but the mascot on it. On a fresh trip that is exactly where nobody
looks, which is how it reads as absent.

The empty state now offers the ways in that apply: add manually, import from a
file, and AirTrail where it is connected. EmptyState grows an optional action
slot for it, so every other caller is untouched.

Closes #2007
2026-08-21 13:27:27 +02:00
Maurice 9094f05604 fix(planner): close the accommodation loop when no times are recorded
With "optimize route from accommodation" on, the drawn route still started at
the first activity on any arrival day and stopped at the last one on any
departure day. The two bookend rules ask whether you can be proven to have been
at the hotel already — but they read a missing check-in time as proof against,
and the hotel picker leaves that field blank by default. So the loop hung open
at one end on every boundary day, and at both ends for a stay booked on a single
day.

With no time recorded there is no bar to clear, so the leg is drawn. Where a
time *is* set the existing rules are untouched: a stop before check-in still
draws nothing (#1465), and a transport arrival is still not a hotel departure
(#1321).

Closes #2009
2026-08-21 13:27:27 +02:00
Maurice 5e78069223 fix(journey): saving a suggestion turns it into an entry
A trip visit shows up in the journey as a suggestion, and opening one puts you
in the entry editor. Pressing Save there only promoted it when a story, a photo
or a linked photo had been added — a title, a mood, the weather or a set of tags
all left it a suggestion, and so did saving it untouched. From the outside that
looks like the save did nothing.

An explicit Save is the user saying this is their entry now. handleSave only
ever runs from the Save button, so there is no autosave to misread as intent.

Closes #2008
2026-08-21 13:27:27 +02:00
Maurice df139a2d96 fix(ui): point import controls inward and give Google Maps its own mark
Every import button in the client wore an outgoing arrow, so the control that
pulls a booking or a place list in looked like it was about to send something
out. The places-import sheet managed to contradict itself inside one screen:
Download in the header, Upload on the row below it, Upload again on the file
step. Imports now take Download, or FileDown where the button opens a file
picker; genuine uploads and the desktop drop zones keep Upload.

The mobile places-import entry point wore an ellipsis, which promises a menu.

The "open in Google Maps" buttons drew the four-segment Google *search* G,
flattened to one colour — a glyph that reads as "run a search". They get a map
pin instead, factored into one component so the desktop and mobile copies stop
drifting apart.

Closes #2005
Closes #2006
2026-08-21 13:27:27 +02:00
Maurice ec3017f9cc fix(ui): keep dropdown panels anchored to their trigger
The three fixed-position popovers computed top/left inline while rendering,
which reads the trigger's rect exactly once. Scroll a sheet afterwards and the
trigger walks off while the panel stays nailed to the viewport; on iOS the
keyboard slides up over a panel that was placed before it existed.

useAnchoredPosition measures in a layout effect and re-measures on scroll
(capture phase, so the sheets' own scroll containers count), on resize and on
visualViewport changes. It also reports how much room is actually left, so a
list opened next to the keyboard shrinks instead of hiding underneath it.

CustomDateTimePicker clamps a fixed 268x360 calendar horizontally, which no
generic box can express, so it takes the bare re-measure signal instead.

Closes #1999
Closes #2000
2026-08-21 13:27:27 +02:00
Maurice 9e13791239 fix(booking-import): geocode transport endpoints on the path clients use
An imported train, bus or rental car showed its pick-up and drop-off in the
review step and then had them gone from the saved booking: no pins, no route,
no error. reservation_endpoints.lat and lng are NOT NULL, so saveEndpoints
drops an endpoint without coordinates, and kitinerary and the LLM name
stations, stops, terminals and rental desks without geo-locating them. Only
airports arrive with coordinates, from the mapper's own table.

The lookup that was meant to fill them in existed — in confirm(), which no
client calls. Both the desktop planner and mobile go preview, review form,
ordinary save, so it never ran.

It runs in preview() now, through a helper confirm() shares, on the throttled
background lane whose own docstring names booking-import as its intended
caller. The query ladder matches the venue lookup beside it: the endpoint plus
the booking's location first, then the address, then the bare name — name-only
is exactly what fails for a desk label like "Curbside Pickup Counter 7", so it
goes last. Answers are cached per preview, since a multi-leg train repeats its
stations and the lane allows about one request a second.

An endpoint that still cannot be placed stays on the item rather than being
filtered, so the review form can show and edit it, and the preview says which
name it could not locate. That warning needed the widget to change too: it
rendered warnings only when a job found nothing at all, which is precisely
the case this is not.

Reported with the full chain, file by file.

Closes #1969
2026-08-20 18:22:29 +02:00
Maurice f574e75dbe fix(pdf): print a day in the order the plan shows it
The export ordered each day by itself, on day_plan_position — one global
number per booking, seeded automatically from the place count of whichever
day happened to render first. On any other day of a span it therefore pointed
at the wrong slot, and where it was still null the export fell back to the
order the API returned rows in, which is reservation_time ASC with nulls
first.

That is why the same trip printed correctly some of the time and swapped two
bookings the rest of it, and why the reporter's workaround worked: adding a
place and removing it again reseeded the position from the day they were
looking at.

The plan has never used that column. It picks transports per day, gives each
one the time that day shows, inserts them among the places and re-sorts the
whole day by the clock — and none of that was reachable from here, because
this file had its own copy of the ordering with subtly different rules.

So the copy is gone and the export goes through utils/dayMerge, the same
functions the plan uses. Two differences fall out of that, both of them the
plan's existing behaviour: a booking attached to a place is no longer printed
twice, and a multi-leg journey prints one row per leg at each leg's own time
rather than one row carrying every leg as a subtitle. The per-segment booking
code #1943 added is read back from the booking, since the leg object does not
carry it.

Closes #1978
2026-08-20 18:08:06 +02:00
Maurice f76ee87336 fix(nav): stop the tab pill from running under the logo and the user menu
The centred tab menu was absolutely positioned, so it had no relationship to
what sat beside it. Its width grows with every enabled addon and every page
plugin, and once it outgrew the free space in the middle of the bar it simply
ran underneath the logo on one side and the bell and user menu on the other.

The only adaptation was a hard-coded 1024px breakpoint that drops the tab
labels — a constant chosen when two or three addons was the whole story, and
one that cannot know a plugin has added anything.

So the bar is three columns in the flow now: the brand block and the action
cluster carry equal weight either side, and the pill sits between them. That
keeps it centred on the bar as before, and makes overlap something that
cannot happen rather than something a breakpoint has to predict. With
min-w-0 the pill gives way and scrolls when even it runs out of room,
instead of pushing the actions off the edge.

Measuring the pill against the free space and folding the overflow into a
menu was the other option. It buys a tidier look at a narrow width and costs
a ResizeObserver on every desktop page, a cache to keep the measurement from
oscillating against the mode it chose, and a new string in 23 locales. Not
for a fix to overlapping.

Closes #1983
2026-08-20 18:08:00 +02:00
Maurice bb0d65bb19 fix(map): read the last fitBounds call without Array.at
The client's tsconfig targets a lib without Array.prototype.at, so the two
new assertions typechecked locally against the test runner but not against
tsc. Index arithmetic instead.
2026-08-20 18:07:54 +02:00
Maurice 7aead0b713 fix(map): stop the day fit from framing everything and then drifting off it
Selecting a day compensated for the day panel twice: the fit reserves the
panel's height in its bottom padding, and then 300ms later the camera was
nudged again with panBy([0, 150]). Both came from the same change, and
together they moved the view 150px further than intended.

Wide days never showed it. A day that runs north to south does: the fit is
height-bound there, so the northernmost stop sits exactly on the top padding
line, and the nudge then pushes it off the canvas. What the user sees is the
map framing the whole day correctly and then sliding upwards, leaving the
first marker outside the view.

The padding alone is right, and it is what MapViewGL has always done with the
same numbers, so this also brings the two renderers back into agreement.

Closes #1982
2026-08-20 18:07:54 +02:00
Maurice d0cc4e2f00 fix(planner): give the day's optimize button room by dropping its label
Every button in the day's route-tools row hands the day somewhere: show the
route, open it in Google Maps, open it in CoMaps, reorder it. Two were
already icon-only. Optimize carried a label on flex: 1 with padding '6px 0',
which is no horizontal padding at all — survivable until CoMaps added a fifth
button, at which point the text sat hard against both edges of its own
button.

Icon-only now, matching the two beside it. The label stays as the accessible
name and the tooltip, so nothing about reaching the action changes, and the
row has room for whatever gets added to it next.

Closes #1981
2026-08-20 17:51:33 +02:00
Maurice 9522e4ce4a fix(budget): keep an uneven split from corrupting the expense total
163.21 across two people is 81.61 and 81.60. Both are exact to the cent — the
client splits properly — but the server discarded the total it was sent and
re-derived it by adding those two as doubles, which lands on
163.20999999999998. That is the number that went into the row.

It looked like a display quirk because everything routed through Intl came
out right; only the places that print a number as it stands showed it. The
stored value was wrong all along, which is why reopening the expense showed
it back, and why the price stamped onto a linked booking carried it too.

The domain already had this rule and followed it everywhere else — the
settlement maths converts to whole cents before it adds anything. The payer
sum was the one arithmetic still done in euros, in both the create and the
update path, so it now goes through the same rule.

The client side is what makes rows that are already stored wrong stop
looking wrong: the expense form and the mobile cost sheet seed their total
from a cent-clean value, and the three panels that print a booking's price
verbatim tidy it when it parses as a number. Free text an import wrote there
passes through untouched rather than becoming NaN.

Closes #1964
2026-08-20 17:44:21 +02:00
Maurice 68bf5316ad fix(planner): stop an empty reservation event from breaking a trip for good
reservation:created and reservation:updated come in two shapes. Normally they
carry the reservation; the accommodation cascade sends them empty, as a
"something changed, go and look" ping that the event contract allows for.

The applier read the id off both. With no reservations loaded yet the
existence check never ran its callback, so the cast slipped through and the
store became [undefined]. From there every render that walked the list threw
"Cannot read properties of undefined (reading 'endpoints')" into the route
error boundary, and since the store is rebuilt from the same events, the trip
stayed unreachable in that browser. The reporter could only get out of it by
clearing localStorage.

Both appliers now decline an event with nothing on it, and the Dexie writer
does too rather than leaning on a swallowed catch. The predicate that walks
the list is total as well: it guarded the property but not the element, and
it is handed straight to Array.filter over a list it does not own.

Declining the empty ping would have left a hotel booked by one member
invisible to the others until they reloaded, so the ping is honoured the way
day:reordered already honours its own — fetch the authoritative list.

The server keeps sending what it sends. Making the cascade carry the full
reservation would mean moving the REST, MCP and RPC paths together with
their pinned tests, and would still leave the client fragile against the
next emitter that does this.

Closes #1979
2026-08-20 17:37:54 +02:00
Maurice 8f171453d3 fix(packing): keep a private item off the other members' screens over MCP
A restricted packing item, changed through an MCP tool, went to every socket
in the trip room. Ticking something off your own list through an assistant
pushed that row to every other member.

The REST and RPC surfaces have scoped these events since #858 — both go
through emitToViewers, which delivers to the owner and the recipients and
nobody else. The MCP tools called safeBroadcast, which took three arguments
and had no way to say who may see the thing that changed.

So safeBroadcast learned that fourth argument, and the packing tools pass
viewersOf(item), which is the same answer the REST route asks for. A shared
item still answers null and still goes to the room in a three-argument call,
so nothing about it changes — including what the hundred-odd broadcast mocks
in the suite record.

The client stops trusting the wire on this too. It used to `put` whatever
arrived, and the offline read hands back every cached row for the trip with
no privacy filter, so a single stray event lodged another member's item in
that browser permanently. It now drops a restricted item that is neither
mine nor shared with me — deleting rather than skipping, so a list that was
already leaked into heals itself the next time an event about that item
comes past.

Closes #1976
2026-08-20 17:30:01 +02:00