refactor(server): extract addon enablement into the addons domain

This commit is contained in:
jubnl
2026-07-28 20:22:01 +02:00
parent cafff003a3
commit ee2ea1d065
64 changed files with 486 additions and 277 deletions
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+35
View File
@@ -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();
}
+7
View File
@@ -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 {}
+67 -3
View File
@@ -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<string, string> = {};
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<string, string> = {
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<keyof typeof after>).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) => ({
+2 -1
View File
@@ -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],
})
+9 -5
View File
@@ -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<typeof svc.updateCollabFeatures>[0]); }
getCollabFeatures() { return this.addons.getCollabFeatures(); }
updateCollabFeatures(body: unknown) { return this.addons.updateCollabFeatures(body as Parameters<AddonsService['updateCollabFeatures']>[0]); }
// Packing templates
listPackingTemplates() { return svc.listPackingTemplates(); }
@@ -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],
})
@@ -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<string, unknown>)
@@ -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),
};
}
}
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
@@ -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;
@@ -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],
})
@@ -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;
@@ -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 {}
@@ -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],
})
+6 -3
View File
@@ -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
@@ -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);
}
@@ -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],
+2 -1
View File
@@ -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],
})
+4 -2
View File
@@ -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); }
+2 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
@@ -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<string>, 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) => {
@@ -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: [] };
@@ -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,
+2 -1
View File
@@ -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
+6 -3
View File
@@ -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.
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+4 -71
View File
@@ -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<string, string> = {};
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<string, string> = {
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<keyof typeof after>).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[];
+1 -1
View File
@@ -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 {
@@ -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
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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 {