mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
5ac6ce40be8259ff659074fc4dbe05f08059da4e
533 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2d7889c237 |
fix(memories): give the photo picker a chronological order it can rely on
The picker got its photos back in whatever order the provider happened to return them. It groups by day and appends pages lazily, so an unordered page lands under the wrong heading and pushes photos out of the fold the reader is looking at. Immich now asks for order: desc on both the search and the album path. An older server that does not know the property drops it silently rather than failing, so each page is sorted again here; the album paths drain every page before sorting and are covered outright. getAssetInfo reports mediaType, the same mapping the listing already used, so the detail route and the picker agree on what an asset is. Synology gets no sort parameter upstream on either path. Search.Search documents none, and _fetchSynologyJson maps every app code except 106/107/119 onto a 400, so a parameter one DSM build rejects would take listing down entirely for that user. Both paths read every page anyway, so ordering the mapped result costs nothing and cannot fail. No media type for Synology yet, deliberately. streamSynologyAsset serves every asset as a JPEG out of SYNO.Foto.Thumbnail, original included; there is no video byte path. Labelling an asset a video would make the lightbox mount a player on a still image, which is worse than the missing badge. The badge, the byte path and the persisted media_type belong together in their own change. Five album cases in the Immich integration suite pinned the old arrival order. Where the position was incidental they now look the asset up by id; where the order is the point they state the chronological one. |
||
|
|
b2d9b25bb6 |
fix(roadtrip): say which filter emptied the list, and stop typing a limit racing itself
Four small things, and one that was not this branch's fault but is now its neighbour. The driving limits saved on every keystroke. Typing "180" sent three settings writes carrying 1, 18 and 180, unordered and concurrent over HTTP/2, so whichever the server committed last won: the number that decides every over-budget warning could quietly end up a tenth of what was typed, and only show it after the next reload. Typed locally now, committed on blur or Enter, and a value that did not actually change writes nothing. The empty-result message always blamed the name box. Four controls narrow that list and only one of them is the name, so picking a section with no hits produced `Nothing on the way matches ""` — empty quotes naming a filter the reader never set. The count line above it was gated the same way, so the section case lost its "x of y" as well. Both follow whatever actually narrowed the list. The check-out dropdown printed a bare ISO date, the only place in the planner that did; it goes through the same helper as the rail beside it. Switching an addon off left open MCP sessions holding its write tools. A `when:` gate runs once per session, at attach time, so the tools stay registered and callable while the REST half answers 404 for the same user in the same moment — and the list that decides whether to tear those sessions down had drifted: airtrail and collections gate tools and were missing from it, which this addon would have inherited. The list now sits beside ADDON_IDS and a parity test reads the gates out of the source and holds it there. Read out of the source rather than out of a runtime registry on purpose: a runtime set only holds the gates whose modules the caller happened to import, so it would pass in a suite that imports none — which is the shape of test that hid this in the first place. RoadtripLimitsCard gets the test it never had, including the one case that matters: three keystrokes are not three writes. |
||
|
|
44d52ab215 |
fix(roadtrip): stop one gesture writing twice, and one search running too early
Five things, all of them a click or a drag doing something the traveller did not ask for. The GL via handle committed a move on every pointerup. Any button started the drag and there was no movement threshold, so a plain click wrote the via to wherever inside the 17px handle the pointer happened to be — at a low zoom that is kilometres, and the write re-routes the whole trip. A right-click ran the drag AND the delete, so one gesture sent two writes; on the platforms where contextmenu comes first, the move landed on an id that no longer existed and reported a failure for a deletion that had worked. Leaflet gets this right for free from `dragend`, which is why the two renderers behaved differently for the same gesture. Primary button only now, with a threshold, and pointercancel puts the pin back instead of committing — the browser fires it when the captured element is torn out of the DOM, which is exactly what a delete does. With the alternatives picker open, one click on the route fired both handlers. Layer registrations are independent in MapLibre, and every alternative starts and ends at the same two stops — when the leg already carries vias the current road is itself one of the offers, so the overlap is the whole leg. The click dropped a via AND chose a different road: two writes and two trip reloads for a gesture made once. The corridor search could be started before the day had routed. The corridor is then built from the straight line between the stops rather than the roads driven, and the routing answer landing a second later changes the line, which clears the search mid-flight and drops every result — no error, no partial result, just the idle empty state again. Routing runs one day at a time about a second apart, so the window is wide open on a long trip. Leaving road trip mode left the alternatives drawn on the map. The switch is in the sidebar and reachable while the bar is open, and the overlay depends only on the picker, so pale blue routes with drive-time pills stayed on an ordinary planner map with no road trip UI left to dismiss them from. Clearing a leg's vias swallowed its failure and closed the picker anyway. Every neighbouring write in that hook reports one. The leg still carried its via and still routed the old way, so the traveller believed they had undone the detour. Walking and cycling legs were counted as driving. A day may legitimately mix modes — splitIntoRuns exists for that — and a two hour hike was reported as "over the longest drive allowed", its minutes went into the day's driving time and its kilometres onto the tank, where a ferry crossing could trip a range warning outright. A leg with no mode still counts, because every leg was a drive before the field existed. The road-trip domain also says out loud that it is online-only, which the repo asks for rather than leaving it to be discovered. A via anchors by POSITION, so a queued write replayed after somebody else moved a stop would re-pin the drive onto a leg nobody chose, silently and hours later; the offline story needs the anchor to be an assignment id first. Until then the surfaces disable the gesture instead of accepting one and dropping it. Six theme-lint markers move onto the line they exempt. The linter suppresses per line, so a marker in the comment block above suppressed nothing and the six road-signage colours the authors documented as intentional were counted anyway. |
||
|
|
239856f35c |
fix(settings): make the self-hosted routing engine an instance setting
The field was rendered in every non-managed user's own Map settings and written to their personal settings row. The CSP is built from something else: bootstrap reads the ADMIN default and puts that origin into `connect-src`. So filling the field in was the one path the product offered, and it could not work — the route calculator happily built its URLs against the typed origin and the browser refused every one of them with a bare TypeError, not a status, so no fallback and no retry could see it. Day-plan routes, transport legs, the mobile day route, Studio's road route and the whole road-trip rail stopped routing, app-wide, with a generic "route could not be calculated". Nothing in the UI could repair it except emptying the field again, and there was no admin field to set the value that would have made the origin allowed. The repo has decided this shape once already: `llm_base_url` is admin-only because it names where the server sends its own requests and only whoever runs the instance can judge what is reachable. This is the same class for a different reason, so it joins the same guard, which is now named for what it actually covers. Clearing stays open to everyone, so a row written before the rule cleans itself up rather than blocking the save. The field moves to the admin's default-user settings, next to the tile URL and the Carto key, where a value takes effect. |
||
|
|
65be8db122 |
test(roadtrip): cover the surface the domain shipped without
The domain arrived with 557 lines, a service test, and nothing else. No controller test, no e2e, and no MCP test — so the guard chain, the trip scoping on every route, the cross-trip track check and the day_edit parity were all unasserted, and the whole domain sat in the 80 catch-all where it could shed twenty points against the headroom of the domains beside it. The e2e is the one that earns its place. `@RequireAddon` is metadata and does nothing on its own, and AddonGuard is not global, so the ordering the controller's own comment calls load-bearing is exactly the kind of thing a later change removes without noticing — it had already happened once. Deleting the guard now turns three cases red, which I checked by deleting it. The MCP tools had never executed at all: the shared harness seeds the addon disabled, so `when: roadtripAddonOn` kept all five from attaching. They now run against the same checks the REST routes make, including the demo account and a member without day_edit, because the repo requires the two surfaces to move together. Two client modules that had no test get one: the overlay builder, where two decisions live that are not cosmetic — which offered road is drawn as the one you are on, and that a label hangs where its own route is unlike every other, rather than at a midpoint that can fall on a stretch two routes share — and the service marker, where the thing worth pinning is that an ordinary place gets nothing back, and that the anchor arithmetic includes the border. The domain carries its own coverage floor now instead of hiding in the catch-all. Pinned under the measured figure on purpose; the exact ratchet gets regenerated with the repo's own script after a full run. Along the way, roadtrip_vias and roadtrip_day_tracks join the reset list every other domain's tables are on. Without that a via written by one case was still there for the next one, which is how the first version of the MCP suite passed an assertion it should have failed. |
||
|
|
41e256bf0f |
feat(roadtrip): answer the corridor search from the place index
Search along the route went to Overpass and only to Overpass, on the one path where that hurts most: one search fans out over sixteen boxes, and each of those races four public mirrors. The index answers the map's explore pills in milliseconds where those mirrors take seconds, and the corridor was the reason the index exists. Two independent things kept it out. The corridor sends its categories as a comma-separated list, and the index branch looked the whole string up as one key, so it missed every time. And the four kinds a drive needs, fuel, charging, rest areas and campsites, had no Overture terms mapped at all. They do now: gas_station and fueling_station, ev_charging_station, rest_stop, and campground with rv_park. The branch is list-aware the way searchOverpassPois already was, and it labels each hit with the category that produced it rather than with the list that was asked for, because the client colours and groups its markers by that field and "fuel,charging" is not a category. It answers all-or-nothing: a category the index has no terms for sends the whole query to Overpass, since answering the half it knows would silently drop the rest while the panel still reported a finished search. The caller's limit is passed through instead of a hardcoded 50, so a mixed search gets the same per-category budget the Overpass path spends. The other outbound path of this addon changes with it. Importing a shared Google route geocoded its waypoints by calling searchNominatim directly, which walked past geocodeQuery and its index lookup. Through geocodeQuery an index hit costs no throttle slot at all, and what the index does not know still falls through on the background lane. A place answered from the index also carries its chain and that chain's Wikidata item, so a branch gets its logo on the pin here exactly as it does on the Overpass path. Sockets stay an OSM field: a charging station answered from the index reports "not stated" rather than claiming it offers nothing. |
||
|
|
1e1dd9b814 |
fix(roadtrip): keep the shaping a traveller did, through every path that moves a stop
A via records which stop it follows by POSITION, so anything that renumbers a day's stops moves the ground under it. The road-trip rail corrects for that; almost nothing else did. reanchor() never renumbered `sequence`. It is allocated per leg and starts at 0 in each, and re-anchoring MERGES two legs whenever the stop between them goes away — so leg A's 0,1 ended up beside leg B's 0,1, and everything that draws the route orders by sequence. The drive ran A0, B0, A1, B1: forward, back, and forward again. Persisted, surviving a reload, and unrepairable except by deleting the vias. The old anchor is read before the updates and used as the primary sort key, so the vias of the earlier leg stay ahead of the ones that merged into it. Reordering the only two stops of a day deleted every via on it. A reorder is modelled as remove-then-insert, and the removal half refuses to treat a two-stop day as still having a drive — right when a stop really goes away, catastrophic when nothing was removed. Two stops is exactly the shape "follow this track" produces, up to nine vias on that one leg, gone to a single drag with no prompt and no undo. Removing a stop, reordering within a day, and assigning a place mid-day never re-anchored at all, though the helpers exist and three other paths use them. The detour then sat on a leg the traveller never chose, or on a leg that no longer existed and vanished — until a later stop brought it back bending a road nobody asked for. Reordering hands a whole new ordering rather than one move, so the anchors there follow the stop they were pinned behind instead of arithmetic on the index, which is correct for any permutation. Duplicating a trip dropped all of it: `stop_type` was missing from the place insert, and neither via nor day-track table was copied. The duplicate looked complete and quietly drove somewhere else. A failed read of the via list emptied it. That read runs after every write and feeds the routing key, so a 502, a dropped connection or a tab going offline re-routed the whole trip along the roads the traveller had steered away from, with nothing said and nothing retried — indistinguishable from having deleted them. Only a 404 or 403 empties now; anything else keeps the last known list and says it may be stale. |
||
|
|
676c82a308 |
feat(roadtrip): remember which track a day follows
The vias already made the day follow the road; nothing said which road. After a reload the badge could only say "shaped by hand", and re-fitting after the stops changed meant finding the track again by eye. One row per day, written in the same transaction as the chain it describes: a day claiming to follow a road whose vias never landed would be worse than a day claiming nothing. Three states rather than two, which is why the field is an object and not an id — absent leaves whatever the day followed alone, null says it follows nothing, an object records the road it now takes. A bare nullable id could not tell "do not touch this" apart from "forget it", and a hand-dragged via on the same day must not wipe the label. The foreign key is the point: delete the imported track and the label cascades with it, so no day is left claiming to follow a line nobody can see. The vias stay, because they are the drive. Both write paths check that the place is a track of this trip before it becomes a label — permission alone would let another trip's place id, or an ordinary place, become a name that can never be drawn. Also clears three reliability findings the scanner raised on this branch: two default sorts given an explicit comparator, and a trailing-slash regex the scanner is right to call backtracking, walked instead. |
||
|
|
6fca238440 |
feat(places): import the stops of a shared Google Maps route
Pasting a /maps/dir/ link into the list-import box answered "could not extract list ID", which is the whole of the complaint: people share a drive far more often than they share a list, and it is the same gesture — somebody pressed Share in Google Maps. Which screen they were on is the URL's business, so the box takes both and the controller decides. No API key and no call to Google for the link itself: the stops are in the URL. Three shapes arrive — the path form with its data blob, the documented ?api=1&origin= form, and a short link that is one of those after a redirect — and a stop the link spells out in coordinates needs no geocoder at all. One that is only a name is geocoded, keeping the name from the link rather than the one the geocoder answers with: somebody who typed a nickname into Google should not find a street address on their trip. A name nobody can place is left out and counted rather than failing the import, because a route of six stops with five findable is five stops more than the traveller had. Coordinates are read as 1d/2d, longitude before latitude, which is the reverse of a place link and the one way to put Berlin in Somalia. A blob that does not line up with the stops is ignored entirely: half a link read confidently is worse than one read as names. |
||
|
|
061259b3d9 |
feat(maps): pass through what OSM knows about a charging station
The corridor query has always ended in `out center tags`, and the projection has always kept six keys out of the whole tag set. Everything an electric car needs to know was in the answer and went in the bin: which plug families the station has, how many of each, at what power, how many vehicles fit, and whether it charges. Reading them costs no extra request, no key and no second query. The filters treat "not stated" as its own answer, which is the only honest way to build them: OSM states a socket type on roughly a third of charging stations and a power on fewer still, so a power filter keeps every station that says nothing rather than reading silence as too slow. The plug dropdown offers only the families this search actually turned up, because CHAdeMO in a list for a corridor with three Type 2 posts on it is a filter that can only return nothing. Socket names stay as they are printed on the plug. "Type 2" is not a string anyone translates, and the lowercase tag key is not what is written on the charger. The tool description follows, as parity requires, including the coverage caveat — a model that reads a missing power as "no fast charging here" would be wrong about most of the map. |
||
|
|
67163e7224 |
feat(roadtrip): lay a whole chain of via points in one write
The single-via route is right for a detour dragged by hand and wrong for anything that derives its anchors from a line. Pinning a day onto a recorded track or a signed scenic road means a couple of dozen points, and one request each re-routes the entire trip once per point, spaced by the routing host's own rate limit — twenty anchors would spend half a minute drawing routes nobody asked to see. replace_legs names the legs to clear rather than taking a flag for the day, and that is the whole difference: a traveller who bent two legs by hand and then adopts a scenic road for a third has not asked to lose the two. The tools follow in the same change, as parity requires. Undoing a detour picks this up too, where the vias of one leg were deleted one request at a time. |
||
|
|
9a0a6f8c3b |
fix(settings): the routing endpoint belongs to the operator
routing_base_url arrived on this branch and was assigned to neither managed set, which MANAGED-KEYS-001 exists to catch. It is locked, alongside the other infrastructure endpoints: the host it names is folded into the CSP once at boot (routingCspOrigins), so a customer who repoints it lands in a policy that does not know their router and the browser blocks the requests with nothing to catch. MANAGED-KEYS-005 pins the list verbatim and is updated with it. |
||
|
|
7829043a00 |
test(app-config): pin the overpass budget against its own constant
The client-side budget moved to twenty-five seconds when the query timeout it has to outlast was written down as a constant, and this assertion stayed on the old twelve. Pointing it at OVERPASS_TIMEOUT_DEFAULT_MS is what the test was after in the first place: the budget must be longer than the timeout the query carries, and a literal here is exactly how the two came apart. |
||
|
|
e11ae6f056 |
feat(roadtrip): shape the drive — vias, alternatives and stops along the way
The road-trip addon's second half: the parts that let a planned drive be argued with rather than only read. Reshaping. A via records which stop it follows by position, and that position is resolved fresh every time the day is routed — so inserting, reordering or removing a stop moved the ground under every via after it, and the drive silently snapped back to the road it had been steered away from while the handle still sat on the map where it was left. The correction is computed against the day as it stands and written with the same change, in one transaction: a half-applied re-anchoring mixes two numbering schemes in one day. The one judgement that needs geometry — which half of a split leg a via belongs to when a stop lands in the middle of it — is measured on the road actually driven, so a fuel stop added to a bent leg keeps the bend on the side it belongs to. Alternatives. OSRM answers most long legs with exactly one route, which read as "there is no other way" when a perfectly good slower road existed. When it offers nothing, the same drive is asked for again without the motorway, and failing that without the tolls — one question at a time, stopping as soon as one lands. The offer says what it is rather than how much slower. Stops along the way. Everything the corridor search can find is a pause, not a destination: fuel, charging, rest areas, campsites, food and sights all sit inside the leg they were found on, carry their own colour and icon, and are left out of every stop count. That colour is defined once and read by the rail, the corridor list, the map pin and the picker — and it replaced the chains' logos, which turned a corridor full of petrol stations into a row of advertisements and left the brands with no logo on file looking like something else entirely. How long a stop takes was readable in the rail and writable nowhere; new places were given an invented hour that nobody had entered. It is now set from the rail, cleared as a zero because the update statement folds a null into "leave it alone", and new places start with nothing. Both columns are rebuilt on one type scale, anchored to the day plan's own sizes so switching tabs does not change the size of the text beside it. The rail reads as trip totals, day cards and a chain of stops with the drives between them; the corridor column as what to look for, how the search is getting on, and what it found. Six category pills over three lines became one dropdown, which is the space the results wanted. Also: the offered routes are framed on the map when the picker opens (`fitKey` carries no coordinates and each renderer reads it as "the selected day"), road trip mode does not cluster at all so the whole drive stays readable zoomed out, and the drive-time labels finally render in the app's own typeface instead of Leaflet's Helvetica. All of it in both renderers. |
||
|
|
1db55f734c |
fix(maps): wait longer than the query we ask Overpass to run
The query carries `[timeout:20]`, so the mirror is allowed twenty seconds of work on it — and the fetch around it was aborted after twelve. A mirror under load that would have answered at fifteen never got the chance, and the caller saw a failed area. That is what makes a corridor search along a long drive report half its stretches as unsearchable: the stretches were being searched, we just hung up. Both numbers now come from one constant, the client budget is the query's own plus room to hand the answer back, and a test holds the relationship rather than the two literals. |
||
|
|
b339ef3f04 |
fix(days): keep a place's stop type on both assignment loaders
Three read paths built the same nested place object by hand, and two of them left `stop_type` out of it while their SQL was already selecting the column. The effect was visible: a petrol station added along a route drew correctly for a moment from the optimistic write, then the server's answer replaced it with a place that had no kind, and the road-trip rail redrew it as an ordinary numbered destination. `getAssignmentWithPlace` now goes through `formatAssignmentWithPlace` like the list path already did, which removes the third copy rather than adding the missing field to it. The two queries in the days service gain the column, and its hand-built shape gains the field it cannot yet be spared. |
||
|
|
d6955f20a6 |
test(roadtrip): cover the routing, corridor and panel behaviour
The addon shipped with only its two pure modules under test, which left the parts that actually talk to the network — and the panel a user reads the answer from — resting on nothing. Covers the routing hook (one request per travel-mode run, day default over trip fallback, retry then give up, no re-fetch on a rename, abort on leave), the corridor search (one query per box carrying every kind, the perpendicular filter, dedupe, the retry pass before a stretch counts as unsearched), the corridor state (roads driven over the line between stops, day follow-along) and both panels. Two findings from writing them, fixed here: - A dead straight leg is reduced to its two ends by the simplifier, and tiling walks point to point — so a 250 km motorway stretch produced a box at each end and nothing in between, while the search still reported itself complete. Steps longer than half a box are now split before tiling. - projectOnSegment guarded a degenerate segment with `lenSq === 0`, which two ends that came through a chain of floating point arithmetic never hit. Also splits the anchor resolution out of computeSchedule, which was three levels of nesting for one question, and sorts the POI cache key with a comparator. |
||
|
|
6aa7884874 |
perf(maps): search a corridor with one query per box, not one per category
The road trip corridor asks for fuel, charging, rest areas and campsites over a dozen boxes, and asking per category was four times the requests for the same answer — slow, and four times the chance a shared Overpass mirror times out and reports the stretch as unsearched. `category` now takes a comma-separated list. Each OSM selector belongs to exactly one category, so a hit is still labelled with the kind it answered, and the result cap is shared out per category so one dense kind can't crowd the rest. The MCP tool takes an array for the same reason. Also puts the POI, plugin and route via-point pins on the map's render clock, the same fix the planned-place pins already had: a library marker repositions on every pointer sample while the canvas draws once a frame, so the suggestions swam during a drag and snapped back on release. |
||
|
|
37179cb608 |
feat(maps): show a chain's own logo on its map pin
A corridor full of petrol stations was a row of identical coloured dots, and the one thing that tells them apart is the brand. OSM carries brand:wikidata on most of them — 88% of the fuel stations in a Berlin box — Wikidata carries the logo as property P154, and Commons serves it. The bytes are proxied and cached rather than linked: one self-hosted instance asking Wikimedia for a handful of logos is a very different egress profile from every visitor's browser announcing which petrol stations they are looking at. The cache is in memory because a GET must not write to the database, and there are only so many fuel brands; a brand with no logo is remembered too, or every pan would ask again. A logo-less or unreachable brand answers 204 and the pin keeps its category icon, so nothing depends on Wikidata being reachable. Both renderers show it, since both read the same category table. |
||
|
|
7cdd61e7fb |
fix(maps): say which source answered, and stop the stubs driving the index
Two halves of one mistake, both found by the suite. Letting each side of the merged search catch its own failure meant the index could contribute nothing while the answer still called itself "trek-places+openstreetmap". That label is not decoration: the search log writes it into the corpus a candidate index gets scored against later, so a list that is entirely OpenStreetMap must not be recorded as though the index had a hand in it. The source now names what actually contributed. And maps.service.test.ts drove the index for real. Its fetch stubs answer with json() and no text(), which is not what the index client reads, so the branch was entered and then failed on the stub — "uses Nominatim when user has no API key" was really testing that a stub is incomplete. With the new circuit breaker those failures also accumulated, and the breaker is process state, so it leaked into whatever file ran next. trekPlacesSearch is stubbed there now, beside the nearby stub that was already there for the same reason. The index paths keep their own files. |
||
|
|
ed5769fc89 |
fix(maps): name the index as the source, and stop it being asked twice
Attribution first, because this branch argues the point itself: the card names the sources with the comment that naming them is a licence obligation rather than decoration, and the copy beside it promises that every field says where it came from. The POI path then stamped Overture rows as OpenStreetMap. Overture is not OpenStreetMap — it carries OSM among other sources under other licences — so that is a wrong attribution rather than a rounded one. The wire contract keeps `source` an open string and nothing on the client switches on it, so saying `trek-places` costs nothing. The MCP tool description no longer calls itself an OpenStreetMap tool either, and says which source answered. Adding one place cost two round trips to the index. The details lookup fetches the place and its answer carries the description; toPlaceRecord dropped that field, so the enrichment fetched the same place again a moment later to read it. It is carried through now and read from what the caller already has, with the second lookup left as the fallback for when there is nothing to read. That description's source URL becomes an href on the client and was taken on `typeof === 'string'` alone. It now goes through the same allow-list a place's website goes through — the file already does this for menu_url, with the comment explaining why — so anything but http(s) loses the link and keeps the text. The instance token was hashed from raw APP_URL, which the shipped compose file leaves commented out. Every default install therefore sent the same token, and a token every caller shares is not a caller: one instance running a large import would rate-limit all the others, who would fall back to Nominatim with nothing to point at. getAppUrl() falls through to ALLOWED_ORIGINS and then to the port, so unconfigured instances still differ. Two client-side leaks. A pick made out of the offline cache was recorded into the shadow corpus, where it moved the two figures the corpus exists to measure — and offline is a setting, so the network is usually reachable and the row landed. And the prefetch never re-checked the session after its request came back: a logout in that window repoints the handle at the anonymous database, so one person's trip area was written where the next person's offline search reads. The cache path also ignored the abort signal, so an answer for a keystroke two keystrokes old could overwrite a newer suggestion list. It rejects the way both surfaces already know to swallow. Left alone, and said in a comment rather than hidden: the index cannot localise. The Overpass path picks name:<lang> and falls back to int_name; the service has no language parameter at all, so a German reader exploring Tokyo gets the Japanese primary names on this path. |
||
|
|
b0aa7f8d95 |
fix(maps): stop asking an index that is not answering, and untie the place cache
Three things an install feels rather than reads. A service that cannot be reached cost the full timeout on every call. Each call site falls back to the old path, but pays 3.5 s first — so an instance behind a firewall that drops rather than refuses, or one that simply has no route to the service, pays it on every keystroke of autocomplete and on all three queries a booking import runs per venue. Ten venues is over a hundred seconds of waiting for an answer that was never coming. A process-wide breaker now fails instantly after four consecutive failures and tries again a minute later, so an unreachable index costs one timeout instead of one per request. A 404 does not count against it, because "no such place" is an answer, and neither does an oversized body, because the service replied. The offline place cache hung off the map-tile switch. That setting is about tiles — its own text says "off keeps the cache to trip data + documents only" — and it gets turned off for space, tens of megabytes of it. A trip's places are about one. Somebody who turned tiles off therefore got an empty offline search with no explanation, and "prepare for offline" reported success having stored no place at all. The two run independently now; the tile progress phase stays gated on the tile setting, so a run without tiles still reports no tile phase. Tests for the merged search path, which had none: the wiring around mergeSearchResults was uncovered, so which list goes in first and what `source` says were both free to change. Neither is cosmetic — index-first is the whole point of the feature, and the search log writes `source` into the corpus a candidate index gets scored against later. Two of the eight cases pin the fix from the previous commit: an index failure keeps the OpenStreetMap answer, and neither source is asked twice. place-shadow lands on its own coverage entry rather than the 80 catch-all, where a new domain could shed twenty points unnoticed. Pinned under its measured figure on purpose; the exact ratchet gets regenerated with the repo's own script after a full run. |
||
|
|
48f0f29273 |
feat(admin): let an operator switch the place index off
trek_places_enabled was read by six call paths — search, autocomplete, the POI pills, booking-import geocoding, the offline area and details resolution — and written by nothing. No admin route, no MCP tool, no switch anywhere in the UI, and the key is not in the settings allow-lists either, so the only way to set it was an INSERT against app_settings by hand on the server. That matters more than the four switches beside it, which trade away quota. This one decides whether every search and every keystroke in the place box leaves the instance at all. The flag reads fail-open, so an install that upgrades without visiting the admin panel starts sending them, which is the right default and exactly why the operator needs somewhere to say no. Adds the pair symmetrically to place-shadow from the same branch: getTrekPlaces /updateTrekPlaces on AddonsService, GET/PUT /api/admin/trek-places with an audit row, and a real switch on the card. The read stays fail-open so it keeps agreeing with MapsService.trekPlacesEnabled(), and a test pins that agreement across every stored value — if those two ever diverge the panel shows "off" while the queries keep leaving, which is worse than either default. The card said this itself: `available` was a display-only prop whose comment read "False until the client is wired to the service; the card then says so instead of offering a switch that would do nothing." It is wired now, so the prop is a switch. Its two badge strings had been shipped to all 23 locales and rendered nowhere; they make way for the switch label and a line naming what answers while the index is off. |
||
|
|
5aa36aaf3d |
fix(maps): stop the index costing a second search, a duplicate and a lost cache
Six things the merged search and the offline cache got wrong. An index failure took the OpenStreetMap answer down with it. Only the OSM half of the Promise.all had a catch, so a rejection on the index side discarded ten results that had already arrived and sent the same query to Nominatim a second time, behind its 1.1 s process-wide throttle. The index refusing a query is ordinary traffic rather than an outage — a common single word without coordinates is turned down upstream as too expensive — so this fired in normal use. Each side now catches its own failure, and the keyless fallback reuses the answer OpenStreetMap already gave instead of asking again for a list that cannot have changed. Dedup never fired for a non-Latin name. namesOverlap splits on [^a-z0-9], so 長谷寺, Кремль and مسجد الشيخ زايد all tokenise to nothing and the function returned false — the same place came back once from each source, one row under the other, and picking either gave it a different id. That is the corpus this whole feature was measured on. Two names with no Latin words are now compared strictly, folded through NFKC so the width variants the sources disagree on still match; sharing a character is deliberately not enough, because deduping 東京タワー against 東京タワー駅前郵便局 would swallow a real result. expand=1 answered nothing for an index place while expand=0 answered in full. The expanded lookup degrades OSM ids to the plain one and had no branch for the new family, so the richer request was the poorer one, over REST and MCP alike. The bias circle could exceed Google's 50 km ceiling. A trip spread over more than that produced a radius Google rejects outright, turning a search into an error toast. Clamped on both sides — the client because it draws the box, the server because the radius arrives over the wire. The offline cache was rebuilt on every sync and never deleted. syncTrip wrote its meta row with put(), which replaced the whole record and dropped the fingerprint saying the cached area was current, so every login, reconnect and manual sync re-downloaded megabytes that had not changed. And clearTripData never touched areaPlaces, so a trip switched off for offline use kept answering offline searches and its rows stayed for the life of the install. clearCachedPlaces, which claimed to do that job, was called from nowhere; its only test asserted with .every() on an empty table and passed on nothing. Picking an offline suggestion ended in an error toast. The details lookup had no cache path, so it failed and the callers fell back to searching for "name, address" — which the cache matches on the folded name alone, so it found nothing for a place that was sitting right there. A miss stays a miss. |
||
|
|
273bac8917 |
fix(maps): keep index ids away from Google, and let the area download be big
Four things the index changed and nothing followed: `gers:` was missing from NON_GOOGLE_PLACE_ID, so isGooglePlaceId said true for every id the index hands out. Five call sites gate on that function to keep unresolvable ids away from Google, which answers them with a billable 400 INVALID_ARGUMENT: photo refs, the editorial summary, the place-photo route, the expanded details lookup and the enrichment photo pass. Since the index answers first for search and autocomplete, a GERS id is what an ordinary new place now carries, so this was the normal case rather than an edge one — three billed lookups per added place, none of which could ever return anything. The area download shared the search path's megabyte cap and its 3.5 s timeout. Both are right for a keystroke and wrong for the offline prefetch, which asks for 3000 rows at once: a city-sized answer sits on that megabyte, readCapped threw, and the caller could only report "index unavailable" — the trip then went offline with an empty place cache and nothing said so. The cap now follows the row count the caller asked for, with a floor, and the bulk call gets a budget that fits a bulk download. It is still capped, so a broken upstream cannot hand us an endless body. searchPlaces returned before the Google branch as soon as the index or OpenStreetMap had anything, and both set google_place_id to null. That is right for a search and wrong for the one caller that wants an identity rather than an answer: list-import enrichment (#886) exists to attach a google_place_id to a bare imported pin, and pickEnrichmentMatch discards every candidate without one. So the import silently stayed unenriched on an instance that pays for a key. That caller now asks for the Google identity explicitly. Nothing changes for an instance without a key — enrichment could never resolve anything there, before or after. Tests cover the three functions this branch had left untested, which is what put the maps ratchet under its functions threshold: the autocomplete index path (a new file, MAPS-AUTO-001..007), the gers details branch when Overpass is down (MAPS-GERS-008), and the client's abort timeout — including that the signal exists at all, which nothing asserted before. |
||
|
|
2b7af6267b |
test(server): cover what this branch added to the pinned domains
The coverage ratchet measures per domain, and this branch put new code into four domains pinned well above the floor without tests reaching it. addons (99/99/99): getPlaceShadow and updatePlaceShadow had none at all, and the parity the service comment claims with PlaceShadowService.enabled() is now asserted rather than asserted in prose. maps (93/86/97/96): geocodeQuery was invisible - its only consumer stubs it away - so the new suite drives the gate, an accepted hit, a hit with an unusable coordinate, an index failure falling through, and both ends of the Nominatim tail. It sits on the booking-import path, where it decides whether thirty sequential lookups leave the instance. admin (86/70/89/88) and place-enrichment (96/95/94/97): the place-shadow handlers and their audit entry, the getAppConfig read, and nearbyWouldMislead, whose lines ran incidentally while nothing asserted the result - the guard could have been inverted and the suite would have stayed green. |
||
|
|
d613c91e16 |
fix(places): keep the chain out of the field that means "this place"
toPlaceRecord wrote the brand's Wikidata item into `wikidata`, and getPlaceDetails spreads the record over the OSM details, so for a chain branch picked from the index that value replaced the building's own tag - and nulled a real one when the place had no brand. resolveIdentity reads that field as the place's own identity, so the photo ladder returned the chain's Commons files as pictures of this branch and the description came back as the company's article without the aboutBrand flag. That is precisely the failure WIKI_IDENTITY_TAGS and readBrandIdentity were written to prevent, in their own words: "a chain's logo passed off as a photo of the restaurant would be a plain lie". The item now travels under brand:wikidata, the key OSM uses and readBrandIdentity already reads, and resolveIdentity picks it up from the payload for a place OSM does not know - so a branch still gets the chain's description, flagged as such, and never its logo. The client test also covers trekPlacesArea, the streaming half of readCapped including the size cap, the configured base URL and the nearby defaults, none of which any test reached. |
||
|
|
0991fa538b |
fix(maps): stop pois() dropping opening hours and reporting a narrowing as none
The index-first branch hardcoded opening_hours: null while the payload it maps carries them, and search_pois advertises "opening hours wherever OSM carries them". It also capped the radius at 20km and then answered clamped: false, so a wide viewport came back narrowed and said it was not - the same field the Overpass path fills honestly. The three red MCP cases were the same code telling the truth from the other side: they stub Overpass one level below the facade, so pois() ran for real, and because trekPlacesEnabled() fails open on a database with no row, a unit test was sending live requests to places.liketrek.com from CI. That is what answered with 50 places instead of the stubbed one. The index client is now mocked in that suite, with the gate left open so the cases still run on the shipping configuration, and the index-first path gets cases of its own here and in a new maps.pois suite: field mapping, the empty and the failing index both dropping through to Overpass, and an outage under both providers still reaching the caller as an error rather than an empty list. maps.service.test.ts drove pois() with no stub at all and leaked the same way. |
||
|
|
fb363f22c8 |
fix(maps): alternate between the index and OpenStreetMap instead of ranking them
Measured on 128 places out of a real trip to Japan, all saved through the old search. Index alone: 51.6 percent inside the top five. Index and OpenStreetMap alternating: 72.7 percent. Two cleverer orderings were tried first and both lost. A score threshold cannot separate the cases at all: at 0.75 it catches 19 of 62 misses and throws away 6 of 66 hits. Promoting results whose name matches what was typed is worse than it sounds, because "Hase Station" shares a word with "Hase-dera" while OpenStreetMap answers in the local language and returns the right temple under a name that shares nothing: that rule put seven weak index matches ahead of the correct answer, which landed at position eight. Alternating puts it at position two. So the merge does not rank. Each source keeps its own ordering and they take turns, index first. An exact business match still opens the list; a landmark Overture does not carry is second rather than eleventh. |
||
|
|
3caafe0548 |
fix(maps): show OpenStreetMap alongside the index, not instead of it
Measured against 128 places out of a real trip to Japan, all saved through the old search: the index answered 51.6 percent inside its top five. For most of the rest it returned the shops AROUND the landmark rather than nothing, which is worse than nothing, because ten plausible wrong answers look like an answer. Overture Places is a dataset of businesses and is very good at those. It largely does not carry temples, bridges, riverside walks, viewpoints or observation decks, which is a fair share of what somebody planning a trip searches for. Two ways to detect "the index does not have this one" were measured and both failed. Its own score does not separate the cases: a 0.75 threshold catches 19 of 62 misses and throws away 6 of 66 hits. Name overlap does not either: 57 false positives, because "Hase Station" shares a word with "Hase-dera". So this does not classify. Explicit search asks both concurrently and merges, name matches first and the index ahead of OpenStreetMap within each group. Autocomplete is untouched, because Nominatim's policy names it as unacceptable use. Nominatim also gets the location bias it never had, without which "Hase-dera" returns the temple in Nara rather than the one in Kamakura the user is standing beside. Two booking-import cases still stubbed searchNominatim after geocoding moved to geocodeQuery, and stubbed it with the old array shape, which made "has coordinates" pass on an undefined lat. |
||
|
|
bac85cb98a |
feat(places): opening hours from the index, with OSM still first
The index can now carry opening hours, from two sources: an OpenStreetMap layer the service keeps as a separate database file, and the schema.org data a place publishes on its own site, read in the same page fetch the description comes from. OSM stays the first choice where it has them. Measured across seven countries it covers 27.5 percent of gastronomy, and an OSM entry describes this exact object and gets corrected by people who walked past it, where a chain's website often carries one set of hours for every branch. The rest is what the operator publishes, which measured 8.3 percent on the same kind of place. Both go through the same expansion into weekday lines, so a card reading `opening_hours` gets one shape and not two. Five maps.service cases went red when search started answering from the index ahead of Google; they are about the shape of the Google request, so they now switch the index off first. |
||
|
|
87a765b763 |
feat(offline): cache the trip's area so place search works with no network
An offline instance had the trip's own places and nothing else, so "find a pharmacy near the hotel" had nothing to answer from: every lookup went to a server. The index can hand over a bounding box in one request, so it does, once, next to the map tiles. A trip to Rostock is roughly a megabyte, refreshed only when the trip's area actually moves. Search and autocomplete fall back to it at the API layer, which is why all ten call sites got the offline path without any of them learning about it, and the answer says `source: 'offline-cache'` rather than pretending to be live. Country packages stay what they were: a SQLite file for anyone who would rather run their own copy of the service. A browser cannot query one without shipping a WASM SQLite, and Germany alone would not fit in IndexedDB, so that door stays where it belongs. /v1/packages lists them, because "you can leave" only means something if the door is visible. |
||
|
|
715993feaf |
feat(places): descriptions from the place's own website
Overture has no descriptions, and the encyclopaedias only describe the places that have an article, which is a fraction of a percent. What Overture does have is the address of the page the description is on, for 43 percent of places, and those pages publish a summary in JSON-LD or og:description precisely so machines can read it. The API fetches each page once and keeps the summary; every instance reads it from there. TREK never opens a business's website itself, which would mean one request per instance per place against small servers. In the ladder it sits below the encyclopaedias and above Google: an article about this exact place beats the operator's own copy, but for a restaurant or a shop the operator's summary is the only one that exists. Credited by host and linked back, with no licence claimed. |
||
|
|
b3d69aa760 |
feat(places): record which search result was picked, on this instance only
The corpus behind "is our own index good enough to replace Nominatim". When somebody searches and then picks a result, the client posts the query, the rank and the pick. Nothing else: no user, no trip, no session, and coordinates only to three decimals. Deliberately decoupled from the search endpoint. Correlating a response with a later pick would mean handing out a ticket on POST /api/maps/search and threading it through all fifteen call sites; the client already holds everything the row needs at the moment of the pick. Evaluation happens offline afterwards: export the corpus, run it against a candidate index, count how often the pick came back in the top five. That is why collection can start before such an index exists, which is the whole point, because the corpus needs weeks of real use and the index needs a server, and those two waits should not be serial. Off unless an admin switches it on, and it reads === 'true' rather than !== 'false'. The three older places switches fail open because a migration backfilled a row for installs already using them; nothing uses this one, so an instance that upgrades and never visits the admin panel logs nothing. Reading is admin only, retention is 180 days, and the switch state is on the unauthenticated app-config endpoint on purpose: whether an instance records what its users search for is something a visitor may know before logging in. |
||
|
|
7f2a63e7f7 |
feat(maps): search places through the TREK Places API
Adds a provider for the project's own place index and puts it in front of the existing ones for search, autocomplete, details and the explore pills. It needs no key, has no quota, and what it returns may be stored, which is true of neither of the two providers it sits in front of. Nothing about the Google path changes. Every call site falls through to exactly what it did before when the index has no answer or the service is unreachable, so a working search keeps working. Two details worth knowing while reading: - Details for a place from the index also resolve its OpenStreetMap record by name and coordinate, because the index has no opening hours yet and never has cuisine or a menu link. Without that the hours a user saw while adding a place vanished from its card afterwards. - The explore pills keep reporting source 'openstreetmap'. It is a typed contract the client switches on, and the places are the same places. TREK_PLACES_URL points at a different instance; unset means the public one. |
||
|
|
2a0ceb2687 |
feat(transit): let an admin route transit search through Google
Transitous has no GTFS coverage for much of Asia, so a search between two Osaka subway stops finds nothing — the stops are not in the feed at all. Add Google as an optional backend behind an admin setting, reusing the install's existing Places key, and fall back to Transitous whenever no key resolves. Uses the Routes API rather than the legacy Directions endpoint the request named: every other Google call here already speaks the v1/X-Goog-Api-Key family, and Google closed the legacy endpoint to Cloud projects created after March 2025. Google returns walking as turn-by-turn navigation steps where MOTIS returns one leg per walking segment, so consecutive walk steps are coalesced into a single leg — otherwise a finely-sliced walk pushed a real journey past the 20-leg cap and the whole itinerary was dropped. Both responses now name the backend that answered. The fallback is silent by design, which left an empty result ambiguous between "no data here" and "the provider you picked never ran", and the admin panel warns when Google is selected but no key resolves — or when only the admin's own key is set, which serves them and nobody else. Cost shaped the implementation, since Google bills where Transitous does not: both field masks are cut to the cheapest SKU tier that still answers the question, cache TTLs are longer than the Transitous path's, and route alternatives ride along inside the already-billed request. Addresses #1699 |
||
|
|
2572132117 |
fix(notices): let the 4.0.0 release modal cover the whole 4.x line
The version window already said 4.0.0 to 5.0.0, and the test above it is even called "covers the whole 4.x line", but that was only half the story: without `recurring: 'per-version'` a notice falls back to a one-time dismissal, so everyone who closed the modal on 4.0.0 never saw it again. It did not come back on 4.1, 4.2 or 4.2.1, which is the opposite of what the window says. The mechanism was already there and in use one entry below, on thank-you-support. The dismissal path records the app version it was closed at, so the notice re-surfaces on the next upgrade and stays down within the same version. Rows dismissed before that column existed read as 0.0.0 and simply come back once. |
||
|
|
b132adf3e3 |
fix(test): repair the escaped newlines and wire the new controller arg
The stderr and --version fixtures had literal line breaks in them, and the admin controller helper still built the controller with eight arguments. |
||
|
|
94914295d1 |
fix(llm): let a model refuse temperature, and say which extractor is installed
Two reports, both about an import that fails without saying why. #2262 — temperature: 0 sat in the shared request body, so it went out on every attempt including both retries. Reasoning models (the gpt-5 family) reject any explicit temperature, and the json_object retry fires on any 400, so the import spent two doomed requests and could not succeed at all. The parameter is now dropped once a server has explicitly said it will not take it. Not as a third link in the chain, though. A gpt-5 rejects max_tokens AND temperature, the API names one parameter per response, and it may name either first — a chain of one-shot ifs converges on only one of the two orders. The retries are a loop over what the server actually said instead: each remedy applies at most once and they compose. Detection is a phrase, not the bare word, because dropping temperature costs the deterministic sampling that small local models depend on, and an unfamiliar wording still falls through to the json_object retry exactly as before. NuExtract and Ollama are untouched: the first attempt always carries temperature, and it is the only one they ever see. #2261 — the bundled extractor is Debian trixie's 24.12.3, roughly eighteen months behind upstream, and the reporter is right that this is invisible: a stale extractor, a mistyped KITINERARY_EXTRACTOR_PATH and a genuinely unsupported provider all look the same from the UI, which is nothing imported and no reason given. The version now appears in the startup log and in a new admin-only GET /api/admin/system-info, and LOG_LEVEL=debug passes the extractor's raw stderr through — the JS ERROR and Invalid-result-type lines the filter drops are the only evidence that a vendor script is missing or failing. Capped at 200 lines, and opt-in because those lines can carry document fragments. Not bumping the package: the filter the reporter needs first ships in KDE Gear 26.08 and no Debian suite carries it, and sid's 26.04.3 wants KF6 6.22 and Qt6 6.10.2 against trixie's 6.6 and 6.8.2 — a partial upgrade of Qt6, KF6 and libc6 from unstable into the production image, on two architectures, that still would not fix the reported carrier. It comes for free with the base-image move after Debian 14. |
||
|
|
eaace6ada4 |
fix(test): assert the untouched GET properly
toMatchObject({ method: undefined }) does not say what it looks like it says.
|
||
|
|
e6b4df5213 |
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. |
||
|
|
2d315df8bf |
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. |
||
|
|
6f057ab9f4 |
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. |
||
|
|
ce58de29a5 |
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 |
||
|
|
c5208967ac |
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. |
||
|
|
dba4434ef6 |
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. |
||
|
|
6d54ac629e |
fix(oauth): accept private-use redirect schemes and loopback ports
POST /oauth/register refused every single-label private-use scheme, so an
MCP client registering workbuddy:// could not connect at all, while the
error it got back advertised "a private custom scheme" as supported.
The cause was two validators that had drifted apart, both of which run on
registration. The provider required a dot in the scheme name, which RFC
8252 section 7.1 only recommends and which protected nothing
(javascript.evil:// passed it). The service behind it had no
custom-scheme branch whatsoever and no [::1], which is what actually
answered the reporter's second symptom, and its localhost exemption was
not gated on http:. So javascript://localhost/%0aalert(1), blob:, about:,
ftp: and wss: were all accepted through the settings UI, and the consent
flow assigns the stored URI to window.location.href. Only the CSP stood
between that and a navigation. Both now decide through one policy in
oauth.helpers.ts.
Widening the scheme set means the deny-list has to carry its own weight:
it grows by the browser, extension and OS-handler schemes that were
previously blocked only as a side effect of the dot rule, including
ms-msdt (Follina) and the ms- namespace by prefix. intent: is denied,
because Chrome follows its embedded fallback URL, which turns it into an
open redirect the authorization server cannot inspect.
Fixing registration alone would have moved the reporter's [::1]:{port}
failure one step later: TREK matched redirect URIs byte for byte while
RFC 8252 section 7.3 frees the port for loopback, so a native client on
an OS-assigned port got a 302 to consent and then invalid_redirect_uri.
The matcher now relaxes the port, and only the port, for http loopback.
Stored URIs are never canonicalised, so the %3A round-trips untouched.
Note this tightens POST /api/oauth/clients for inputs it accepts today.
That is the security half of the change and applies at create time only,
so no already-registered client changes behaviour.
Closes #2227
|
||
|
|
9ed88adf34 |
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 |
||
|
|
bc10fba389 |
feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning (#2226)
* feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning A plugin whose author has not updated its `trek` range for the running TREK can now be installed, updated and activated when the operator sets TREK_PLUGINS_IGNORE_TREK_RANGE to a truthy value. Every gate (registry picker, post-extraction re-check, sideload, dev-link, activation) warns instead of refusing; a missing range is tolerated the same way; "install latest" takes the newest published version. The plugin-API version gate is not lifted and the Discover compatibility verdict stays truthful. The admin is warned at every step: each bypass is logged, install/upload/link/ update responses carry a `trekRangeBypassed` marker, and both admin panels show a "Version checks off" pill, an "Install anyway" confirm dialog before a registry install, the same notice after a path that could not ask first, and a persistent chip on the row while the plugin runs outside its range. Nine new i18n keys in every locale; wiki pages updated (Environment-Variables, Admin-Plugins, Plugin-Development, Security-Hardening). * refactor(plugins): one useRangeBypass hook behind both admin shells SonarQube flagged PR 2226 at 10.8% duplicated new code: the range-bypass logic had been added to the desktop and phone plugin panels as identical copies. The marker/warning types, the row chip, the "Install anyway" offer, the dialog copy and the confirm-or-notice state now live in useRangeBypass (next to useInstanceSettings, the existing shared-logic precedent); each panel keeps only its own dialog/sheet markup. * docs(claude): record the SonarQube gate rules and the no-English-placeholder i18n rule The CI SonarCloud gate (Sonar way, on new code: >=80% coverage, <=3% duplication, A ratings, hotspots reviewed) was only discoverable by waiting 20-25 minutes for the workflow. The root CLAUDE.md now states the conditions, the desktop/mobile shell-mirror duplication trap and its shared-hook fix, and how to check a PR with the sonarqube MCP first; client and server CLAUDE.md point at it. shared/CLAUDE.md says every locale needs a real translation — parity cannot tell an English placeholder from a translation. * docs(claude): keep CLAUDE.md to commands, invariants, gates and philosophy Verified every concrete claim in the five CLAUDE.md files against the code and fixed the stale ones: the websocket server is the Nest realtime gateway, MCP tools live in <domain>.mcp.ts files, the client "native shell" section described code that was never written, offline maps also prefetch vector basemaps, plus several renamed files and wrong table names. Then trimmed everything that drifts on its own: dates, migration history, counts, and inventories of stores, slices, domains, sync files and CLI commands. What stays is the commands, the invariants with the file that enforces each one, what CI actually runs, the reference implementations to copy, and the rules for new code. Each file now opens with that rule. |
||
|
|
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. |