mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
6b6fbbd94c090d3a7a5dc2709bf4e3d3a3a33ccc
1677 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
8c5a207a9b | Make the AI notice say who is accountable | ||
|
|
12ff2d3228 | Move the AI notice up and trim it | ||
|
|
6f76c691da | Say in the README how we use AI | ||
|
|
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%. |
||
|
|
86e2a96eb6 |
fix(client): a deliberate logout should not book a return ticket
Pressing "log out" clears isAuthenticated while the page you were on is still mounted, so ProtectedRoute re-renders for it and sends you to /login?redirect=<that page>. Log out from the dashboard and the next login goes to the dashboard - beating the startup destination every time after the first, which is exactly how it looked: worked once, never again. A session that ended on its own still gets the return ticket, because coming back to where you were is the whole point of that parameter. A deliberate sign-out is a fresh start and now gets none, so the startup destination decides. The Navbar already hinted at this with its unused noRedirect state; the flag lives in the store instead, since all seven call sites of logout() need it, not just that one. |
||
|
|
795cc50dae |
fix(client): stop the startup redirect racing the settings request
RootRedirect waited for whoever else might call loadSettings, which on a cold start meant waiting for a request queued behind everything else a launch fires off. Traced it in the browser: the settings came back as the 388th request, long after the redirect had given up and gone to the dashboard. That is what "it still just opens the dashboard" was. It now asks for the settings itself - the store de-dupes, so this costs nothing when a load is already in flight - and login spends its 2.6s takeoff animation fetching them instead of leaving that time idle, so by the time it hands over they are already there. Also folds the five copies of setShowTakeoff + navigate-after-2600ms into one takeOff(), since all of them needed the same fix. |
||
|
|
e7851e79ff |
fix(client): honour the start page on a device that never mirrored it
The mirror answered "dashboard" for a browser that had simply never been told, and RootRedirect took that as the user's choice - so the setting was ignored on every device except the one that set it, and on that one too after a logout cleared the mirror. The setting itself was stored correctly the whole time, which is what made it look like nothing happened. readStartDestination now says "unknown" rather than guessing, and a device in that state waits for the real settings instead. That costs one wait, once per device; afterwards the mirror answers and the launch is as fast as before. Bounded by a timeout, because loadSettings deliberately leaves isLoaded false on a failed request so it can retry - without the floor, a launch with no backend would sit on the spinner forever. |
||
|
|
0a824cec84 |
docs(wiki): document the startup destination and the tab deep link
Includes the tab ids the ?tab= parameter takes - they are the historic German names, which is not something anyone would guess. That matters for the people who asked: a shortcut or a browser wrapper can point at a tab without the setting at all. |
||
|
|
6bf2e0708d |
feat(client): add the Startup section to General settings
Desktop and mobile both get it, in the style of the file they live in - the On/Off button pairs on desktop, segments and a picker sheet on mobile. The tab picker only appears once "Active trip" is chosen, since there is nothing for it to mean otherwise. Labels come from the same keys the planner's tab bar uses, so the picker can never name a tab differently from the tab itself. |
||
|
|
44eff7177e |
feat(client): open TREK on the active trip when asked to
Settings -> General -> Startup: start on the dashboard (unchanged default) or straight in the active trip, on a tab of your choice. Three clicks and three loads less for someone entering expenses while actually travelling, which is where the request came from. The redirect sits on '/' because that is what a shortcut, a bookmark and the installed PWA all launch (manifest start_url). /dashboard keeps working as itself - redirecting there too would leave no way back to it. The preference is mirrored into localStorage, the way the language and the theme snapshot already are, so the first paint can decide without waiting on GET /api/settings. That keeps the default launch exactly as fast as before: only someone who asked for the trip pays for the lookup, and if it fails or they have no trip they get the dashboard. Right after login the store already holds the real settings, so those win there. Login stops hardcoding /dashboard as its fallback and hands the decision to the same place, so there is one answer to "where does TREK open". |
||
|
|
688ce0fae2 |
feat(client): let a trip URL open on a given tab
/trips/:id?tab=finanzplan opens the planner straight on that tab. Read in the state initializer, not an effect, so the planner never paints the plan view and swaps a frame later. The param is consumed on arrival; the session memory keeps the choice across a reload. Two things the tab machinery got away with while nothing ever started on a tab other than plan: - the guard evicted anything not in TRIP_TABS, but enabledAddons is an optimistic guess until the addon feed answers, and collab is guessed off - so a requested collab tab was thrown away a render before we knew the addon was on. It now waits for the feed, like it already did for plugins. - the lazy loads hang off handleTabChange, which the tab we *start* on never goes through. Opening on Files showed an empty list, and that was already true for a tab restored from a previous visit. Tab ids move to constants/tripTabs so the settings picker can name them the way the tab bar does instead of keeping a second list. |
||
|
|
99ee307034 |
feat(server): expose the active trip as its own endpoint
GET /api/trips/active answers with the trip a user means by "my trip" right now: the one running today, else the next one starting, else the most recently started. Archived trips never qualify. That order is the dashboard hero's (sortTrips), on purpose - the startup redirect this is for has to land on the same trip the dashboard features, or the two would disagree about which trip is yours. Kept apart from list() because it runs on a first paint: four columns of one row instead of every trip with its per-trip day and place counts. |
||
|
|
aca6c556ed |
test(server): cover the Synology provider
Completes the pass over the providers that moved into the gated tree. The session handling is the part that most needed it: Synology invalidates a SID on timeout (106), on a duplicate login (107) and when it does not recognise it (119), and the service is supposed to clear it, re-login once and repeat the call. That retry had no case at all — a regression there would have looked like "Synology randomly stops working" and nothing would have failed. Also pinned: a SID that no longer decrypts after a key rotation triggers a fresh login rather than a dead session; a stored device id rides along so a trusted device skips OTP; an app-level error that is not a session code is NOT retried; the album listing merges personal, shared and shared-with-me, survives a partial source failure, and collapses an id that appears in two sources; and an SSRF block stays a 400 with the guard's own message while any other transport failure is a 500. |
||
|
|
c0d90d57b2 |
refactor(server): fold the memories domain into providers
The seven files in services/memories/ become injectable services. Every SQL statement, error string, status code and header moves verbatim; what changes is who owns what. photoResolverService was two files in one, so it stayed two: the trek_photos rows are a repository under photos/, the provider dispatch is PhotoResolverService under memories/. That is the photos/ vs memories/ boundary — storage there, dispatch here — and it is also why the module graph is a straight line: TrekPhotosModule is a leaf both halves import, so neither needs a forwardRef. pipeAsset and the ServiceResult envelope stay free functions over the Express response. pipeAsset is deliberately not a StreamableFile: it forwards the upstream 206 and Content-Range so a <video> can seek (#823), swaps Cache-Control to no-store on an error status, and answers differently once headers are sent. The trek-photo cache keeps its in-flight map at module scope. That map is the stampede guard, and the scheduler builds its own instance through a lazy require outside the container — a per-instance map would give them separate guards and ten tiles would fetch the same asset ten times, silently. journeyService's dynamic import() of synologyService is gone: it existed only to duck the import cycle, which no longer exists. MemoriesModule imports RealtimeModule explicitly even though it is @Global, so a single-domain e2e TestingModule can still resolve the broadcast. The providers arrived at 60% branch coverage or less, because services/memories/ sits outside the gate and the integration suites drive the domain over HTTP without reaching the failure paths. New suites cover what had no case at all: the access checks that decide who may fetch someone else's photo, the stampede guard, the thumbnail mtime check, the poster-less-video 404, and Immich end to end with safeFetch stubbed — including the hidden-asset filter older servers depend on (#1474) and the http -> https canonical detection that must NOT rewrite a changed host. |
||
|
|
5b6c79299f |
docs(server): record what the coverage gate does and does not measure
PR #1844 went red with every suite green: the diff moved MCP tools from src/mcp/, which the gate ignores, into src/nest/**, which it measures. That did not change any behaviour - it revealed that airports.mcp.ts had never had a test in either world, and dragged the aggregate to 79.86%. Worth stating in the gotcha list, because journey.ts and _shared.ts still have to make the same move. |
||
|
|
67c1633155 |
refactor(server): lift the memories vocabulary and the uploads anchor into nest
Two preparations the provider conversion needs, and neither changes behaviour. The ServiceResult envelope, the provider-agnostic asset shapes and pipeAsset move to nest/memories/memories.helpers.ts, which touches no database. The controllers and the provider services can name them without importing each other - the same reason notifications/notification-events.ts exists. What is left in services/memories/helpersService.ts is the DB-backed half (the access checks, the album-link lookups) and it folds with the providers. pipeAsset stays a free function over the Express response on purpose. It is not a StreamableFile candidate: it forwards the upstream's 206 and Content-Range so a <video> can seek (#823), swaps Cache-Control to no-store on an error status, and has to answer differently once headers are already sent. All three controllers take @Res() anyway. The three copies of `path.join(__dirname, '../../../uploads')` become one UPLOADS_ROOT. __dirname moves with the file and none of these would have thrown on a wrong depth - mkdirSync and multer create the wrong tree without a word, and every stored photo lands somewhere nobody serves from. UPLOADS-ROOT-001 pins it. The depth happens to be identical for nest/memories, so nothing moves today; the point is that the next move only has one place to get wrong. |
||
|
|
aa4ed69677 |
refactor(server): give trek_photos its own repository
photoResolverService was two files in one. One half registers and drops rows in trek_photos and touches no photo provider at all; the other dispatches a stream or an info lookup to immich or synology. Keeping them together meant every consumer that only wanted to store a row - the unified trip-photo service, the journey service - dragged both providers in behind it. The storage half becomes TrekPhotosRepository in nest/photos, over DatabaseService. That is also where the photos/ vs memories/ line belongs: storage here, provider dispatch with memories. photos.bridge.ts serves the two modules still in services/ that register rows, and dies with them. Every statement moved verbatim, including the two rules worth stating out loud: getOrCreate re-encrypts a passphrase onto an existing row rather than inserting a second one, and deleteIfOrphan never deletes a local photo, because its bytes are ours and the file would outlive the row. |
||
|
|
7b666736df |
test(server): cover the MCP surfaces that moved into the gated tree
The coverage gate reads src/nest/** only, so the tools that lived in src/mcp/
were never measured. Moving them exposed what was never covered:
- airports.mcp.ts sat at 0% branches - search_airports and get_airport had no
test at all, in either world
- the two weather catch blocks, which turn a provider failure into an isError
result instead of a transport-level error
- the transport guards: trip access, day ids belonging to another trip, the
not-a-transport-type check on update, and delete_transport in full
- the price path, where a transport with a price links a budget item and
broadcasts budget:created
None of this is new behaviour; it is behaviour that shipped untested because it
sat outside the measured tree.
One case documents a subtlety worth keeping: the weather fallback message only
fires for a rejection with no .message at all. `new Error('')` still carries an
empty one and passes straight through, so the case rejects with a non-Error.
|
||
|
|
3efedd5e48 |
refactor(server): decorate the last three MCP prompts
The prompts registrar is gone. Each prompt moves to the domain that owns its data instead of a shared file that had to import three bridges to serve them. packing-list goes to PackingMcp and budget-overview to BudgetMcp, both reusing the `when` addon gate their tools already carry — so a disabled addon keeps the prompt out of the listing, exactly as the `if (isAddonEnabled(...))` around the registration call did. Both keep taking the trip title through trips.bridge: TripsModule imports PackingModule and BudgetModule, so injecting TripsService would close a cycle. Same documented in-container exception as atlas.mcp.ts, and the registrar reached for it the same way. token_auth_notice goes to a new AuthMcp — it is a statement about how the session authenticated, not about trip data, and it injects nothing because the message is static. Its `if (isStaticToken)` guard becomes `when: (ctx) => ctx.isStaticToken`; the registry hands `when` the session context, so an OAuth 2.1 session never sees the prompt listed at all, which is what the registrar achieved by skipping registration. src/mcp/tools/ is down to _shared.ts and journey.ts. journey stays until services/journeyService folds, which waits on memories. |
||
|
|
cacab0795f |
refactor(server): decorate the weather, airport and transport MCP tools
Three of the four remaining hand-written registrars are gone. The tools keep their names, descriptions, input schemas, annotations and result shapes; the registration-time scope gates (`if (canWeather)`, `if (canGeo)`, `if (!canWrite(scopes, 'reservations')) return`) become declarative access markers, resolved by trekMcpAccessPolicy like every other decorated domain. get_weather / get_detailed_weather move into a new WeatherMcp over the injected WeatherService, including the catch that answers with the upstream message and isError rather than throwing - a client sees "Weather service not available." instead of a transport failure when the provider is down. search_airports / get_airport move into a new AirportsMcp with no injected service at all: the airport table is a static dataset. AirportsService owns the flight-endpoint backfill against the database, which neither tool touches. create/update/delete_transport move into ReservationsMcp rather than a domain of their own, because a transport IS a reservation - same table, same service, same reservation_edit permission - and that class already injects everything they needed from the four bridges the registrar was importing. That was budget.bridge's last outside-container consumer, so linkBudgetItemToReservation is pruned with it; the file is down to one export and dies as soon as BudgetMcp stops needing AuthService. journey.ts stays: it still reaches into services/journeyService, which cannot fold until memories does. |
||
|
|
790dd3b728 |
refactor(server): break the memories import cycle
immich -> unified -> photoResolver -> immich was a real cycle, every edge a
value import; journeyService only got past it with a dynamic import(). Nothing
in that cluster can become a provider while it stands, because Nest would need a
forwardRef through three modules.
It had exactly one cause: syncAlbumAssets and syncSynologyAlbumLink called
addTripPhotos, which lives in unifiedService. Both functions are two things
stapled together - fetch the album from the provider, then write it to the trip
- and only the first half is provider-specific.
So the providers keep the half they own (collectAlbumSelection,
collectSynologyAlbumSelection) and unifiedService keeps the half it owns
(syncImmichAlbum, syncSynologyAlbum). Every error string, status code and return
shape is the one the combined function produced, including the per-provider
wording the split had to preserve ('Could not reach Immich' stays in immich).
The plan called for pulling addTripPhotos and getOrCreateTrekPhoto into one
neutral module instead. Together they do not work as a pair: such a module still
needs immich and synology to fetch, so the cycle only moves.
There is a second cut that works - photoResolverService is really two files, and
its trek_photos CRUD half touches no provider, so splitting that out also opens
both loops with less churn. It is not the one taken here because it leaves
immich -> unified standing, i.e. a provider depending on the trip aggregate.
That direction is what tied the knot in the first place. The CRUD split is still
worth doing, but as the photos/ vs memories/ module boundary, not as the cycle
fix.
server/src/services/memories/ is a DAG now: helpers <- immich/synology <-
photoResolver <- unified.
|
||
|
|
97f524cdfd |
feat(server): give the memories endpoints their Zod contracts
The last nine memories entries in the body-contract allow list are gone: three each on the immich, synology and unified controllers. They read @Body() as Record<string, unknown> and coerced by hand. The schemas are deliberately permissive and loose, and that is the point rather than a shortcut. Paging arrives as `2` or `'2'` because the controller does `Number(page) || 1`; synology_skip_ssl arrives as a boolean or the string 'true'; auto_upload is accepted in any shape because only a real boolean is acted on. A schema that described the ideal body instead of the real one would turn working clients into 400s, and parity outranks tightening. Narrowing them is a separate, client-visible change. Loose objects for the same reason: a strict object strips unknown keys, and `selections[]` goes straight to the services as `Selection[]` - stripping there would be a silent data loss the day a provider adds a field. This unblocks be-memories-fold, which could not move a controller whose body had no contract to move with it. |
||
|
|
ac96644508 |
refactor(server): fold the notifications domain out of services/
Four files taken apart rather than moved, because they only worked as a knot. The mailer becomes its own leaf module. AuthService sends the password-reset mail while NotificationsModule imports AuthModule for the demo gate in NotificationsMcp - a mailer inside the notifications domain would have closed a hard cycle that only a forwardRef could open again. Webhook and ntfy stay with notifications; auth has no business importing them. The channel registry stays a module singleton and does NOT become a provider. PluginRuntimeService pushes its channel getter in at onModuleInit, and notifications.bridge.ts builds its own NotificationsService for the scheduler crons and the memories senders. A provider would hand the bridge a second, empty registry - every plugin channel would then go quiet with no error anywhere. CHOVR-015 drives that seam end to end: a channel published through the runtime, delivered through the bridge. Nothing pinned it before; the existing cases all set the source themselves. The built-ins no longer self-register by side-effect import. They used to exist only because notificationPreferencesService happened to `import './notifications/builtins'`; removing that line would have silenced email, webhook and ntfy without a failure. They are built from the injected transports and registered from NotificationsService's constructor, so both instances have them. No cached nodemailer transport: MailerService.createTransport re-reads the SMTP config and skipTlsVerify per send, exactly as the three inline call sites did, so an admin changing them does not have to restart. Preferences become a provider over DatabaseService. Eight places built NotificationsService by hand, so a tests/helpers/notifications.ts factory keeps the next constructor parameter a one-file diff instead of an eight-file one. |
||
|
|
712a33626a |
refactor(server): give the notification vocabulary its own module
The event names, the channel ids and the ExternalChannel contract move to nest/notifications/notification-events.ts, which imports nothing. The cluster only looked acyclic: notifications.ts and channelRegistry.ts both reached back into notificationPreferencesService for NotifEventType, and both edges were `import type`, so they compiled away. Putting any of those names in a value position - an enum, a const array, an injected class - would have made the cycle real, with builtins.ts as a third ring. Nothing can fold while that is true. The drift guard against the shared NotificationEventKey moves with the union it guards; it sat three files away from it. |
||
|
|
03493a2edd |
refactor(server): fold webauthn, account erasure, oauth and the wiki out of services/ (#1840)
* refactor(server): move the webauthn config and the account erasure into nest/auth webauthnConfig resolved the RP ID through its own getSetting() on the db singleton; it is a WebauthnConfigService over DatabaseService now, injected into AuthService (passkey_configured) and PasskeyService (four ceremonies). readEnv() is still read per call, so an operator flipping the RP ID at runtime takes effect on the next ceremony instead of after a restart. userCleanupService follows as UserCleanupService. It keeps taking removeUserFromBudgetItems from budget.bridge rather than injecting BudgetService: BudgetModule imports AuthModule for the demo guard in BudgetMcp, so AuthModule cannot import BudgetModule back, and breaking that edge the other way drags BudgetController, BudgetMcp and its trips.bridge chain into every graph that imports AuthModule. The legacy module had no tests of its own - the erasure only ever ran through AdminService.deleteUser and TripsService.deleteGuest - so USER-CLEANUP-001..009 pin both halves, including the orphan data dir that no permissions row can describe and the all-or-nothing deletion transaction. * refactor(server): fold the oauth 2.1 server out of services/ 694 lines move into OauthService over DatabaseService, AddonsService and AuditService. The thin wrapper that only forwarded is gone, and so is the second path to the MCP addon flag - validateAuthorizeRequest read it through addons.bridge while the wrapper read it through the injected service, which would have let the two drift. The pending authorization codes stay a module singleton in oauth.pending-codes.ts instead of becoming instance state. The SDK still serves /oauth/authorize, /oauth/register and /oauth/consent from its own router, mounted on Express before app.init(), so it reaches the domain through the new oauth.bridge.ts - two maps would break the authorization-code flow silently, since the SDK only ever answers "Authorization grant is invalid.". The import-time sweep keeps its .unref() and is a named function now so a test can drive it. scopes and sessionManager are imported deep, never through the src/mcp barrel: the barrel re-exports revokeUserSessionsForClient and pulls the domain bridges back in, which is the nest -> mcp -> nest cycle. Same comment as admin.service and auth.service carry. The 70 cases of the legacy suite moved over unchanged, bound to a hand-built service. Fifteen more cover what free functions never let anyone reach: the code store at capacity, an expired code, an expired access token, refresh against a public and an unknown client, the RFC 8707 resource checks, and that a code written through the container is read back through the bridge. * refactor(server): move the wiki reader into the help module wikiService had exactly one consumer, HelpController, and touches no database - it stays a plain module rather than becoming a provider, the way auth/ephemeral-tokens.ts does, so the text and asset caches keep being one per process without any ceremony around it. The WIKI_DIR anchor needed one more level: src/services sits three below the repo root, src/nest/help sits four. The existing path-resolution case caught it, which is what it is there for - a wrong anchor makes help degrade to the GitHub fallback without anybody noticing. Also brings the nest README back in line: services/ is down to five top-level files, and the passkey and budget entries no longer point at paths that were deleted since they were written. |
||
|
|
5eab8128bd |
refactor(server): fold the ephemeral-token store and the in-app action registry (#1836)
Two small leftovers, moved to the domains that own them. ephemeralTokens holds the short-lived tokens the WebSocket handshake and the file-download links use. It already had startTokenCleanup/stopTokenCleanup with an unref-ed handle — the right shape. What was missing is anyone calling stop: index.ts kicked the sweep off with a bare require() after listen() and nothing ever ended it. AuthService owns that lifecycle now, through onModuleInit/onModuleDestroy, which is where it belongs — it is the service that issues the tokens. The store itself stays module state. websocket.ts consumes tokens from outside the container, and a token store is only useful if the issuer and the consumer share one. Same reasoning as the weather cache in #1835. inAppNotificationActions is a 22-line handler registry with exactly one consumer; it moves next to it as notifications/in-app-actions.ts. services/ is down to 9 files, from 44. |
||
|
|
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. |
||
|
|
9c46f31c57 |
refactor(server): fold airports and distance, drop the boot-time backfill cycle (#1834)
Two thirds of be-weather-airports-fold. weatherService (513 LOC, a process cache and a setInterval at import) is left for its own PR. airportService split along the line that was already there. The dataset lookup touches no database — it reads one JSON file and keeps it in memory — and both MCP registrars call it from outside the container, so findByIata/searchAirports are free functions in nest/airports/airports.data.ts. backfillFlightEndpoints needs the connection and became a method on AirportsService. That backfill was the interesting part. db/database.ts ran it at module eval through a bare require() back into services/, so the module graph closed a cycle that only held because `db` happened to be initialised by the time the require resolved. It runs in onApplicationBootstrap now: it repairs rows, nothing boots on it, and the container is fully built by then. The cycle had a visible cost. tests/setup.console-noise.ts carried a suppression for `Cannot find module '../services/airportService'`, because vitest's transform pipeline could not resolve that require at all — it fired in roughly sixty test files. That suppression is now narrowed to the only case left: an e2e TestingModule whose fixture DB has no reservations table. distanceService split the same way: haversineKm is arithmetic and moved to common/geo.ts; getFlightDistanceKm reads the database and became a private method on AuthService, its only caller. AirportsModule imports DatabaseModule explicitly. @Global only reaches modules that are in the graph, and the airports e2e builds its container from AirportsModule alone — exactly the trap the nest README now documents. The search ranking gets its first direct tests. It sat in services/, outside the coverage gate, and only ever ran incidentally through the route; moving it into src/nest made the gap measurable (16% branches). The ten cases pin the scoring ladder — exact IATA short-circuit, ICAO over prefix, IATA prefix over city prefix, city prefix over substring — and the missing-dataset fallback, which no test had reached. 90% branches now. |
||
|
|
c7fed4b1f8 |
refactor(server): make the place-photo cache a provider (#1833)
The last file in be-places-helpers-fold, and the one that had to be a provider rather than a move: it carries three in-memory structures that only do their job if the process has exactly one of them. recentFailures stops a screenful of markers hammering an API that is down inFlight stops a stampede when several requests miss the same placeId knownOnDisk saves an existsSync per cache hit A second instance gives the stampede guard its own inFlight map, so two simultaneous requests for the same uncached place both call the provider. That is why the scheduler takes this one from the container instead of constructing it: SchedulerDeps gains a placePhotos entry alongside backups, filled from app.get() in index.ts — the shape PR #1827 was built for. The module-scope mkdirSync becomes onModuleInit, and GOOGLE_PHOTO_DIR is read per call through RuntimeEnvService rather than frozen at import. That is not cosmetic: TREK_PLACE_PHOTO_DIR is the seam the tests use to keep off the real uploads tree, and its own test used to have to import the module dynamically to set the variable in time. The __dirname fallback moved a level with the file and is corrected accordingly. Consumers: maps, places and share inject it; places.helpers.reclaimPhotoCache takes it as a parameter, since it is a free function. Six test seams follow. Four path mocks become injected stubs with the same mock functions behind them; the collections case keeps the REAL implementation, because what it asserts is that removeIfUnreferenced counts collection_places as a reference (#1081) — a stub there would assert nothing. The share e2e overrides the provider in its container. services/ is down to 14 top-level files, from 44 when this started. |
||
|
|
4e6b45016a |
refactor(server): move the KML importer and place-image helpers into places/ (#1832)
Two of the three files in be-places-helpers-fold. placePhotoCache is left for its own PR — it holds three module-level caches and its sweep hangs off the cron, so it needs both a provider with real state and a second entry in the scheduler contract. kmlImport.ts is 212 lines with no state, no database and no I/O — parsing, sanitising and coordinate arithmetic. It is nest/places/kml-import.helpers.ts now, unchanged, next to the places.helpers.ts that already consumes it. placeImage.ts moves to nest/places/place-image.ts as free functions. Making reclaimPlaceImage a provider would have split it from PLACE_IMAGES_DIR, which cannot be injected: nest/common/place-image-upload.ts reads it at module scope to configure multer's storage, before any container exists. That constant is the one thing here that could have broken quietly: path.resolve(__dirname, '../../uploads/places') __dirname moves with the file. From src/services that resolved to <server>/uploads/places; from src/nest/places the same string would have resolved to <server>/src/uploads/places — a directory multer would have happily created, leaving every uploaded place image in the wrong place and every existing one unreachable. The path is now '../../../uploads/places', verified to resolve to the identical absolute directory, and uploads-dirs.test.ts covers it. |
||
|
|
90e35e6970 |
refactor(server): fold llmConfig and unsplash out of services/ (#1831)
Two of the three leftovers in be-misc-fold. The third (wikiService) needs a Zod contract in @trek/shared first and is left for its own PR. llmConfig.ts was pure movement: 70 lines of encryption-aware config shaping with no state and no database, sitting in services/ while all four of its callers are in src/nest. It is nest/llm-parse/llm-config.ts now, same functions, same file. unsplashService.ts needed an actual provider. Its key resolution reads both the env and the users table (env var, then the caller's stored key, then any admin's), so the injected connection was unavoidable. Its own module because trips and places both reach it, and because folding it into either would have made the other import a trip/place service for an image search. The four exports became methods on UnsplashService. The SSRF guard in saveUnsplashCover is untouched — it still refuses any host but the Unsplash image CDN and still goes through safeFetch — as is the unauthenticated-endpoint fallback when no key resolves (#1449). The env read stays live through RuntimeEnvService rather than a registerAs token. readEnv() is what the function did before, and a boot-frozen token would change when an operator's UNSPLASH_ACCESS_KEY takes effect. Two test seams follow the code: the unsplash unit tests bind the four methods so every existing assertion reads unchanged, and the trips controller's path mock of the module becomes an injected stub. services/ is down to 17 top-level files. |
||
|
|
7dea88120b |
docs(server): bring the nest README up to the foundation work (#1830)
The README is the binding spec, and seven PRs have landed patterns it does not mention. Someone reading it today would rebuild things that now exist, and would walk into two traps that cost real time this week. Added to the foundation section: common/ and why those helpers are free functions, auth/jwt-verify.ts and why JWT_SECRET must not move to app-config, query-helpers/, trip-membership/ and the cycle that put it in its own module, AddonGuard + @RequireAddon with its ordering requirement, and demo-write. New subsection on reaching the container from outside it. The choice is settled now: hand the dependency in, declared structurally, the way index.ts fills the scheduler's SchedulerDeps. Bridges stay correct for the MCP registrars, but the README should say what they cost — a bridge rebuilds its services with `new`, so one added constructor parameter meant editing five bridge files in a single PR. Two gotchas that are cheap to read and expensive to rediscover: Never gate a multipart upload with a guard. Guards run before the parser, so throwing leaves the body unread and Node resets the connection — the client gets ECONNRESET rather than the 403. @Global only applies to modules that are in the graph. An e2e TestingModule built around one domain module does not get AppConfigModule for free, and a provider injecting RuntimeEnvService takes every suite importing that module down with it. Also corrects an assumption the migration plan makes and this README implied: the trip-access and canEdit methods on the domain services are not dead weight waiting for TripAccessGuard. Across the five domains piloted for it, 40 of the 46 callers are *.mcp.ts tools, which never pass through an HTTP guard. Only 6 sit in controllers. Counted, not estimated. |
||
|
|
22d178d2f9 |
refactor(server): one addon guard, one demo-mode condition (#1829)
Two pieces of copy-paste in the guard layer. Three addon guards — collections, airtrail, journey — did the same thing and differed only in the addon id they read and the word leading the 404. They are one AddonGuard plus a @RequireAddon(addonId, label) decorator now, read through the Reflector. A handler-level decorator overrides the controller's, and a route without the decorator is not gated at all. The demo-mode upload block was copy-pasted into six handlers, each carrying its own copy of the message. All six call isDemoWriteBlocked now, against the same DEMO_WRITE_ERROR body. That one is deliberately NOT a guard, though all six sites are handlers and a guard is the obvious shape — and this PR did build one first. Guards run before the multipart parser, so throwing from one leaves the request body unread and Node resets the connection: the client gets ECONNRESET instead of the 403. PROFILE-015 caught it. The check has to sit inside the handler, after the interceptor has drained the upload, for the response to arrive at all. The function carries that reasoning so the next person does not rediscover it. Two smaller findings on the way there. The DemoWriteGuard attempt broke eight e2e suites that never touch demo mode: AuthModule listed it as a provider, and RuntimeEnvService was not resolvable inside a TestingModule built around one domain module. AppConfigModule is @Global, but @Global only applies to modules that are in the graph. The five modules whose controllers now inject RuntimeEnvService import it explicitly, the same way PermissionsModule is handled. And both new guards get a test asserting the decorators are actually applied. Once a check moves out of a handler, that handler's own tests stop noticing if the decorator is dropped — the assertions on __guards__ and the @RequireAddon metadata are what keeps the four upload endpoints and the four addon route groups gated, and gated on the right addon. |
||
|
|
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. |
||
|
|
a634b2a26b |
feat(server): give the scheduler a contract with the container (#1827)
The scheduler runs outside the Nest container but needs four things inside it: backupService, trekPhotoCache, placePhotoCache and airtrailSync. It reaches all four with a require() or await import() at call time. This settles how that should work before four separate folds each invent their own answer, and wires one consumer as proof. It takes the dependencies from the container rather than growing four more bridges, for a reason that showed up while writing the previous PRs: adding one constructor parameter to a service meant hand-editing five bridge files that rebuild their services with `new`. app.get() has no such cost, and hands back the container singleton instead of a second instance. The scheduler declares what it needs structurally — a `backups` with a createBackup — and never names BackupService. That is not fastidiousness: an import of the class would pull in src/nest/backup → src/services/backupService → src/scheduler, which is the exact cycle the lazy `await import()` in runBackup existed to dodge. With the dependency handed in, the scheduler stops importing backupService entirely and the cycle is gone; backupService still imports the scheduler for VALID_INTERVALS, but now only in one direction. index.ts fills the contract from the app buildApp() already initialised, right before the first cron starts, the way bootstrap.ts hands the /mcp handler its registry. Two smaller things fall out. The Nest BackupService was swallowing createBackup's prefix parameter, so it could only ever produce 'backup' names; it forwards it now. And the scheduler can be started without a container at all — its unit tests do exactly that — so runBackup says so in the log instead of throwing, with a case covering it. The other three consumers stay on their require() calls; they move with their own folds. |
||
|
|
1e05501b9d |
refactor(client): finish the React 19 migration (#1826)
Mechanical, and it ends the migration visibly.
45 files imported the whole react-dom default export to reach one function.
They now import createPortal by name, across 54 call sites; six files already
did it this way. main.tsx keeps its react-dom/client import, which is a
different module and already correct.
forwardRef is gone from every component. In React 19 ref is an ordinary prop,
so the wrapper only obscured the signature:
NumericInput — a plain DOM ref, no imperative handle involved
JourneyMap — useImperativeHandle stays, only the wrapping goes
JourneyMapGL — same
JourneyMapAuto — same
Each takes `ref?: Ref<Handle>` in its own Props interface now, so the ref is
part of the declared surface instead of arriving through a generic parameter.
The two journey bindings in glLazy shrink to one line each. They existed as
forwardRef wrappers purely to carry the ref past the lazy boundary; with ref an
ordinary prop it rides through the spread like everything else, which makes all
six bindings in that file identical again.
The five React.FC annotations go. Three were `Record<string, React.FC<{size?:
number}>>` for provider icons in files that never import React at all — those
only typechecked through the UMD global — and are now ComponentType. The two
pages become function declarations.
TranslationContext uses the 19 context shorthand, which leaves its React default
import unused, so that goes too.
Also drops two dead vi.mocks of react-dom/server. Their comment still said "used
in createMarkerIcon", which stopped being true when marker icons moved to the
in-house serializer — nothing under src/ imports react-dom/server any more
except the parity test that exists to compare against it.
|
||
|
|
eb8e777741 |
chore(client): move zustand to 5.0.14 (#1825)
The last React-18 leftover in the dependency tree. v4 declared
use-sync-external-store as a hard dependency and its main entry imported the
shim; v5 declares no dependencies at all and calls React.useSyncExternalStore
directly. Only zustand/traditional still reaches for the shim, and nothing here
imports it, so it no longer reaches the bundle.
The upgrade itself needed one line. What it needed more of was checking, because
the one thing v5 changes about behaviour does not show up as a test failure.
v4 subscribed through useSyncExternalStoreWithSelector, which memoised the
selector's result. v5 hands React a plain getSnapshot of
`() => selector(api.getState())` and compares with Object.is. A selector that
builds a fresh object or array therefore returns a new reference on every call,
and React reads that as "the store changed" — an infinite render loop, or at
best a re-render on every parent render.
All 294 store selectors return a property or a primitive. None builds an object
literal, array literal, filter/map/slice result, `?? []` default, Object.keys or
a new Set/Map, so none of them needed useShallow. The suite confirms it: 533
files, 11,165 tests, and zero "getSnapshot should be cached" or "Maximum update
depth" warnings anywhere in the output.
The one source change is the seedStore test helper, which was typed against a
hand-written `{ setState: (partial, replace?: boolean) => void }` shape. As of
v5 setState is an overload pair — partial with replace?: false, full state with
replace: true — and no single signature is assignable to that. Typing it against
zustand's own StoreApi fixes all 129 resulting errors and is what the helper
meant in the first place.
|
||
|
|
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. |
||
|
|
3dfbb3fd02 |
perf(planner): load the PDF export and the transport modal on demand (#1821)
Two more things every trip paid for whether or not anyone used them. downloadTripPDF hung statically off the days sidebar, so opening any trip pulled in the whole PDF builder. Both call sites are already async and already wrap the call in try/catch, so a failed chunk surfaces as the export error the handler had anyway. TransportModal was already rendered conditionally, which means lazy bites immediately. It is worth more than its own 32 kB: it is the only path to TransitSearchPanel, and that chunk carried tz-lookup — about 200 kB of packed zone geometry. TripPlannerPage 339.78 -> 305.70 kB entry chunk 258.24 -> 218.70 kB TransitSearchPanel 198.67 -> 26.51 kB (and now on demand) TripPDF 226.02 -> 27.78 kB (and now on demand) eager path unchanged at 1,483 kB Worth stating plainly: tz-lookup itself did not go away. It sits in PlaceFormModal.helpers now, and PlaceFormModal is still a static import of the planner, so a trip still loads it. Prying that loose means touching placeOpenState, whose consumers assume a synchronous lookup — a separate change with its own care. |
||
|
|
cf62567c48 |
fix(map): stop untrusted values reaching the marker HTML (#1820)
* fix(map): drop the casing layer before its source on teardown
Tearing down the reservation overlay logged an error on every map unmount:
Source "trek-reservations" cannot be removed while layer
"trek-reservations-lines-transit-casing" is using it.
The casing layer arrived with the transit paths in 3.2.0 but was never added to
destroy(), which only removed the main line layer. The try/catch around it gave
no protection either — the engine reports this by firing an error event rather
than throwing — so the source was silently left behind on the style.
The layer id is a named constant now, so the place that creates it and the place
that removes it can no longer drift apart.
The lifecycle test grew the assertion that would have caught it: the fake map
now refuses to remove a source that a layer still references, the same way the
real one does. Verified against the unfixed code — two tests fail without the
change.
* fix(map): stop untrusted values reaching the marker HTML
Both map renderers build their marker HTML as a string and hand it to
L.divIcon / innerHTML. Two of the interpolations carry values a user controls,
and neither was escaped.
place.image_url is the worse of the two, because it is not admin-only: any trip
member with edit rights can set it. placeUpdateRequestSchema is a plain record
of unknown values, and the length guard in places.controller covers name,
description, address and notes but not image_url. The renderers do check the
value, but only with startsWith('/uploads/') — a payload like
/uploads/x" onerror="…" y="
satisfies that and then breaks out of the src="…" attribute.
category.color is the same shape one line up, reachable by an admin. It also
reaches SharedTripPage, which builds its own divIcon and answers without a
guard, so that payload lands in front of people who have no account here.
Colours are allow-listed rather than escaped. Escaping stops the attribute
breakout but leaves a working CSS value behind, and `url(https://…)` in a
background is a tracking pixel. Anything that is not #rgb/#rrggbb falls back.
image_url is a URL, not an enum, so that one is escaped; the prefix check stays.
Server side, the category contract now demands a hex colour, and the endpoints
validate through DTO classes instead of @Body('color') — a property read carries
no metatype, so the global pipe never saw it. That retires two entries from the
body-contract allow list. It replaces the hand-rolled 400 for a missing name
with the pipe's envelope, the same trade the places and trips migrations made.
The response schema deliberately still accepts any string: the write path was
open for a long time, so tightening it would make an instance fail on its own
stored rows. The client allow-list is what covers those.
Verified against the unpatched renderer — the three new cases fail without it.
* perf(client): serialize the map icons without react-dom/server
Six modules imported react-dom/server statically to turn a lucide icon into an
SVG string. That put Fizz — roughly 190 kB raw, 57 kB gzip — into a chunk three
lazy routes share, the Leaflet renderer among them, which is the default. For
output that is always a single <svg> with a flat list of shape children.
mapViewport chunk 189,532 -> 3,710 B
Tooltip chunk 194,333 -> 1,970 + 4,860 B
precache total 17,856 -> 17,675 KiB
utils/iconMarkup.ts serializes React elements directly. It is generic over
elements rather than over lucide's icon tables on purpose: iconNode is closed
over inside createLucideIcon and never exported, so calling the forwardRef
render function is what survives a lucide upgrade.
Byte-compatibility is checked, not asserted. iconMarkup.parity.test.ts diffs the
output against real Fizz for every exported icon in three prop shapes — the test
file is the one place still allowed to import react-dom/server, and it never
reaches a bundle. Two traps it covers: viewBox must not be kebab-cased into
view-box, and Fizz emits <path …></path> rather than a self-closing tag.
TripPDF loses its lazy renderer entirely — ensureRenderer, the module-level
handle and the two "not ready yet" guards that returned an empty string. There
is nothing left to load.
An eslint rule keeps the import from creeping back. Without it the regression is
invisible: the build stays green, the app keeps working, and the chunk quietly
grows by 190 kB again.
|
||
|
|
c089e75f08 |
perf(map): load one GL engine instead of both (#1819)
* perf(map): load one GL engine instead of both MapViewGL, JourneyMapGL and the settings preview each imported mapbox-gl and maplibre-gl statically, so rollup emitted one 2.83 MB chunk carrying both. Every map user downloaded 1.8 MB of mapbox plus 1.03 MB of maplibre and then ran exactly one of them. The 110 kB stylesheet was merged the same way. mapbox users 2,827,647 -> 1,797,952 B (-1.03 MB, CSS 110.6 -> 40.8 kB) maplibre users 2,827,647 -> 1,027,635 B (-1.80 MB, CSS 110.6 -> 69.8 kB) The engine is a prop now rather than a module import, and the pairing happens behind the lazy boundary in glLazy.tsx. No module reaches both SDKs statically any more, which is the whole mechanism — the components themselves stayed engine-agnostic, they already guarded on the provider everywhere the two genuinely differ. Both imports of a pair start together instead of in sequence: the engine is by far the larger half, and fetching the component first would put its round trip in front of the download that actually costs time. check:gl-split guards it. A stray static import anywhere in the map tree brings the merged chunk straight back with a green build and a working app, so the script greps the built chunks for a marker of each SDK and fails when one file holds both. Not on the bare string "maplibre" — the provider value and the maplibre_style setting key appear in app code too, so that matches the shared core chunk as well. Two boundaries were missing and are added here, because the split makes them reachable: the GL boundaries had no resetKeys, so a chunk failure under one provider kept showing Leaflet after switching to the other, and the settings preview had a Suspense but no boundary at all — that is the one place a user flips providers live, so it is the likeliest chunk to fail. Worth being precise about the gain: the service worker precaches every chunk, so an installed PWA still downloads both engines. What this buys is the critical path on a cold cache, and parse/compile/execute always — a megabyte of unused engine ran on every map open. * perf(client): split out the video player and the ISO subdivision data Two heavyweights that shipped with routes that rarely use them. plyr (112 kB raw / 33 kB gzip, plus 32 kB of CSS) hung off three lightboxes and through them off the FileManager, JournalBody and MTripShell chunks. The lightboxes only render the player on their video branch anyway, so the split costs one frame of placeholder — a black rectangle in the target geometry, so the lightbox does not jump when the chunk lands. iso-3166-2 is a single 238 kB data blob with no tree-shaking to be had, and only fetchRegionOptions touches it. It now loads after the hasRegions check, so it is fetched only for countries that get a region picker at all: holidayRegions drops from 257 kB to 14 kB. The import sits inside the existing try — a failed chunk yields the same empty array as a failed request, rather than an unhandled rejection in the two callers that only do .then(setRegions). tz-lookup is deliberately left alone. TransitSearchPanel builds the departure time it sends to MOTIS out of tzAt(), so a not-yet-loaded window there is a wrong query rather than a late pixel — that one needs its own change with its own care. |
||
|
|
49e68fb07c |
build(client): give react and the core libs their own chunks (#1818)
Everything that changes with a release currently drags the entry chunk with it, and 63 of 141 chunks import the entry statically. Measured with a one-token edit to a leaf route module and a rebuild, 70 of 141 chunks rehash — just over 1 MB gzip that every installed PWA re-downloads per deploy, since the service worker registers with autoUpdate. Pulling react and the core libraries out cuts that to 847 kB: rehashed 70/141 -> 60/140 gzip 1,038,497 B -> 847,688 B (-18.4%) entry 580,672 B -> 258,105 B eager JS 1,482,722 B -> 1,482,523 B (unchanged) eager CSS same file, same hash No first paint win, then — the gain is a smaller update delta and a halved entry to parse on weak devices. Three of the five groups the plan called for are left out, and not silently: mapbox-gl/maplibre-gl, leaflet and react-markdown already sit in async chunks whose hashes survive a release, so a group would only rename them. Worse, without tags: ['$initial'] a group also collects modules that hang behind React.lazy today and turns its chunk into a static import of the entry — measured, that put the 2.8 MB GL chunk into the index.html modulepreload and took eager JS to 4.61 MB, undoing the route split for maps. Note for the next person to touch this: vite 8 bundles with rolldown, so rollupOptions is only an alias and both manualChunks and advancedChunks are deprecated in favour of codeSplitting.groups. A config using the old names builds green and has no effect. Also refreshes two comments that still quoted pre-split bundle numbers. |
||
|
|
057d4ba503 |
perf(client): pick the viewport branch at the route (#1817)
Every page imported its mobile screen statically and chose while rendering, so each route chunk carried both trees and the viewport only decided which half stayed dark. Worse, because ten route chunks shared the mobile tree, rollup hoisted it into the entry chunk — so every desktop user downloaded the whole mobile shell before seeing a single page. The branch now decides the chunk. A phone never fetches the desktop planner, a desktop never the mobile shell. entry chunk 1,243 -> 581 kB (gzip 349 -> 177) TripPlannerPage 914 -> 340 kB VacayPage 361 -> 57 kB AdminPage 413 -> 225 kB SettingsPage 259 -> 155 kB Splitting inside each page instead was measured and rejected: it leaves the desktop code in the same module as the switch, so a phone would still download it and then wait for a second, serial request on top — 914 kB and a waterfall against 550 kB in parallel. The pass-through wrapper in each page stays deliberately. check-page-pattern scans from the default export to the next top-level function and rejects hooks there; making the desktop component the default export fails it immediately on dashboard, planner and atlas. RouteFallback gets a mobile face. It renders above MobileShell, so the --m-* tokens do not resolve for it and the desktop spinner on bg-surface would be a foreign white sheet in front of the mobile screen. The five scattered viewport spot checks are replaced by one contract covering all eleven routes, which is where the switch now lives. |
||
|
|
1d94814b57 |
perf(planner): load the tab panels on demand (#1816)
* refactor(costs): move the pure split maths out of the panel module splitEqualShares, calculateTicketShares and TicketItem sit next to the payer maths they belong with. They are pure functions with no module state and no imports from the panel, so this is a move and nothing else. The reason it matters: the mobile cost sheet and its model import these two functions from CostsPanel, and MTripShell is a static import of the trip planner page. Any attempt to load the costs panel on demand would therefore pull the whole 97 kB module straight back in through the mobile branch. * perf(planner): load the tab panels on demand Each tab panel only mounts while its own tab is active, yet all six shipped in the planner chunk. The page was 1,203 kB after route splitting — larger than the entire entry chunk. It is now 914 kB, with the panels in six chunks of their own (files 99, collab 87, packing 71, costs 62, reservations 32, todo 31 kB). The panels also get the error boundaries they never had. fe-error-boundary covered the maps and the plugin frame but not these, so without them a dead panel chunk would throw past to the route boundary and take the whole planner with it. The boundary sits outside the Suspense, since Suspense only owns the pending promise and a rejected one throws straight past it. One boundary per panel rather than one around the content area: a shared one would already be mounted with the visible tab, so switching tabs would swap the entire planner for the placeholder instead of just the part still loading. PluginFrame stays static — DayDetailPanel and PlaceInspector import it from the plan tab anyway, so splitting it here would move nothing. The two import modals stay static for the same reason, via the mobile sheets. The wiring tests now await their panel. That includes the ones still passing: React.lazy caches the resolved module for the whole file, so they were only green because an earlier test had already pulled it in. |
||
|
|
e5962c22d5 |
chore(client): tighten the precache ceiling and correct the offline docs (#1815)
* chore(client): tighten the precache ceiling now the entry chunk is split The 6 MB ceiling was set against the pre-split bundle, where the entry chunk alone was 5.2 MB. It is now 1.2 MB and the largest precached entry is the heic-to chunk at 3.0 MB, so the old limit had nothing left to catch. Worth being explicit about what the limit does: a file above it is dropped from the manifest and the build still succeeds, printing only that the file will not be precached. The PWA is then quietly broken offline for that file, which is why the ceiling has to stay close to the real bundle. Also records why every route chunk is precached rather than pulled in at runtime, since that is the reason route splitting did not shrink the install. * docs(wiki): correct the offline cache table The table still described an api-data cache holding API responses for 24 hours under NetworkFirst. That cache is gone: API requests are NetworkOnly because Workbox keys entries by URL and cannot vary them on the session cookie, so on a shared device one account's data could be served to the next. Documenting a cache that deliberately does not exist is the wrong way round for a privacy decision. Map tiles were listed at 1 000 entries; the actual cap is 12 288 and has to stay in step with MAX_TILES in tilePrefetcher.ts. The two GL tile caches were missing altogether, and the precache line now says that it covers every page rather than just the shell. |
||
|
|
96e4ff5932 |
perf(client): load the page chunks on demand (#1812)
* feat(client): retry a failed page chunk once before giving up A dynamic import fails for two different reasons, and only one of them is worth a second attempt. If the file is gone because a deploy happened while the tab was open, nothing but a reload helps and reloadOnceForChunk already owns that path. If the fetch itself went wrong — hotel wifi, an aborted request, a service worker handing back a truncated entry — one retry fixes it. The retry cannot reuse the original specifier, since Vite rewrites it into a hashed asset URL at build time; the only usable URL is the one the browser names in the failure message, and a fresh query on it misses both the HTTP cache and the Workbox precache. This is also where a healthy load is acknowledged. Clearing the reload marker used to sit in main.tsx right after render(), which runs before React commits and long before any chunk is fetched, so the marker was gone before it was ever needed. * perf(client): load the page chunks on demand Every one of the twenty pages was a static import, so the entry chunk carried the planner, the journal, the atlas and the vacation planner for anyone who only opened the dashboard. They now load through lazyWithRetry; the entry chunk drops from 5,183 kB to 1,243 kB (gzip 1,300 -> 349). LoginPage stays static on purpose: anyone logged out lands there, and every other route redirects there first. The Suspense boundary sits above <Routes> rather than inside ProtectedRoute so it stays mounted across navigations. react-router runs location updates inside a transition, which keeps the current page on screen while the next chunk arrives instead of flashing a spinner on every jump. The precache is unchanged by design: workbox picks up the new chunks, so the install size stays where it was (107 -> 178 entries, +31 KiB) and every route remains available offline. This buys first paint, not install size. |