mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
a3d1fc7808fedd8dcc658da5998627e8ee5f87a5
69 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3a0604b3f5 |
feat(config): put the operator-owned surfaces behind TREK_MANAGED (#1946)
* feat(config): put the operator-owned surfaces behind TREK_MANAGED The switch landed last release with nothing behind it. This is what it turns off, plus the four fixes that came out of walking every one of these surfaces. Nothing here changes anything for an install that does not set the flag. That is not a hope, it is what managed-inert.test.ts asserts: the mode stays off for any unparseable value, no other variable can turn it on, every settings key stays writable, and splitManagedKeys hands back the same object rather than a copy so no write path can quietly diverge on self-hosted installs only. WHAT THE MODE WITHHOLDS Fourteen routes carry @ManagedForbidden: key validation, static MCP tokens, JWT rotation, the OIDC admin pair, restore and auto-backup, plugin sideload and reload, the Ollama model list and pull, and the SMTP test send. Each names the reason it is not the instance admin's, and the reason is mandatory. Eighteen settings keys are filtered instead of refused. SMTP, the OIDC toggles and the WebAuthn pair share one handler with keys the admin does own, and the llm_* family is reachable through per-user settings, the instance defaults and the addon config alike, so a route-level refusal would be both too coarse and incomplete. A locked key is skipped and reported as managed_keys; the rest of the save goes through. Refusing the request would take the registration toggles down with the SMTP block. GET /api/auth/me/settings stops returning the three API keys in the clear. It is the only endpoint that decrypts a stored credential back to the caller, which is fine when the admin pasted it in and wrong when the operator supplied it. Two multipart routes check in the handler rather than the guard: a guard throws before the parser drains the body and the client gets an ECONNRESET instead of the 403 (PROFILE-015). They still carry the marker so the boot gate sees them. WHAT KEEPS IT FROM DRIFTING validate-managed-routes.ts refuses to boot on a marking that is not on the reviewed list, a list entry whose route lost its marking, or a marking with no reason. The third case has no equivalent in the guard gate next door and is the one that matters most: a blank reason destroys the only record of why a control was taken away, and nothing recovers that later. It found two wrong entries in my own list while I was writing this. managed-keys.test.ts asserts that every key in ADMIN_SETTINGS_KEYS and DEFAULTABLE_USER_SETTING_KEYS belongs to exactly one of two sets. A key added to either list belongs to neither until somebody decides, and that is the failure this prevents: the lists grow, the mode does not. PLACES_API_BASE AND PLACES_API_KEY The nine Places endpoints spell out googleapis.com, so an install that wants those calls to leave through something of its own has no way to say so. One variable, substituted where every call already funnels through. A second one supplies the key, and when it is set the users table is not consulted at all — a per-user key would route around whatever the endpoint counts, and those columns are readable by any admin of the instance. FOUR FIXES THAT ARE NOT ABOUT THE MODE An admin setting somebody's password now bumps password_version and clears their MCP tokens. An admin sets a password for one reason, and until now verifyJwtAndLoadUser kept accepting every cookie the intruder already had: the one action taken to lock them out was the one action that did not. The four OIDC provider calls go through the SSRF guard. Three are worse than they look — the token, userinfo and JWKS URLs come out of the discovery response, so they are chosen by the other end, not by an admin. safeFetchLlm is renamed safeFetchAdminConfigured and keeps its old name as an alias, because it is the variant that allows loopback and LAN: a self-hoster with Authentik on localhost:9000 must keep working. The token exchange follows no redirect at all, since one would hand client_secret to a second host. DELETE /api/admin/users/:id/mfa clears another account's TOTP. The passkey half has existed since passkeys landed; the TOTP half never did, which left "somebody on the trip lost their phone" with no answer short of an operator in the database. Never for the caller themselves. TREK_PLUGIN_PERMISSIONS=off refuses to boot when the install is centrally administered. A warning is right for a machine its owner chose to trust; where the person who set the flag and the people whose data is in the database are not the same person, fail closed. TEXTS Four strings lose the phrase "self-hosted" because they describe somebody else's software — an Ollama endpoint, an AirTrail instance, a service a plugin talks to — and read better without it in both modes. The one string that describes TREK itself keeps it, and the mode picks a second key instead, so self-hosters lose nothing. A grep gate in shared/ keeps the canonical locale honest, because the parity check compares the file set and the top-level keys and never looks at a value: re-wording a string is invisible to CI today, and so is putting it back. * feat(maps): supply the map credential with the instance when centrally administered MAPBOX_ACCESS_TOKEN joins PLACES_API_KEY as an operator-supplied value. SettingsService injects it when the settings are read, and picks GL as the provider if the reader is still on Leaflet, since a token is always present. The key moves from the customer set to the locked set. It is the one locked key whose value is public by design — a pk.* token has to reach the browser to be useful — so this is not about secrecy: a per-user save landing on top of the instance token would only break the map for that user. Both settings fields for it go away with it. * fix(auth): count the operator key when reporting whether Google features work has_maps_key only looked at the users table, but getMapsKey prefers the PLACES_API_KEY env over anything stored there. On an install that supplies the key that way and has an empty column, every lookup worked while the client was told there was no key and hid the search, the autocomplete and the place photos. Found by walking the running instance rather than the code: the map came up, the search box did not. * feat(admin): hide the settings a centrally administered install cannot act on A control that answers 403 is worse than no control: the AI section rendered the refusal as "Request failed with status code 403", which tells an admin they broke something rather than that the decision is not theirs. So the surfaces go rather than being disabled in place: - AI parsing and AirTrail leave the addon list, and the AI addon route now refuses outright. The endpoint, the model and what a document costs come with the instance, so there is no instance decision left — not even whether it runs. - Immich and Synology Photos leave it too. Both connect to a server the reader runs at home, and there is no route from here to one. - The SMTP block, the admin webhook and ntfy targets, and the passkey RP ID and origins follow the same rule: they configure something the operator owns, and a wrong RP ID on a shared parent domain reaches past this instance entirely. - The About tab goes. It is about the project — what TREK is, where to file a bug, where to support it — and is aimed at the person running the software. The version and the link to the source move to the sidebar footer, on both the desktop page and the mobile twin, because AGPL 13 wants that offer wherever people reach the software over a network. - The update check stops asking GitHub and reports nothing available. Separately, the version footers lose the "self-hosted" suffix everywhere, not just here. It read as a label on the software rather than on the install, and the version alone says what it needs to. * feat(config): refuse to boot centrally administered without an at-rest key Measured on a running instance rather than assumed: with ENCRYPTION_KEY unset, the at-rest key falls back to a file in the data volume, and backupService bundles that file into every archive it builds. The archive is downloadable by any instance admin, so the encryption ships with its own key in the same zip. The skip in backupService already handles this — it only omits the file when the key came from the env — but nothing made sure the env actually carried one. Provisioning sets it; forgetting it was silent. validateEnvAtBoot now carries a second kind of check next to the schema: cross- field rules that only apply with the flag set, for combinations where every value is individually valid and the result is wrong. Empty without the flag, so an install that never asked to be centrally administered cannot meet it. * feat(client): add an attachment point for install-specific screens Two empty exports on the client, an empty module on the server, and the four places that read them. Nothing here adds a route, a nav entry or an endpoint — the arrays are empty and the module contributes nothing, which managed-ext.test pins so it stays that way. Read unconditionally, never behind a flag. On the server a conditional import would tie the module graph to an environment variable, and the graph is built once per app while the flag is read live; on the client an empty array costs nothing to spread, so every build walks the same path instead of one build walking a path nobody tests. Anything attached here follows the same rules as the rest of src/nest and src/components: a real module with a controller and a provider, bodies through the Zod pipe, routes under the global guards, tests. * test(shared): move the wording gate out of src so shared typechecks again CI installs shared with its own dependencies only (npm ci --workspace shared), so there is no @types/node and the `node:fs` import inside src/ failed to compile. The tests passed the whole time — vitest supplies the module — which is why this only showed up in the typecheck step. The walk moves to scripts/, next to i18n-parity.mjs, and the spec imports it the same way that one does. It also runs on its own now (`node scripts/i18n-selfhost-wording.mjs`), which is what you want when CI names a line you have to go find. * test(server): repair the call sites typecheck:tests caught tests/ sits outside the build tsconfig, so none of this showed up in a green test run — which is exactly the gap `npm run typecheck:tests` exists to close. Three kinds, all mine: - Seven `import type` paths for RuntimeEnvService carried one `../` too many. Type-only imports are erased, so vitest never resolved them and nothing failed. - VersionCheckJob and PluginsController grew a constructor parameter and eleven hand-wired `new` calls kept passing the old count. The argument arrived as undefined and no assertion happened to read it. - ConditionContext grew `managed`, and the shared context object in the conditions suite did not. The condition also had no test of its own, so it has one now: both directions, since the registry uses `is: false` to keep a notice away and a notice that only belongs on a centrally administered install would use `is: true`. |
||
|
|
9dd0a851ca |
fix: eleven reported bugs across atlas, budget, collections, journey, vacay and the LLM settings (#1923)
* fix(mcp): accept per-leg times on create_transport and update_transport metadata was pinned to Record<string, string>, so a legs array could not get through and a multi-leg flight built over MCP lost every intermediate time. The booking ended up with correct endpoints and no legs, which every reader then treats as a single segment. legs is now a real, typed parameter with its contract in @trek/shared, shaped exactly the way the planner form and the importers write metadata.legs. The folding checks what the readers depend on: one leg fewer than endpoints (the AirTrail dedupe builds its signatures on that), day ids that belong to the trip, airport codes that agree with their endpoint, and the times, where a stopover endpoint carries the departure and the last one the arrival. metadata keeps its type and its replace semantics. Segments smuggled through metadata.legs as a JSON string used to be stored and then ignored by everything that read them; they are refused now and point at the legs parameter instead. Closes #1914 * fix(atlas): refuse a bucket list entry that is already on the list Nothing gave a bucket entry an identity, so the same country could be added as often as it was clicked. A wish is now identified by name, country, target date and, where it has them, coordinates. A different target date is a different wish and still lands, which is what the report asked for; two real places sharing a name stay separate because their coordinates differ. Empty string and NULL are normalised on both sides of the comparison, otherwise the dateless case, which is the one that was reported, would slip through. Deliberately no unique index and no migration. better-sqlite3 is synchronous and the server is a single process, so the race an index would guard against cannot happen here, and an index would have needed a destructive dedupe of existing rows first. create_bucket_list_item had no target_date parameter at all, so over MCP the allowed "same place, other date" case was unreachable. It takes one now. The plugin RPC reports the duplicate as bad params instead of letting it surface as an internal error. Closes #1898 * fix(settings): keep a free-form LLM endpoint in admin hands Any account could name the address the server calls, and the guard in front of it deliberately allows loopback and LAN so a self-hosted Ollama works. That is a reasonable trade for whoever runs the instance, because the network is theirs. It is not one for an arbitrary account, which could aim a request from inside that network at a target only it chose and read how the target answered. A free base URL, and the local provider that is one by another name, now come only from an admin controlled source: the addon config, the instance-wide defaults, or an admin's own settings. OpenAI and Anthropic stay available to everyone, because they go to a fixed address with the user's own key. The resolver is where this is enforced, since it is the single point both the booking import and the plugin host pass through, and the only one that also covers values already sitting in the database. The write path refuses as well, so nothing is stored that would never be read. The hint text promised something users no longer have, so it changed with it. Closes #1772 * fix(calendar): span a date-only booking to its end date A DATE event without DTEND is exactly one day per RFC 5545, so a multi-day booking that carries an end date still collapsed onto its first day. It gets an exclusive DTEND now, built the same way the stay branch already builds one. A booking without an end date serialises byte for byte as before. While in here: the stays query joins reservations on the accommodation, and two bookings pointed at the same stay fanned it out into two events sharing one UID. Duplicate UIDs in a feed are undefined behaviour and calendar clients pick one at random. The same "at most one booking per stay" assumption sat in the accommodations service, where it was worse than cosmetic: accommodation_id carries no foreign key, so deleting a stay took only the first booking and left the second pointing at something that no longer existed. Both the sync and the delete walk all of them now. The callers keep reading linkedReservationId; the list is additional. Refs #1869 * fix(vacay): say that the region is required, because it is The placeholder called the region optional while the add button stayed disabled until one was picked. The wording predates multiple calendars per plan, which is what introduced the gate. Only the value of the existing key changed, in all locales, so the key set stays identical. Two things the same screen got wrong were in the way of a clean answer: The button was clickable while the region list was still loading, which put the user in exactly the state the wording describes, and it is the state the report was filed from. It waits for the list now. Changing the country on an existing calendar kept the previous country's regions on screen, so a German region could be picked for the Netherlands. The list is cleared with the country, which only works in combination with the loading gate: without it, the empty list would happen to enable the button instead. Closes #1813 * fix(collections): let a saved place's address be corrected The edit form offered name, category and labels, and the address only on the way in. The cause was not the form: the update contract never declared address, so the validation pipe stripped it and the UPDATE had no column for it. Coordinates were pulled through for #1435 and the address was missed. It travels the whole chain now, contract to service to both forms. No migration, the column has always been there. The mobile add sheet had no address input at all, showing whatever the search result carried as read-only, so editing would have worked while creating still would not. It has one. The MCP tool description had drifted further than the address, never having mentioned the coordinates either. Both are in it now. Closes #1870 * fix(collections): keep the place search usable after the first save The add dialog stays open on purpose so several places can be added in a row, but after a save it gave no way back into the search. A search with no results was silent, with no empty state and no toast, so typing another name and pressing enter looked like nothing happened. The reporter described both halves and it is the silent search that matches "nothing happens" most closely. There is an empty state now, using a key that already existed in every locale and was unused. The search row is sticky inside the dialog body, so it stays reachable after the save instead of sitting above the scroll position. Also fixed while in here: refreshing the collection after each add reset the select mode, the selection and the label filter. In the very flow this dialog is built for, the user quietly lost all three. Closes #1921 * fix(vacay): make the phone year grid and the dock button do what they look like Two separate faults met on this screen. Tapping a day in the year grid did nothing unless the edit mode was already on, and the only way into it was a floating button that looks exactly like the one the dock draws in the same spot. A tap now opens that month in the editor, where the cells are big enough to hit. At 390px the grid cells are around 21px, which is too small to be mutating data with in the first place. The dock's plus was not wired for this route, so it fell through to creating a trip on the dashboard. Two different actions were stacked in one place, and the wrong one was on top whenever the screen had not painted yet, which includes the time its chunk is still loading. The dock leaves the slot to the screen now, and holds the geometry so the tab groups stay symmetric. Closes #1811 * fix(budget): settle in whole cents so balances stop drifting The report is a balance that moves on its own, with nothing added, edited or removed, on a trip where everything is in euro. Two mechanisms were behind it and both are real. The display currency is a fourth value, a per-user setting, so "everything is in euro" never covered it. Each balance was converted and rounded on its own, and the set no longer summed to zero: at 0.855 and 0.9312 it came out one cent over, at 0.94 it came out even. That cent appears and disappears with the six hour rate cache alone, which is exactly the symptom. The flows were rounded separately from the balances, so the two never had to agree. And a receipt line nobody was ticked for counted toward the total, and therefore toward the payer's credit, while landing in no share. That difference is permanent, and the client sent it that way. The settlement converts once, at the edge, and does integer arithmetic after that. Equal splits distribute the credited cents by largest remainder rather than the entered total, so credits and debits of one expense cancel exactly even in a foreign currency. Display conversion goes through the same largest-remainder allocation, so the sum survives it. With that, the 0.01 epsilon can go. It could never create a cent, but it stranded them: a one cent balance was filtered out of the flows while staying visible in the list, which is the "settle up offers nothing" half of the report. Closes #1382 * fix(journey): reach the book export and the suggestion toggle on a phone Below 1024px the hero card is dropped, and it held the only way to either action. The floating bar that replaces it was never given them. It has them now, and the hero is unmounted rather than hidden, so there is no second invisible copy of the same controls in the document. The bar's own buttons moved onto the appearance tokens while they were being touched. The phone screen never had either action at all and now carries both. The book itself was hardwired to English, cover and dates included, while the keys for it had been sitting in every locale unused. It takes the page's translator and locale, sets the document language, and prints mood and weather, which were on screen but never in the book. Closes #1848 * fix(journey): offer the connected photo providers in the phone entry editor The desktop editor grew an external photo source in #1632; the phone editor that came out of the mobile rewrite never did, so editing an entry on a phone had no way to Immich or Synology. It mirrors the desktop mechanics, including the part that is easy to miss: a save that creates the entry and then fails on the photos keeps the sheet open, and without remembering what was just created the retry creates a second entry. The sheet no longer kicks off its own addon load. The list is loaded once on boot and this only reacts to it; a second load races the other consumers and overwrites the store when it lands. Closes #1808 * fix(mobile): let the document scroll so Safari can collapse its URL bar iOS Safari only shrinks its address bar when the document scrolls, and TREK's never did: the root was locked and the mobile shell put a scroll container inside it. On the bottom tab layout that costs about a sixth of the viewport, permanently. Below 768px the document is the scroller again and the full-bleed screens measure their own height instead of leaning on the shell. That makes the body scroll locks real for the first time on a phone, and they were not ready for it. Three of them reset the overflow unconditionally, one of those on every run of its effect rather than on unmount, so a system notice could hand the page back while a sheet was still open. They all go through one reference counted lock now, which is the part that had to land first. Worth knowing for the issue: this reaches the flow screens. The trip planner, the journal, the atlas and the vacay screen keep their own full-height scrollers and still will not collapse the bar. Closes #1809 * fix(mobile): keep the screen gradient visible behind the translucent cards The gradient layer introduced with the scroll change sat at z-index -10. A negative layer paints above the canvas but below the backgrounds of ordinary blocks, and body carries an opaque one, so the gradient was hidden behind it. The mobile cards are translucent and take their depth from what is behind them, so every surface ended up compositing against a single flat colour and the whole phone UI read as one tone. The layer is positioned at z-index 0 now and the content above it at 10, which puts both over body's background. Same visual as before the scroll change, and the shell still owns no scroll container. * test(reservations): teach the accommodation delete stub about the id lists The RPC walks every linked booking since the delete stopped stopping at the first one. The stub still answered with the two singular fields, so the loop had nothing to iterate and the cascade events never fired. * fix(settings): move the free-form LLM endpoint out of user settings entirely Hiding it from non-admins closed the hole, but it left the option sitting in the admin's own personal settings, which is not what was asked for and not where it belongs: an instance has exactly one address it calls out to, and there was a second, invisible place to set it. The provider list is now the two hosted ones for everybody, the endpoint field is gone, and the resolver takes the address from the admin-controlled instance defaults for every caller including an admin. It no longer reads the users table at all. The write path refuses the keys for everyone, so nothing can be stored that would never be read. The endpoint is configured on the addon in the admin settings, which already carries provider, model, base URL and key, plus the Ollama model list. Nothing is lost, it just has one home now. Behaviour change worth a release note: an admin who set a personal Ollama endpoint has to move it to the addon config. A non-admin was already in that position after the previous commit. Refs #1772 * fix(journey): make the book preview usable on a phone The preview document carried no viewport, so a phone laid it out at its default 980px while the page is A4 landscape and about 1123px wide. It spilled out of the frame, which reads as being zoomed far in. Declaring the real width lets the browser scale the page to fit. Printing is unaffected, it goes by @page. The preview chrome had the same problem in miniature: a title and two labels on one row, sized inline, which does not fit a phone and fits translated labels even less. It moved to a stylesheet, and below 640px the card goes full-bleed, the decorative title steps aside and the two actions share the width. Refs #1848 * fix(mobile): stop the shell trapping the screens' own overlays Lifting the background layer into the positioned range meant the content wrapper had to be positioned too, and a positioned wrapper with a z-index is a stacking context. Every fixed overlay a screen renders inside it got clamped to that level: the vacay view/edit FAB sits at z-50 precisely so it beats the dock at z-40, and it disappeared behind it. The layer goes back to a negative z-index and the wrapper back to no positioning at all. What it needed in the first place was for body to stop painting over it, which is one line in the phone media block rather than a new stacking context. * fix(mobile): let the phone background override actually win A media query adds no specificity, so the themed body rule further down the file kept winning on source order and painted over the shell's background layer. The override is on html body now. The gradient was back to being invisible, which is the flat single-tone phone UI again. * fix(vacay): draw today's ring around the day instead of replacing it On the phone, today short-circuited the day-cell matrix and returned a ring on an empty cell. A vacation day or a company holiday logged for today was stored and counted in the totals, but the cell showed nothing, so it read as if the tap had not taken. Today is an annotation now, layered like the school band and the shared-calendar rings, which is how the desktop month card has always treated it. The strong ink stays only where the cell has no fill of its own, since --m-ink is light in dark mode and would disappear on a pastel. * fix(map): stop serving a stale GL style document from the service worker The Workbox rule for Mapbox matched every URL under api.mapbox.com, and the style document lives there too. Under StaleWhileRevalidate the map was built from the previous revision on every load, with the refresh landing one load too late, so a style republished in Studio with different label languages kept rendering the old ones. The network tab showed a 200 throughout, because the service worker's own answer is a real 200, which is why this looked like the app rewriting text-field at runtime. The style now has its own rule ahead of the tile rules, on NetworkFirst so it is current whenever the network is, and still cached so the map opens offline. Its own small cache too: sharing an LRU with three thousand tiles made the staleness come and go at random. OpenFreeMap styles had the same problem and are covered by the same rule. Closes #1924 |
||
|
|
3c5e406229 |
feat(places): link an expense to a place, the way bookings already do (#1917)
* feat(places): link an expense to a place, the way bookings already do Recording what a museum ticket or a guided tour cost meant leaving the place, opening the Costs tab and typing it in again — while a booking has had a Costs block in its own form all along. The place form now carries that block. Same component, same flow: with nothing linked it offers Create expense, which saves the place and then opens the Costs editor for a new expense pointing at it; once linked it shows the expense with edit and remove. budget_items gains place_id beside the reservation_id it already had, with the same nullable FK and the same ON DELETE SET NULL. Desktop and phone both, and the mobile editor carries the link through its own save path rather than the desktop one. The expense belongs to the place, not to a day assignment, so a place sitting on five days is not five tickets. Hanging it off the assignment would have meant adding a place to three days quietly creating three expenses and removing a day quietly deleting one. Deleting a place takes its expense with it in the same transaction and announces it, on every path that deletes a place: REST, both MCP tools and the plugin RPC. Closes #1298 * test(places): teach the RPC stubs about linkedExpenseIds The delete paths read the linked expense before the place is gone, so the two hand-built PlacesService stubs threw on the new method instead of reaching the hook, the remove and the broadcast — four failures out of one missing line. |
||
|
|
079064fdcd | refactor(server): delete trips.bridge via leaf membership reads and prompt relocation | ||
|
|
08dac57577 |
refactor(server): migrate plugin jobs off node-cron
The plugin host's job scheduler now uses the cron package (the one @nestjs/schedule wraps), so node-cron and @types/node-cron leave the dependency tree entirely. Verified against both real libraries that the accepted cron grammar is identical — no plugin's declared schedule tightens — and pinned that with an unmocked compat test. |
||
|
|
ba7d55f999 |
Finish the contract ratchet, the upload bridge and the client leftovers
BODY_CONTRACT_ALLOW_LIST is empty. The last seventeen handlers type their
body with a createZodDto class, so the fail-closed boot gate has nothing left
to forgive; it still refuses a boot for a new handler that reads @Body()
without one.
Several of those contracts describe rather than constrain, and that is the
whole care in this change. The pipe runs before the handler, so a schema
stricter than the handler moves a user-visible answer:
- TagsController.create answers { error: 'Tag name is required' }, pinned
byte-for-byte in two tests, and the pipe formats as `field: message`. The
DTO leaves name optional and the handler keeps the check that owns the
message. Same shape for the booking-import confirm body, which additionally
gains per-item validation the handler never did.
- PluginsController.activate and install answer 503 when plugins are disabled,
and check that BEFORE looking at the body. A schema that rejected first
would turn that into a 400 for an operator whose server has plugins off.
- setEgressHosts reads a non-array `hosts` as "clear the list", which is the
documented reset. Typing it as string[] would have removed a working admin
action rather than tightened anything.
- PluginRoutesController.route answers { route: null } at 200 for everything
it dislikes, so the client falls back to straight lines. A 400 there would
turn a graceful degradation into a visible error.
files.bridge is gone. The multer options are built by
MulterModule.registerAsync from an AllowedFileTypesService leaf, so the
fileFilter reads the operator's extension list from the container instead of
from a second FilesService built outside it. Nest injects
MULTER_MODULE_OPTIONS with @Optional(), so a token that fails to resolve is
not a boot error: it silently falls back to defaults, with no filter and no
size cap. FILE-020 already proved that for trip files by uploading an .svg
over HTTP and expecting 400; journey uploads had no such case, and now do.
VIDEO_UPLOAD is untouched, including the filename callback that forces .jpg
regardless of what the client sends, which guards the public share proxy.
PluginFrame gets an onError. A frame whose document fails to load left a
blank element at full height, which reads as a hung app rather than as a
plugin that is gone.
typecheck:tests runs in CI now, with the test tree at zero errors. tz-lookup
is left as it is: the build puts it in a lazy helpers chunk, not the entry
chunk, and the remaining split would trade a synchronous render for an async
one to move 172 kB that is already off the critical path.
|
||
|
|
7078ac7a4d |
Keep the mobile FX widget off the network after unmount
Client CI was red on this branch while dev was green, and no client file had changed. The 536 test files all passed; the job failed on three unhandled rejections, all `window is not defined` from MDashWidgets, attributed to whichever case happened to be running when they landed. The widget fetches api.frankfurter.dev directly and sets state from both handlers with no unmount guard. MSW runs with onUnhandledRequest: 'warn', so that request was never intercepted: it left the runner, came back after the environment was gone, and React tried to schedule an update against a torn down tree. Load dependent by construction, which is why dev happened to pass. Two halves, because either alone leaves the other broken. The widget now aborts in flight work on unmount and on refetch, and treats an abort as "no one is waiting" rather than as a failure to report. That matters outside the runner too: this is a third party call on a mobile screen a user can leave immediately. The handler set gains the third party endpoints the app calls directly rather than through /api, so no suite reaches the real host. An empty rate list is the shape the widget already handles. Also clears twenty more test files, each at its root cause. Two more were passing for the wrong reason: oidc.service.test.ts built AuthService with an AtlasService in the membership slot, undefined for userCleanup and mailer, and the mailer stub in the tokens slot, the same shift admin.service.test.ts had. And CAL-010 claims to serialise a flight byte for byte, but createReservation ignores reservation_time, so the row is written with a null time and CalendarService drops it: the recorded snapshot is truthful about the data the case builds and silent about the flight it advertises. |
||
|
|
49c3c57c31 |
Give the ephemeral tokens a provider, and clear the next wave of type errors
ephemeral-tokens.ts was free functions, and AuthService owned the cleanup timer only because it happened to be the domain that mints tokens. Issuing them and sweeping them are different jobs, and FilesService and TokenService mint or consume them too. EphemeralTokenService owns the lifecycle and hands the three operations out. The store and the interval stay module state next door, and that is load-bearing rather than a leftover: src/websocket.ts consumes ws tokens on the upgrade handshake, outside the container, before any injector is in reach. Per-instance state would hand it an empty Map and close every connection with 4001, which no test would catch because the ws suites drive the transport directly. WS-027 through WS-029 pin the session gate that runs through exactly that path. The MAX_STORE_SIZE back-pressure is the second reason: createEphemeralToken returns null at 10,000 live tokens and TokenService turns that into a 503. A second store would raise the real ceiling with nobody noticing. Its own leaf module rather than a provider in AuthModule: TokensModule mints download and ws tokens as well, AuthModule already imports TokensModule, and TokensModule is documented as a deliberate leaf that three graphs reach for. Same shape as MailerModule, which broke AuthModule <-> NotificationsModule. Twelve hand-wired construction sites in tests needed the new collaborator. The typecheck named all twelve; before this branch they would have been undefined at runtime. Sixteen more test files are cleared here too, each at its root cause. Four of them were passing for the wrong reason: files.controller.test.ts drove two handlers with shifted arguments, a string tripId where the trip row belongs and undefined where tripId and id belong. It stayed green because FilesService is fully stubbed and the stubs ignore what they are given, so DELETE /:id/permanent and DELETE /trash/empty never covered that the trip row reaches the permission check. conflictUpdate.test.ts was missing real collaborators on PlacesService and PackingService. None of the exercised paths reach them, so it passed by luck. assignments.service.test.ts builds its service by hand, and the one consumer of the collaborator it was missing swallows every throw in a bare catch. A broken journey reconciliation would still have shown 28 green tests. scheduler.test.ts mocks two modules with paths that resolve one directory above the server root, so they mock nothing at all. Harmless only because src/scheduler.ts imports neither. |
||
|
|
f691da8f7e |
Read online users through the facade, not a lazy require
admin.service.ts reached getOnlineUserIds with require('../../websocket')
inside a try/catch — the last lazy require of the ws singleton in the tree,
and exactly what RealtimeService exists to prevent. The facade gains the
third pass-through next to broadcast and broadcastToUser, delegating at call
time like its siblings so per-file vi.mock stubs keep flowing through.
The catch stays at the admin call site rather than moving into the facade: an
admin list that shows everyone offline is a better answer than a 500, but
that judgement belongs to this caller, not to every future one.
Typechecking the tests turned up two more things behind the arity errors.
admin.service.test.ts built AuthService with an AtlasService in the
membership slot and no mailer at all, so its last four collaborators were
shifted by one. Nothing failed, because no case there reaches a path that
uses them.
Its getOidcSettings/updateOidcSettings wrappers referenced methods AdminService
no longer has. Nothing called them: OIDC settings moved to OidcService and the
two cases moved with it (ADMIN-SVC-047/048 now live in oidc.service.test.ts).
Only the wrappers stayed behind.
|
||
|
|
4780a7be90 |
Typecheck the test tree, and fix what it found
server/tsconfig.json has include: ["src"], so tests/ was never typechecked.
A service that gained a constructor parameter did not break its hand-wired
call sites at compile time — the argument arrived as undefined and threw at
runtime, if anything read it at all. The notifications sweep hit 46 such
sites and only a stash-diff against a throwaway tsconfig found them.
tsconfig.tests.json makes that permanent. It is not wired into CI yet: the
tree still reports 170 errors, and turning it on now would block every
unrelated PR.
Three of the first fixes were not type noise.
DAY-SVC-034 asserted that createAccommodation rolls back a failed
reservation insert. That method moved to AccommodationsService in the
days/accommodations split, so the case was calling undefined — which throws,
which satisfied .toThrow(). The rollback has been unchecked ever since,
while the case reported green. Repointed at the owner; the invariant holds.
host-rpc.test.ts typed its response as RpcResponse & Partial<RpcError>.
Both carry ok, one as true and one as false, so the intersection is never
and all 97 property reads in the file were errors nobody could see.
auth.controller.test.ts passes {} as the body in cases that stub the service
and assert on the mapping around it. That is correct as written — the point
of several of them is that the error path never reads the body — so anyBody()
names the intent instead of inventing fields the assertions do not read.
Three changePassword calls were genuinely missing the @Res() argument.
|
||
|
|
d95aa49c7f |
Split the trip aggregate root into its parts
TripsService injected fourteen other services. Almost all of them were there for two read aggregates - the MCP trip summary and the offline bundle - and for the member roster, not for anything the write path does. Reading is what fans out; writing is not. Membership goes to trip-members/: listMembers, addMember, removeMember, transferOwnership and the four guest methods, plus the six routes, which keep their paths. It is a NEW module, not the trip-membership/ leaf next door: deleting a guest erases their plugin data and re-splits their expenses, so it needs auth and budget, and AuthModule already imports that leaf. Giving it those dependencies there would have closed a hard cycle. This one is a sink - nothing imports it back except trips. The read aggregates go to trip-read-model/, which is where the nine-service fan-out now lives. What is left is the write core plus day generation, on eight parameters instead of fourteen: database, reservations, days, permissions, budget, vacay, realtime, unsplash. The last four are all reachable from updateTrip's date-shift transaction, which is why the count does not fall to three. Trips still reaches budget through ReservationsModule, so trips.bridge.ts survives this. Saying otherwise would have been the easy claim to make and it would not have been true. |
||
|
|
3d522be0e5 |
Give accommodations one home instead of two halves
The routes lived in reservations/ while the SQL lived on DaysService, so the controller delegated every call across a domain boundary to reach its own data, and neither module owned the thing outright. The seven statements move into AccommodationsService unchanged and the domain gets its own directory, module, DTO, RPC and MCP surface. What stays in days/ is the reorder side: assertNoInvertedAccommodation and resyncAccommodationDays are about re-dating day rows, and the stay is the thing being carried, not the thing doing the carrying. Two edges fall out of this. AccommodationsService drops its DaysService dependency, and DaysMcp drops DatabaseService and PlacesService, because create_place_accommodation was their only user - which lets DaysModule stop importing PlacesModule and closes one leg of the latent Days -> Places -> Assignments -> Days cycle. DAY-SVC-016..026, 029 and 035 move to accommodations.service.test.ts with their case ids intact. The reservations e2e container assembles both modules now: it builds a single domain module on purpose, which is exactly what catches a move like this, and it caught it. |
||
|
|
ad312cdd43 |
Give day notes a domain of their own
days/ carried two fachlichkeiten with a full file set each: days.controller, days.service, days.mcp, days.rpc, days.dto next to the five day-notes ones. The note files move out unchanged; because the new directory sits at the same depth, not one import path inside them changed. The two modules deliberately do not reference each other in either direction: dayExists is raw SQL on the days table in the note service, and DaysService reads day_notes the same way, so a module edge would buy nothing and cost a cycle. DayNotesModule has to be registered in PluginsRuntimeModule as well as AppModule - DayNotesRpc owns four of the 113 wire methods and the registry boots with requireTotalCoverage, so leaving it out would stop every test app that assembles the plugin runtime. The days e2e container assembles both modules now: the note routes still nest under the days prefix, and a container that builds only DaysModule would 404 on them while production served them fine. |
||
|
|
6680bc7c74 |
Split PluginsModule into four, and gate coverage per domain
PluginsModule was 21 controllers, 24 domain imports and 10 providers in one class, which made it the hub most of the Nest graph ran through: AdminModule imported it for a single cascade-disable call and inherited all of it. It is now PluginsRuntimeModule (supervisor, capability router, hook contracts), PluginContributionsModule (the 14 read-only hook controllers, moved to contributions/), PluginOAuthModule (the credential leaf, moved to oauth/) and a composition root that keeps the install and delivery controllers. AdminModule imports the runtime alone. The three plugin files that reached for the db singleton alongside the injected DatabaseService no longer do. plugin-host-state and signature-status take the connection from the caller, and the three per-user settings functions that were bare exports 'so the RPC host wiring can call them directly' are now PluginUserSettingsService - the host wiring is providers, so it injects them. Coverage moves from one repo-wide 80% to a per-domain ratchet. The single number let a well-covered domain subsidise a thin one: booking-import sits at 50% and integrations at 20%, and the aggregate still cleared the bar, so either could have lost another ten points unnoticed. Each domain now carries its own measured floor, regenerated by scripts/coverage-thresholds.mjs. Two test-side findings on the way: expect(undefined).toContain(SomeClass) PASSES in this vitest version - it only throws for a string needle. Every '<X> is listed in its module providers' guard was written that way, so all twelve of them silently stopped guarding the moment their class moved. They go through expectRegisteredProvider now, which asserts the array shape first. The atlas region test paid for the one-off decompression of the multi-MB admin1 bundle inside its own 5s budget, which is why it failed under load and passed alone. The bundle is warmed in beforeAll instead. |
||
|
|
f72aba0c91 |
Finish the plugin RPC decorator rollout
The last 20 wire methods move onto the decorators: db.* and the three unconditional methods (DbRpc), meta.* (MetaRpc) and the host-mediated surface of users/broadcasts/notify/ai/oauth/scheduler (HostSurfaceRpc). That closes the ledger at 113/113, so the legacy has() blocks in rpc-host.ts are gone and the router is down to what was never domain-specific: build the granted subset of the dispatch map, dispatch, audit, map errors. PluginHostDepsFactory was the 26-argument wiring sheet behind those blocks. With the handlers in the domains it has two dependencies left, so it is now PluginRpcHostFactory: the plugin's own sqlite handle, the inter-plugin router bound with this plugin as the caller, and the audit sink. The 18 host-to-plugin hook calls get the same treatment. They used to spell out the fn name and the timeout at each controller, which meant nothing checked that a hook:* grant on the consent screen had a consumer behind it, that the fn matched what the SDK documents, or that two call sites agreed on the budget. PluginHooks declares all 18 with @PluginHook and performs them, and the controllers call it by name. With both halves complete, requireTotalCoverage goes on: a KNOWN_METHOD without a handler, or a hook without a consumer, now fails app boot instead of surfacing as a PERMISSION_DENIED nobody can explain. |
||
|
|
611f94e820 |
Consolidate the schema-error fallback and cover the defensive branches
Aiming the new files at 95% rather than at the 80% gate turned up something worth fixing rather than testing around: `error.issues[0]?.message ?? 'bad input'` had been copied into twenty handlers. That is twenty two-branch fallbacks that no test can reach, because a real Zod failure always carries at least one issue. They are now one schemaMessage() in rpc-params.ts, with one test that does reach both sides. The rest of this is the branches the handlers inherited from the router: the `!= null` link ternaries, the "service said no" paths, the ValidationError and NotFoundError translations, and a reject case per schema-validated method. Two limits found while measuring, both real rather than laziness. istanbul counts each @PluginController line as a branch and only ever takes one side, so eight branches across the rpc files can never be covered and a two-method file caps out around 50%. And the three update schemas stay deliberately permissive, because the migration's rule is parity over tightening, so their reject branch is defence in depth no payload reaches. Everything else went from 50 uncovered branches to 29. |
||
|
|
294cfcd8d7 |
Move the addon-gated domains onto the decorators
collab, journal, atlas, vacay and collections: 32 more methods, so the registry owns 93 of 113. rpc-host.ts is at 565 lines, down from 1332, and the deps factory at 384, down from 944. These five are the risky group, because their addon gates lived inside the deps factory's closure rather than in the router. A handler that calls its service directly still works, just without the check, and neither the compiler nor the router tests would notice. So addon-gates.rpc.test.ts walks all 37 gated methods as one table and asserts two things for each: with the addon off it is refused with that addon's message, and the service is never reached. The table length is pinned, so a later method added without a row is a failure rather than a silent gap. One ordering detail worth knowing: on the atlas writes the router evaluated its params before the addon gate inside the closure, so a malformed country code still reports BAD_PARAMS rather than the addon. The handlers keep that order. The collections service reports refusals as status-tagged errors rather than typed ones, so mapCollectionError came across with the handlers: 403 and 404 become RESOURCE_FORBIDDEN, 400 and 409 become BAD_PARAMS, and anything untagged stays a HOST_ERROR instead of being dressed up as a permission problem. |
||
|
|
010242b107 |
Move costs, reservations and accommodations onto the decorators
Eleven more methods, so the registry owns 61 of 113. rpc-host.ts is at 771 lines,
down from 1332.
costs is the first addon-gated domain, and the addon check sits in a different
place in each of the two reads: inside the membership callback for getByTrip, so a
caller without trip access never learns whether the addon is on, and before
everything for listMine, which has no trip to check. Both orderings are asserted.
The writes keep their own refusal wording ("no permission to edit costs on trip N")
instead of going through requireTripEdit, which words it differently.
The booking paths are cascade-heavy and stay that way: creating a reservation can
create an accommodation and a budget item, deleting one removes both again, and
deleting a lodging block takes its partner reservation and budget item with it.
Each cascade is a broadcast the web app relies on, so they are asserted by event
name and order.
CostsRpc reaches the trip list through trips.bridge rather than by injection.
BudgetModule cannot import TripsModule, because TripsModule already imports
BudgetModule; budget.mcp.ts uses the same escape for the same reason.
|
||
|
|
2ec6d006b0 |
Move the trip surface onto the decorators
Twelve more methods, so the registry owns 50 of 113. rpc-host.ts is down from 1332 lines to 830 and the deps factory from 944 to 500. trips.update keeps its two field-level gates: archiving and changing the cover each need their own admin-configurable permission on top of trip_edit, so a plugin cannot do either on a trip it may otherwise edit. Member management keeps its own grant for the same kind of reason, since adding a member grants trip access, and the owner still cannot be removed through it because that would orphan the trip. canEditTrip stays in HostDeps for now: the db:meta gate still resolves an entity's edit permission through it and has not moved yet. |
||
|
|
c326d39a07 |
Move places, days and itinerary onto the decorators
Eight more methods, so the registry owns 38 of 113 and rpc-host.ts is under 1000 lines for the first time. itinerary.* lives in assignments/, not in days/, because AssignmentsModule already imports DaysModule and putting the handler the other way round would have closed a cycle. It keeps riding on day_edit, which is what the app gates it on. The delete path in places carries an ordering constraint that is silent when broken, so it has two tests of its own: the journey hook has to run BEFORE the row goes, because journey_entries.source_place_id is ON DELETE SET NULL and afterwards there is nothing left to detach, and the place id has to be scoped to the trip before the hook runs at all, because the hook keys on the place alone and would otherwise detach another trip's entries even though the delete itself is refused. |
||
|
|
dbb0016cac |
Move the file surface onto the decorators
Six more methods, so the registry owns 30 of 113, and the deps factory is down to 660 lines from 944. Files are the awkward corner of the plugin surface and stay that way on purpose. Reading a file's bytes is a separate grant from reading its metadata, because a passport scan is more sensitive than its filename, and the three writes use three distinct rights instead of one domain action. FILES-RPC-006 pins that by watching which permission each operation actually asks for. This is also the only plugin path that touches disk, so the tests lean on it: the extension blocklist, the size cap checked before the read rather than after, a path that resolves outside the uploads root, and the demo-account upload refusal that mirrors the REST guard. |
||
|
|
ca88676858 |
Move packing onto the decorators and de-duplicate its privacy fan-out
Nine more methods, so the registry owns 24 of 113. The #858 privacy logic existed three times: PackingService had the primitives, the controller layered emitToViewers and broadcastUpdate on top privately, and the plugin deps factory carried a standalone copy of all four, with a comment asking for them to be kept in lockstep by hand. Migrating packing meant either copying them a fourth time or fixing that, so the two private controller methods moved into PackingService and the factory copy is gone. Controller and PackingRpc now call the same code, and the controller suite proves it by passing with all 45 assertions unchanged. The four public/private transitions get one test each, because each one is a way to leak a private item to the whole trip room, plus a test that the privacy is read before the write rather than after: reading it afterwards would report "already private", skip the room delete, and leave the item on every member's screen. |
||
|
|
62978aab5a |
Move five more domains onto the plugin RPC decorators
categories, weather, rates, todos and daynotes: 11 more methods, taking the registry to 15 of 113. Handler bodies and messages are verbatim; the ledger test tracks the split. Two things the move surfaced rather than introduced. PluginGuards could not stay in PluginsModule: that module imports 22 domain modules, so a domain importing it back for the guards would close a hard cycle and the only way out would be forwardRef. It now lives in a PluginGuardsModule leaf that imports nothing which leads back, the same shape MailerModule got for AuthModule. And the todo and daynote writes broadcast. Those events are not decoration, they are what keeps an open session from showing stale data after a plugin write, so they came across from the deps factory with the handlers, and there is a test that a failed write broadcasts nothing. Worth knowing for the next domains: todos are edited under 'packing_edit', not under a todo-specific action, and TODO-RPC-003 pins that. Each new class also asserts it is actually listed in its module providers, since a decorated class nobody registers is invisible to discovery and answers PERMISSION_DENIED with no other symptom. |
||
|
|
9b3d37ea29 |
Move the tags plugin surface onto the decorators
Wires the registry into the host and takes the first domain across, so the kit lands with a worked example rather than as scaffolding nobody uses yet. The registry is an additive, optional fourth constructor parameter on PluginRpcHost, so all 107 existing construction sites keep compiling untouched; that is what makes the rest of the rollout a per-domain move instead of a big-bang one. bindInto runs LAST in the constructor, so a method still owned by one of the has() blocks collides loudly instead of being silently overridden. tags is the right pilot: four methods, a service that is already DI-native, and it exercises the read/write permission split, the userless refusal and a BadParams write. Handler bodies and messages are verbatim; only deps.foo(...) became this.tags... and uid became ctx.actingUserId. dispatch now strips the supervisor's _inv marker. The supervisor resolves the acting user from it before dispatch and then passes the request on untouched, so it has been visible to every handler and is in effect a reserved param name. Nothing reads it: auditResource takes named fields only, and the one handler that touches the params object as a whole (packing.setBagMembers) reads a named field off it. Dropping it keeps a future strict parse from rejecting calls every shipped plugin makes. Two tests carry the rollout from here. rpc-coverage.test.ts derives both owner sets from the code and proves they partition the method vocabulary exactly, so a PR that moves a method and forgets to delete the legacy registration fails in CI. And because a decorated class that no module registers is invisible to discovery, with every call to it coming back PERMISSION_DENIED and no other symptom, tags.rpc.test.ts asserts TagsRpc is actually listed in providers. |
||
|
|
6b6fbbd94c |
Lift the plugin RPC resource gates into an injectable
A decorated handler cannot reach the router's private tripRead / requireActor / requireTripEdit, nor the canEditTripAs and requireAddon that live inside the deps factory's closure. PluginGuards holds all of them so a *.rpc.ts class can inject what it needs. Every message is copied character for character, including the two asymmetries that look like typos and are not: a trip read says "trip reads", while requireActor appends the word "writes" to whatever noun it is given, and some domains throw their own inline variant rather than calling it at all. The tests pin the exact strings, not just the refusal, because shipped plugins read them. |
||
|
|
7b11680cc3 |
Add a decorator kit for the plugin RPC surface
The method-to-permission association exists twice today: declaratively in METHOD_PERMISSION, and imperatively as 44 if (has(...)) blocks in rpc-host.ts, with nothing asserting the two agree. protocol-paths.test.ts checks that each method has some permission, never that it is the same one the router gates on, and plugin-audit.ts inherits any drift silently. @PluginMethod binds the two together. Its permission argument is typed as MethodPermission<M>, so it resolves to the single literal the table assigns to that method and a mismatch does not compile. validate() re-checks the same argument against the same table at boot, because types evaporate under an as cast. @PluginHook does the same against HOOK_PERMISSION. UNCONDITIONAL_METHODS was missing from envelope.ts, so the three methods the router registers outside every grant check had no vocabulary of their own. Adding it here, with a compile-time proof that it stays disjoint from KNOWN_METHODS: giving those three a METHOD_PERMISSION row would change which methods isAuditable considers, and settings.get is deliberately the one unconditional method that is not audited. The kit is written to stay extractable into its own package, so an ESLint rule pins what it may import. Note the rule is a deny list on purpose: these patterns are gitignore-style, and a leading ** would exclude the parent directory of every allowed path, which makes the ! re-includes silently ineffective. |
||
|
|
7d0e90eb47 |
Make the plugin permission facts live in one place
The hook->permission map existed verbatim twice (the host supervisor and the plugin SDK) and the permission list four times (envelope, SDK manifest, and both admin panels in the client). All of them were in sync when measured - 63 entries each, 15 hook entries each - so this changes no behaviour. It removes a quiet outage: a hook whose permission is added on only one side fails silently in production, with the plugin installing, activating and then simply never being called. protocol/envelope.ts is the single source now. METHOD_PERMISSION was typed Record<KnownMethod, string>, so its value side was unconstrained - 'db:read:trps' compiled and made the method unreachable forever. It is `as const satisfies Record<KnownMethod, KnownPermission>` now, which catches a typo'd value, a missing key and an extra key. The derived KnownPermission and MethodPermission<M> are what the @PluginMethod decorator will check its argument against. Deliberately no explicit annotation on METHOD_PERMISSION: with one, typeof is that Record, MethodPermission<M> widens to the whole union, and the check silently evaporates. server/scripts/gen-plugin-facts.ts imports envelope.ts and writes the SDK and shared copies. It does not scrape. The guard it replaces pulled the block out of plugin-supervisor.ts with a regex that goes null on a reformat, and it ran only from prepublishOnly - drift would have surfaced as a broken npm release. The generated files are checked in because publish-plugin-sdk builds from a standalone checkout with no server present. The SDK's re-exported types stay wide. plugin-sdk compiles under strict, and grantGaps indexes HOOK_PERMISSION with a plain string, so an `as const` there is TS7053 and would break the publish. Strictness belongs on the host side. --check also fails on a permission with no admin.plugins.perm.* translation and on one the CLI has no hint for. Both are silent today. plugin-sdk/** was in no workflow path filter, so nothing in CI ever ran its tests. It is in the filter now, alongside a job that runs the drift check. |
||
|
|
f0f69df67e |
Fold the journey domain into Nest and finish the MCP migration
journeyService (1324 LOC, 36 exports) and journeyShareService (203) become JourneyDomainService and JourneyShareService. Every SQL statement, error string and broadcast is the one that shipped. The two could never be split: the share service gates on isOwner, which lives in the domain service, so it injects it. JourneyDomainModule is a leaf carrying just the two services. Places, assignments and the plugin host each need a handful of journey functions; importing JourneyModule for that would drag MemoriesModule and both photo providers into their graphs. Same shape as TrekPhotosModule. The 23 tools in src/mcp/tools/journey.ts become JourneyMcp. src/mcp/tools/ is down to _shared.ts and registerTools() no longer fans out to anything. journey.bridge.ts keeps four exports, for the journey resources in src/mcp/resources.ts that register at import time. McpAccessMode is host-augmentable now. journey:share is neither read nor write, so the three share tools would have had to be opaque predicates - and predicates bypass the access policy entirely, invisible to the boot-time scope gate. A scope typo in one would ship as a silently ungated tool. McpAccessModeRegistry mirrors McpAccessGroupRegistry; unaugmented, mode stays 'read' | 'write'. Fixes a latent bug in tests/helpers/mcp-test-controllers.ts: it constructed PlacesService with five of eight arguments. tsconfig only includes src, so nothing typechecked it, and the missing dependencies were harmless until the journey skeleton hooks landed on the place write paths. journey.mcp.ts arrived from src/mcp/ at 14.5% branch coverage - the legacy registrar had no test file of its own. 40 tool tests take it to 93.4%, and 25 cases for the skeleton lifecycle, the photo gallery and the preference row take the domain service from 73.9% to 82.8%. |
||
|
|
a7d789fdd5 |
refactor(server): move weather out of services/, give its cache sweep a lifecycle (#1835)
Finishes be-weather-airports-fold. The README has listed weather as the reference implementation since the migration started, while 513 lines of it sat in the legacy layer. The file is nest/weather/weather.impl.ts now, and WeatherService owns what it could not own before: the cache sweep. That sweep was a bare setInterval at module scope, so every process that so much as imported the file started a five-minute timer and nothing ever stopped it — there was nothing to call. It is startCacheCleanup/stopCacheCleanup now, driven by onModuleInit/onModuleDestroy, and the handle is unref-ed so a cache sweep can never hold the process open. The old test file says as much: its second line is "Prevent the module-level setInterval from running during tests", with a vi.useFakeTimers() to do it. That workaround is no longer what keeps the timer quiet. The cache itself stays module state, deliberately. The MCP weather tools call getWeather directly from outside the container, and a cache is only worth having if every caller sees the same one. Hoisting it into the class would mean handing the registrar the container instance to get exactly what a module singleton already provides — the same trade the place-photo cache resolved the other way, because there the scheduler needed the instance anyway. Coverage is the reason this PR carries eleven new tests rather than none. Code in services/ is ungated; the moment it lands in src/nest it counts against the 80% branch threshold, and weather.impl arrived at 75%. The gap was in paths worth having: a failing archive lookup with and without an upstream reason, a far-future date whose climate rows are all null, an expired entry being re- fetched, and the sweep evicting one on its own. Plus the lifecycle itself — start is idempotent, stop is idempotent, the handle is unref-ed. 79% now. services/ is down to 11 files, from 44. |
||
|
|
9814480a96 |
refactor(server): move the JWT verify into nest/auth, delete four dead middlewares (#1828)
middleware/auth.ts exported seven things. Two of them — extractToken and verifyJwtAndLoadUser — are the canonical session check every auth surface goes through. The other five (authenticate, requireCookieAuth, optionalAuth, adminOnly, demoUploadBlock) are Express middlewares with no production caller left; the guards in nest/auth replaced them and only comments still mention them. The two live functions move to nest/auth/jwt-verify.ts and the file goes, along with middleware/validate.ts (nothing but its own test imported it) and middleware/idempotency.ts (its only caller was `authenticate`, and IdempotencyInterceptor has replaced it). AuthRequest and OptionalAuthRequest go with them — src/typings/express.d.ts is what types req.user now. Eight importers repoint: the three guards, auth/files/plugins-proxy services, platform.routes and the global MFA policy. Two deliberate departures from the plan for this package. JWT_SECRET does not move to app-config. It is not an env value: it is generated into data/.jwt_secret, and the admin panel rotates it at runtime through updateJwtSecret. `export let` is precisely what makes a rotation take effect in-process — a registerAs token would freeze the boot value and silently break rotation. The verify also keeps reading the db singleton rather than DatabaseService. That change is not a one-line swap: it gives the three guards a constructor dependency, which means registering them as providers everywhere they are used. Measured: 38 directories use those guards and 21 of them do not import AuthModule, on top of 34 e2e tests that each build a container around a single domain module. That is its own PR, not a rider on one that deletes dead files. tests/unit/middleware/auth.test.ts covered real behaviour, so it is not simply deleted. Its extractToken cases and the five verify cases it drove through `authenticate` come back as tests/unit/nest/auth/jwt-verify.test.ts, now hitting verifyJwtAndLoadUser directly. Four cases are new: the password_version gate — the whole reason this function exists — had no direct coverage at all. Its three adminOnly cases are dropped because AdminGuard, their successor, is covered by three e2e suites. |
||
|
|
939452f9d8 |
refactor(server): consolidate trip access and fold the query helpers into the container (#1824)
* refactor(server): route every trip-access check through DatabaseService
Twelve Nest services carry the same verifyTripAccess/canEdit pair. Six already
asked DatabaseService.canAccessTrip; the other six went through
services/tripAccess, a nine-line re-export of the identical function. Same
query, same result, two ways to reach it.
All twelve now take the injected route, so services/tripAccess is gone.
The bigger duplication was in the types. Thirteen files declared their own
`Trip` for the same return value, in three different shapes:
NonNullable<ReturnType<typeof verifyTripAccess>> 6x
{ user_id: number } 4x
TripAccess 3x
The narrow one is why assignments, days, places and accommodations all wrote
`as Trip | null | undefined` on the call — the cast existed to shrink
TripAccess down to a local type that was never meant to differ from it. All
thirteen now say TripAccess, and the four casts are gone with no other change.
middleware/tripAccess.ts goes too: it was already dead in production — nothing
under src/ imported requireTripAccess or requireTripOwner, only comments in the
services that had absorbed them. Its test went with it.
* refactor(server): fold the query helpers and trip joins into the container
Two more legacy files whose only reason for staying outside the container was
that they predated it.
services/queryHelpers.ts held five exports doing two different jobs. Three are
the batch loaders that keep the list endpoints off N+1 queries — they hit the
database and are now QueryHelpersService over the injected connection, imported
by assignments, days, places and share. The other two, formatAssignmentWithPlace
and ratingAggregate, touch no database at all; they sat next to the loaders only
because the loaders were free functions too. Those stay free functions, in
common/rowShape.ts.
The README note on the assignments service claimed the loaders had to stay in
services/ because the day, share and place services were unmigrated. All three
have since moved, so the reason is gone.
services/tripMembership.ts becomes TripMembershipService in its own module. It
could have folded into auth or trip-invite, but both call it — as do oidc and
the plugin RPC host — and putting it in either would have made AuthModule and
TripInviteModule import each other.
Neither module is @Global, matching PermissionsModule: e2e TestingModules
resolve them transitively through each consumer's explicit import.
Test seams changed shape accordingly. Two path mocks of services/tripMembership
became injected stubs; the trip-invite e2e overrides the provider in the
container instead. The oidc test keeps the real implementation, which is what
OIDC-SVC-045 was always asserting.
ratingAggregate moved out of the ungated services/ tree into the coverage-gated
one, so it gets the direct tests it never had — as does the tags/participants
pass-through, which two long-dead fixture variables in the moved test file had
been waiting for.
Also drops four stale comments that outlived their subject: the requireTripAccess
middleware they pointed at was deleted in the previous commit.
|
||
|
|
6dda05f672 |
refactor(server): move the stateless helpers into nest/common (#1823)
Eight files in services/ hold no state and touch no database: avatarUrl, conflictResult, demo, passwordPolicy, timezoneService, cookie, and the two crypto helpers. They are imported from 65 places, 62 of them already inside src/nest, so having them live in the legacy layer was pure inertia. They stay plain functions rather than becoming providers. Three importers sit outside the container and would have no way to reach an injectable one: db/migrations.ts runs before the app is built, and middleware/auth.ts and middleware/mfaPolicy.ts run beside it. Wrapping these in DI would buy ceremony and nothing else. mfaCrypto and apiKeyCrypto move together into common/crypto — they read the same ENCRYPTION_KEY, and the directory says so. services/ drops from 44 files to 36, 9,870 lines to 8,493. One trap worth recording: src/app-config and src/nest/app-config both exist. cookie.ts imported '../app-config' from services/, which resolved to the former; after the move the same string silently pointed at the latter, which has no readEnv. It is now explicit about which one it wants. Nothing else changes — no signatures, no behaviour, no tests rewritten beyond their import paths. |
||
|
|
f718240ffc |
Let a dayTintProvider send its own colour
Four tones cannot keep a twenty-stop trip's legs apart, which is most of the point of colour-coding one. A contribution can now carry `#rrggbb` alongside `tone`, per region or as a shorthand. The split of control is: the plugin picks the hue, the host picks the weight. A colour rides the same per-theme, per-region alphas as a tone, and its OKLCH lightness is clamped into a per-theme band first, so no contribution can hand a user a day card they can't read. The band brackets the built-in tones rather than tightening around them, so an ordinary colour passes through untouched and a hex equal to a tone renders exactly like that tone. Hue and chroma are never touched - it bounds a colour, it doesn't boost one, so a near-white grey still lands as a faint tint. Only `#rrggbb` survives normalization, and that is a security boundary rather than tidying: the value ends up inside a `color-mix()` in an inline `background`, a shorthand that takes a layer list, so a string closing the paren early could append a `url(...)` and beacon everyone viewing the trip. A region gets exactly one paint instruction - what it names beats the shorthand, a colour beats a tone at the same level - so the client never has a tie to break. A region naming a colour the host can't use degrades to `default` like a bogus tone already did. |
||
|
|
c89d2bf3a1 |
Add a dayTintProvider plugin hook
Lets a plugin colour-code days in the Plan sidebar. trip-segments can already announce "Kanazawa begins" in the day plan, but it can't make day 12 look like it belongs to Kanazawa. dayScheduleProvider can't cover this: it renders a fixed row, and it's capped at 60 items per provider, so one item per day would tint the first 60 days of a six-month trip and silently stop. This hook is bounded by day count instead. GET /api/day-tints/:tripId returns per-day tones for three regions of a day card - the number badge (also the mobile day chip), the header row, and the expanded activity list. `tone` is a shorthand filling every region a contribution doesn't name; unnamed regions render as they do today. Tones only, never hex, so the host can pick the alpha per theme and per region. One contribution per day, resolved whole: first entry wins within a provider, first granted provider wins across plugins. |
||
|
|
d203d8cccd |
chore: move notifications backend to proper DI
The send() dispatcher (services/notificationService.ts) and the in-app store (services/inAppNotifications.ts) fold together into the DI-native NotificationsService over DatabaseService + RealtimeService; the prefs matrix, transports, channel registry and inAppNotificationActions stay plain infra modules. A 1-export notifications.bridge covers scheduler, legacy adminService/memories and the six lazy fire-and-forget sends; AdminController and the plugin RPC host (24th factory dep) inject. The 5-tool registrar + notifications-in-app resource move to notifications.mcp.ts. Tests move with case IDs preserved (NSVC-*, INOTIF-*, + NSVC-020 bridge pin); ~18 suites repoint path mocks/warm-ups; the module e2e goes DI-native. Also carries the verified-quirk fixes that would normally trail as a separate fix(server) commit (file-overlap with the fold): toUtcIso on all three created_at paths (INOTIF-013/014), respond claims the response CAS before running the action handler with release-on-failure (INOTIF-011/012), synology's fire-and-forget send gains .catch, the dispatch-failure log unwraps Error messages (NSVC-021), and the MCP resource description drops its false 'unread first' claim. |
||
|
|
ae3ba18b9e |
chore: move atlas backend to proper DI
Fold the 1612-line services/atlasService.ts into the DI-native AtlasService: the stats aggregation, visited countries/regions with the #1490 tombstone and cascade logic, and the bucket-list CRUD become methods over the injected DatabaseService. The ~750-line pure-geo half — the bundled admin0/admin1 boundary stores with their #1576 streaming builders, the point-in-polygon indexes, Nominatim geocoding with its shared throttle, and every module-scoped cache including the import-time unref'd cleanup interval — moves verbatim to the plain module atlas-geo.ts so the caches stay process-global across the container instance, the bridge instance and test helpers (assetPath re-anchored one directory deeper, the only non-verbatim line). The 10-tool mcp/tools/atlas.ts registrar plus all four atlas resources in mcp/resources.ts move onto the decorator registry as atlas.mcp.ts — the when: atlas-addon gate is parity here (legacy tools and resources both gated; the REST controller deliberately does not). resources.test.ts retires with its last two cases, which move to tools-atlas-expanded.test.ts. The plugin RPC host swaps its 9 atlas imports for the injected AtlasService (23rd constructor dep); a minimal 2-export atlas.bridge.ts serves the one legacy consumer, authService.getTravelStats, and dies when authService migrates. Adopts atlas.dto.ts over the existing shared schemas and clears all three AtlasController entries from body-contract-allow-list.ts: the hand-rolled 'name and country_code are required' 400 becomes the ZodValidationPipe envelope (todo/places trade) while the whitespace-only bucket name keeps its legacy 'Name is required' trim guard. Fixes the verified defects the relocation had faithfully carried, each with a regression test (ATLAS-SVC-031..036 plus two MCP casing cases): the four multi-statement mark/unmark writes now run in db.transaction() (the region cascade nests as a savepoint), the trip-less countryPlaces early return honours manually_marked, the '|| null' bindings that dropped lat/lng 0 and empty-string notes on bucket update become '?? null', the mutating bucket SQL is user-scoped, and the MCP region/country-places tools uppercase their codes to match REST. Quirks deliberately preserved: the detached GET-path place_regions backfill writes, getStats' two divergent return shapes, the import-time cache-cleanup interval, getCountryFromAddress's unvalidated bare 2-letter codes, and the whitespace-only-name silent no-op on bucket update. Tests move with their case IDs: atlasService.test.ts becomes unit/nest/atlas.service.test.ts (pure-geo imports from atlas-geo, DB calls through the constructed service, plus bridge-delegation cases), the atlas e2e converts to the DI-native pattern (real SQL against the full temp-db schema), and the plugin-host path mock becomes a constructor stub. |
||
|
|
cab49ea511 | chore: move collections backend to proper DI | ||
|
|
4f0f80ad69 |
chore: move place backend to proper DI
Fold the 1029-line services/placeService.ts into the DI-native PlacesService: the CRUD + ratings SQL, the GPX/KML/KMZ importers and the Google/Naver list importers become methods over the injected DatabaseService, byte-identical statements, COALESCE semantics and the If-Match conflict protocol (#1135). The pure half — frozen XML parsers, the KMZ unpacker, the dedup predicates, the Google hex-id parsers, reclaimPhotoCache — moves to places.helpers.ts (maps.helpers precedent). The 10-tool mcp/tools/places.ts registrar plus the trek://trips/{tripId}/places resource move onto the decorator registry as places.mcp.ts. search_place travels with them because its gate is places:read, not the read-only geo group, and now injects MapsService. TripsService, DaysMcp, BookingImportService and the plugin RPC host (PluginHostDepsFactory's 21st constructor dep) all inject PlacesService, so the domain needs no bridge at all. The two assignments.bridge imports that places.mcp.ts keeps are a deliberate cycle break: AssignmentsModule imports DaysModule and DaysModule now imports PlacesModule for days.mcp.ts's place creation, so injecting AssignmentsService there would close DaysModule -> PlacesModule -> AssignmentsModule -> DaysModule. Same seam reservations.mcp.ts uses, same trade trips.bridge.ts documents. Also folds services/placeEnrichment.ts in — its DB/websocket/Maps half becomes PlacesService methods over the injected DatabaseService/RealtimeService/ MapsService, its pure match selector joins places.helpers.ts — which retires nest/maps/maps.bridge.ts with its last consumer. Adopts places.dto.ts for the seven grandfathered body contracts and clears every PlacesController entry from body-contract-allow-list.ts. Two consequences worth knowing: the bespoke 400s 'Place name is required', 'ids must be an array of numbers' and 'URL is required' are now the ZodValidationPipe envelope, and because a pipe runs before the handler a malformed body 400s ahead of the trip-access 404 it used to follow — the same trade the todo and trips migrations took. placeBulkUpdateRequestSchema.ids drops .min(1) so the endpoint's empty-list short-circuit stays reachable. Fixes four defects the relocation had faithfully carried, each with a failing-first regression test: - create dropped lat/lng of exactly 0, so a place on the equator or the prime meridian lost its coordinates (`x || null` cannot tell absent from zero). - duration_minutes: 0 was unsettable — replaced by the 60-minute default on create, and read as "absent" by COALESCE on update. - the journey delete hook fired on unscoped ids: DELETE /:id, POST /bulk-delete and the delete_place MCP tool could detach another trip's journey entries for an id they then refused. All three scope first via the new PlacesService.scopedIds, mirroring the guard PluginHostDepsFactory already had. bulk_delete_places also moves its hook ahead of the DELETE — journey_entries.source_place_id is ON DELETE SET NULL, so running it afterwards left the entries as orphans. - the place search treated % and _ as LIKE wildcards; a bare % returned the whole trip. The three clauses now carry ESCAPE with the term escaped. Plus hardening on the Google list fetch: the response is capped at 8 MB (declared content-length and post-read length, the transit.service precedent) and its JSON.parse is guarded, so a malformed provider payload produces the existing 'Invalid list data received from Google Maps' 400 instead of throwing. Quirks deliberately preserved: the non-COALESCE route_color (#776), currency/transport_mode staying unclearable, importGpx returning null for a file that parses but yields no usable geometry, update skipping the If-Match check when the stored updated_at is null, and every string-valued `x || null`. Tests move with their case IDs: placeService.test.ts and the wrapper suite merge into unit/nest/places.service.test.ts, kmzUnpack into places.helpers.test.ts, placeEnrichment into places.enrichment.test.ts. The places e2e converts to the DI-native pattern (real SQL against temp-db DDL), tools-places.test.ts is kept and extended so it exercises the decorator path through the test registry, and the plugin-host and booking-import path mocks become constructor stubs. |
||
|
|
724b52aa92 |
fix(plugins): broadcast itinerary.unassign to open sessions (#1705)
assign broadcast, unassign did not, so the place stayed on the Plan tab until a manual refresh. It emits the same event as the REST route now. createPlace, updatePlace and deletePlace in the same file had the identical gap. |
||
|
|
287dceb532 |
chore: wire TripsService into the plugin RPC host and retire the legacy tripService
Swap the factory's six tripService imports for the injected TripsService; delete services/tripService.ts and the collab/vacay bridges with it; prune the days/budget bridges to their surviving exports; update the migration docs and regenerate migration-graph.md. |
||
|
|
a2861d40f3 |
feat(server): injectable RealtimeService over the ws singleton
Nest services inject RealtimeService (call-time delegation to the websocket module, DatabaseService pattern) instead of importing broadcast/broadcastToUser globals. Zero behavior change; test edits are constructor-arg additions plus RealtimeModule in e2e harness imports only. |
||
|
|
ee2ea1d065 | refactor(server): extract addon enablement into the addons domain | ||
|
|
cafff003a3 | refactor(server): move instance-URL resolution to app-config | ||
|
|
7f34dadf70 | chore: move budget backend to proper DI | ||
|
|
a3e6eb52a4 | chore: fold exchangeRateService into the nest budget domain | ||
|
|
6ff936c613 | chore: move auditLog backend to proper DI | ||
|
|
1d56de3897 | chore: move permissions backend to proper DI | ||
|
|
2d1eec76c3 | chore: move day backend to proper DI | ||
|
|
203d67c45f |
chore: move reservations backend to proper DI
Folds the 626-line services/reservationService.ts into the DI-native ReservationsService — byte-identical SQL, statuses, bodies, error strings and broadcasts; the legacy file is deleted. - reservations.mcp.ts: the 5 legacy tools (create/update/delete_reservation, reorder_reservations, link_hotel_accommodation) + the trip-reservations resource move to the decorator registry (identical names, schemas, annotations, error strings, broadcasts); the canWrite/canRead registration gates become declarative access markers. Legacy registrar and resource entries removed. - reservations.bridge.ts: 9 legacy-named exports + 3 type re-exports for the outside-container consumers (tripService, airtrail import/sync, the still-legacy transit + transports registrars, transitItineraryService's type-only import). - notifyBookingChange folds to the legacy (tripId, actorId) signature; the controller passes user.id and the plugin-host factory drops its last plain-function reservation import for a private method over the injected service (constructor unchanged). - TripsService and BookingImportService inject ReservationsService instead of importing legacy functions. - Tests: reservations.service.test.ts rewritten as a real-SQL suite (RESV-SVC-001..030, RES-TRAV-001..005 moved verbatim, RESV-BRIDGE-001..009 pinning the bridge); both e2e suites converted to the DI-native pattern; path mocks repointed to the bridge or constructor stubs; tools-reservations kept and extended with the moved resource cases. Quirks preserved on purpose (parity first — see the follow-up fix commit): the raw-accommodation_id metadata-sync gate, the un-transactioned multi-statement writes, the truthy updatePositions dayId check, the empty-string COALESCE keeps, and the TEXT accommodation_id normalization. |
||
|
|
588457710c | chore: move vacay backend to proper DI |