diff --git a/migration-graph.md b/migration-graph.md index 4948cf985..81fce3ca2 100644 --- a/migration-graph.md +++ b/migration-graph.md @@ -1,28 +1,37 @@ # Legacy `src/services/` dependency graph -Generated from the actual imports in `server/src` on **2026-07-27** (after the -budgetService migration — completing step 1 of the dependency-honest order -below and unblocking the tripService hub; the exchangeRateService fold was its -prep half, following the Wave-2 permissions + auditLog pair). -Regenerate any -time — the extraction script only parses `from './x'` / -`from '../services/x'` imports: - -```bash -cd server && python3 /gen-graph.py # or re-run the grep-based analysis -``` +Generated from the actual imports in `server/src` on **2026-07-28** (after the +budgetService migration + its quirk-fix pass — completing step 1 of the +dependency-honest order below and unblocking the tripService hub — and the +Phase 0 quick wins: `getAppUrl`/`getMcpSafeUrl` → `src/app-config` deleted the +fake `→ notifications` edges for the identity/MCP stack and freed mapsService; +the addon-enablement reads moved from adminService into `nest/addons/` +(`addons.bridge.ts` for out-of-container consumers), collapsing the admin +god-file's ~27-consumer fan-in to the admin routes + one `admin-2` residual +and freeing oauthService). +This regeneration parses **static `from '...'`, `require('...')` and dynamic +`import('...')`** specifiers, so the lazy edges the earlier grep-based analysis +missed (scheduler jobs, the db-boot airport backfill, the fire-and-forget +notification sends) are now included. Regenerate any time by re-running that +three-pattern import scan over `server/src` (a throwaway script suffices; the +patterns are the whole trick). How to read it: - **imports (services/)** — what the legacy file pulls from other legacy files. A service is *migration-ready* when this column contains only helpers (see classification below) and/or `tripAccess` (Wave-2 "don't migrate, delete": absorb into `DatabaseService.canAccessTrip`). + Edges tagged **(lazy)** are `import()`/`require()` at call time — they never block a + migration, because a migrated Nest service can keep the same lazy import until the target + domain migrates (collab/packing/trips/reservations/vacay all do exactly that for + `notificationService`). - **imported by (services/)** — legacy files that would need a **bridge or repoint** when this one migrates (a legacy module can't inject). - **nest consumers** — in-container consumers: repoint to the injected service (`exports: [XService]` + module import), never a bridge. - **out-of-container consumers** — `mcp/`, `scheduler.ts`, `websocket.ts`, `db/`, - `middleware/`, `index.ts`: these are the **bridge pressure** (todo.bridge.ts precedent). + `middleware/`, `systemNotices/`, `index.ts`: these are the **bridge pressure** + (todo.bridge.ts precedent). ## Node classification @@ -55,7 +64,7 @@ How to read it: placeEnrichment → places — the exchangeRateService → budget fold landed 2026-07 on exactly this pattern). -## Domain-level graph (edges = "must migrate first, or bridge") +## Domain-level graph (edges = "must migrate first, or bridge"; dotted = lazy, non-blocking) ```mermaid flowchart TD @@ -71,15 +80,16 @@ flowchart TD airport["airportService (boot special case)"]:::ready wiki[wikiService]:::ready collections[collectionsService]:::ready + maps[mapsService]:::ready + oauth[oauthService]:::ready end + place[placeService]:::blocked - maps[mapsService]:::blocked notifSvc[notificationService]:::blocked transit[transitService]:::blocked transitItin[transitItineraryService]:::blocked admin[adminService]:::blocked auth[authService]:::blocked - oauth[oauthService]:::blocked oidc[oidcService]:::blocked passkey[passkeyService]:::blocked journey[journeyService]:::blocked @@ -90,14 +100,13 @@ flowchart TD cleanup[userCleanupService]:::infra trip --> cleanup + collections -.-> notifSvc place --> maps - maps --> notifCluster transit --> maps transitItin --> transit notifSvc --> notifCluster admin --> auth & notifSvc & cleanup auth --> atlas - oauth --> admin oidc --> auth passkey --> auth journey --> memories @@ -105,8 +114,10 @@ flowchart TD memories --> notifSvc & admin ``` -(`placeService → mapsService` is via the `placeEnrichment` helper; `mapsService/notificationService -→ notifications cluster` are hard imports. `memories/` ↔ admin/notificationService edges make the +(`placeService → mapsService` is via the `placeEnrichment` helper; `notificationService +→ notifications cluster` is a hard import — the former `mapsService/transitService/webauthnConfig/ +oauthService → notifications` edges were only `getAppUrl`/`getMcpSafeUrl` and died with the +Phase 0 move to `src/app-config`, putting mapsService on the frontier. `memories/` ↔ admin/notificationService edges make the journey/memories corner tangle with the admin corner. The former `auth/collections/backup → permissions` and `notifSvc/oauth → auditLog` edges are gone since the 2026-07 Wave-2 pair: the permissions consumers repointed to `nest/permissions/permissions.bridge`, @@ -114,20 +125,23 @@ the writeAudit consumers to `nest/audit/audit.bridge`, and the log*-only consume `nest/audit/audit-log.logger` — none of them block a migration anymore. The former `trip → budget` and `cleanup → budget` edges are gone since the 2026-07 budgetService migration: both repointed to `nest/budget/budget.bridge`, leaving tripService with no -unmigrated domain dependency — the hub is frontier-ready.) +unmigrated domain dependency — the hub is frontier-ready. The dotted +`collections -.-> notifSvc` edge is a call-time `import()` for the collection-invite +notification: it stays as-is through a migration, exactly like the identical lazy sends in the +already-migrated collab/packing/trips/reservations/vacay services.) ## Full adjacency table | service | imports (services/) | imported by (services/) | nest consumers | out-of-container consumers | |---|---|---|---|---| -| `adminService` | apiKeyCrypto, authService, avatarUrl, llmConfig, memories/helpersService, notificationService, passwordPolicy, userCleanupService (+ `permissions.bridge`) | airtrail/airtrailSync, memories/thumbnailService, oauthService | nest/addons/addons.service.ts, nest/admin/admin.service.ts, nest/booking-import/booking-import.service.ts, nest/booking-import/features.controller.ts, nest/budget/budget.mcp.ts, nest/collab/collab.mcp.ts, nest/collections/collections-addon.guard.ts, nest/integrations/airtrail-addon.guard.ts, nest/journey/journey.service.ts, nest/llm-parse/llm-config.resolver.ts, nest/oauth/oauth.service.ts, nest/packing/packing.mcp.ts, nest/platform/platform.routes.ts, nest/plugins/host/plugin-host-deps.factory.ts, nest/plugins/journal-entry-rows.controller.ts, nest/plugins/plugin-runtime.service.ts, nest/plugins/plugins.service.ts, nest/todo/todo.mcp.ts, nest/vacay/vacay.mcp.ts | mcp/index.ts, mcp/resources.ts, mcp/tools/atlas.ts, mcp/tools/journey.ts, mcp/tools/prompts.ts, mcp/tools/trips.ts, scheduler.ts | +| `adminService` | apiKeyCrypto, authService, avatarUrl, llmConfig, memories/helpersService, notificationService, passwordPolicy, userCleanupService (+ `permissions.bridge`) | (none) | nest/admin/admin.service.ts, nest/packing/packing.mcp.ts (`deletePackingTemplate`, the `admin-2` residual) | scheduler.ts | | `airportService` | (none) | (none) | nest/airports/airports.service.ts, nest/booking-import/kitinerary-mapper.ts | db/database.ts, mcp/tools/mapsWeather.ts, mcp/tools/transports.ts | | `apiKeyCrypto` | (none) | adminService, airtrail/airtrailService, authService, llmConfig, mapsService, memories/helpersService, memories/immichService, memories/photoResolverService, memories/synologyService, memories/unifiedService, notifications, oidcService, unsplashService | nest/plugins/plugin-oauth.service.ts, nest/plugins/plugin-runtime.service.ts, nest/plugins/plugins.service.ts, nest/settings/settings.service.ts | db/migrations.ts | | `atlasService` | (none) | authService | nest/atlas/atlas.service.ts, nest/plugins/host/plugin-host-deps.factory.ts | mcp/resources.ts, mcp/tools/atlas.ts | | `authService` | apiKeyCrypto, atlasService, avatarUrl, demo, distanceService, ephemeralTokens, mfaCrypto, passwordPolicy, tripMembership, userCleanupService, webauthnConfig (+ `permissions.bridge`) | adminService, oidcService, passkeyService | nest/assignments/assignments.mcp.ts, nest/auth/auth.service.ts, nest/auth/passkey-enabled.guard.ts, nest/budget/budget.mcp.ts, nest/collab/collab.mcp.ts, nest/days/day-notes.mcp.ts, nest/days/days.mcp.ts, nest/oidc/oidc.service.ts, nest/packing/packing.mcp.ts, nest/reservations/reservations.mcp.ts, nest/tags/tags.mcp.ts, nest/todo/todo.mcp.ts, nest/vacay/vacay.mcp.ts | mcp/index.ts, mcp/tools/atlas.ts, mcp/tools/collections.ts, mcp/tools/journey.ts, mcp/tools/notifications.ts, mcp/tools/places.ts, mcp/tools/transit.ts, mcp/tools/transports.ts, mcp/tools/trips.ts | | `avatarUrl` | (none) | adminService, authService, inAppNotifications, journeyService, tripService | nest/budget/budget.service.ts, nest/collab/collab.service.ts, nest/files/files.service.ts, nest/packing/packing.service.ts, nest/reservations/reservations.service.ts | (none) | -| `backupService` | (none — `permissions.bridge` only) | (none) | nest/backup/backup.controller.ts, nest/backup/backup.service.ts | (none) | -| `collectionsService` | placeImage (+ `permissions.bridge`) | (none) | nest/collections/collections.service.ts, nest/plugins/host/plugin-host-deps.factory.ts | mcp/tools/collections.ts | +| `backupService` | (none — `permissions.bridge`, plugin backup/paths infra only) | (none) | nest/backup/backup.controller.ts, nest/backup/backup.service.ts | (none) | +| `collectionsService` | notificationService (lazy), placeImage (+ `permissions.bridge`) | (none) | nest/collections/collections.service.ts, nest/plugins/host/plugin-host-deps.factory.ts | mcp/tools/collections.ts | | `conflictResult` | (none) | placeService | nest/packing/packing.controller.ts, nest/packing/packing.service.ts, nest/places/places.controller.ts, nest/plugins/host/plugin-host-deps.factory.ts | (none) | | `cookie` | (none) | (none) | nest/auth/auth-public.controller.ts, nest/auth/auth.service.ts, nest/auth/passkey.controller.ts, nest/oidc/oidc.controller.ts, nest/oidc/oidc.service.ts | (none) | | `demo` | (none) | authService | nest/auth/auth.controller.ts, nest/collections/collections.controller.ts, nest/files/files.controller.ts, nest/places/places.controller.ts, nest/plugins/host/plugin-host-deps.factory.ts, nest/trips/trips.controller.ts | middleware/auth.ts, middleware/mfaPolicy.ts | @@ -139,12 +153,12 @@ unmigrated domain dependency — the hub is frontier-ready.) | `journeyShareService` | journeyService | (none) | nest/journey/journey.service.ts | mcp/tools/journey.ts | | `kmlImport` | (none) | placeService | (none) | (none) | | `llmConfig` | apiKeyCrypto | adminService | nest/llm-parse/llm-client.factory.ts, nest/llm-parse/llm-config.resolver.ts | (none) | -| `mapsService` | apiKeyCrypto, notifications, placePhotoCache | placeEnrichment, transitService | nest/booking-import/booking-import.service.ts, nest/maps/maps.service.ts | mcp/tools/mapsWeather.ts, mcp/tools/places.ts | +| `mapsService` | apiKeyCrypto, placePhotoCache | placeEnrichment, transitService | nest/booking-import/booking-import.service.ts, nest/maps/maps.service.ts | mcp/tools/mapsWeather.ts, mcp/tools/places.ts | | `mfaCrypto` | (none) | authService | (none) | (none) | -| `notificationPreferencesService` | notifications, notifications/channelRegistry | inAppNotifications, notificationService, notifications, notifications/channelRegistry | nest/admin/admin.service.ts, nest/notifications/notifications.service.ts, nest/plugins/install/manifest.ts | (none) | -| `notificationService` | inAppNotifications, notificationPreferencesService, notifications, notifications/channelRegistry (+ `audit-log.logger`) | adminService, memories/synologyService, memories/unifiedService | nest/admin/admin.controller.ts, nest/collab/collab.service.ts, nest/packing/packing.service.ts, nest/plugins/host/plugin-host-deps.factory.ts, nest/trips/trips.service.ts | scheduler.ts | -| `notifications` | apiKeyCrypto, notificationPreferencesService (+ `audit-log.logger`) | mapsService, notificationPreferencesService, notificationService, notifications/builtins, oauthService, transitService, webauthnConfig | nest/auth/auth.service.ts, nest/notifications/notifications.service.ts, nest/oauth/oauth.service.ts, nest/oidc/oidc.service.ts, nest/platform/platform.routes.ts, nest/plugins/plugin-oauth.service.ts | index.ts, mcp/index.ts, mcp/oauthProvider.ts | -| `oauthService` | adminService, notifications (+ `audit.bridge`) | (none) | nest/oauth/oauth-api.controller.ts, nest/oauth/oauth.service.ts | mcp/index.ts, mcp/oauthProvider.ts | +| `notificationPreferencesService` | notifications, notifications/builtins, notifications/channelRegistry | inAppNotifications, notificationService, notifications, notifications/channelRegistry | nest/admin/admin.service.ts, nest/notifications/notifications.service.ts, nest/plugins/install/manifest.ts | (none) | +| `notifications` | apiKeyCrypto, notificationPreferencesService (+ `audit-log.logger`) | notificationPreferencesService, notificationService, notifications/builtins | nest/auth/auth.service.ts, nest/notifications/notifications.service.ts | (none) | +| `notificationService` | inAppNotifications, notificationPreferencesService, notifications, notifications/channelRegistry (+ `audit-log.logger`) | adminService, collectionsService, memories/synologyService, memories/unifiedService | nest/admin/admin.controller.ts, nest/collab/collab.service.ts, nest/packing/packing.service.ts, nest/plugins/host/plugin-host-deps.factory.ts, nest/reservations/reservations.service.ts, nest/trips/trips.service.ts, nest/vacay/vacay.service.ts | scheduler.ts | +| `oauthService` | (none — `addons.bridge` + `audit.bridge` only) | (none) | nest/oauth/oauth-api.controller.ts, nest/oauth/oauth.service.ts | mcp/index.ts, mcp/oauthProvider.ts | | `oidcService` | apiKeyCrypto, authService, tripMembership | (none) | nest/oidc/oidc.service.ts | (none) | | `passkeyService` | authService, webauthnConfig | (none) | nest/admin/admin.service.ts, nest/auth/passkey.controller.ts | (none) | | `passwordPolicy` | (none) | adminService, authService | (none) | (none) | @@ -155,40 +169,47 @@ unmigrated domain dependency — the hub is frontier-ready.) | `queryHelpers` | (none) | placeService | nest/assignments/assignments.service.ts, nest/days/days.service.ts, nest/share/share.service.ts | (none) | | `timezoneService` | (none) | airtrail/airtrailMapper, transitItineraryService, tripService | (none) | (none) | | `transitItineraryService` | distanceService, timezoneService, transitService (+ type-only `reservations.bridge`) | (none) | (none) | mcp/tools/transit.ts | -| `transitService` | mapsService, notifications | transitItineraryService | nest/transit/transit.controller.ts | mcp/tools/transit.ts | +| `transitService` | mapsService | transitItineraryService | nest/transit/transit.controller.ts | mcp/tools/transit.ts | | `tripAccess` | (none) | tripService | nest/booking-import/booking-import.service.ts, nest/budget/budget.service.ts, nest/collab/collab.service.ts, nest/integrations/airtrail-import.controller.ts, nest/packing/packing.service.ts, nest/reservations/reservations.service.ts, nest/todo/todo.service.ts | (none) | | `tripMembership` | (none) | authService, oidcService | nest/plugins/host/plugin-host-deps.factory.ts, nest/trip-invite/trip-invite.service.ts | (none) | -| `tripService` | avatarUrl, timezoneService, tripAccess, userCleanupService (+ `budget.bridge`, `days.bridge`, `reservations.bridge`) | (none) | nest/budget/budget.mcp.ts, nest/feeds/feeds.service.ts, nest/plugins/host/plugin-host-deps.factory.ts, nest/trips/trips.controller.ts, nest/trips/trips.service.ts | mcp/resources.ts, mcp/tools/prompts.ts, mcp/tools/trips.ts | +| `tripService` | avatarUrl, timezoneService, tripAccess, userCleanupService (+ `budget.bridge`, `collab.bridge`, `days.bridge`, `packing.bridge`, `reservations.bridge`, `vacay.bridge`) | (none) | nest/budget/budget.mcp.ts, nest/feeds/feeds.service.ts, nest/plugins/host/plugin-host-deps.factory.ts, nest/trips/trips.controller.ts, nest/trips/trips.service.ts | mcp/resources.ts, mcp/tools/prompts.ts, mcp/tools/trips.ts | | `unsplashService` | apiKeyCrypto | placeService | nest/trips/trips.controller.ts, nest/trips/trips.service.ts | (none) | -| `userCleanupService` | (none — `budget.bridge` only) | adminService, authService, tripService | (none) | (none) | +| `userCleanupService` | (none — `budget.bridge`, plugin paths infra only) | adminService, authService, tripService | (none) | (none) | | `weatherService` | (none) | (none) | nest/plugins/host/plugin-host-deps.factory.ts, nest/weather/weather.controller.ts, nest/weather/weather.service.ts | mcp/tools/mapsWeather.ts | -| `webauthnConfig` | notifications | authService, passkeyService | (none) | (none) | +| `webauthnConfig` | (none) | authService, passkeyService | (none) | (none) | | `wikiService` | (none) | (none) | nest/help/help.controller.ts | (none) | ## Subdirectory clusters - **`notifications/`**: `channelRegistry` ⇄ `notificationPreferencesService` (cycle), - `builtins → notifications + channelRegistry`. Migrates as one unit with - `notifications.ts`/`notificationService`. + `builtins → notifications + channelRegistry`, and `notificationPreferencesService → + builtins` (registering the built-in channels closes a second loop). Migrates as one unit + with `notifications.ts`/`notificationService`. - **`memories/`**: `helpersService` (base) ← immich/synology/unified/photoResolver; - `photoResolverService` is the seam `journeyService` consumes; `thumbnailService → adminService` - and `synology/unified → notificationService` couple this cluster to the admin corner. + `photoResolverService` is the seam `journeyService` consumes (it also pulls + immich/synology/thumbnail/`trekPhotoCache` — the latter is swept by `scheduler.ts`); + `synology/unified → notificationService` couples this + cluster to the admin corner (`thumbnailService`'s adminService edge was only + `isAddonEnabled` — now `addons.bridge`); `immichService` writes audits via `nest/audit/audit.bridge`. - **`airtrail/`**: `airtrailClient` (base) ← mapper ← service ← import/sync; `import`/`sync` - consume `adminService` and (since the 2026-07 reservations fold) the - `nest/reservations/reservations.bridge` instead of the deleted `reservationService`; since + consume (since the 2026-07 reservations fold) the + `nest/reservations/reservations.bridge` instead of the deleted `reservationService` + (their adminService edge was only `isAddonEnabled` — now `addons.bridge`); since the auditLog fold, `airtrailService` writes audits via `nest/audit/audit.bridge` and `airtrailSync` logs via the plain `nest/audit/audit-log.logger` (same split in `memories/immichService` → bridge). ## Decoding "what's next" -**Ready frontier** (all legacy deps are helpers or `tripAccess`): +**Ready frontier** (all legacy deps are helpers, `tripAccess`, or lazy sends): | Candidate | Why now / why not | Bridge tax (legacy dependents + out-of-container) | |---|---|---| -| **tripService** ← pick | budgetService landed (2026-07), so the hub's last unmigrated domain edge is gone (its remaining legacy deps are helpers + the budget/days/reservations bridges); every other migration keeps paying it a repoint tax until it moves | no legacy dependents; mcp resources/prompts/trips registrars + the budget/todo/days bridges' summary seams | -| **collectionsService** | Newly frontier-ready — its only legacy deps are the placeImage helper + `permissions.bridge` | `mcp/tools/collections.ts` | +| **tripService** ← pick | budgetService landed (2026-07), so the hub's last unmigrated domain edge is gone (its remaining legacy deps are helpers + the budget/collab/days/packing/reservations/vacay bridges); every other migration keeps paying it a repoint tax until it moves | no legacy dependents; mcp resources/prompts/trips registrars + `budget.mcp.ts`'s getTripOwner/listMembers seam | +| **collectionsService** | Frontier-ready — legacy deps are the placeImage helper + `permissions.bridge`, plus a call-time lazy `import()` of notificationService that a migrated service keeps (collab precedent) | `mcp/tools/collections.ts` | | **atlasService** | Zero deps, but its legacy dependent is `authService` (Wave-5) → bridge lives long | `authService`; `mcp/tools/atlas.ts`, `mcp/resources.ts` | +| **mapsService** | Frontier-ready since the Phase 0 `getAppUrl` → app-config move (remaining legacy deps are the apiKeyCrypto/placePhotoCache helpers); unblocks transitService → placeService → transitItineraryService | `placeEnrichment`, `transitService`; `mcp/tools/mapsWeather.ts`, `mcp/tools/places.ts` | +| **oauthService** | Dependency-free since the Phase 0 addons extraction (adminService edge was only `isAddonEnabled` → `addons.bridge`) — but the coherence order keeps it after admin so the `mcp/oauthProvider.ts` merge (`mcp-2`) lands with it | `mcp/index.ts`, `mcp/oauthProvider.ts` | | **weatherService / wikiService / airportService** | Independent leaves; airport has the `db/database.ts` boot lazy-require special case | little / none | **Blocked, and by what (shortest unblock path):** @@ -197,20 +218,25 @@ unmigrated domain dependency — the hub is frontier-ready.) migration** (its budget edge and `userCleanupService`'s are now `budget.bridge` repoints, joining the collab/vacay/reservations/days ones). See the frontier table above. -- `placeService` ← `mapsService` (via placeEnrichment) ← notifications cluster. +- `placeService` ← `mapsService` (via placeEnrichment) — mapsService itself is + frontier-ready since the Phase 0 app-url move deleted its notifications edge. - `notificationService` ← notifications cluster (its auditLog edge is now the plain `audit-log.logger` import — gone as a blocker since the 2026-07 Wave-2 pair). - `transitItineraryService` ← `transitService` (← mapsService) — its reservation edge is now the `reservations.bridge` repoint (type-only). -- `adminService`/`oauthService`/`authService` corner: `authService` ← `atlasService` +- `adminService`/`authService` corner: `authService` ← `atlasService` (its permissions edge is now the `permissions.bridge` repoint); `adminService` ← - `authService` + `notificationService`; `oauthService` ← `adminService`. Order inside the - corner: atlas → auth → admin → oauth (oidc/passkey ride auth; permissions done 2026-07). + `authService` + `notificationService`. `oauthService` is dependency-free since the + Phase 0 addons extraction (its adminService edge was only `isAddonEnabled`, now + `addons.bridge`), but the coherence order stays atlas → auth → admin → oauth so the + `mcp/oauthProvider.ts` merge (`mcp-2`) lands after admin (oidc/passkey ride auth; + permissions done 2026-07). - `journeyService` ← `memories/` cluster (which itself touches admin + notificationService) → `journeyShareService` after. - `collectionsService`/`backupService`: unblocked since the permissions fold (their edges are - `permissions.bridge` repoints now); backup still stays last by design (owns the - closeDb/reinitialize lifecycle). + `permissions.bridge` repoints now; collections' notificationService edge is lazy — see + above); backup still stays last by design (owns the closeDb/reinitialize lifecycle and the + plugin backup/paths infra). **Dependency-honest order** (each step's deps are already done at that point; `vacayService`, `reservationService` (the residue fold), `dayService` and the @@ -238,3 +264,11 @@ Wave-2 `permissions` + `auditLog` pair were the first frontier picks — all don those domains migrate. - Every remaining Wave-3/4 domain has legacy `tripService` as a dependent — each migration before tripService pays a small bridge/repoint tax the files migration didn't have. +- (2026-07-28 regeneration) The earlier grep-based analysis only saw static imports, so it + missed every lazy edge: the scheduler's `require()` jobs (admin, notificationService, + placePhotoCache, airtrailSync, trekPhotoCache), the `db/database.ts` boot backfill's + airportService require, `index.ts` → ephemeralTokens, `systemNotices/conditions.ts` → + adminService, and `collectionsService`'s call-time `import()` of notificationService. It + also under-reported `tripService`'s bridge repoints (six, not three: budget, collab, days, + packing, reservations, vacay). None of this changes the order — lazy edges don't block — + but the bridge-pressure column was undercounting scheduler/system-notice consumers. diff --git a/server/src/mcp/index.ts b/server/src/mcp/index.ts index 487cc80e6..4c9180c11 100644 --- a/server/src/mcp/index.ts +++ b/server/src/mcp/index.ts @@ -5,7 +5,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/ import { User } from '../types'; import { verifyMcpToken, verifyJwtToken } from '../services/authService'; import { getUserByAccessToken } from '../services/oauthService'; -import { isAddonEnabled } from '../services/adminService'; +import { isAddonEnabled } from '../nest/addons/addons.bridge'; import { ADDON_IDS } from '../addons'; import { registerResources } from './resources'; import { registerTools } from './tools'; diff --git a/server/src/mcp/resources.ts b/server/src/mcp/resources.ts index ab7f2faa0..a57627d29 100644 --- a/server/src/mcp/resources.ts +++ b/server/src/mcp/resources.ts @@ -4,7 +4,7 @@ import { listTrips, getTrip, getTripOwner, listMembers } from '../services/tripS import { listPlaces } from '../services/placeService'; import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService'; import { getNotifications } from '../services/inAppNotifications'; -import { isAddonEnabled } from '../services/adminService'; +import { isAddonEnabled } from '../nest/addons/addons.bridge'; import { ADDON_IDS } from '../addons'; import { canAccessJourney, getJourneyFull, listEntries, listJourneys } from '../services/journeyService'; import { canRead, canReadTrips } from './scopes'; diff --git a/server/src/mcp/tools/atlas.ts b/server/src/mcp/tools/atlas.ts index d8cdb7c82..920db029d 100644 --- a/server/src/mcp/tools/atlas.ts +++ b/server/src/mcp/tools/atlas.ts @@ -6,7 +6,7 @@ import { getStats as getAtlasStats, listManuallyVisitedRegions, markRegionVisited, unmarkRegionVisited, getCountryPlaces, updateBucketItem, } from '../../services/atlasService'; -import { isAddonEnabled } from '../../services/adminService'; +import { isAddonEnabled } from '../../nest/addons/addons.bridge'; import { ADDON_IDS } from '../../addons'; import { TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, diff --git a/server/src/mcp/tools/journey.ts b/server/src/mcp/tools/journey.ts index 677be11fd..3196c98c4 100644 --- a/server/src/mcp/tools/journey.ts +++ b/server/src/mcp/tools/journey.ts @@ -11,7 +11,7 @@ import { import { createOrUpdateJourneyShareLink, deleteJourneyShareLink, getJourneyShareLink, } from '../../services/journeyShareService'; -import { isAddonEnabled } from '../../services/adminService'; +import { isAddonEnabled } from '../../nest/addons/addons.bridge'; import { ADDON_IDS } from '../../addons'; import { TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, diff --git a/server/src/mcp/tools/prompts.ts b/server/src/mcp/tools/prompts.ts index 211786c7d..4f090a585 100644 --- a/server/src/mcp/tools/prompts.ts +++ b/server/src/mcp/tools/prompts.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { canAccessTrip } from '../../db/database'; import { getTripSummary } from '../../services/tripService'; import { listItems as listPackingItems } from '../../nest/packing/packing.bridge'; -import { isAddonEnabled } from '../../services/adminService'; +import { isAddonEnabled } from '../../nest/addons/addons.bridge'; import { ADDON_IDS } from '../../addons'; export function registerMcpPrompts(server: McpServer, _userId: number, isStaticToken = false): void { diff --git a/server/src/mcp/tools/trips.ts b/server/src/mcp/tools/trips.ts index 8fbf986de..ba391f64f 100644 --- a/server/src/mcp/tools/trips.ts +++ b/server/src/mcp/tools/trips.ts @@ -12,7 +12,7 @@ import { import { createOrUpdateShareLink, getShareLink, deleteShareLink, } from '../../nest/share/share.bridge'; -import { isAddonEnabled, getCollabFeatures } from '../../services/adminService'; +import { isAddonEnabled, getCollabFeatures } from '../../nest/addons/addons.bridge'; import { ADDON_IDS } from '../../addons'; import { countMessages, listPolls } from '../../nest/collab/collab.bridge'; import { diff --git a/server/src/nest/addons/addons.bridge.ts b/server/src/nest/addons/addons.bridge.ts new file mode 100644 index 000000000..2b8b5a7a1 --- /dev/null +++ b/server/src/nest/addons/addons.bridge.ts @@ -0,0 +1,35 @@ +import { db } from '../../db/database'; +import { DatabaseService } from '../database/database.service'; +import { AddonsService } from './addons.service'; + +/** + * Non-Nest entry point for the addons domain — for code running OUTSIDE the + * Nest container (the legacy MCP registrars/resources, systemNotices, the + * pre-init platform.routes Express router, the module-level `when:` gate + * closures in the DI-discovered *.mcp.ts files, and the legacy + * oauthService/airtrailSync/thumbnailService modules). Exports the legacy + * services/adminService names 1:1 so repointing a consumer is an + * import-path-only diff. Inside the container, inject AddonsService instead. + * Delete this file when the last legacy consumer migrates. + * + * Only the enablement READS are bridged — the paired writers + * (updateBagTracking/updateCollabFeatures) have in-container consumers only. + * The reads are uncached per-call queries on purpose: admin toggles must stay + * immediately visible without invalidation wiring. + * + * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto + * the shared better-sqlite3 singleton. + */ +const addons = new AddonsService(new DatabaseService(db)); + +export function isAddonEnabled(addonId: string): boolean { + return addons.isAddonEnabled(addonId); +} + +export function getBagTracking(): { enabled: boolean } { + return addons.getBagTracking(); +} + +export function getCollabFeatures(): { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean } { + return addons.getCollabFeatures(); +} diff --git a/server/src/nest/addons/addons.module.ts b/server/src/nest/addons/addons.module.ts index 078c3b52a..2a7bb1d5d 100644 --- a/server/src/nest/addons/addons.module.ts +++ b/server/src/nest/addons/addons.module.ts @@ -6,9 +6,16 @@ import { AddonsService } from './addons.service'; * GET /api/addons — enabled add-ons + photo providers (was an inline handler in * server/src/app.ts). The addon sub-features (atlas, vacay) keep their own * modules; this only serves the EXACT /api/addons listing. + * + * Exports AddonsService — the owner of addon/feature-flag enablement reads + * (isAddonEnabled + bag-tracking/collab-features flags, extracted from + * adminService per finding `admin-1`) — for the in-container consumers. + * Deliberately NOT @Global (permissions precedent): e2e TestingModules resolve + * it transitively through each consumer's explicit import. */ @Module({ controllers: [AddonsController], providers: [AddonsService], + exports: [AddonsService], }) export class AddonsModule {} diff --git a/server/src/nest/addons/addons.service.ts b/server/src/nest/addons/addons.service.ts index f4742ef4c..387260796 100644 --- a/server/src/nest/addons/addons.service.ts +++ b/server/src/nest/addons/addons.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; import type { Addon } from '../../types'; -import { getBagTracking, getCollabFeatures } from '../../services/adminService'; import { getPhotoProviderConfig } from '../../services/memories/helpersService'; /** @@ -9,6 +8,14 @@ import { getPhotoProviderConfig } from '../../services/memories/helpersService'; * inline `GET /api/addons` handler performed (server/src/app.ts). The SQL, * ordering, boolean coercions and the merged photo-provider entries are * reproduced 1:1 so the body is byte-identical for the client. + * + * Also the single owner of addon/feature-flag enablement state (moved verbatim + * from services/adminService.ts, finding `admin-1`): isAddonEnabled plus the + * bag-tracking and collab-features flags. The boolean polarities differ on + * purpose — bag tracking is opt-in (`=== 'true'`, default OFF), the collab + * sub-features are opt-out (`!== 'false'`, default ON). Reads are uncached + * per-call queries so admin toggles stay immediately visible; out-of-container + * consumers go through addons.bridge.ts. */ @Injectable() export class AddonsService { @@ -18,6 +25,63 @@ export class AddonsService { return this.dbs.connection; } + isAddonEnabled(addonId: string): boolean { + const addon = this.db.prepare('SELECT enabled FROM addons WHERE id = ?').get(addonId) as + | { enabled: number } + | undefined; + return !!addon?.enabled; + } + + getBagTracking() { + const row = this.db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as + | { value: string } + | undefined; + return { enabled: row?.value === 'true' }; + } + + updateBagTracking(enabled: boolean) { + this.db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run( + enabled ? 'true' : 'false', + ); + return { enabled: !!enabled }; + } + + getCollabFeatures() { + const rows = this.db + .prepare( + "SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')", + ) + .all() as { key: string; value: string }[]; + const map: Record = {}; + for (const r of rows) map[r.key] = r.value; + return { + chat: map['collab_chat_enabled'] !== 'false', + notes: map['collab_notes_enabled'] !== 'false', + polls: map['collab_polls_enabled'] !== 'false', + whatsnext: map['collab_whatsnext_enabled'] !== 'false', + }; + } + + updateCollabFeatures(features: { chat?: boolean; notes?: boolean; polls?: boolean; whatsnext?: boolean }) { + const mapping: Record = { + chat: 'collab_chat_enabled', + notes: 'collab_notes_enabled', + polls: 'collab_polls_enabled', + whatsnext: 'collab_whatsnext_enabled', + }; + const before = this.getCollabFeatures(); + const stmt = this.db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)'); + for (const [feat, key] of Object.entries(mapping)) { + if (features[feat] !== undefined) stmt.run(key, features[feat] ? 'true' : 'false'); + } + const after = this.getCollabFeatures(); + // Collab flags gate MCP tool/resource registration, so callers must know + // whether anything actually flipped — a no-op save must not tear down every + // live MCP session (#1414). + const changed = (Object.keys(after) as Array).some((k) => after[k] !== before[k]); + return { features: after, changed }; + } + list() { const addons = this.db .prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order') @@ -58,8 +122,8 @@ export class AddonsService { } return { - collabFeatures: getCollabFeatures(), - bagTracking: getBagTracking().enabled, + collabFeatures: this.getCollabFeatures(), + bagTracking: this.getBagTracking().enabled, addons: [ ...addons.map((a) => ({ ...a, enabled: !!a.enabled })), ...providers.map((p) => ({ diff --git a/server/src/nest/admin/admin.module.ts b/server/src/nest/admin/admin.module.ts index 421ea1963..9af8b8c60 100644 --- a/server/src/nest/admin/admin.module.ts +++ b/server/src/nest/admin/admin.module.ts @@ -4,9 +4,10 @@ import { AdminService } from './admin.service'; import { PluginsModule } from '../plugins/plugins.module'; import { SettingsModule } from '../settings/settings.module'; import { AuditModule } from '../audit/audit.module'; +import { AddonsModule } from '../addons/addons.module'; @Module({ - imports: [PluginsModule, SettingsModule, AuditModule], + imports: [PluginsModule, SettingsModule, AuditModule, AddonsModule], controllers: [AdminController], providers: [AdminService], }) diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index d03518df4..be9d5bc1f 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import * as svc from '../../services/adminService'; +import { AddonsService } from '../addons/addons.service'; import { SettingsService } from '../settings/settings.service'; import { invalidateMcpSessions } from '../../mcp'; import { getPreferencesMatrix, setAdminPreferences } from '../../services/notificationPreferencesService'; @@ -13,7 +14,10 @@ import { adminResetPasskeys } from '../../services/passkeyService'; */ @Injectable() export class AdminService { - constructor(private readonly settings: SettingsService) {} + constructor( + private readonly settings: SettingsService, + private readonly addons: AddonsService, + ) {} // Users listUsers() { return svc.listUsers(); } @@ -41,16 +45,16 @@ export class AdminService { deleteInvite(id: string) { return svc.deleteInvite(id); } // Feature toggles - getBagTracking() { return svc.getBagTracking(); } - updateBagTracking(enabled: unknown) { return svc.updateBagTracking(enabled as boolean); } + getBagTracking() { return this.addons.getBagTracking(); } + updateBagTracking(enabled: unknown) { return this.addons.updateBagTracking(enabled as boolean); } getPlacesPhotos() { return svc.getPlacesPhotos(); } updatePlacesPhotos(enabled: boolean) { return svc.updatePlacesPhotos(enabled); } getPlacesAutocomplete() { return svc.getPlacesAutocomplete(); } updatePlacesAutocomplete(enabled: boolean) { return svc.updatePlacesAutocomplete(enabled); } getPlacesDetails() { return svc.getPlacesDetails(); } updatePlacesDetails(enabled: boolean) { return svc.updatePlacesDetails(enabled); } - getCollabFeatures() { return svc.getCollabFeatures(); } - updateCollabFeatures(body: unknown) { return svc.updateCollabFeatures(body as Parameters[0]); } + getCollabFeatures() { return this.addons.getCollabFeatures(); } + updateCollabFeatures(body: unknown) { return this.addons.updateCollabFeatures(body as Parameters[0]); } // Packing templates listPackingTemplates() { return svc.listPackingTemplates(); } diff --git a/server/src/nest/booking-import/booking-import.module.ts b/server/src/nest/booking-import/booking-import.module.ts index f4547bc57..45b16ce3b 100644 --- a/server/src/nest/booking-import/booking-import.module.ts +++ b/server/src/nest/booking-import/booking-import.module.ts @@ -8,9 +8,10 @@ import { LlmParseModule } from '../llm-parse/llm-parse.module'; import { ReservationsModule } from '../reservations/reservations.module'; import { PermissionsModule } from '../permissions/permissions.module'; import { BudgetModule } from '../budget/budget.module'; +import { AddonsModule } from '../addons/addons.module'; @Module({ - imports: [LlmParseModule, ReservationsModule, PermissionsModule, BudgetModule], + imports: [LlmParseModule, ReservationsModule, PermissionsModule, BudgetModule, AddonsModule], controllers: [BookingImportController, FeaturesController], providers: [BookingImportService, KitineraryExtractorService, ImportJobsService], }) diff --git a/server/src/nest/booking-import/booking-import.service.ts b/server/src/nest/booking-import/booking-import.service.ts index d5cebccbb..ec3ab1e66 100644 --- a/server/src/nest/booking-import/booking-import.service.ts +++ b/server/src/nest/booking-import/booking-import.service.ts @@ -5,7 +5,7 @@ import { verifyTripAccess } from '../../services/tripAccess'; import { ReservationsService } from '../reservations/reservations.service'; import { createPlace } from '../../services/placeService'; import { BudgetService } from '../budget/budget.service'; -import { isAddonEnabled } from '../../services/adminService'; +import { AddonsService } from '../addons/addons.service'; import { ADDON_IDS } from '../../addons'; import { searchNominatim } from '../../services/mapsService'; import { DatabaseService } from '../database/database.service'; @@ -26,6 +26,7 @@ export class BookingImportService { private readonly reservations: ReservationsService, private readonly permissions: PermissionsService, private readonly budget: BudgetService, + private readonly addons: AddonsService, ) {} private get db() { @@ -235,7 +236,7 @@ export class BookingImportService { // Turn an extracted price into a real linked cost (Costs addon), so the // booking shows up as an expense — not just a price in metadata. - if (isAddonEnabled(ADDON_IDS.BUDGET)) { + if (this.addons.isAddonEnabled(ADDON_IDS.BUDGET)) { const meta = reservationData.metadata && typeof reservationData.metadata === 'object' ? (reservationData.metadata as Record) diff --git a/server/src/nest/booking-import/features.controller.ts b/server/src/nest/booking-import/features.controller.ts index 825cf6ec6..fd7507852 100644 --- a/server/src/nest/booking-import/features.controller.ts +++ b/server/src/nest/booking-import/features.controller.ts @@ -1,12 +1,15 @@ import { Controller, Get } from '@nestjs/common'; import { KitineraryExtractorService } from './kitinerary-extractor.service'; -import { isAddonEnabled } from '../../services/adminService'; +import { AddonsService } from '../addons/addons.service'; import { ADDON_IDS } from '../../addons'; /** Exposes server feature flags consumed by the frontend to show/hide optional UI. */ @Controller('api/health') export class FeaturesController { - constructor(private readonly extractor: KitineraryExtractorService) {} + constructor( + private readonly extractor: KitineraryExtractorService, + private readonly addons: AddonsService, + ) {} @Get('features') features() { @@ -14,7 +17,7 @@ export class FeaturesController { bookingImport: this.extractor.isAvailable(), // Addon-level flag (per-user config availability is reported per-file in // the preview response). Drives whether the client shows AI affordances. - aiParsing: isAddonEnabled(ADDON_IDS.LLM_PARSING), + aiParsing: this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING), }; } } diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index 4da026a69..8e64bcedd 100644 --- a/server/src/nest/budget/budget.mcp.ts +++ b/server/src/nest/budget/budget.mcp.ts @@ -6,7 +6,7 @@ import { } from '@trek/nest-mcp'; import { z } from 'zod'; import { isDemoUser } from '../../services/authService'; -import { isAddonEnabled } from '../../services/adminService'; +import { isAddonEnabled } from '../addons/addons.bridge'; import { ADDON_IDS } from '../../addons'; import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; import { getTripOwner, listMembers } from '../../services/tripService'; diff --git a/server/src/nest/collab/collab.mcp.ts b/server/src/nest/collab/collab.mcp.ts index 7a3e049c8..4dc2434ff 100644 --- a/server/src/nest/collab/collab.mcp.ts +++ b/server/src/nest/collab/collab.mcp.ts @@ -6,7 +6,7 @@ import { } from '@trek/nest-mcp'; import { z } from 'zod'; import { isDemoUser } from '../../services/authService'; -import { isAddonEnabled, getCollabFeatures } from '../../services/adminService'; +import { isAddonEnabled, getCollabFeatures } from '../addons/addons.bridge'; import { ADDON_IDS } from '../../addons'; import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; import { CollabService } from './collab.service'; diff --git a/server/src/nest/collections/collections-addon.guard.ts b/server/src/nest/collections/collections-addon.guard.ts index 7820f84e0..40de86956 100644 --- a/server/src/nest/collections/collections-addon.guard.ts +++ b/server/src/nest/collections/collections-addon.guard.ts @@ -1,5 +1,5 @@ import { CanActivate, HttpException, Injectable } from '@nestjs/common'; -import { isAddonEnabled } from '../../services/adminService'; +import { AddonsService } from '../addons/addons.service'; import { ADDON_IDS } from '../../addons'; /** @@ -9,8 +9,10 @@ import { ADDON_IDS } from '../../addons'; */ @Injectable() export class CollectionsAddonGuard implements CanActivate { + constructor(private readonly addons: AddonsService) {} + canActivate(): boolean { - if (!isAddonEnabled(ADDON_IDS.COLLECTIONS)) { + if (!this.addons.isAddonEnabled(ADDON_IDS.COLLECTIONS)) { throw new HttpException({ error: 'Collections addon is not enabled' }, 404); } return true; diff --git a/server/src/nest/collections/collections.module.ts b/server/src/nest/collections/collections.module.ts index 7740dc13d..2e6dbf4af 100644 --- a/server/src/nest/collections/collections.module.ts +++ b/server/src/nest/collections/collections.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; import { CollectionsController } from './collections.controller'; import { CollectionsService } from './collections.service'; import { CollectionsAddonGuard } from './collections-addon.guard'; +import { AddonsModule } from '../addons/addons.module'; @Module({ + imports: [AddonsModule], controllers: [CollectionsController], providers: [CollectionsService, CollectionsAddonGuard], }) diff --git a/server/src/nest/integrations/airtrail-addon.guard.ts b/server/src/nest/integrations/airtrail-addon.guard.ts index 5ebe5fb7b..c592813dc 100644 --- a/server/src/nest/integrations/airtrail-addon.guard.ts +++ b/server/src/nest/integrations/airtrail-addon.guard.ts @@ -1,5 +1,5 @@ import { CanActivate, HttpException, Injectable } from '@nestjs/common'; -import { isAddonEnabled } from '../../services/adminService'; +import { AddonsService } from '../addons/addons.service'; import { ADDON_IDS } from '../../addons'; /** @@ -10,8 +10,10 @@ import { ADDON_IDS } from '../../addons'; */ @Injectable() export class AirtrailAddonGuard implements CanActivate { + constructor(private readonly addons: AddonsService) {} + canActivate(): boolean { - if (!isAddonEnabled(ADDON_IDS.AIRTRAIL)) { + if (!this.addons.isAddonEnabled(ADDON_IDS.AIRTRAIL)) { throw new HttpException({ error: 'AirTrail addon is not enabled' }, 404); } return true; diff --git a/server/src/nest/integrations/airtrail.module.ts b/server/src/nest/integrations/airtrail.module.ts index ec4545158..c8e50a8b4 100644 --- a/server/src/nest/integrations/airtrail.module.ts +++ b/server/src/nest/integrations/airtrail.module.ts @@ -2,6 +2,8 @@ import { Module } from '@nestjs/common'; import { AirtrailController } from './airtrail.controller'; import { AirtrailImportController } from './airtrail-import.controller'; import { PermissionsModule } from '../permissions/permissions.module'; +import { AddonsModule } from '../addons/addons.module'; +import { AirtrailAddonGuard } from './airtrail-addon.guard'; /** * AirTrail integration domain. The connection lives under @@ -10,7 +12,8 @@ import { PermissionsModule } from '../permissions/permissions.module'; * services/airtrail/* (plain functions over better-sqlite3). */ @Module({ - imports: [PermissionsModule], + imports: [PermissionsModule, AddonsModule], controllers: [AirtrailController, AirtrailImportController], + providers: [AirtrailAddonGuard], }) export class AirtrailModule {} diff --git a/server/src/nest/journey/journey.module.ts b/server/src/nest/journey/journey.module.ts index 4d1f9d8fc..57aabbefd 100644 --- a/server/src/nest/journey/journey.module.ts +++ b/server/src/nest/journey/journey.module.ts @@ -3,8 +3,10 @@ import { JourneyController } from './journey.controller'; import { JourneyPublicController } from './journey-public.controller'; import { JourneyService } from './journey.service'; import { JourneyAddonGuard } from './journey-addon.guard'; +import { AddonsModule } from '../addons/addons.module'; @Module({ + imports: [AddonsModule], controllers: [JourneyController, JourneyPublicController], providers: [JourneyService, JourneyAddonGuard], }) diff --git a/server/src/nest/journey/journey.service.ts b/server/src/nest/journey/journey.service.ts index 60ce6287e..a2fb0471b 100644 --- a/server/src/nest/journey/journey.service.ts +++ b/server/src/nest/journey/journey.service.ts @@ -4,7 +4,7 @@ import * as svc from '../../services/journeyService'; import * as share from '../../services/journeyShareService'; import { uploadToImmich, streamImmichAsset } from '../../services/memories/immichService'; import { streamPhoto } from '../../services/memories/photoResolverService'; -import { isAddonEnabled } from '../../services/adminService'; +import { AddonsService } from '../addons/addons.service'; import { ADDON_IDS } from '../../addons'; import type { Response } from 'express'; @@ -16,14 +16,17 @@ import type { Response } from 'express'; */ @Injectable() export class JourneyService { - constructor(private readonly dbs: DatabaseService) {} + constructor( + private readonly dbs: DatabaseService, + private readonly addons: AddonsService, + ) {} private get db() { return this.dbs.connection; } journeyAddonEnabled(): boolean { - return isAddonEnabled(ADDON_IDS.JOURNEY); + return this.addons.isAddonEnabled(ADDON_IDS.JOURNEY); } // Journeys diff --git a/server/src/nest/llm-parse/llm-config.resolver.ts b/server/src/nest/llm-parse/llm-config.resolver.ts index d69784b3b..b2c7b7580 100644 --- a/server/src/nest/llm-parse/llm-config.resolver.ts +++ b/server/src/nest/llm-parse/llm-config.resolver.ts @@ -1,5 +1,5 @@ import { ADDON_IDS } from '../../addons'; -import { isAddonEnabled } from '../../services/adminService'; +import { AddonsService } from '../addons/addons.service'; import { decryptLlmApiKey, LLM_PROVIDERS, type LlmProvider, type ResolvedLlmConfig } from '../../services/llmConfig'; import { DatabaseService } from '../database/database.service'; import { SettingsService } from '../settings/settings.service'; @@ -19,6 +19,7 @@ export class LlmConfigResolver { constructor( private readonly settings: SettingsService, private readonly dbService: DatabaseService, + private readonly addons: AddonsService, ) {} /** @@ -27,7 +28,7 @@ export class LlmConfigResolver { * else null. This is the single place the API key is decrypted. */ resolve(userId: number): ResolvedLlmConfig | null { - if (!isAddonEnabled(ADDON_IDS.LLM_PARSING)) return null; + if (!this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING)) return null; return this.readInstanceConfig() ?? this.readUserConfig(userId); } diff --git a/server/src/nest/llm-parse/llm-parse.module.ts b/server/src/nest/llm-parse/llm-parse.module.ts index 42c82afe2..e6092cadf 100644 --- a/server/src/nest/llm-parse/llm-parse.module.ts +++ b/server/src/nest/llm-parse/llm-parse.module.ts @@ -4,13 +4,14 @@ import { LlmLocalService } from './llm-local.service'; import { LlmLocalController } from './llm-local.controller'; import { LlmConfigResolver } from './llm-config.resolver'; import { SettingsModule } from '../settings/settings.module'; +import { AddonsModule } from '../addons/addons.module'; /** * Provides the LLM booking-import fallback; imported by BookingImportModule. * Exports LlmConfigResolver for the plugin host wiring (PluginHostDepsFactory). */ @Module({ - imports: [SettingsModule], + imports: [SettingsModule, AddonsModule], controllers: [LlmLocalController], providers: [LlmParseService, LlmLocalService, LlmConfigResolver], exports: [LlmParseService, LlmConfigResolver], diff --git a/server/src/nest/oauth/oauth.module.ts b/server/src/nest/oauth/oauth.module.ts index 5bff52955..518bb4168 100644 --- a/server/src/nest/oauth/oauth.module.ts +++ b/server/src/nest/oauth/oauth.module.ts @@ -4,6 +4,7 @@ import { OauthApiController } from './oauth-api.controller'; import { OauthService } from './oauth.service'; import { RateLimitService } from '../auth/rate-limit.service'; import { AuditModule } from '../audit/audit.module'; +import { AddonsModule } from '../addons/addons.module'; /** * OAuth 2.1 server (MCP). Public token/userinfo/revoke endpoints + the SPA's @@ -12,7 +13,7 @@ import { AuditModule } from '../audit/audit.module'; * strangler lists /oauth/token, /oauth/userinfo, /oauth/revoke explicitly. */ @Module({ - imports: [AuditModule], + imports: [AuditModule, AddonsModule], controllers: [OauthPublicController, OauthApiController], providers: [OauthService, RateLimitService], }) diff --git a/server/src/nest/oauth/oauth.service.ts b/server/src/nest/oauth/oauth.service.ts index 281d3c3a1..4f4b5bee6 100644 --- a/server/src/nest/oauth/oauth.service.ts +++ b/server/src/nest/oauth/oauth.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import * as oauth from '../../services/oauthService'; -import { isAddonEnabled } from '../../services/adminService'; +import { AddonsService } from '../addons/addons.service'; import { ADDON_IDS } from '../../addons'; import { getMcpSafeUrl } from '../../app-config'; @@ -11,7 +11,9 @@ import { getMcpSafeUrl } from '../../app-config'; */ @Injectable() export class OauthService { - mcpEnabled(): boolean { return isAddonEnabled(ADDON_IDS.MCP); } + constructor(private readonly addons: AddonsService) {} + + mcpEnabled(): boolean { return this.addons.isAddonEnabled(ADDON_IDS.MCP); } mcpSafeUrl(): string { return getMcpSafeUrl(); } consumeAuthCode(code: string) { return oauth.consumeAuthCode(code); } diff --git a/server/src/nest/packing/packing.mcp.ts b/server/src/nest/packing/packing.mcp.ts index 7d40965dd..0ce6409d1 100644 --- a/server/src/nest/packing/packing.mcp.ts +++ b/server/src/nest/packing/packing.mcp.ts @@ -6,7 +6,8 @@ import { } from '@trek/nest-mcp'; import { z } from 'zod'; import { isDemoUser } from '../../services/authService'; -import { isAddonEnabled, deletePackingTemplate } from '../../services/adminService'; +import { isAddonEnabled } from '../addons/addons.bridge'; +import { deletePackingTemplate } from '../../services/adminService'; import { ADDON_IDS } from '../../addons'; import { safeBroadcast, noAccess, hasTripPermission, permissionDenied, isAdminUser, adminRequired } from '../../mcp/tools/_shared'; import { PackingService } from './packing.service'; diff --git a/server/src/nest/platform/platform.routes.ts b/server/src/nest/platform/platform.routes.ts index da83a1ba6..e373ce662 100644 --- a/server/src/nest/platform/platform.routes.ts +++ b/server/src/nest/platform/platform.routes.ts @@ -7,7 +7,7 @@ import { verifyJwtAndLoadUser } from '../../middleware/auth'; import { db } from '../../db/database'; import { mcpHandler } from '../../mcp'; import { trekOAuthProvider, trekClientsStore } from '../../mcp/oauthProvider'; -import { isAddonEnabled } from '../../services/adminService'; +import { isAddonEnabled } from '../addons/addons.bridge'; import { ADDON_IDS } from '../../addons'; import { ALL_SCOPES } from '../../mcp/scopes'; import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router'; diff --git a/server/src/nest/plugins/host/plugin-host-deps.factory.ts b/server/src/nest/plugins/host/plugin-host-deps.factory.ts index 0aff1da89..eae84c92b 100644 --- a/server/src/nest/plugins/host/plugin-host-deps.factory.ts +++ b/server/src/nest/plugins/host/plugin-host-deps.factory.ts @@ -15,7 +15,7 @@ import { randomUUID } from 'node:crypto'; import { PermissionsService } from '../../permissions/permissions.service'; import { listTrips, updateTrip, createTrip, removeMember as removeTripMemberSvc, NotFoundError, ValidationError } from '../../../services/tripService'; import { createPlace, updatePlace, deletePlace } from '../../../services/placeService'; -import { isAddonEnabled } from '../../../services/adminService'; +import { AddonsService } from '../../addons/addons.service'; import { isDemoEmail } from '../../../services/demo'; import { ADDON_IDS } from '../../../addons'; import { listJourneys, listEntries as listJournalEntriesSvc, createEntry as createJournalEntrySvc, updateEntry as updateJournalEntrySvc, deleteEntry as deleteJournalEntrySvc, createJourney as createJourneySvc, deleteJourney as deleteJourneySvc } from '../../../services/journeyService'; @@ -40,12 +40,6 @@ import { PluginRpcHost, ForbiddenResource, BadParams } from './rpc-host'; import { appendAudit } from './plugin-audit'; import { getPluginDataDb, budgetFor } from './plugin-host-state'; -// A subsystem read is refused when its addon is off — parity with the app, where a -// disabled addon means there is simply nothing to read (same shape as the Costs gate). -function requireAddon(addonId: string, noun: string): void { - if (!isAddonEnabled(addonId)) throw new ForbiddenResource(`the ${noun} addon is disabled`); -} - // collectionsService self-gates per-collection role by THROWING status-tagged errors // (assertAccess 404, assertCanEdit 403, validation 400). Map them onto the RPC error // classes so a plugin sees RESOURCE_FORBIDDEN / BAD_PARAMS instead of HOST_ERROR. @@ -156,6 +150,7 @@ export class PluginHostDepsFactory { private readonly days: DaysService, private readonly permissions: PermissionsService, private readonly exchangeRates: ExchangeRatesService, + private readonly addons: AddonsService, ) {} /** @@ -179,6 +174,11 @@ export class PluginHostDepsFactory { } create(id: string, granted: ReadonlySet, router: PluginCallRouter): PluginRpcHost { + // A subsystem read is refused when its addon is off — parity with the app, where a + // disabled addon means there is simply nothing to read (same shape as the Costs gate). + const requireAddon = (addonId: string, noun: string): void => { + if (!this.addons.isAddonEnabled(addonId)) throw new ForbiddenResource(`the ${noun} addon is disabled`); + }; return new PluginRpcHost(id, granted, { // Resolve the data handle lazily on every access rather than capturing it once. // disable()/uninstall() drop the entry from `running` BEFORE awaiting the kill grace @@ -210,7 +210,7 @@ export class PluginHostDepsFactory { broadcastToUser: (userId, payload) => broadcastToUser(userId, { type: `plugin:${id}`, ...payload }), audit: (entry) => appendAudit(this.db.connection, entry), // --- Costs (budget items) --- - budgetAddonEnabled: () => isAddonEnabled(ADDON_IDS.BUDGET), + budgetAddonEnabled: () => this.addons.isAddonEnabled(ADDON_IDS.BUDGET), // Same gate as a REST/MCP budget mutation: the acting user must have trip // access AND the 'budget_edit' permission for their global role. canEditCosts: (tripId, userId) => { diff --git a/server/src/nest/plugins/journal-entry-rows.controller.ts b/server/src/nest/plugins/journal-entry-rows.controller.ts index 8353e43c5..bea66cd36 100644 --- a/server/src/nest/plugins/journal-entry-rows.controller.ts +++ b/server/src/nest/plugins/journal-entry-rows.controller.ts @@ -2,7 +2,7 @@ import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common'; import type { Request } from 'express'; import { DatabaseService } from '../database/database.service'; import { canAccessJourney } from '../../services/journeyService'; -import { isAddonEnabled } from '../../services/adminService'; +import { AddonsService } from '../addons/addons.service'; import { ADDON_IDS } from '../../addons'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { pluginsEnabled } from './kill-switch'; @@ -69,6 +69,7 @@ export class JournalEntryRowsController { constructor( private readonly runtime: PluginRuntimeService, private readonly dbs: DatabaseService, + private readonly addons: AddonsService, ) {} @Get(':entryId') @@ -76,7 +77,7 @@ export class JournalEntryRowsController { @Param('entryId') entryIdRaw: string, @Req() req: Request & { user?: { id: number } }, ): Promise<{ providers: ProviderResult[] }> { - if (!pluginsEnabled() || !isAddonEnabled(ADDON_IDS.JOURNEY)) return { providers: [] }; + if (!pluginsEnabled() || !this.addons.isAddonEnabled(ADDON_IDS.JOURNEY)) return { providers: [] }; const entryId = Number(entryIdRaw); const userId = req.user?.id; if (!Number.isFinite(entryId) || userId == null) return { providers: [] }; diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index 1fa088167..aeeb84f1a 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -27,7 +27,7 @@ import { assertHostCompatible, PluginRegistryService, RegistryError } from './re import { hostSatisfies, hostVersion } from './install/host-compat'; import { keyFingerprint } from './signature-status'; import { AuditService } from '../audit/audit.service'; -import { isAddonEnabled } from '../../services/adminService'; +import { AddonsService } from '../addons/addons.service'; import type { PluginDependency } from './install/manifest'; import type { VersionMismatch, PluginDepRow } from './dependencies'; import { parseDependencies, disabledRequiredAddons, resolveDependencyState, enableOrder, findDependentsTransitive, DependencyCycleError } from './dependencies'; @@ -166,6 +166,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { constructor( private readonly dbs: DatabaseService, private readonly audit: AuditService, + private readonly addons: AddonsService, private readonly registry?: PluginRegistryService, private readonly hostDeps?: PluginHostDepsFactory, ) {} @@ -514,7 +515,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { } const deps = parseDependencies(row.dependencies); - const disabledAddons = disabledRequiredAddons(deps, isAddonEnabled); + const disabledAddons = disabledRequiredAddons(deps, (id) => this.addons.isAddonEnabled(id)); if (disabledAddons.length) { throw new PluginDependencyError(`plugin ${id} requires disabled addon(s): ${disabledAddons.join(', ')}`, 'ADDON_DISABLED', { addons: disabledAddons, diff --git a/server/src/nest/plugins/plugins.module.ts b/server/src/nest/plugins/plugins.module.ts index 86195e456..cbfde0071 100644 --- a/server/src/nest/plugins/plugins.module.ts +++ b/server/src/nest/plugins/plugins.module.ts @@ -38,6 +38,7 @@ import { CollabModule } from '../collab/collab.module'; import { VacayModule } from '../vacay/vacay.module'; import { PermissionsModule } from '../permissions/permissions.module'; import { AuditModule } from '../audit/audit.module'; +import { AddonsModule } from '../addons/addons.module'; /** * Plugin system (#plugins). M0 read side + M2 isolated runtime + M3 frontend: @@ -49,7 +50,7 @@ import { AuditModule } from '../audit/audit.module'; @Module({ // The DI-native domain services the plugin host wiring injects // (PluginHostDepsFactory); DatabaseModule is @Global, so not listed. - imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, PermissionsModule, AuditModule], + imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule, PermissionsModule, AuditModule, AddonsModule], controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController, ViewContributionsController, TripCardContributionsController, PluginPhotosController, PluginCalendarController, MapMarkersController, MapLayersController, PluginRoutesController, DayScheduleController, PdfSectionsController, AtlasLayersController, JournalEntryRowsController, PluginUserSettingsController, PluginOAuthController, PluginActivityController], providers: [PluginsService, PluginRuntimeService, PluginRegistryService, PluginOAuthService, PluginHostDepsFactory], // Exported so the admin addon-toggle handler can cascade-disable plugins whose diff --git a/server/src/nest/plugins/plugins.service.ts b/server/src/nest/plugins/plugins.service.ts index e903c3249..5544ca49a 100644 --- a/server/src/nest/plugins/plugins.service.ts +++ b/server/src/nest/plugins/plugins.service.ts @@ -7,7 +7,7 @@ import { maybe_encrypt_api_key, decrypt_api_key } from '../../services/apiKeyCry import { readAudit } from './host/plugin-audit'; import { keyFingerprint } from './signature-status'; import { pluginBudgetUsage } from './host/plugin-host-state'; -import { isAddonEnabled } from '../../services/adminService'; +import { AddonsService } from '../addons/addons.service'; import { parseDependencies, disabledRequiredAddons, resolveDependencyState, type PluginDepRow, type PluginDependencies, type VersionMismatch } from './dependencies'; import { hostSatisfies, hostVersion } from './install/host-compat'; import type { PluginDependency } from './install/manifest'; @@ -91,7 +91,10 @@ export interface PluginListItem { @Injectable() export class PluginsService { - constructor(private readonly dbs: DatabaseService) {} + constructor( + private readonly dbs: DatabaseService, + private readonly addons: AddonsService, + ) {} private get db() { return this.dbs.connection; @@ -121,7 +124,7 @@ export class PluginsService { ); const plugins: PluginListItem[] = rows.map((r) => { const deps = parseDependencies(r.dependencies); - const disabledAddons = disabledRequiredAddons(deps, isAddonEnabled); + const disabledAddons = disabledRequiredAddons(deps, (id) => this.addons.isAddonEnabled(id)); const state = resolveDependencyState(deps, installed); // Mirrors the order of assertActivatable's gate, so the card explains the same // blocker the activate call would hit rather than a second, lesser one. diff --git a/server/src/nest/todo/todo.mcp.ts b/server/src/nest/todo/todo.mcp.ts index 27a9f130a..16763ba2f 100644 --- a/server/src/nest/todo/todo.mcp.ts +++ b/server/src/nest/todo/todo.mcp.ts @@ -6,7 +6,7 @@ import { } from '@trek/nest-mcp'; import { z } from 'zod'; import { isDemoUser } from '../../services/authService'; -import { isAddonEnabled } from '../../services/adminService'; +import { isAddonEnabled } from '../addons/addons.bridge'; import { ADDON_IDS } from '../../addons'; import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; import { TodoService } from './todo.service'; diff --git a/server/src/nest/vacay/vacay.mcp.ts b/server/src/nest/vacay/vacay.mcp.ts index c533021ac..a6bb010f1 100644 --- a/server/src/nest/vacay/vacay.mcp.ts +++ b/server/src/nest/vacay/vacay.mcp.ts @@ -7,7 +7,7 @@ import { } from '@trek/nest-mcp'; import { z } from 'zod'; import { isDemoUser, getCurrentUser } from '../../services/authService'; -import { isAddonEnabled } from '../../services/adminService'; +import { isAddonEnabled } from '../addons/addons.bridge'; import { ADDON_IDS } from '../../addons'; import { VacayService } from './vacay.service'; diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index d1801cd8b..ba9702b44 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -565,22 +565,6 @@ export function deleteInvite(id: string) { return {}; } -// ── Bag Tracking ─────────────────────────────────────────────────────────── - -export function getBagTracking() { - const row = db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as - | { value: string } - | undefined; - return { enabled: row?.value === 'true' }; -} - -export function updateBagTracking(enabled: boolean) { - db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run( - enabled ? 'true' : 'false', - ); - return { enabled: !!enabled }; -} - // ── Places Photos ───────────────────────────────────────────────────────── export function getPlacesPhotos() { @@ -629,56 +613,6 @@ export function updatePlacesDetails(enabled: boolean) { return { enabled: !!enabled }; } -// ── Collab Features ─────────────────────────────────────────────────────── - -const COLLAB_FEATURE_KEYS = [ - 'collab_chat_enabled', - 'collab_notes_enabled', - 'collab_polls_enabled', - 'collab_whatsnext_enabled', -] as const; - -export function getCollabFeatures() { - const rows = db - .prepare( - "SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')", - ) - .all() as { key: string; value: string }[]; - const map: Record = {}; - for (const r of rows) map[r.key] = r.value; - return { - chat: map['collab_chat_enabled'] !== 'false', - notes: map['collab_notes_enabled'] !== 'false', - polls: map['collab_polls_enabled'] !== 'false', - whatsnext: map['collab_whatsnext_enabled'] !== 'false', - }; -} - -export function updateCollabFeatures(features: { - chat?: boolean; - notes?: boolean; - polls?: boolean; - whatsnext?: boolean; -}) { - const mapping: Record = { - chat: 'collab_chat_enabled', - notes: 'collab_notes_enabled', - polls: 'collab_polls_enabled', - whatsnext: 'collab_whatsnext_enabled', - }; - const before = getCollabFeatures(); - const stmt = db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)'); - for (const [feat, key] of Object.entries(mapping)) { - if (features[feat] !== undefined) stmt.run(key, features[feat] ? 'true' : 'false'); - } - const after = getCollabFeatures(); - // Collab flags gate MCP tool/resource registration, so callers must know - // whether anything actually flipped — a no-op save must not tear down every - // live MCP session (#1414). - const changed = (Object.keys(after) as Array).some((k) => after[k] !== before[k]); - return { features: after, changed }; -} - // ── Packing Templates ────────────────────────────────────────────────────── export function listPackingTemplates() { @@ -804,11 +738,10 @@ export function deleteTemplateItem(itemId: string) { } // ── Addons ───────────────────────────────────────────────────────────────── - -export function isAddonEnabled(addonId: string): boolean { - const addon = db.prepare('SELECT enabled FROM addons WHERE id = ?').get(addonId) as { enabled: number } | undefined; - return !!addon?.enabled; -} +// isAddonEnabled and the bag-tracking/collab-features flags moved to +// nest/addons/addons.service.ts (bridge: nest/addons/addons.bridge.ts) — +// finding `admin-1`. listAddons/updateAddon + photo-provider CRUD stay here +// until the Wave-5 admin migration. export function listAddons() { const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[]; diff --git a/server/src/services/airtrail/airtrailSync.ts b/server/src/services/airtrail/airtrailSync.ts index 4fd3ac69d..1861b6160 100644 --- a/server/src/services/airtrail/airtrailSync.ts +++ b/server/src/services/airtrail/airtrailSync.ts @@ -1,7 +1,7 @@ import { ADDON_IDS } from '../../addons'; import { db } from '../../db/database'; import { broadcast } from '../../websocket'; -import { isAddonEnabled } from '../adminService'; +import { isAddonEnabled } from '../../nest/addons/addons.bridge'; import { logError, logInfo } from '../../nest/audit/audit-log.logger'; import { getReservation, getReservationWithJoins, updateReservation } from '../../nest/reservations/reservations.bridge'; import { diff --git a/server/src/services/memories/thumbnailService.ts b/server/src/services/memories/thumbnailService.ts index d328b5797..1c2ec786e 100644 --- a/server/src/services/memories/thumbnailService.ts +++ b/server/src/services/memories/thumbnailService.ts @@ -2,7 +2,7 @@ import { Jimp } from 'jimp' import path from 'path' import fs from 'fs/promises' import crypto from 'crypto' -import { isAddonEnabled } from '../adminService' +import { isAddonEnabled } from '../../nest/addons/addons.bridge' import { ADDON_IDS } from '../../addons' const THUMB_MAX = 800 diff --git a/server/src/services/oauthService.ts b/server/src/services/oauthService.ts index 4080677d1..d313e5710 100644 --- a/server/src/services/oauthService.ts +++ b/server/src/services/oauthService.ts @@ -1,6 +1,6 @@ import crypto, { randomBytes, createHash, randomUUID } from 'crypto'; import { db } from '../db/database'; -import { isAddonEnabled } from './adminService'; +import { isAddonEnabled } from '../nest/addons/addons.bridge'; import { validateScopes } from '../mcp/scopes'; import { ADDON_IDS } from '../addons'; import { User } from '../types'; diff --git a/server/src/systemNotices/conditions.ts b/server/src/systemNotices/conditions.ts index 97dd3eac4..a3a1f3c7d 100644 --- a/server/src/systemNotices/conditions.ts +++ b/server/src/systemNotices/conditions.ts @@ -1,5 +1,5 @@ import semver from 'semver'; -import { isAddonEnabled } from '../services/adminService.js'; +import { isAddonEnabled } from '../nest/addons/addons.bridge.js'; import type { NoticeCondition, SystemNotice } from './types.js'; interface ConditionContext { diff --git a/server/tests/e2e/addons.e2e.test.ts b/server/tests/e2e/addons.e2e.test.ts index d81aa7b91..4b3e0ac4d 100644 --- a/server/tests/e2e/addons.e2e.test.ts +++ b/server/tests/e2e/addons.e2e.test.ts @@ -1,8 +1,9 @@ /** * GET /api/addons e2e — exercises the AddonsController through the real - * JwtAuthGuard against a temp SQLite db. getCollabFeatures + getPhotoProviderConfig - * are mocked; the addons/photo_providers/photo_provider_fields reads run against - * the temp db. Asserts the byte-identical body the legacy inline handler produced. + * JwtAuthGuard against a temp SQLite db. getPhotoProviderConfig is + * mocked; the addons/photo_providers/photo_provider_fields/app_settings reads + * run against the temp db (the collab/bag-tracking flags are real AddonsService + * reads since the admin-1 extraction). Asserts the byte-identical body the legacy inline handler produced. */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import request from 'supertest'; @@ -24,6 +25,7 @@ const { db } = vi.hoisted(() => { tmp.exec(`CREATE TABLE photo_provider_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, settings_key TEXT, payload_key TEXT, sort_order INTEGER);`); + tmp.exec(`CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);`); return { db: tmp }; }); @@ -31,12 +33,9 @@ vi.mock('../../src/db/database', () => ({ db, canAccessTrip: vi.fn(), isOwner: vi.fn(), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {}, })); -const { getCollabFeatures, getBagTracking, getPhotoProviderConfig } = vi.hoisted(() => ({ - getCollabFeatures: vi.fn(() => ({ chat: true, notes: true, polls: true, whatsnext: true })), - getBagTracking: vi.fn(() => ({ enabled: true })), +const { getPhotoProviderConfig } = vi.hoisted(() => ({ getPhotoProviderConfig: vi.fn(() => ({ url: 'https://immich.example' })), })); -vi.mock('../../src/services/adminService', () => ({ getCollabFeatures, getBagTracking })); vi.mock('../../src/services/memories/helpersService', () => ({ getPhotoProviderConfig })); import { AddonsModule } from '../../src/nest/addons/addons.module'; @@ -57,6 +56,8 @@ describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => { beforeAll(async () => { seedUser(db as never, { id: 1 }); + // bag tracking is opt-in (=== 'true'); collab flags default ON with no rows + db.prepare("INSERT INTO app_settings (key, value) VALUES ('bag_tracking_enabled', 'true')").run(); db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES ('packing','Packing','trip','Backpack',1,1)").run(); db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES ('disabled','Disabled','trip','X',0,2)").run(); db.prepare("INSERT INTO photo_providers (id, name, icon, enabled, sort_order) VALUES ('immich','Immich','Image',1,1)").run(); diff --git a/server/tests/e2e/collections.e2e.test.ts b/server/tests/e2e/collections.e2e.test.ts index 124ec7055..969df3016 100644 --- a/server/tests/e2e/collections.e2e.test.ts +++ b/server/tests/e2e/collections.e2e.test.ts @@ -32,7 +32,6 @@ vi.mock('../../src/db/database', () => ({ })); const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) })); -vi.mock('../../src/services/adminService', () => ({ isAddonEnabled })); vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() })); vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) })); @@ -40,6 +39,7 @@ import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { createUser, createTrip, createCategory } from '../helpers/factories'; import { CollectionsModule } from '../../src/nest/collections/collections.module'; +import { AddonsService } from '../../src/nest/addons/addons.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; @@ -51,7 +51,10 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () => let tripId: number; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [CollectionsModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [CollectionsModule] }) + .overrideProvider(AddonsService) + .useValue({ isAddonEnabled }) + .compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/journey.e2e.test.ts b/server/tests/e2e/journey.e2e.test.ts index 78ab6f1ed..845b3cd32 100644 --- a/server/tests/e2e/journey.e2e.test.ts +++ b/server/tests/e2e/journey.e2e.test.ts @@ -26,7 +26,6 @@ const { db } = vi.hoisted(() => { vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) })); -vi.mock('../../src/services/adminService', () => ({ isAddonEnabled })); // The controller's pure helpers (isVideoExtension/isVideoMime/MAX_VIDEO_SIZE) // now come from the real files.constants; only the request-time app_settings // read is mocked, preserving the old '*'-allowlist semantics for the fixtures. @@ -43,6 +42,7 @@ const { sharesvc } = vi.hoisted(() => ({ sharesvc: { getPublicJourney: vi.fn() } vi.mock('../../src/services/journeyShareService', () => sharesvc); import { JourneyModule } from '../../src/nest/journey/journey.module'; +import { AddonsService } from '../../src/nest/addons/addons.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; describe('Journey e2e (real auth guard + temp SQLite)', () => { @@ -50,7 +50,10 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, JourneyModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, JourneyModule] }) + .overrideProvider(AddonsService) + .useValue({ isAddonEnabled }) + .compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/e2e/oauth.e2e.test.ts b/server/tests/e2e/oauth.e2e.test.ts index 88c1d3944..de686c1a3 100644 --- a/server/tests/e2e/oauth.e2e.test.ts +++ b/server/tests/e2e/oauth.e2e.test.ts @@ -37,7 +37,6 @@ vi.mock('../../src/app-config', async (importOriginal) => { }); const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) })); -vi.mock('../../src/services/adminService', () => ({ isAddonEnabled })); const { oauthSvc } = vi.hoisted(() => ({ oauthSvc: { @@ -51,6 +50,7 @@ const { oauthSvc } = vi.hoisted(() => ({ vi.mock('../../src/services/oauthService', () => oauthSvc); import { OauthModule } from '../../src/nest/oauth/oauth.module'; +import { AddonsService } from '../../src/nest/addons/addons.service'; import { DatabaseModule } from '../../src/nest/database/database.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; @@ -59,7 +59,10 @@ describe('OAuth e2e (real guards + temp SQLite)', () => { let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, OauthModule] }).compile(); + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, OauthModule] }) + .overrideProvider(AddonsService) + .useValue({ isAddonEnabled }) + .compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 2251bde30..397386c84 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -20,6 +20,7 @@ import { CollabService } from '../../src/nest/collab/collab.service'; import { VacayService } from '../../src/nest/vacay/vacay.service'; import { PermissionsService } from '../../src/nest/permissions/permissions.service'; import { AuditService } from '../../src/nest/audit/audit.service'; +import { AddonsService } from '../../src/nest/addons/addons.service'; /** * Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests @@ -31,6 +32,7 @@ export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFacto const permissions = new PermissionsService(dbs); const exchangeRates = new ExchangeRatesService(); const budget = new BudgetService(dbs, permissions, exchangeRates); + const addons = new AddonsService(dbs); return new PluginHostDepsFactory( budget, new ReservationsService(dbs, permissions, budget), @@ -41,7 +43,7 @@ export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFacto new PluginOAuthService(dbs), new DayNotesService(dbs, permissions), new AssignmentsService(dbs, permissions), - new LlmConfigResolver(new SettingsService(dbs), dbs), + new LlmConfigResolver(new SettingsService(dbs), dbs, addons), dbs, new FilesService(dbs, permissions), new CollabService(dbs, permissions), @@ -49,10 +51,11 @@ export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFacto new DaysService(dbs, permissions), permissions, exchangeRates, + addons, ); } /** A PluginRuntimeService constructed the way Nest would: with a real host-deps factory. */ export function createPluginRuntime(dbs: DatabaseService, registry?: PluginRegistryService): PluginRuntimeService { - return new PluginRuntimeService(dbs, new AuditService(dbs), registry, createHostDepsFactory(dbs)); + return new PluginRuntimeService(dbs, new AuditService(dbs), new AddonsService(dbs), registry, createHostDepsFactory(dbs)); } diff --git a/server/tests/integration/oauth.test.ts b/server/tests/integration/oauth.test.ts index 50e804287..db5f773f2 100644 --- a/server/tests/integration/oauth.test.ts +++ b/server/tests/integration/oauth.test.ts @@ -44,15 +44,6 @@ vi.mock('../../src/config', () => ({ DEFAULT_LANGUAGE: 'en', })); -const { isAddonEnabledMock } = vi.hoisted(() => { - const isAddonEnabledMock = vi.fn().mockReturnValue(true); - return { isAddonEnabledMock }; -}); -vi.mock('../../src/services/adminService', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, isAddonEnabled: isAddonEnabledMock }; -}); - vi.mock('../../src/app-config', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getMcpSafeUrl: () => 'https://trek.example.com' }; @@ -79,13 +70,11 @@ function makePkce() { return { verifier, challenge }; } -// A7: under the unified Nest app the adminService mock only reaches the directly -// imported isAddonEnabled (OauthService.mcpEnabled); oauthService.ts reads the -// addon state through its own import that the Nest module graph loads unmocked, -// so it falls back to the real DB row. Drive BOTH so the MCP-enabled state is -// consistent across mcpEnabled() AND validateAuthorizeRequest()/token/revoke. +// Since the admin-1 extraction, both OauthService (injected AddonsService) and +// the legacy oauthService (addons.bridge) resolve the MCP addon from the real +// addons row, so driving the DB row keeps mcpEnabled() AND +// validateAuthorizeRequest()/token/revoke consistent from one source. function setMcpEnabled(enabled: boolean) { - isAddonEnabledMock.mockReturnValue(enabled); testDb.prepare( "INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('mcp', 'MCP', 'AI assistant integration', 'integration', 'Terminal', ?, 12)" ).run(enabled ? 1 : 0); diff --git a/server/tests/unit/mcp/tools-addon-gating.test.ts b/server/tests/unit/mcp/tools-addon-gating.test.ts index bff2e4741..a41c27b9d 100644 --- a/server/tests/unit/mcp/tools-addon-gating.test.ts +++ b/server/tests/unit/mcp/tools-addon-gating.test.ts @@ -36,7 +36,7 @@ const { isAddonEnabledMock } = vi.hoisted(() => { const isAddonEnabledMock = vi.fn().mockReturnValue(true); return { isAddonEnabledMock }; }); -vi.mock('../../../src/services/adminService', () => ({ +vi.mock('../../../src/nest/addons/addons.bridge', () => ({ isAddonEnabled: isAddonEnabledMock, getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }), })); diff --git a/server/tests/unit/mcp/tools-atlas-expanded.test.ts b/server/tests/unit/mcp/tools-atlas-expanded.test.ts index fa78f3c21..5b9c3fe5a 100644 --- a/server/tests/unit/mcp/tools-atlas-expanded.test.ts +++ b/server/tests/unit/mcp/tools-atlas-expanded.test.ts @@ -35,7 +35,7 @@ vi.mock('../../../src/config', () => ({ const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); -vi.mock('../../../src/services/adminService', () => ({ +vi.mock('../../../src/nest/addons/addons.bridge', () => ({ isAddonEnabled: vi.fn().mockReturnValue(true), getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }), })); diff --git a/server/tests/unit/mcp/tools-collab-polls-chat.test.ts b/server/tests/unit/mcp/tools-collab-polls-chat.test.ts index 7abeb5a07..38df7c2cb 100644 --- a/server/tests/unit/mcp/tools-collab-polls-chat.test.ts +++ b/server/tests/unit/mcp/tools-collab-polls-chat.test.ts @@ -37,7 +37,7 @@ vi.mock('../../../src/config', () => ({ const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); -vi.mock('../../../src/services/adminService', () => ({ +vi.mock('../../../src/nest/addons/addons.bridge', () => ({ isAddonEnabled: vi.fn().mockReturnValue(true), getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }), })); diff --git a/server/tests/unit/mcp/tools-journey.test.ts b/server/tests/unit/mcp/tools-journey.test.ts index bc8982af0..6606f1342 100644 --- a/server/tests/unit/mcp/tools-journey.test.ts +++ b/server/tests/unit/mcp/tools-journey.test.ts @@ -34,7 +34,7 @@ vi.mock('../../../src/config', () => ({ const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock, broadcastToUser: broadcastMock })); -vi.mock('../../../src/services/adminService', async (importOriginal) => { +vi.mock('../../../src/nest/addons/addons.bridge', async (importOriginal) => { const original = await importOriginal() as Record; return { ...original, isAddonEnabled: vi.fn().mockReturnValue(true) }; }); diff --git a/server/tests/unit/mcp/tools-prompts.test.ts b/server/tests/unit/mcp/tools-prompts.test.ts index 3da8eacb6..018a899d9 100644 --- a/server/tests/unit/mcp/tools-prompts.test.ts +++ b/server/tests/unit/mcp/tools-prompts.test.ts @@ -42,7 +42,7 @@ const { isAddonEnabledMock } = vi.hoisted(() => { const isAddonEnabledMock = vi.fn().mockReturnValue(true); return { isAddonEnabledMock }; }); -vi.mock('../../../src/services/adminService', () => ({ +vi.mock('../../../src/nest/addons/addons.bridge', () => ({ isAddonEnabled: isAddonEnabledMock, getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }), })); diff --git a/server/tests/unit/mcp/tools-vacay.test.ts b/server/tests/unit/mcp/tools-vacay.test.ts index d855aa862..fac7bedba 100644 --- a/server/tests/unit/mcp/tools-vacay.test.ts +++ b/server/tests/unit/mcp/tools-vacay.test.ts @@ -42,7 +42,7 @@ vi.mock('../../../src/config', () => ({ const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); -vi.mock('../../../src/services/adminService', () => ({ +vi.mock('../../../src/nest/addons/addons.bridge', () => ({ isAddonEnabled: vi.fn().mockReturnValue(true), getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }), })); diff --git a/server/tests/unit/nest/addons.bridge.test.ts b/server/tests/unit/nest/addons.bridge.test.ts new file mode 100644 index 000000000..aaf896d82 --- /dev/null +++ b/server/tests/unit/nest/addons.bridge.test.ts @@ -0,0 +1,61 @@ +/** + * addons.bridge delegation — one case per bridged export (the permissions + * bridge-test precedent). The bridge constructs a module-level AddonsService + * over the reinitialize-proof db Proxy, so a real in-memory SQLite exercises + * the byte-identical SQL relocated from services/adminService (`admin-1`), + * including the polarity quirks: isAddonEnabled reads the addons integer + * column, bag tracking is opt-in (=== 'true'), collab flags opt-out + * (!== 'false'). + */ +import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; + +// ── DB setup ────────────────────────────────────────────────────────────────── +// The db/database mock builds a real in-memory SQLite; the test body reaches it +// straight through the mocked `db` export (no hoisted require needed). + +vi.mock('../../../src/db/database', async () => { + const { default: Database } = await import('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + return { db, closeDb: () => {}, reinitialize: () => {} }; +}); + +import { db as testDb } from '../../../src/db/database'; +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { isAddonEnabled, getBagTracking, getCollabFeatures } from '../../../src/nest/addons/addons.bridge'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + testDb.prepare("DELETE FROM app_settings WHERE key IN ('bag_tracking_enabled', 'collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')").run(); +}); + +describe('addons.bridge', () => { + it('isAddonEnabled reflects the addons row and is false for an unknown id', () => { + testDb + .prepare("INSERT OR REPLACE INTO addons (id, name, type, icon, enabled, sort_order) VALUES ('test-addon', 'Test', 'page', 'star', 1, 99)") + .run(); + expect(isAddonEnabled('test-addon')).toBe(true); + testDb.prepare('UPDATE addons SET enabled = 0 WHERE id = ?').run('test-addon'); + expect(isAddonEnabled('test-addon')).toBe(false); + expect(isAddonEnabled('no-such-addon')).toBe(false); + }); + + it('getBagTracking is opt-in: absent row is OFF, only the string true enables', () => { + expect(getBagTracking()).toEqual({ enabled: false }); + testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', 'true')").run(); + expect(getBagTracking()).toEqual({ enabled: true }); + }); + + it('getCollabFeatures is opt-out: absent rows are ON, only the string false disables', () => { + expect(getCollabFeatures()).toEqual({ chat: true, notes: true, polls: true, whatsnext: true }); + testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('collab_polls_enabled', 'false')").run(); + expect(getCollabFeatures()).toEqual({ chat: true, notes: true, polls: false, whatsnext: true }); + }); +}); diff --git a/server/tests/unit/nest/addons.service.test.ts b/server/tests/unit/nest/addons.service.test.ts index 1208df9ce..44bb787e4 100644 --- a/server/tests/unit/nest/addons.service.test.ts +++ b/server/tests/unit/nest/addons.service.test.ts @@ -10,12 +10,6 @@ vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, rein import { db as dbConn } from '../../../src/db/database'; import { DatabaseService } from '../../../src/nest/database/database.service'; -const { getBagTracking, getCollabFeatures } = vi.hoisted(() => ({ - getBagTracking: vi.fn(() => ({ enabled: false })), - getCollabFeatures: vi.fn(() => ({})), -})); -vi.mock('../../../src/services/adminService', () => ({ getBagTracking, getCollabFeatures })); - const { getPhotoProviderConfig } = vi.hoisted(() => ({ getPhotoProviderConfig: vi.fn(() => ({})) })); vi.mock('../../../src/services/memories/helpersService', () => ({ getPhotoProviderConfig })); @@ -25,30 +19,36 @@ function svc() { return new AddonsService(new DatabaseService(dbConn)); } -// Feed the three reads in order: addons, providers, fields. -function feedReads(addons: unknown[], providers: unknown[], fields: unknown[]) { +// Feed list()'s reads in call order: addons, providers, fields (.all), then the +// collab-features rows (.all) and the bag-tracking row (.get) from app_settings. +function feedReads( + addons: unknown[], + providers: unknown[], + fields: unknown[], + collabRows: unknown[] = [], + bagRow: { value: string } | undefined = undefined, +) { dbMock._stmt.all .mockReturnValueOnce(addons) .mockReturnValueOnce(providers) - .mockReturnValueOnce(fields); + .mockReturnValueOnce(fields) + .mockReturnValueOnce(collabRows); + dbMock._stmt.get.mockReturnValueOnce(bagRow); } beforeEach(() => { vi.clearAllMocks(); dbMock._stmt.all.mockReturnValue([]); - getCollabFeatures.mockReturnValue({}); - getBagTracking.mockReturnValue({ enabled: false }); + dbMock._stmt.get.mockReturnValue(undefined); getPhotoProviderConfig.mockReturnValue({}); }); describe('AddonsService.list', () => { - it('returns the collab features and the bag-tracking flag from the admin service', () => { - getCollabFeatures.mockReturnValue({ comments: true }); - getBagTracking.mockReturnValue({ enabled: true }); - feedReads([], [], []); + it('returns the collab features and the bag-tracking flag from app_settings', () => { + feedReads([], [], [], [{ key: 'collab_chat_enabled', value: 'false' }], { value: 'true' }); const res = svc().list(); - expect(res.collabFeatures).toEqual({ comments: true }); + expect(res.collabFeatures).toEqual({ chat: false, notes: true, polls: true, whatsnext: true }); expect(res.bagTracking).toBe(true); expect(res.addons).toEqual([]); }); @@ -232,3 +232,67 @@ describe('AddonsService.list', () => { expect((res.addons[1] as { type: string }).type).toBe('photo_provider'); }); }); + +// Direct coverage of the enablement reads/writers relocated from +// services/adminService (finding `admin-1`). The polarity asymmetry is on +// purpose: bag tracking is opt-in (=== 'true'), collab flags opt-out (!== 'false'). +describe('AddonsService addon/feature flags', () => { + it('isAddonEnabled coerces the enabled column (1/0/missing row)', () => { + dbMock._stmt.get.mockReturnValueOnce({ enabled: 1 }); + expect(svc().isAddonEnabled('budget')).toBe(true); + dbMock._stmt.get.mockReturnValueOnce({ enabled: 0 }); + expect(svc().isAddonEnabled('budget')).toBe(false); + dbMock._stmt.get.mockReturnValueOnce(undefined); + expect(svc().isAddonEnabled('nope')).toBe(false); + }); + + it('getBagTracking is opt-in: only the literal string true enables it', () => { + dbMock._stmt.get.mockReturnValueOnce({ value: 'true' }); + expect(svc().getBagTracking()).toEqual({ enabled: true }); + dbMock._stmt.get.mockReturnValueOnce({ value: 'false' }); + expect(svc().getBagTracking()).toEqual({ enabled: false }); + dbMock._stmt.get.mockReturnValueOnce(undefined); // absent row → OFF + expect(svc().getBagTracking()).toEqual({ enabled: false }); + }); + + it('updateBagTracking persists true/false strings and echoes the flag (ADMIN-SVC-030)', () => { + expect(svc().updateBagTracking(true)).toEqual({ enabled: true }); + expect(dbMock._stmt.run).toHaveBeenCalledWith('true'); + expect(svc().updateBagTracking(false)).toEqual({ enabled: false }); + expect(dbMock._stmt.run).toHaveBeenCalledWith('false'); + }); + + it('getCollabFeatures is opt-out: absent rows default ON, only false disables', () => { + dbMock._stmt.all.mockReturnValueOnce([ + { key: 'collab_chat_enabled', value: 'false' }, + { key: 'collab_polls_enabled', value: 'true' }, + ]); + expect(svc().getCollabFeatures()).toEqual({ chat: false, notes: true, polls: true, whatsnext: true }); + }); + + it('updateCollabFeatures writes only the provided flags and reports changed (#1414, ADMIN-SVC-070)', () => { + // before-read: all default ON; after-read: chat flipped off → changed + dbMock._stmt.all.mockReturnValueOnce([]).mockReturnValueOnce([{ key: 'collab_chat_enabled', value: 'false' }]); + const first = svc().updateCollabFeatures({ chat: false }); + expect(first.changed).toBe(true); + expect(first.features.chat).toBe(false); + expect(dbMock._stmt.run).toHaveBeenCalledTimes(1); + expect(dbMock._stmt.run).toHaveBeenCalledWith('collab_chat_enabled', 'false'); + + // identical save → before and after read the same → no change, MCP sessions must survive + dbMock._stmt.run.mockClear(); + dbMock._stmt.all + .mockReturnValueOnce([{ key: 'collab_chat_enabled', value: 'false' }]) + .mockReturnValueOnce([{ key: 'collab_chat_enabled', value: 'false' }]); + const second = svc().updateCollabFeatures({ chat: false }); + expect(second.changed).toBe(false); + expect(dbMock._stmt.run).toHaveBeenCalledWith('collab_chat_enabled', 'false'); + + // undefined flags are not written + dbMock._stmt.run.mockClear(); + dbMock._stmt.all.mockReturnValueOnce([]).mockReturnValueOnce([]); + const third = svc().updateCollabFeatures({}); + expect(third.changed).toBe(false); + expect(dbMock._stmt.run).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/nest/llm-parse/llm-config.resolver.test.ts b/server/tests/unit/nest/llm-parse/llm-config.resolver.test.ts index 8465fd8c9..c8c09a375 100644 --- a/server/tests/unit/nest/llm-parse/llm-config.resolver.test.ts +++ b/server/tests/unit/nest/llm-parse/llm-config.resolver.test.ts @@ -6,12 +6,12 @@ const { dbMock } = vi.hoisted(() => { }); vi.mock('../../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} })); -const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn() })); -vi.mock('../../../../src/services/adminService', () => ({ isAddonEnabled })); +const isAddonEnabled = vi.fn(); import { LlmConfigResolver } from '../../../../src/nest/llm-parse/llm-config.resolver'; import { DatabaseService } from '../../../../src/nest/database/database.service'; import type { SettingsService } from '../../../../src/nest/settings/settings.service'; +import type { AddonsService } from '../../../../src/nest/addons/addons.service'; // The resolver injects SettingsService — a stub instance instead of the old // legacy-module path mock (same behaviors as before the DI move). The @@ -20,7 +20,8 @@ const getUserSettings = vi.fn(() => ({}) as Record); const getDecryptedUserSetting = vi.fn(() => null as string | null); const settingsStub = { getUserSettings, getDecryptedUserSetting } as unknown as SettingsService; -const resolver = new LlmConfigResolver(settingsStub, new DatabaseService(dbMock as never)); +const addonsStub = { isAddonEnabled } as unknown as AddonsService; +const resolver = new LlmConfigResolver(settingsStub, new DatabaseService(dbMock as never), addonsStub); function setInstanceConfig(config: unknown) { dbMock._stmt.get.mockReturnValue(config === undefined ? undefined : { config: JSON.stringify(config) }); diff --git a/server/tests/unit/nest/oauth.service.test.ts b/server/tests/unit/nest/oauth.service.test.ts index 66bbdd3c7..fe7c13cb5 100644 --- a/server/tests/unit/nest/oauth.service.test.ts +++ b/server/tests/unit/nest/oauth.service.test.ts @@ -26,7 +26,6 @@ const { oauth } = vi.hoisted(() => ({ vi.mock('../../../src/services/oauthService', () => oauth); const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn() })); -vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled })); const { getMcpSafeUrl } = vi.hoisted(() => ({ getMcpSafeUrl: vi.fn() })); vi.mock('../../../src/app-config', async (importOriginal) => { @@ -35,9 +34,11 @@ vi.mock('../../../src/app-config', async (importOriginal) => { }); import { OauthService } from '../../../src/nest/oauth/oauth.service'; +import type { AddonsService } from '../../../src/nest/addons/addons.service'; import { ADDON_IDS } from '../../../src/addons'; -function svc() { return new OauthService(); } +const addonsStub = { isAddonEnabled } as unknown as AddonsService; +function svc() { return new OauthService(addonsStub); } beforeEach(() => vi.clearAllMocks()); diff --git a/server/tests/unit/nest/platform.controller.test.ts b/server/tests/unit/nest/platform.controller.test.ts index d5417857e..801259248 100644 --- a/server/tests/unit/nest/platform.controller.test.ts +++ b/server/tests/unit/nest/platform.controller.test.ts @@ -20,7 +20,7 @@ vi.mock('../../../src/middleware/auth', () => ({ verifyJwtAndLoadUser: h.verifyJ vi.mock('../../../src/db/database', () => ({ db: { prepare: h.dbPrepare } })); vi.mock('../../../src/mcp', () => ({ mcpHandler: h.mcpHandler })); vi.mock('../../../src/mcp/oauthProvider', () => ({ trekOAuthProvider: {}, trekClientsStore: {} })); -vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: h.isAddonEnabled })); +vi.mock('../../../src/nest/addons/addons.bridge', () => ({ isAddonEnabled: h.isAddonEnabled })); vi.mock('../../../src/app-config', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getMcpSafeUrl: h.getMcpSafeUrl }; diff --git a/server/tests/unit/plugins/journal-entry-rows.controller.test.ts b/server/tests/unit/plugins/journal-entry-rows.controller.test.ts index 60523cf66..ea69ec8e8 100644 --- a/server/tests/unit/plugins/journal-entry-rows.controller.test.ts +++ b/server/tests/unit/plugins/journal-entry-rows.controller.test.ts @@ -20,11 +20,13 @@ vi.mock('../../../src/db/database', () => ({ import { db as dbConn } from '../../../src/db/database'; import { DatabaseService } from '../../../src/nest/database/database.service'; vi.mock('../../../src/services/journeyService', () => ({ canAccessJourney })); -vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled })); vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); import { JournalEntryRowsController } from '../../../src/nest/plugins/journal-entry-rows.controller'; import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; +import type { AddonsService } from '../../../src/nest/addons/addons.service'; + +const addonsStub = { isAddonEnabled } as unknown as AddonsService; // eslint-disable-next-line @typescript-eslint/no-explicit-any const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any; @@ -33,7 +35,7 @@ function controller(invoke: (id: string) => unknown, providers = ['p1']) { providersOf: vi.fn(() => providers), invokeHook: vi.fn(async (id: string) => invoke(id)), } as unknown as PluginRuntimeService; - return { c: new JournalEntryRowsController(runtime, new DatabaseService(dbConn)), runtime }; + return { c: new JournalEntryRowsController(runtime, new DatabaseService(dbConn), addonsStub), runtime }; } const row = (over: Record = {}) => ({ label: 'Distance', value: '12 km', ...over }); diff --git a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts index 42eff69e7..bbbaac3a6 100644 --- a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts +++ b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts @@ -47,7 +47,6 @@ vi.mock('../../../src/db/database', () => { vi.mock('../../../src/websocket', () => ({ broadcast, broadcastToUser })); // Addon gate — flip per test to exercise the "addon disabled" branch of the reads. const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true as boolean) })); -vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled })); // DI-native services are constructor-injected into PluginHostDepsFactory below — // stub instances instead of path mocks (same behaviors as before the DI move). const budgetStub = { @@ -324,7 +323,8 @@ import { DatabaseService } from '../../../src/nest/database/database.service'; // The factory under test, wired exactly like PluginsModule does — but with the // DI-native domain services replaced by the stubs above. The shim keeps the // ~45 historical call sites unchanged and supplies a default no-op router. -const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub, collabStub, vacayStub, daysStub, permissionsStub, exchangeRatesStub); +const addonsStub = { isAddonEnabled } as unknown as import('../../../src/nest/addons/addons.service').AddonsService; +const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub, collabStub, vacayStub, daysStub, permissionsStub, exchangeRatesStub, addonsStub); const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} }; const createRealRpcHost = (id: string, granted: ReadonlySet, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router); diff --git a/server/tests/unit/plugins/settings-isolation.test.ts b/server/tests/unit/plugins/settings-isolation.test.ts index f7fa9536b..e276dff1f 100644 --- a/server/tests/unit/plugins/settings-isolation.test.ts +++ b/server/tests/unit/plugins/settings-isolation.test.ts @@ -20,6 +20,7 @@ vi.mock('../../../src/db/database', () => dbMock); import { db as dbConn } from '../../../src/db/database'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { AuditService } from '../../../src/nest/audit/audit.service'; +import { AddonsService } from '../../../src/nest/addons/addons.service'; vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} })); import { createTables } from '../../../src/db/schema'; @@ -123,7 +124,7 @@ describe('a plugin channel label is bounded by the host', () => { VALUES ('loud', 'Loud', 'active', 1, '1.0.0', '[]', '[]', ?, '{}')`, ).run(JSON.stringify({ notificationChannel: { title: '🎉'.repeat(5) + 'A'.repeat(500) } })); - const rt = new PluginRuntimeService(new DatabaseService(dbConn), new AuditService(new DatabaseService(dbConn))); + const rt = new PluginRuntimeService(new DatabaseService(dbConn), new AuditService(new DatabaseService(dbConn)), new AddonsService(new DatabaseService(dbConn))); // Stand the plugin up as a granted, active notificationChannel provider. (rt as unknown as { supervisor: { running: Map } }).supervisor.running.set('loud', { id: 'loud', status: 'active', hooks: ['notificationChannel'], granted: new Set(['hook:notification-channel']), diff --git a/server/tests/unit/services/adminService.test.ts b/server/tests/unit/services/adminService.test.ts index dc4878ec1..14e40a77b 100644 --- a/server/tests/unit/services/adminService.test.ts +++ b/server/tests/unit/services/adminService.test.ts @@ -58,8 +58,6 @@ import { listInvites, createInvite, deleteInvite, - getBagTracking, - updateBagTracking, listPackingTemplates, createPackingTemplate, updatePackingTemplate, @@ -78,7 +76,6 @@ import { checkVersion, listAddons, updateAddon, - updateCollabFeatures, listMcpTokens, deleteMcpToken, } from '../../../src/services/adminService'; @@ -309,22 +306,6 @@ describe('Invites', () => { }); }); -// ── Bag tracking ────────────────────────────────────────────────────────────── - -describe('Bag tracking', () => { - it('ADMIN-SVC-029 — getBagTracking returns enabled state', () => { - const result = getBagTracking() as any; - expect(typeof result.enabled).toBe('boolean'); - }); - - it('ADMIN-SVC-030 — updateBagTracking persists the value', () => { - updateBagTracking(true); - expect((getBagTracking() as any).enabled).toBe(true); - updateBagTracking(false); - expect((getBagTracking() as any).enabled).toBe(false); - }); -}); - // ── Packing templates ───────────────────────────────────────────────────────── describe('Packing templates', () => { @@ -695,19 +676,6 @@ describe('updateAddon', () => { }); }); -describe('updateCollabFeatures', () => { - it('ADMIN-SVC-070 — reports whether a flag actually flipped (#1414)', () => { - const first = updateCollabFeatures({ chat: false }); - expect(first.changed).toBe(true); - expect(first.features.chat).toBe(false); - // identical save → no change, MCP sessions must survive - const second = updateCollabFeatures({ chat: false }); - expect(second.changed).toBe(false); - const third = updateCollabFeatures({ chat: true }); - expect(third.changed).toBe(true); - }); -}); - // ── MCP Tokens ──────────────────────────────────────────────────────────────── describe('MCP Tokens', () => { diff --git a/server/tests/unit/services/airtrailWriteGate.test.ts b/server/tests/unit/services/airtrailWriteGate.test.ts index c28cf5cb7..5311573f8 100644 --- a/server/tests/unit/services/airtrailWriteGate.test.ts +++ b/server/tests/unit/services/airtrailWriteGate.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; */ vi.mock('../../../src/db/database', () => ({ db: { prepare: vi.fn() } })); -vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: vi.fn(() => true) })); +vi.mock('../../../src/nest/addons/addons.bridge', () => ({ isAddonEnabled: vi.fn(() => true) })); vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ logError: vi.fn(), logInfo: vi.fn() })); vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); vi.mock('../../../src/nest/reservations/reservations.bridge', () => ({ diff --git a/server/tests/unit/services/oauthService.test.ts b/server/tests/unit/services/oauthService.test.ts index dd33b295d..7020cd62f 100644 --- a/server/tests/unit/services/oauthService.test.ts +++ b/server/tests/unit/services/oauthService.test.ts @@ -37,7 +37,7 @@ vi.mock('../../../src/services/apiKeyCrypto', () => ({ vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn(), sessions: new Map() })); import { revokeUserSessionsForClient } from '../../../src/mcp/sessionManager'; vi.mock('../../../src/demo/demo-reset', () => ({ saveBaseline: vi.fn() })); -vi.mock('../../../src/services/adminService', () => ({ +vi.mock('../../../src/nest/addons/addons.bridge', () => ({ isAddonEnabled: vi.fn().mockReturnValue(true), getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }), })); @@ -73,7 +73,7 @@ import { getConsent, isConsentSufficient, } from '../../../src/services/oauthService'; -import { isAddonEnabled } from '../../../src/services/adminService'; +import { isAddonEnabled } from '../../../src/nest/addons/addons.bridge'; beforeAll(() => { createTables(testDb);