mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
8fe90d1783d657a628b1e8faa7d100a5d7e17fbd
166 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bc85ec1eea |
fix: the follow-ups the v4.2.1 audit turned up
Five findings from auditing the release branch, none of them a blocker for it, all of them things it would have shipped with. The mirrored booking price only survived an edit when the form happened to fill metadata of its own. Both reservation forms and the phone sheet sent `metadata: null` whenever the form had no metadata key to send, and null clears the column outright — so restaurant, event, tour, parking, other, a hotel with no check-in times and a transport with no carrier details all lost the price again on the next save. They send the object now: it still clears what the form dropped, and the server carries the price across (#2233). Your share counted expenses nobody has paid for, while the balances right below it stopped counting them. The tile contradicted the panel it sits in (#2225). App.tsx kept its own list of routes that must not mount authed-only widgets, and it had already drifted from the one the response interceptor uses: this one named /public/journey/, that one covers all of /public/. It reads isAuthPublicPath now, so a route cannot be public to one and not the other. LLM_TIMEOUT_MS and TREK_PLUGINS_IGNORE_TREK_RANGE shipped in .env.example but not in the compose file most installs actually read. And a typo in the Russian offline copy: Завершёна → Завершена. |
||
|
|
973ce5f8ff |
fix(mobile): give the trip map a way back to every day
The phone map filters down to one day and had no way back out. The chip rail can only swap one day for another, the second tap on the active chip is the day sheet, and the seed guard deliberately does not re-seed — so a day picked on the map stayed picked until you left the trip and came back (#2257). Desktop has had the deselect since the day header got it. Its own round control beside the rail, map view only: the plan timeline is single-day, so there is nothing to widen there, and the day sheet keeps the one route into it that also exists on the map. It toggles — the first press parks the day it came from and frames the whole trip, the second puts that day back with its route, or falls back to the focus day if a remote delete retired it in between. Leaving the map restores it too, since an empty timeline would be the worse answer. |
||
|
|
1b71b8633d |
fix(offline): the PWA starts offline again and the download does what it says
Four separate defects sat behind "offline mode is broken". The app shell. On the first online launch after a version bump, App.tsx deleted EVERY Cache Storage bucket and unregistered EVERY service worker, then reloaded. That is exactly wrong for a Workbox autoUpdate build, which swaps its precache atomically on install and never leaves an empty one. Between the purge and the completion of a fresh 22 MB precache the origin had no shell at all; anyone who closed the app or lost signal inside that window could no longer start the PWA offline. It also threw away the map tiles and documents the user had downloaded on purpose. It now asks the worker to fetch the new build and reloads once it has taken over. The version marker also moves inside the guarded path, because it was written after the purge and outside its try: a throwing setItem left the caches gone, the marker unwritten and the reload unreached, repeating on every launch. The reported version is validated before it reaches this device's storage and before it is compared against the stored marker, so a value that is not a release tag can neither be persisted nor start an update on every launch. The session. loadUser's offline branch kept the persisted session but never called onAuthSuccess, the only caller of setAuthed(true), and the mutation queue's flush, syncAll and prepareForOffline are all gated on it. A session that launched without network could therefore never sync again for the rest of its life, even after the signal returned: the offline settings buttons spun and returned instantly having done nothing, and queued edits never uploaded. It also left reads pointed at the anonymous database instead of the user's own. The date rule. A trip is only cached when it has not ended, and one that ended over a week ago is actively evicted. The per-trip switch was an opt-out list, so ticking a finished trip changed nothing at all. For anyone whose trips are all in the past, "Download for offline use" stored none of them, reported success, and the cache stayed at zero. Switching a trip on now pins it, which overrides both the skip and the eviction, and the switch shows the honest position rather than promising storage that never happens. What the screen says. A sync that never started (already running, offline, signed out) was indistinguishable from one that stored everything: the progress bar painted full and a green "Ready for offline use" appeared over a run of nothing. Both syncs now report why they stopped and the screen says so. A rejected Dexie read no longer renders as "No trips cached yet", and the clear button stays reachable when the database holds data that no bookkeeping row points at. A run that rejects outright says so too: the trip list it starts from is a plain fetch, so a network blip took the whole thing down and left the last progress frame on screen looking like a finished download. The 401-with-HTML branch in the API client now confirms a reachable proxy before tearing the worker down, since several of TREK's own routes answer 401 with a text/html body. Both settings shells move onto one shared hook rather than growing the same logic twice, and the tile prefetch caps its depth when the browser refused persistent storage. Opaque tiles are billed at about 7 MB each, and an origin that is not exempt from eviction loses its whole bucket, precached shell included, which Workbox then never refills because sw.js has not changed. Closes #2228 |
||
|
|
d77f1c1758 |
fix(oauth): accept private-use redirect schemes and loopback ports
POST /oauth/register refused every single-label private-use scheme, so an
MCP client registering workbuddy:// could not connect at all, while the
error it got back advertised "a private custom scheme" as supported.
The cause was two validators that had drifted apart, both of which run on
registration. The provider required a dot in the scheme name, which RFC
8252 section 7.1 only recommends and which protected nothing
(javascript.evil:// passed it). The service behind it had no
custom-scheme branch whatsoever and no [::1], which is what actually
answered the reporter's second symptom, and its localhost exemption was
not gated on http:. So javascript://localhost/%0aalert(1), blob:, about:,
ftp: and wss: were all accepted through the settings UI, and the consent
flow assigns the stored URI to window.location.href. Only the CSP stood
between that and a navigation. Both now decide through one policy in
oauth.helpers.ts.
Widening the scheme set means the deny-list has to carry its own weight:
it grows by the browser, extension and OS-handler schemes that were
previously blocked only as a side effect of the dot rule, including
ms-msdt (Follina) and the ms- namespace by prefix. intent: is denied,
because Chrome follows its embedded fallback URL, which turns it into an
open redirect the authorization server cannot inspect.
Fixing registration alone would have moved the reporter's [::1]:{port}
failure one step later: TREK matched redirect URIs byte for byte while
RFC 8252 section 7.3 frees the port for loopback, so a native client on
an OS-assigned port got a 302 to consent and then invalid_redirect_uri.
The matcher now relaxes the port, and only the port, for http loopback.
Stored URIs are never canonicalised, so the %3A round-trips untouched.
Note this tightens POST /api/oauth/clients for inputs it accepts today.
That is the security half of the change and applies at create time only,
so no already-registered client changes behaviour.
Closes #2227
|
||
|
|
61bef334d5 |
feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning (#2226)
* feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning A plugin whose author has not updated its `trek` range for the running TREK can now be installed, updated and activated when the operator sets TREK_PLUGINS_IGNORE_TREK_RANGE to a truthy value. Every gate (registry picker, post-extraction re-check, sideload, dev-link, activation) warns instead of refusing; a missing range is tolerated the same way; "install latest" takes the newest published version. The plugin-API version gate is not lifted and the Discover compatibility verdict stays truthful. The admin is warned at every step: each bypass is logged, install/upload/link/ update responses carry a `trekRangeBypassed` marker, and both admin panels show a "Version checks off" pill, an "Install anyway" confirm dialog before a registry install, the same notice after a path that could not ask first, and a persistent chip on the row while the plugin runs outside its range. Nine new i18n keys in every locale; wiki pages updated (Environment-Variables, Admin-Plugins, Plugin-Development, Security-Hardening). * refactor(plugins): one useRangeBypass hook behind both admin shells SonarQube flagged PR 2226 at 10.8% duplicated new code: the range-bypass logic had been added to the desktop and phone plugin panels as identical copies. The marker/warning types, the row chip, the "Install anyway" offer, the dialog copy and the confirm-or-notice state now live in useRangeBypass (next to useInstanceSettings, the existing shared-logic precedent); each panel keeps only its own dialog/sheet markup. * docs(claude): record the SonarQube gate rules and the no-English-placeholder i18n rule The CI SonarCloud gate (Sonar way, on new code: >=80% coverage, <=3% duplication, A ratings, hotspots reviewed) was only discoverable by waiting 20-25 minutes for the workflow. The root CLAUDE.md now states the conditions, the desktop/mobile shell-mirror duplication trap and its shared-hook fix, and how to check a PR with the sonarqube MCP first; client and server CLAUDE.md point at it. shared/CLAUDE.md says every locale needs a real translation — parity cannot tell an English placeholder from a translation. * docs(claude): keep CLAUDE.md to commands, invariants, gates and philosophy Verified every concrete claim in the five CLAUDE.md files against the code and fixed the stale ones: the websocket server is the Nest realtime gateway, MCP tools live in <domain>.mcp.ts files, the client "native shell" section described code that was never written, offline maps also prefetch vector basemaps, plus several renamed files and wrong table names. Then trimmed everything that drifts on its own: dates, migration history, counts, and inventories of stores, slices, domains, sync files and CLI commands. What stays is the commands, the invariants with the file that enforces each one, what CI actually runs, the reference implementations to copy, and the rules for new code. Each file now opens with that rule. |
||
|
|
6cbefd23e8 |
feat(studio): filter photos by entry, upload from Studio, and switch stops off
The content browser gets a filter over the journey's pictures: all of them, the ones no entry holds, or one entry at a time, with the day and the place under each name. An entry card in the Entries tab reaches its own pictures the same way. Pictures can now come in without leaving Studio, into the entry the filter names or into the gallery, from the panel or dropped straight onto the page, and a photo element can fill its page or the whole spread in one press. Stops can be taken out of the arithmetic. A journal entry carries a stats_excluded flag; an entry that has it is off the route, out of the distance, out of the countries and not a step, while staying in the journal. Studio lists every stop in the travel panel with a switch, the entry editor carries the same switch on desktop and phone, and the entry card says when a day is off the route. |
||
|
|
b82962f95b |
Feat/plugin instance actions (#2209)
* feat(shared): scope plugin action descriptors * feat(plugin-sdk): instance-scoped settings actions * feat(plugins): run instance-scoped actions from the admin settings dialog * feat(admin): instance-scoped plugin actions in the settings dialog * fix(plugins): surface instance actions without instance fields and harden the action gate * docs(plugin-sdk): note that hosts before 4.2.0 ignore action scope * fix(admin): open the dialog for instance actions, explain inactive 404s, guard the save window * docs(wiki): plugin pages for instance actions and the admin panel changes |
||
|
|
7c857173cf |
fix(costs): three-decimal currencies stay typable and refunds work on iOS (#2175, #2176)
The split and receipt fields rejected the third decimal a KWD or BHD amount seeds, on desktop and in the mobile sheet, which share the currency-aware guard now. Signed amounts get a sign toggle on the numeric input because the iOS decimal pad has no minus key, and the under/over hint no longer swaps direction for a negative total. |
||
|
|
1e6ab069cd |
Fix/sdk default (#2199)
* feat(plugin-sdk): settings-field default + unknown-attribute warning
* feat(shared): default on the plugin settings-field contract
* feat(server): persist and serve plugin settings-field defaults
* feat(client): pre-fill plugin settings defaults in the admin and user forms
* feat(server): enforce required plugin settings on both save paths
* feat(client): block plugin-settings save on an empty required field
* feat(server): resolve plugin settings defaults at runtime
A settings-field `default` was stored and served to the form but never
reached the plugin: the child's init envelope, `ctx.settings.get()`, the
channel dispatch's readAll and the OAuth provider config all read the
stored blob only. Fold `stored ?? default` in on every path that hands
config to a plugin (settings-defaults.ts), and let a default satisfy
`required` on both save gates — refusing a save the runtime would have
accepted was a contradiction.
Also: one `isFilled` rule shared by the save gate and `hasRequired`
(checkbox exempt, whitespace empty); the install parser drops a
non-boolean checkbox default or a select default outside its options,
matching what the SDK already refuses; export SETTING_FIELD_KEYS so the
SDK's mirror can be parity-tested.
* feat(client): share the plugin settings form rules across all three forms
The mobile user settings screen was a third consumer of the fields/config
contract that never got `default` pre-fill or the required check, and
the seed/patch loops lived in five hand copies. Extract
seedSettingsValues / findMissingRequired / settingsPatch into one module
and use it from the admin instance form, the desktop user tab and the
mobile screen. The user forms now surface a 4xx `{ error }` from the
server (the required-field refusal) instead of the generic toast.
* feat(plugin-sdk): seed dev fixtures from manifest defaults
`trek-plugin dev` handed the plugin only the fixture values, so a plugin
relying on its manifest defaults read undefined in dev and only in dev.
Seed ctx.config / ctx.settings from the declared defaults (a fixture
value still wins) via a new exported settingDefaults(manifest, scope).
SETTING_FIELD_KEYS is now parity-tested against the host's list.
* docs(plugins): document settings defaults as runtime fallbacks
A `default` is the field's effective value wherever nobody set one, not
just a form pre-fill; `required` is enforced on both ends; unknown
attributes are dropped at install and warned on by validate. Add a
Settings section with the attribute table to the SDK README and update
the ctx type comments, the shared schema, and the wiki guide.
* add missing exports
|
||
|
|
639c40f8de |
fix(packing): a bag weighs what everyone put in it, not just what you can see
A bag's weight was summed in the browser over the item list the viewer is allowed to see. Personal items belonging to other members are filtered out of that list by design (#858), so their weight vanished with them: two people looking at the same shared bag saw two different totals, and neither saw the real one. That number is then measured against `weight_limit_grams`, an absolute airline limit. A per-viewer subtotal there is not merely incomplete but wrong — a shared bag could sit over its limit and warn nobody. The sum now happens in SQL over every row and ships as `total_weight_grams` on the bag, with `unassigned_weight_grams` alongside so the "no bag" pile and the grand total follow the same rule; a screen mixing true totals with per-viewer ones would be worse than either. Only integers cross the wire: a member learns that a bag is heavier than the items they can see, never a name, category, quantity or owner. That disclosure is deliberate and bounded, and it is what the issue asks for. The item lists stay viewer-scoped, so #1767 ("someone shared it with you, but they are the one bringing it") is unchanged — it just no longer decides what a bag weighs. Totals also had to stay live, because the reported repro is two people looking at the same screen. A private item's event reaches only its owner, which is the very rule that made the totals wrong, so item writes now also ping the room with a content-free `packing:bag-totals` and everyone re-reads the numbers. All three surfaces — the bag sidebar, the bag modal and the mobile sheet — read the server figure and fall back to the local sum only for a bag cached offline before this change. Closes #2191 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S5VwmiBQ9sPCJwfmpv64r3 |
||
|
|
b7f9c1e8ae |
feat(journey): make the linked trips' GPX tracks a switch, off by default
#1260 drew the GPX tracks of every trip behind a journey onto its map, with nothing to switch off — the reasoning being that a route imported into a linked trip is part of the journey's story. For a trip carrying a season of recorded drives it is instead a map nobody asked for, drawn from places that never became an entry. So it becomes a journey-level setting, owner-only like the other Journey Settings, and it starts off: a journal should show what its author put in it, and #1260's set is everything the linked trips happen to contain. The switch gates the request, not just the rendering — the endpoint ships full unthinned geometry, which is megabytes fetched on every journey open for a map nobody wanted. `journeyTracks()` itself is deliberately untouched, so TREK Studio keeps the set its saved books were laid out with. Also fixes what the reporter's screenshot actually shows: JourneyMap fed track coordinates into `fitBounds`, so one drive across a country zoomed the journey map out until the entries were specks. Tracks now only frame the map when there is nothing else to frame, which is what JourneyMapGL already did — the same journey used to open at two different zooms depending on the renderer. Closes #2194 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S5VwmiBQ9sPCJwfmpv64r3 |
||
|
|
46a89e7a90 | fix(map): surface geolocation failures as a localized toast | ||
|
|
03bd5c77af | fix(weather): day-local named anchor, cache ttl and past-days forecast window (#2167) | ||
|
|
c21e86e3bd | feat(planner): surface and edit day-assignment notes across desktop, mobile and pdf (#2163) | ||
|
|
2e0c496a93 | fix(planner): edit details handoff from the transit journey dialog (#2148) | ||
|
|
37d7365707 | feat(collab): markdown poll questions, multiline inputs and wrapping options (#2177) | ||
|
|
d5a3427cbb | fix(packing): accept weight, bag and quantity on create across REST, MCP and plugin RPC (#2154) | ||
|
|
2834967919 | fix(i18n): drop the self-hosted wording from the airtrail description | ||
|
|
3472d06e62 |
feat(admin): rebuild the addons page as a tile grid with sub-shelves
The three stacked sections become one grid of cards per group, with the type shown on the tile itself. Bag tracking, the collab features and the photo providers hang off their parent addon as compact shelf rows instead of full-width siblings, and each group header counts how many of its addons are on. Along the way: - naver_list_import, airtrail and llm_parsing get catalog entries, so their names and descriptions translate like everyone else - dark detection moves into a shared useIsDark hook that watches the .dark class instead of re-deriving settings + matchMedia locally - the ToggleSwitch knob uses --accent-text so a light accent in dark mode no longer renders white-on-white |
||
|
|
394201cd32 |
feat(plugins): instance-wide settings editor in the admin UI (#2170)
* chore: bump version to 4.1.0 [skip ci] * fix(collections): stop findDuplicateCollectionPlace merging places by coordinates alone Three places answered "is this a place the trip already has?", not two: PlacesService.findDuplicatePlace and isPlaceDuplicate got the shared strategy list in #2130; CollectionsService.findDuplicateCollectionPlace was missed and kept its own hand-rolled copy of the pre-#2130 order. Unlike findDuplicatePlace, this one had no isPlaceDuplicate() guard in front of it — savePlace calls it directly — so the coordinate fallback for a NAMED candidate was live, not latent: two distinct places sharing an address (a restaurant and the bar upstairs) would report each other as duplicates the moment their coordinates round to the same ~11 m box, regardless of name. It also never read google_place_id/google_ftid/osm_id at all, even though every collection_places row stores them, so a place renamed since it was saved could be saved again instead of being recognised by its provider id. Now walks placeMatchStrategies() from @trek/shared, same as findMatchingPlaceId: provider id, then name, then coordinates only when there is no name. savePlace passes the provider id fields through since they are already on the request body; the two bulk call sites (importablePlaces, saveFromTripPlaces) are unchanged, since their source rows don't carry provider ids to pass. Closes #2137 * fix(collections): ask the same question at all three call sites savePlace was not the only caller of findDuplicateCollectionPlace. The bulk copy already carries google_place_id/google_ftid/osm_id into the row it writes but asked without them, and the import picker never selected them at all, so a renamed place was recognised on one path and not on the other two. The picker answering differently from the import is the drift the method exists to prevent: a row shown as new would come back refused. COLLECTIONS-SVC-102 and -103 cover the bulk copy and the picker. * fix(map): give the Leaflet map its own zoom ceiling, so a vector basemap cannot take the planner down Opening a trip on 4.1.0 could land on the error boundary with "Map has no maxZoom specified", and so could the map settings tab, which left no screen from which to pick a different basemap. Leaflet answers getMaxZoom() from the map options first and otherwise from a layer that brought one, and only a GridLayer ever contributes via its beforeAdd hook. A vector basemap is a GL canvas on a plain L.Layer, so it contributes nothing, and MapContainer set no ceiling of its own. MarkerClusterGroup.onAdd refuses an infinite one by throwing, before any clustering, so an empty trip hit it too. That path opened when the default basemap became a MapLibre style. It reaches further than an operator choosing OpenFreeMap: resolveTileUrl sends a keyless CARTO template to the app default, which is now a vector style, so every instance still carrying the old CARTO basemap fell into it on upgrade. The ceiling belongs on the map rather than on the base layer, because it has to hold whichever of the three branches renders. 19 matches what the raster and satellite layers already carried, so nothing changes for the maps that worked. SharedTripPage gets the same treatment: it has no cluster to throw, but it draws the same vector basemap and its fitBounds asks for a ceiling. mapZoomCeiling.test.ts pins Leaflet's own semantics against the real library rather than a mock, since a mocked react-leaflet would have kept passing through all of this. * fix(map): put the CARTO key back on the template the settings preview draws The map settings fields hold what the user is editing rather than what useTileUrl already resolved, so the preview received a bare template with no key on it. resolveTileUrl then read it as a keyless CARTO url and did what it is meant to do with one: fall back to the app default. The preview drew OpenFreeMap while the fields said CARTO, whether or not a key was saved. Same reason the tab crashed for anyone on a CARTO basemap even after entering a key, which is the loop that made this hard to escape: the key is entered on the page the fallback had taken down. FE-COMP-MAP-033b pins the template the preview is handed, with and without a key. * fix(admin): put the CARTO key back on the default-settings map preview too Same shape as the user-facing map tab, and reported alongside it: the field holds what the admin is editing, so the preview resolved a keyless CARTO template and drew the app default instead of the basemap being set. * fix(mobile): put the CARTO key back on the phone map settings preview too The third preview with the same shape, and the one that matters most while the crash is live: the phone layout is where someone would go to change the basemap when the desktop tab will not open. * fix(calendar): a fully timed stay is its two hand-overs, not a week-long bar Closes #2136 A stay was always exported as an all-day block across its whole range, and the check-in and check-out markers came on top of it. When the stay records both ends of its clock the markers already say everything the block does, and the block is the half nobody can act on: it takes a week of calendar to repeat what two one-hour events state precisely. Same shape as the parking and rental windows in #2068. Only where the markers actually stand in for the booking, though. They are emitted once per stay and titled from its lowest-id reservation, so a second room on the same stay would otherwise be left with nothing at all. A stay that knows only one end keeps its block too, since nothing else carries the other end's date. CAL-025b covers the drop, -025c the one-sided stay, -025d the second room. * fix(mobile): the phone admin map preview needs the CARTO key too The fourth preview with the same shape, missed when the other three were fixed. Sonar pointed at it: the desktop and phone admin pages are near-identical, so the duplication report named the file the change had not reached. * chore: bump version to 4.1.1 [skip ci] * feat(plugins): admin editor for instance-wide plugin settings The scope:'instance' settings a plugin declares had API endpoints but no UI. - shared: plugins.schema.ts — the settings-field descriptor and the admin instance-config GET/PUT wire contracts, imported by both sides - server: GET /api/admin/plugins/:id/config now returns the declared fields alongside the (masked) values; PUT re-spawns an ACTIVE plugin (its child reads config once, at init) and reports it via 'restarted'; the admin list carries instanceSettingsCount so the UI gates its menu item without a fetch - client: 'Instance settings' row action opening a form modal (desktop) / MSheet (phone) in both admin shells — checkbox/select/secret rendering as in the user settings form; an untouched secret mask is never sent back - i18n: three new admin.plugins.* keys in every locale * refactor(plugins): move the admin body contracts to @trek/shared The remaining plugin DTO schemas (install, link, activate, uninstall, retrust, update, egress-hosts, user-settings update, route) now live in shared/src/plugins/plugins.schema.ts with their inferred types, alongside the instance-config contracts. The journey-contract doctrine notes (#1842) moved with them — each body stays deliberately loose so the handlers keep the rejections and fallbacks they already own, and new spec tests pin that looseness so a well-meant tightening breaks a test that explains itself. plugins.dto.ts is now thin createZodDto wrappers over the shared schemas (same class names, no controller changes), and the client's pluginInstall types its options and body against PluginInstallRequest. * fix(admin): offer Allowed hosts only to plugins that declared operatorEgress The row's egress chip was already gated on the declaration, but the kebab menu (desktop) and row action sheet (phone) offered Allowed hosts on every installed plugin — inviting the admin to widen egress for plugins that never asked for it. Both entries now render only when the plugin declares operatorEgress; the dialog's unsupported-notice fallback stays as the truth-teller for stale list data. * test(admin): close the Sonar gaps on the instance-settings form The PR gate flagged new-code coverage (76.4%) and duplication (10.9%). - The two identical 39-line handler blocks were logic, not markup — they move into one shared useInstanceSettings hook; each shell keeps its own markup (the deliberate desktop/phone divergence stays) - New tests per shell cover the untested branches: every field type renders and round-trips into the save payload (select, checkbox/MToggle, number, hint, no-label key fallback), a failed fetch is a toast, a rejected save shows the server reason and keeps the edits, and the dialog/sheet closes without saving - INS-009 covers the controller's no-body '|| {}' fallback --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: michael-bohr <263459669+michael-bohr@users.noreply.github.com> Co-authored-by: Maurice <mauriceboe@icloud.com> |
||
|
|
eb7d595203 |
feat(admin): show the MCP tools a plugin will advertise
The manifest declaration only becomes an admin-visible control if the admin can actually read it. Nothing rendered capabilities.mcpTools, so approving mcp:tools meant approving text you would first meet inside a chat. The pre-install dialog now lists the tool names and descriptions, beside the existing access and egress sections, and the grant gets an at-a-glance chip like every other capability. Desktop and mobile both, since the panels are parallel copies. |
||
|
|
21f4a1241b |
feat(plugins): mcp:tools permission and the mcpToolProvider hook
Modelled on the hook:* family rather than on geolocation:read. The host dispatches INTO the child for every tool call, so it needs what HOOK_PERMISSION buys: providersOf()'s active-and-holds-the-grant check, the second gate in invokeHook, and the acting-user binding. That last one is the point. The user is bound host-side from the supervisor's invocation map, so a tool called by user A reaches A's data whatever the model puts in args. The permission keeps no hook: prefix because the surface it opens is MCP, not a TREK render slot, and nothing keys off that prefix. It is the first HOOK_PERMISSION value shaped that way; `satisfies` still holds. One commit, because it cannot be split. requireTotalCoverage makes a HOOK_PERMISSION key with no @PluginHook consumer a boot failure, not a test failure, and PluginRpcRegistryService.onModuleInit runs in every buildApp() — so a half-landed version breaks every integration test in the repo. The generator is chained to it too: it refuses to run without both the en admin label and the CLI hint. 15s is the longest budget of any read hook. The others back a render someone is waiting on and fall back when they time out; this one backs a chat turn, and the plugin is likely calling a third-party API of its own. Still under the supervisor's unused 30s default, which is a hung client rather than a budget. One callTool fn for every tool, since tool names are runtime data and hookContracts() is keyed by (hook, fn). PluginDefinition exists in two copies with no generator between them, so both get McpToolProvider. grantGaps and the dev mock host need no change: both read HOOK_PERMISSION, which is generated. PLUGHOOK-010, plus the CONTRACTS row. |
||
|
|
092223c259 |
feat(mcp): plugins:use OAuth scope
One coarse scope rather than per-plugin granularity. Which plugins are
installed is runtime data, so a per-plugin scope cannot be statically
enumerated: it would break the ${group}:${mode} derivation and the
AssertExact guards in nest-mcp-policy.ts, and its consent copy could not
be written ahead of time for a third-party plugin name. Tokens issued
before a plugin existed could never carry its scope either. Per-plugin
granularity already exists one layer down as the admin's mcp:tools grant.
'use' is a fourth mode, which widens ScopeMode. Nothing else moves:
McpAccessGroup and McpAccessMode derive from Scope, so both AssertExact
guards self-heal, and the policy's fallback branch already covers a third
mode the way it covers files:content.
The presets needed an explicit exclusion. Five of the six are written as
"everything except deletes", so plugins:use would have joined Claude.ai,
Cursor, Windsurf and Zed silently and handed out third-party tool
execution without anyone choosing it. That filter was duplicated across
the desktop and mobile files with nothing pinning it, so both now use one
shared constant, and FE-OAUTH-SCOPES-012 holds the line for the next
scope like this one.
The consent copy says what the scope does not do: the RPC host authorises
from grants frozen at construction, so this scope plus weather:read still
reaches whatever the plugin itself was granted.
Two i18n gates, not one. The dot-only group key is tracked by
i18n-parity.mjs, whose regex has no colon in its character class; the
colon-bearing label/description pair is invisible to it but is compared by
client/tests/unit/i18n/parity.test.ts, which diffs merged bundles. All 23
locales get all three keys, including the three in neither gate.
The consent glyph gets a ':use' branch. Falling through to the eye would
tell the user a tool-running scope only looks at things.
|
||
|
|
84b51a8af5 |
refactor(places): one door onto the place-matching rule
Two consumers decide whether a candidate place is one the trip already has, and they spelled the rule out separately: the bulk importers ask an in-memory set (isPlaceDuplicate), while findDuplicatePlace asks the database because its caller needs the matched row's id. The two had drifted — the SQL copy falls back to a coordinate match for a NAMED candidate, which the in-memory copy deliberately never does, since that would merge the restaurant and the bar at the same address. The order now lives once in @trek/shared as placeMatchStrategies(), and each consumer is a mechanical interpreter of it: provider id (the only part that survives a rename, #1550), then name, then coordinates and only when there is no name. Adds PlacesService.findMatchingPlaceId as the public door for an importer that has to link to the existing place rather than merely skip the candidate. findDuplicatePlace stays private behind it — it also returns google_ftid for the bulk importer's backfill, which is that caller's business and not part of "which place is this?". No behaviour change for either existing caller. The coordinate branch is currently unreachable from the one call site, which sits inside an isPlaceDuplicate() guard that has already matched on name and returned; the drift only bites once something calls it standalone. COORD_DEDUP_TOLERANCE and externalIdsOf move to shared and are re-exported from places.helpers, so collections.service and places.service keep their imports. |
||
|
|
b401da2264 |
fix(maps): default the Leaflet basemap to OpenFreeMap
CARTO started stamping "API KEY REQUIRED" across every keyless basemap tile on 26.08.2026. #2060 added a key field as the immediate answer, but it leaves a self-hosted instance needing an email exchange with CARTO before its maps look right, and every default in the client still pointed at CARTO. Default to OpenFreeMap Positron instead: no key, no account, no request limit, commercial use allowed, attribution required. OpenFreeMap serves vector tiles only, so the Leaflet basemap is a MapLibre style document rather than an XYZ template. maplibre-gl-leaflet hangs a GL canvas into Leaflet's own tile pane, which leaves everything above it alone - markers, GeoJSON, clusters, plugin layers, the custom panes, and the click handling, since Leaflet's CSS already puts pointer-events:none on that layer. maplibre-gl still arrives only through a dynamic import, so no chunk that draws a map grows by a megabyte. Switched: the trip planner, the MapView fallback every preview goes through, the journey map, collections, the atlas, the shared trip page and the offline tile prefetch. OpenFreeMap Positron and Bright join the tile presets in settings, the admin defaults and both mobile screens. A CARTO template that is already saved keeps working when a key backs it - that stays a supported choice. Without a key it is not drawn at all: resolveTileUrl falls back to the default rather than to a watermark, so an instance that upgrades with one in its settings sees the new basemap immediately and needs no migration. The stored value is left as it is, so the Map settings tab still shows what was chosen and the warning underneath it explains why - one sentence appended, in all 23 locales. OpenFreeMap asks for a credit of its own; OpenStreetMap alone under its tiles is wrong and a licence problem. attributionForTile() derives it from the URL rather than from a flag at each call site, and the shared trip map, which carried no attribution at all, now gets one. Offline maps keep working and get cheaper. A vector style is four things instead of one, so sync/glPrefetcher.ts walks the style document, its sprite, the Latin glyph ranges of each font stack it uses, the TileJSON - the planet path is versioned, so guessing it answers 200 with an empty body - and then the .pbf tiles. Over the Berlin bounding box TREK computes, raster z10-16 is 2237 requests and 34.6 MB and gives up above z16; vector z10-14 is 177 requests and 32.1 MB and stays sharp above that, because MapLibre scales the z14 tile rather than asking for another. The Workbox rule for tiles.openfreemap.org moves to CacheFirst and shares the prefetcher's cache, so browsing tops a downloaded region up instead of filling a second copy beside it - a versioned tile path can never be stale. Clearing offline maps clears it too. The atlas loses its second raster layer. It existed to warm the HTTP cache of neighbouring zoom levels, which a vector basemap does not need; a second one would only cost a second WebGL context. OpenFreeMap has no label-free style, so its labels are switched off on the live map instead - they are all symbol layers, while borders and coastlines are line and fill - re-armed on style.load so a theme switch keeps them off. Theme changes call setStyle in place rather than rebuilding the layer: a swap per toggle would leak a WebGL context, and browsers cap those in the low teens. |
||
|
|
9e0fb19e79 |
fix(studio): keep the auto layout inside the book contract
Auto layout wrote journey data straight into the document without holding it to the caps the contract sets, and the save path sends the document unvalidated - so the route refused the whole body with a 400 and the editor reported "couldn't save" for the rest of the session, because autosave kept offering the same rejected book. The everyday way in was the itinerary list: thirteen stops a page over two pages is eighty elements on a spread capped at sixty, so a run of twenty bare stops was unsavable and a longer one put two spreads over. Sixty was below what this app's own layouts draw, which makes it a bug with a constant in front of it rather than a limit, so the cap goes to ninety and the page keeps the design somebody chose. It stays a serialisation limit: the largest document the contract now admits is under four megabytes against the book route's eight. A long journal entry, a long place name, a journey past eighty countries and a long title each did the same thing on their own, and a bound story did it on open without anyone touching Auto layout. The caps become named exports on the contract and every producer builds against them, so the two cannot drift apart again - which is also why the stations chunk is derived from the cap and the salvage fixture counts off it. Fixes #2085 |
||
|
|
661777f81f |
feat(plugins): let a plugin attach a photo to a journal entry
A journal importer can create the journey, create its entries and write their stories, and then it stops: there was no way to give an entry a picture. The seven journal.* methods cover text and nothing else, so an importer reading an export archive could only upload its photos as trip files, which live on a trip and never reach the journey's gallery. journal.addEntryPhoto takes the bytes. Deliberately not a link to an existing gallery photo and not a provider asset: an importer holding an archive has neither, it has a file it just unpacked. The access decision stays where it already was. addPhoto answers null unless the acting user may edit the journey the entry belongs to, and null becomes RESOURCE_FORBIDDEN here, so a plugin reaches exactly the entries its acting user does. Everything the REST upload refuses, this refuses too: images only and no SVG, the operator's allowed-file-types setting, 10MB decoded, demo mode. The stored filename is the host's, never the plugin's, because the gallery dedupes on file_path. The object is written before any row points at it and removed again when the link is refused. JournalRpc moves into its own module for this. It now needs StorageService and the EXIF backfill out of MemoriesModule, and JourneyDomainModule exists precisely so places, assignments and the plugin host can reach a few journey functions without dragging both photo providers into their graphs. Discussion #1365 |
||
|
|
5a7b8d75aa |
feat(api): add GET /api/v1/stats for dashboard widgets
Homepage's customapi widget and everything shaped like it want a handful of numbers from one request; /api/v1/trips hands out the rows, but a widget maps fields, it cannot aggregate a list. The figures come from AtlasService.getTravelStats, the same call behind the dashboard's passport card, rather than from a second count. Visited countries carries years of rules — countries reached only by a flight, layovers that do not count, countries hidden by hand — and a second implementation would disagree with the card next to it within a release. That is also why the controller sits in atlas/ and not in public-api/: PublicApiModule stays a leaf, so the route comes to the data instead. TravelStatsController next door already does this for /api/auth/travel-stats. ApiTokenGuard is provided locally rather than imported with PublicApiModule — @UseGuards instantiates a guard in the declaring controller's module, so an export would still leave its TokenService unresolvable here. last_trip means the most recently started trip, not the most recently created one, and is null while every trip is still ahead. Closes #1367 |
||
|
|
1e1a80c199 |
feat(api): expose shortlisted places, detached bookings and the bucket list
Reading the surface back against real data turned up three things it dropped without saying so. Places with no day were invisible, because the payload only reached places through day_assignments. On a live instance that is half of them, and they are the ones carrying a coordinate and no claim about when — exactly what an integration can act on. They come back under `unplanned_places`, minus the hotel, which is already reported as an accommodation. Bookings with no day were dropped by the day filter. reservations.day_id is nullable and deleting a day detaches its bookings rather than removing them, so this is not a hypothetical. The bucket list had no endpoint at all. It hangs off the user rather than a trip, so it gets its own, and it answers whether or not the Atlas addon is switched on: the addon decides what TREK shows, not what a key can read. Also fixes ?include=notes returning the bare trip: places, notes and reservations are reported on days, so asking for one now implies days instead of quietly answering with nothing. The module stays a leaf. Reaching for AtlasService pulled AuthModule and the storage registry in behind it and broke the surface's own boot, so the one SELECT lives here like every other query in the file. |
||
|
|
d559fdda1d |
feat(settings): give API keys their own section
Minting a key for other software meant finding it under MCP, behind a heading about AI assistants and gated on an addon it does not need. It now sits on its own in Settings, above the MCP block, and works on an instance with MCP switched off. Deliberately separate state rather than another branch of the tab's shared hook: the section has to stand on its own, and that hook is already carrying forty fields through three levels of props. Translated into all 23 languages. |
||
|
|
4b120d39c8 |
feat(api): separate API keys from MCP tokens, add travellers
An MCP token drives every assistant tool; an API key reads trips over HTTP. Handing the same credential to a third-party integration gives it the whole MCP surface, so the two are now separate kinds on `mcp_tokens` and the kind sits in the WHERE clause — a token of the wrong kind is indistinguishable from one that does not exist. Also adds `travellers` to the trip payload: display name and an owner flag, no ids and no email addresses. An integration key reads its owner's itinerary; it is not a way to enumerate the people around them. Documents the surface in the wiki, since integrators need something to read that is not the source. |
||
|
|
9cddf683a5 |
feat(api): rate-limit /api/v1 and report when a trip last changed
Two gaps from the first pass, both about a client that polls. Rate limiting is keyed by user rather than by IP. A self-hosted integration and its owner's browser routinely leave the same address, and an IP bucket would let one starve the other; 120 requests a minute is generous for a sync surface and still bounded, so a runaway poll degrades its own integration instead of the instance. updated_at now ships with every trip, and its docblock says plainly what it is not: a content timestamp. It moves when the trip's own fields change — title, dates, currency, cover — and stays put when a place, a day or a note is edited, because the child tables carry no updated_at of their own. That also rules out the ?since= filter this was meant to enable: it would silently hide every trip whose itinerary changed, which is worse than no filter at all. Making it honest needs a schema change, not an API field, so the field is documented for what it is and the filter is left out. |
||
|
|
fd6381c814 |
feat(api): add a versioned read-only public API for integrations
TREK's REST surface is internal: it answers a session cookie, carries no version, and changes with the client. That is fine for our own frontend and useless for anyone building against us — the Dawarich integration people have been asking for since April needs something it can rely on not moving. So this adds a small one rather than opening the existing 549 endpoints: GET /api/v1/trips the trips the caller can reach GET /api/v1/trips/:id one trip, ?include= picks the sections Auth reuses the trek_ tokens users already manage under Settings, via Bearer or X-API-Key. No new credential type, no new table — the guard calls the same hash lookup the MCP transport does. It deliberately refuses a session JWT and, for now, OAuth bearers: a credential whose scopes this surface does not yet interpret is worse than one it rejects outright. Access is decided per trip against canAccessTrip, the same owner-or-member predicate as everywhere else, and a trip the caller may not read answers 404 identically to one that does not exist — a 403 would confirm the id is real and turn the endpoint into a way to count someone else's trips. The payload is not the rows. Ids, foreign keys and order_index stay inside; accommodation day ids resolve to ISO dates, places arrive in planned order, and every dated thing carries its date so a consumer can join on days rather than on ids it has no way to look up. |
||
|
|
d091f4a815 |
fix(shared): read the regex groups defensively in the datetime helper
shared compiles with noUncheckedIndexedAccess, so a capture group is string | undefined there even when the pattern guarantees it. Pull the groups through optional access with explicit fallbacks instead of indexing into the match, which also drops the two throwaway locals. No behaviour change: same inputs, same outputs, the spec is untouched. |
||
|
|
016fdeec64 |
fix(booking-import): read a printed 12-hour clock instead of slicing it (#2094)
splitIso reached the time by taking characters 11..16, so "2026-06-11T01:11 PM" became 01:11. The hour survived and the half of the day did not, which is why AM and 24-hour times in the same document imported correctly. The arrival then sorted before the departure and the router rolled it onto the next day, so a three-hour hop was rendered as an overnight. The client has done this properly since #1725; this is the server half, which was never pulled across. normalizeLocalDateTime rewrites a value only when it actually finds a meridiem, so a date-only value, a value carrying an offset and anything the parser does not know come back byte for byte. The mapper has only two exits for a date, toIsoString and splitIso, so this covers all eight types and every emitted field at once. It also stops sameConnection comparing a NaN timestamp, which silently merged unrelated legs into one booking. |
||
|
|
15588d20c9 |
fix(feeds): keep a staged booking out of the public feed and the shared trip
The ICS feed is public with a token that never expires and prints the confirmation number into the DESCRIPTION, and the shared-trip payload does a SELECT * over reservations with share_bookings on by default. Neither filters anything, so whatever lands on a trip is published the moment it is written. That is fine while a person types every booking in, and it stops being fine as soon as something writes on their behalf. reservations.ingest_state is that gate. It defaults to live, SQLite backfills every existing row on the ALTER, and no current writer names the column, so every booking visible today stays visible. Nothing writes staged yet. The predicate takes its table alias because share.service.ts queried reservations without one. A stay is withheld only when bookings point at it and all of them are staged: a hand-added hotel has no booking at all and has to keep showing up. |
||
|
|
ab3751761c |
feat(mcp): files and settings scope groups
Two domains an assistant cannot reach at all today need somewhere to hang, so this is the credential half on its own, ahead of the tools. files:content is a third mode rather than a second group. Listing the documents on a trip and reading the bytes inside a booking PDF are different privileges, and the plugin host already draws that line between db:read:files and db:read:files:content. It is not implied by files:write, the same way journey:share is not implied by journey:write; the policy's fallback branch covers both. settings:write is scoped to display preferences. Settings also holds the webhook URL, the ntfy token and the Mapbox, Carto and LLM keys, so the tools that ride this scope work off an allow-list rather than off the settings row. 34 scopes across 16 groups, translated into all 23 languages. |
||
|
|
1f66ef1d32 |
fix(maps): let users and admins enter a CARTO API key (#2060)
CARTO stamps an "API KEY REQUIRED" watermark onto every basemap tile fetched without a key since 26.08.2026, and both CartoDB presets plus the built-in fallback are CARTO. The key is free and needs no account, so add it as a setting rather than dropping the provider. carto_api_key sits next to mapbox_access_token: per user in Settings/Map, instance-wide under Admin/User Defaults, encrypted at rest, injected from CARTO_API_KEY on managed instances. Unlike Mapbox it never switches the map provider, since a raster key says nothing about the renderer. The key is appended as ?key= at render time instead of being stored in the template, so a saved URL stays portable and survives a rotation. That is also what reaches the four maps that pinned CARTO and ignored the user's tile setting: atlas, collections, the public trip share and the public journey. The two share pages read it from their token-gated payload, the same way baseCurrency already travels. Also lifts the release notice above page chrome. Its overlay sat at z-index 50, under the Vacay mode toolbar at 61, so the dialog was neither on top nor modal. Adds a six-step layer scale to index.css and moves the three notice shells onto it. Closes #2054 Closes #2052 |
||
|
|
800f97b8ce |
feat(mobile): swipe the day details to step through days (#2059)
* @ feat(mobile): swipe the day details to step through days The phone shell hangs the days off a chip rail pinned to the top of the screen, which on a large phone is a thin target a thumb cannot reach one handed. The day details fill the rest of the screen and did nothing. They now take a horizontal swipe: left for the next day, right for the previous one, with the panel following the finger, rubber banding at the first and last day and springing back when the swipe falls short. The chip rail is untouched and still the accessible path; a committed swipe announces the day it reached through a live region, and the active chip scrolls itself back into view once the rail overflows. The gesture never calls preventDefault or stops propagation on a touch event and sets no touch-action, so the browser keeps sole ownership of the card scroll and of the header chip strip, and the long press reorder bridge keeps its own 10px self cancel. In edit mode the swipe also refuses to claim a press held past the bridge window. @ * @ fix(mobile): line the map controls up and widen the POI bar The compass sat on its own under the POI bar, centre top, while the locate button floated 42px above the dock on the other end of the screen. Both are one handed controls, so they now share the band just above the dock: compass bottom left, locate bottom right, off the same --bottom-nav-h the map already reads, a dock gap lower than before. That frees the row under the chip rail, so the nearby places bar takes the full width between the screen margins and its segments end up the size of everything else the thumb aims at there. Desktop keeps the floating, content width bar. @ |
||
|
|
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. |
||
|
|
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 |
||
|
|
502a3fd4e8 |
fix(shared): express the URL scheme check without a control-character regex
Same behaviour, and the root lint job stops failing on no-control-regex. The stripping is the point and has to stay: browsers drop everything at or below U+0020 before they resolve a scheme, so a tab spliced into 'javascript:' is still a javascript: URL to them. |
||
|
|
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. |
||
|
|
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. |
||
|
|
eadb3703bc |
fix(shared,tests): bound the book contract and close the gaps that hid all this
Book page setup and element geometry were unbounded numbers, so a shared book could be saved with zero, negative or astronomical dimensions and every consumer downstream - the canvas, the PDF export - had to cope. Day-note caps had drifted from the limits their own doc comment states, and icon had none. On the test side: the path filter meant a PR touching only the root lockfile ran no jobs at all, which is exactly the change class most likely to break a native binding; the canAccessTrip mock is hand-copied into 72 files and had already drifted from the real query; the path-traversal test asserted only status !== 200, so a 500 from a broken guard passed it; and one unit test shelled out to a real kitinerary binary if the host happened to have one. |
||
|
|
b163c1e05d |
fix(server): hold DELETE bodies to the same contract as every other write
The body-contract boot gate only looked at POST/PUT/PATCH, so a DELETE that reads a body slipped past it. Two do; one was already typed, the other read photo_id straight off an untyped Record. Give it a contract from shared like everything else, then add DELETE to the gate so the next one cannot repeat it. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
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.
|