import { ADDON_IDS, MCP_GATED_ADDON_IDS } from '../../addons'; import { readEnv } from '../../app-config'; import { updateJwtSecret } from '../../config'; import { Addons } from '../../db/entities/Addons.entity'; import type { AddonConfig } from '../../db/entities/Addons.entity'; import { AppSettings } from '../../db/entities/AppSettings.entity'; import { AuditLog } from '../../db/entities/AuditLog.entity'; import { DocumentProviders } from '../../db/entities/DocumentProviders.entity'; import { McpTokens } from '../../db/entities/McpTokens.entity'; import { OauthTokens } from '../../db/entities/OauthTokens.entity'; import { PhotoProviderFields } from '../../db/entities/PhotoProviderFields.entity'; import { PhotoProviders } from '../../db/entities/PhotoProviders.entity'; import { Places } from '../../db/entities/Places.entity'; import { PushSubscriptions } from '../../db/entities/PushSubscriptions.entity'; import { TripFiles } from '../../db/entities/TripFiles.entity'; import { Trips } from '../../db/entities/Trips.entity'; import { Users } from '../../db/entities/Users.entity'; import type { AddonsRepository } from '../../db/repositories/Addons.repository'; import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository'; import type { AuditLogRepository } from '../../db/repositories/AuditLog.repository'; import type { DocumentProvidersRepository } from '../../db/repositories/DocumentProviders.repository'; import type { McpTokensRepository } from '../../db/repositories/McpTokens.repository'; import type { OauthTokensRepository } from '../../db/repositories/OauthTokens.repository'; import type { PhotoProviderFieldsRepository } from '../../db/repositories/PhotoProviderFields.repository'; import type { PhotoProvidersRepository } from '../../db/repositories/PhotoProviders.repository'; import type { PlacesRepository } from '../../db/repositories/Places.repository'; import type { PushSubscriptionsRepository } from '../../db/repositories/PushSubscriptions.repository'; import type { TripFilesRepository } from '../../db/repositories/TripFiles.repository'; import type { TripsRepository } from '../../db/repositories/Trips.repository'; import { UserIdentityTakenError, type UsersRepository, type AdminEditPatch, } from '../../db/repositories/Users.repository'; import { utcSuffix } from '../../db/types'; import { invalidateMcpSessions } from '../../mcp'; // Import from sessionManager directly, NOT the ../../mcp barrel — the direct // path keeps this module's graph minimal, and the split predates the barrel's // shrink to process-wide state. The invalidateMcpSessions barrel import below // is deliberately separate: it is only reached from the controller, never from // the cron path. import { revokeUserSessions, revokeUserSessionsForClient } from '../../mcp/sessionManager'; import { emitUserDeleted } from '../../plugin-user-lifecycle'; import { AddonsService } from '../addons/addons.service'; import { DataPathsService } from '../app-config/data-paths.service'; import { AuthService } from '../auth/auth.service'; import { PasskeyService } from '../auth/passkey.service'; import { UserCleanupService } from '../auth/user-cleanup.service'; import { readAppSetting } from '../common/app-settings.registry'; import { avatarUrl } from '../common/avatarUrl'; import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto'; import { DomainError } from '../common/domain-error'; import { MANAGED_FORBIDDEN_ERROR } from '../common/managed'; import { validatePassword } from '../common/passwordPolicy'; import { getPhotoProviderConfig } from '../common/photo-provider-config'; import { DATABASE_BACKUP, type DatabaseBackupStrategy } from '../database/database-backup.interface'; import { UnitOfWork } from '../database/unit-of-work'; import { prepareLlmAddonConfigForWrite, maskLlmAddonConfig } from '../llm-parse/llm-config'; import { NotificationsService } from '../notifications/notifications.service'; import { PermissionsService } from '../permissions/permissions.service'; import { PERMISSION_ACTIONS } from '../permissions/permissions.service'; import { RealtimeService } from '../realtime/realtime.service'; import { SessionsService } from '../sessions/sessions.service'; import { BCRYPT_COST, compareVersions, isDocker, readVersionCache, writeVersionCache, type VersionInfo, } from './admin.helpers'; import { InjectRepository } from '@mikro-orm/nestjs'; import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; import crypto from 'crypto'; import fs from 'fs'; /** Outbound GitHub calls: hard timeout and response-size cap (server/CLAUDE.md). */ const GITHUB_TIMEOUT_MS = 10_000; const GITHUB_MAX_BYTES = 2_000_000; /** Failed version checks cache briefly so an outage isn't refetched per page load. */ const VERSION_FAILURE_TTL = 60_000; /** * Admin domain service — owns admin's data access (folded from the legacy * services/adminService.ts with the 2026-08 migration, converted onto * repositories by Plan 3i Task 1): user CRUD, instance stats, the permission * matrix, the audit-log read side, OIDC settings, the demo baseline, GitHub * release/version checks, invite tokens, the three places feature toggles, * addons + photo providers, MCP tokens, OAuth sessions and JWT rotation. * * Every quirk stays byte-for-byte: the `||` falsy defaults (never `??`), * post-insert/post-update re-selects instead of RETURNING, the COALESCE-shaped * partial update (now a repository call that omits unchanged keys — the same * net effect), the #1362 guest exclusions and the exact error strings. The * legacy `{ error, status }` envelope is the return contract — the * controller's `ok()` helper turns it into an HttpException, so nothing here * throws. * * The three self-protection invariants (own-account delete, own-MFA reset, * last-admin demote) stay exactly where the legacy had them: plain JS * comparisons in this service, never folded into a repository method's WHERE * clause (R4). * * The bag-tracking/collab-feature toggles, user defaults, passkey reset and * packing templates delegate to the services that own those tables. The pure * and module-scoped pieces (compareVersions, isDocker, the version cache) live * in admin.helpers.ts. */ /** The later of two stored timestamps; both are canonical UTC text, which sorts as it reads. */ function latest(a: string | null | undefined, b: string | null | undefined): string | null { if (!a) return b ?? null; if (!b) return a; return a > b ? a : b; } /** The 409 the up-front checks give, for a write that lost the race to another one. */ function identityTakenMessage(err: UserIdentityTakenError): string { return err.field === 'email' ? 'Email already taken' : 'Username already taken'; } @Injectable() export class AdminService { constructor( @InjectRepository(Users) private readonly users: UsersRepository, @InjectRepository(AuditLog) private readonly auditLog: AuditLogRepository, @InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository, @InjectRepository(Addons) private readonly addonsRepo: AddonsRepository, @InjectRepository(PhotoProviders) private readonly photoProviders: PhotoProvidersRepository, @InjectRepository(PhotoProviderFields) private readonly photoProviderFields: PhotoProviderFieldsRepository, @InjectRepository(DocumentProviders) private readonly documentProviders: DocumentProvidersRepository, @InjectRepository(McpTokens) private readonly mcpTokens: McpTokensRepository, @InjectRepository(OauthTokens) private readonly oauthTokens: OauthTokensRepository, @InjectRepository(Trips) private readonly trips: TripsRepository, @InjectRepository(Places) private readonly places: PlacesRepository, @InjectRepository(TripFiles) private readonly tripFiles: TripFilesRepository, @InjectRepository(PushSubscriptions) private readonly pushSubscriptions: PushSubscriptionsRepository, private readonly addons: AddonsService, private readonly passkeys: PasskeyService, private readonly auth: AuthService, private readonly permissions: PermissionsService, private readonly notifications: NotificationsService, private readonly userCleanup: UserCleanupService, private readonly realtime: RealtimeService, private readonly uow: UnitOfWork, @Inject(DATABASE_BACKUP) private readonly database: DatabaseBackupStrategy, private readonly dataPaths: DataPathsService, private readonly sessions: SessionsService, ) {} // ── User CRUD ────────────────────────────────────────────────────────────── async listUsers() { // Guests (#1362) are accountless trip participants, not real users — keep them out // of admin user management entirely. const users = await this.users.listForAdmin(); const lastSeen = await this.sessions.lastSeenByUser(); let onlineUserIds = new Set(); try { // The catch stays here rather than in the facade: an admin list that shows // everyone as offline is a better answer than a 500, and that judgement // belongs to this caller, not to every future one. onlineUserIds = this.realtime.getOnlineUserIds(); } catch { /* */ } return users.map((u) => ({ ...u, avatar_url: avatarUrl(u), created_at: utcSuffix(u.created_at), updated_at: utcSuffix(u.updated_at as string), last_login: utcSuffix(u.last_login), // The later of the last sign-in and the last request. The cookie renews // itself while someone keeps using TREK, so they may not have signed in // for weeks and still have been here an hour ago. last_active: utcSuffix(latest(u.last_login, lastSeen.get(u.id))), online: onlineUserIds.has(u.id), })); } async createUser(data: { username: string; email: string; password: string; role?: string }) { const username = data.username?.trim(); const email = data.email?.trim(); const password = data.password?.trim(); if (!username || !email || !password) { throw new DomainError(400, 'Username, email and password are required'); } const pwCheck = validatePassword(password); if (!pwCheck.ok) throw new DomainError(400, pwCheck.reason ?? ''); if (data.role && !['user', 'admin'].includes(data.role)) { throw new DomainError(400, 'Invalid role'); } // Guests (#1362) live in a reserved synthetic namespace; never let one block a real account. const existingUsername = await this.users.findIdByUsernameExact(username); if (existingUsername) throw new DomainError(409, 'Username already taken'); const existingEmail = await this.users.findIdByEmailCI(email); if (existingEmail) throw new DomainError(409, 'Email already taken'); const passwordHash = bcrypt.hashSync(password, BCRYPT_COST); let insertedId: number; try { insertedId = await this.users.insertAdminCreatedUser({ username, email, password_hash: passwordHash, role: data.role || 'user', }); } catch (err) { // Another write took the name or address between the check and the insert. if (err instanceof UserIdentityTakenError) throw new DomainError(409, identityTakenMessage(err)); throw err; } const user = await this.users.findAdminSummary(insertedId); return { user, insertedId, auditDetails: { username, email, role: data.role || 'user' }, }; } async updateUser(id: string, data: { username?: string; email?: string; role?: string; password?: string }) { const userId = Number(id); const username = typeof data.username === 'string' ? data.username.trim() : data.username; const email = typeof data.email === 'string' ? data.email.trim() : data.email; const { role, password } = data; const user = await this.users.findById(userId); if (!user) throw new DomainError(404, 'User not found'); if (role && !['user', 'admin'].includes(role)) { throw new DomainError(400, 'Invalid role'); } // An empty string used to fall through `username || null` into COALESCE and // silently mean "leave unchanged". Say so instead of pretending it worked. if (username === '') throw new DomainError(400, 'Username cannot be empty'); if (email === '') throw new DomainError(400, 'Email cannot be empty'); if (username && username !== user.username) { const conflict = await this.users.findIdByUsernameExactExcluding(username, userId); if (conflict) throw new DomainError(409, 'Username already taken'); } if (email && email !== user.email) { const conflict = await this.users.findIdByEmailCI(email, userId); if (conflict) throw new DomainError(409, 'Email already taken'); } if (password) { const pwCheck = validatePassword(password); if (!pwCheck.ok) throw new DomainError(400, pwCheck.reason ?? ''); } const passwordHash = password ? bcrypt.hashSync(password, BCRYPT_COST) : null; // Don't let the admin UI demote the last remaining admin — that would leave the // instance with no one able to manage it (and on OIDC-only setups, no recovery). #1274 // SECURITY: plain JS comparisons, deliberately never folded into a // repository method's WHERE clause (R4) — two repository reads, one // service-level `if`. if (role && role !== 'admin') { const currentRole = await this.users.getRole(userId); if (currentRole === 'admin') { const adminCount = await this.users.countAdmins(); if (adminCount <= 1) throw new DomainError(400, 'Cannot remove the last admin'); } } // An admin sets a password for exactly one reason: the account is believed // compromised. Without bumping password_version, verifyJwtAndLoadUser keeps // accepting every cookie the intruder already holds, so the one action taken // to lock them out was the one action that did not. Both self-service paths // (changePassword, resetPassword) have always done this; this one had not. const newPv = password ? (user.password_version ?? 0) + 1 : null; const patch: AdminEditPatch = {}; if (username) patch.username = username; if (email) patch.email = email; // An address the admin enters is one the admin vouches for, so an SSO sign-in // may link to it again. The edit dialog sends the email on every save, so only // a new address counts, not the unchanged one or the same one in another case. if (email && email.toLowerCase() !== user.email.toLowerCase()) patch.email_self_asserted = 0; if (role) patch.role = role; if (password) { patch.password_hash = passwordHash!; patch.password_version = newPv!; } // The password-reset transaction's CURRENT boundary, preserved exactly // (R4): the users UPDATE, the mcp_tokens DELETE, the oauth_tokens revoke // and the push_subscriptions DELETE stay inside the SAME uow.transactional // call, never split across separate un-transacted repository calls. try { await this.uow.transactional(async () => { await this.users.applyAdminEdit(userId, patch); if (password) { // The version bump only invalidates JWT cookies. These two stores carry // their own credentials and are revoked separately, exactly as the // self-service paths do it. await this.mcpTokens.deleteAllForUser(userId); try { await this.oauthTokens.revokeAllForUser(userId); } catch { /* very old installs predate oauth_tokens */ } // Push devices outlive every session, so the intruder's browser would // keep receiving this account's notifications. They go with the rest. await this.pushSubscriptions.deleteAllForUser(userId); await this.sessions.revokeAll(userId); } }); } catch (err) { if (err instanceof UserIdentityTakenError) throw new DomainError(409, identityTakenMessage(err)); throw err; } if (password) { try { revokeUserSessions(Number(id)); } catch { /* best-effort, same as elsewhere */ } } const updated = await this.users.findAdminSummary(userId); const changed: string[] = []; if (username) changed.push('username'); if (email) changed.push('email'); if (role) changed.push('role'); if (password) changed.push('password'); return { user: updated, previousEmail: user.email, changed, }; } async deleteUser(id: string, currentUserId: number) { // SECURITY: plain JS comparison, deliberately kept exactly here — never // folded into a repository method's WHERE clause (R4). if (Number.parseInt(id) === currentUserId) { throw new DomainError(400, 'Cannot delete own account'); } const userToDel = await this.users.findIdAndEmail(Number(id)); if (!userToDel) throw new DomainError(404, 'User not found'); await this.userCleanup.deleteUserCompletely(userToDel.id); await emitUserDeleted(userToDel.id); // let plugins erase their own per-user data return { email: userToDel.email }; } resetUserPasskeys(id: string) { return this.passkeys.adminResetPasskeys(Number(id)); } /** * Clear another account's TOTP so its owner can enrol again. * * The passkey half of this has existed since passkeys landed; the TOTP half * never did, which left one ordinary event with no answer: somebody on a trip * loses their phone. Without this the only ways out are an operator reaching * into the database or the account being deleted and rebuilt. * * Never for the caller themselves. An admin who wants their own MFA gone * disables it through Settings, where the current password is required — * making that reachable from here would turn a stolen admin session into a * way to strip the second factor off the very account it came from. */ async resetUserMfa(id: string, actingUserId: number): Promise<{ success?: boolean; email?: string }> { const targetId = Number(id); // SECURITY: plain JS comparison, deliberately kept exactly here — never // folded into a repository method's WHERE clause (R4). if (targetId === actingUserId) { throw new DomainError(400, 'Use Settings to change your own two-factor setup'); } const target = await this.users.findIdEmailMfaEnabled(targetId); if (!target) throw new DomainError(404, 'User not found'); // Same three columns disableMfa clears, so an admin reset and a self-service // disable leave the account in exactly one state rather than two; the // account's sessions end with the second factor, as on that path. await this.uow.transactional(async () => { await this.users.disableMfa(targetId); await this.sessions.revokeAll(targetId); }); return { success: true, email: target.email }; } // ── Stats ────────────────────────────────────────────────────────────────── async getStats() { const totalUsers = await this.users.countNonGuest(); const totalTrips = await this.trips.count(); const totalPlaces = await this.places.count(); const totalFiles = await this.tripFiles.count(); return { totalUsers, totalTrips, totalPlaces, totalFiles }; } // ── Permissions ──────────────────────────────────────────────────────────── async getPermissions() { const current = await this.permissions.getAllPermissions(); const actions = PERMISSION_ACTIONS.map((a) => ({ key: a.key, level: current[a.key], defaultLevel: a.defaultLevel, allowedLevels: a.allowedLevels, })); return { permissions: actions }; } async savePermissions(permissions: Record) { const { skipped } = await this.permissions.savePermissions(permissions); return { permissions: await this.permissions.getAllPermissions(), skipped }; } // ── Audit Log ────────────────────────────────────────────────────────────── async getAuditLog(query: { limit?: string; offset?: string }) { const limitRaw = Number.parseInt(String(query.limit || '100'), 10); const offsetRaw = Number.parseInt(String(query.offset || '0'), 10); const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 100, 1), 500); const offset = Math.max(Number.isFinite(offsetRaw) ? offsetRaw : 0, 0); const rows = await this.auditLog.listPage(limit, offset); const total = await this.auditLog.count(); const entries = rows.map((r) => { // Unparseable details fall back to the raw string rather than the old // { _parse_error: true } sentinel, which the admin UI rendered literally. let details: Record | string | null = null; if (r.details) { try { details = JSON.parse(r.details) as Record; } catch { details = r.details; } } const created_at = r.created_at && !r.created_at.endsWith('Z') ? r.created_at.replace(' ', 'T') + 'Z' : r.created_at; return { ...r, created_at, details }; }); return { entries, total, limit, offset }; } // ── Demo Baseline ────────────────────────────────────────────────────────── async saveDemoBaseline(): Promise<{ message?: string }> { if (!readEnv().demo.enabled) { throw new DomainError(404, 'Not found'); } try { // Lazy require: demo-reset is a demo-only module. const { saveBaseline } = require('../../demo/demo-reset') as typeof import('../../demo/demo-reset'); await saveBaseline(this.database); return { message: 'Demo baseline saved. Hourly resets will restore to this state.' }; } catch (err: unknown) { console.error(err); throw new DomainError(500, 'Failed to save baseline'); } } // ── GitHub Integration ───────────────────────────────────────────────────── /** * GitHub fetch with the timeout + size cap the outbound-fetch rule requires * (server/CLAUDE.md). Returns null on any failure; callers decide the fallback. */ private async fetchGithub(url: string): Promise { try { const resp = await fetch(url, { headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' }, signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS), }); if (!resp.ok) return null; const text = await resp.text(); if (text.length > GITHUB_MAX_BYTES) { console.error(`[admin] GitHub response exceeded ${GITHUB_MAX_BYTES} bytes, ignoring`); return null; } return JSON.parse(text); } catch (err: unknown) { console.error(`[admin] GitHub request failed: ${err instanceof Error ? err.message : String(err)}`); return null; } } async getGithubReleases(perPage: string = '10', page: string = '1') { // The query arrives as raw strings from the admin UI; clamp to what the // GitHub API accepts instead of interpolating them into the URL as given. const per = Math.min(Math.max(Number.parseInt(perPage, 10) || 10, 1), 100); const pg = Math.max(Number.parseInt(page, 10) || 1, 1); const qs = new URLSearchParams({ per_page: String(per), page: String(pg) }); const data = await this.fetchGithub(`https://api.github.com/repos/liketrek/TREK/releases?${qs}`); return Array.isArray(data) ? data : []; } async checkVersion(): Promise { // Lazy require, re-anchored for nest/admin/ (was ../../package.json in services/). const currentVersion: string = readEnv().app.appVersion || require('../../../package.json').version; const isPrerelease = currentVersion.includes('-pre.'); // Answered rather than refused on a centrally administered install: the admin // page calls this on open, and a 403 there would be an error message where a // quiet surface belongs. The operator decides when to upgrade, so from inside // the instance there is nothing available. if (readEnv().managed.enabled) { return { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker, is_prerelease: isPrerelease, }; } const cached = readVersionCache(); if (cached) return cached; const fallback: VersionInfo = { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker, is_prerelease: isPrerelease, }; // Failures cache too (on a shorter TTL), so a GitHub outage doesn't mean a // live fetch on every admin page load — the legacy code only cached success. const fail = () => { writeVersionCache(fallback, VERSION_FAILURE_TTL); return fallback; }; let result: VersionInfo; if (isPrerelease) { // Fetch release list and find the newest prerelease const data = (await this.fetchGithub( 'https://api.github.com/repos/liketrek/TREK/releases?per_page=100', )) as Array<{ tag_name?: string; html_url?: string; prerelease?: boolean }> | null; if (!data) return fail(); const prereleases = Array.isArray(data) ? data.filter((r) => r.prerelease) : []; if (!prereleases.length) return fail(); // Pre-compute stripped versions, then sort descending const tagged = prereleases.map((r) => ({ r, v: (r.tag_name || '').replace(/^v/, '') })); tagged.sort((a, b) => compareVersions(b.v, a.v)); const latest = tagged[0].v; const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0; result = { current: currentVersion, latest, update_available, release_url: tagged[0].r.html_url || '', is_docker: isDocker, is_prerelease: true, }; } else { const data = (await this.fetchGithub('https://api.github.com/repos/liketrek/TREK/releases/latest')) as { tag_name?: string; html_url?: string; } | null; if (!data) return fail(); const latest = (data.tag_name || '').replace(/^v/, ''); const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0; result = { current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker, is_prerelease: false, }; } writeVersionCache(result); return result; } async checkAndNotifyVersion(): Promise { try { const result = await this.checkVersion(); if (!result.update_available) return; const lastNotified = await readAppSetting(this.appSettings, 'last_notified_version'); if (lastNotified === result.latest) return; // INSERT OR REPLACE shape — the SAME dialect storage's `upsertOrReplace` // (Plan 3i Task 2, SS1) already added to AppSettingsRepository, reused // here rather than adding a second method for the identical SQL text // (`INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)`). await this.appSettings.upsertOrReplace('last_notified_version', result.latest); await this.notifications.send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: result.latest }, }); } catch { // Silently ignore — version check is non-critical } } // ── Addons ───────────────────────────────────────────────────────────────── async listAddons() { const addons = (await this.addonsRepo.listAllOrdered()) // Hidden rather than shown-and-refused, because a toggle that answers 403 // is worse than no toggle. // // AI parsing: the endpoint, the model and what a document costs all belong // to the operator, so there is no instance decision left to make — not even // whether it runs. // // AirTrail and Dawarich: both addons exist to reach a server the admin // runs themselves, and a managed instance has no route to one. // // AI assistant: the same reasoning as AI parsing, and its connection // would put a key of the instance's own beside the operator's. .filter( (a) => !( readEnv().managed.enabled && (a.id === ADDON_IDS.LLM_PARSING || a.id === ADDON_IDS.AI_ASSISTANT || a.id === ADDON_IDS.AIRTRAIL || a.id === ADDON_IDS.DAWARICH) ), ); const providers = (await this.photoProviders.listAllOrdered()) // Immich and Synology Photos are servers the admin runs at home. A managed // instance cannot reach one (its egress does not go there, and it should // not), so offering the connection would only produce a timeout. .filter(() => !readEnv().managed.enabled); const fields = await this.photoProviderFields.listAllOrderedForAdminShelf(); const fieldsByProvider = new Map(); for (const field of fields) { const arr = fieldsByProvider.get(field.provider_id) || []; arr.push(field); fieldsByProvider.set(field.provider_id, arr); } // Document providers are the Documents addon's shelf rows, the same way // photo providers are Journey's. Hidden on a managed instance for the same // reason: Paperless, Papra, Nextcloud, OpenCloud and a Synology NAS are all // servers the admin runs at home, and a hosted TREK has no route to one. // // They carry no `config` routes, unlike photo providers: the credentials do // not belong to a user here but to a trip, so they are entered in the trip // rather than in settings. The admin decides only whether a provider may be // offered at all. const docProviders = (await this.documentProviders.listAllOrdered()).filter(() => !readEnv().managed.enabled); return [ ...addons.map((a) => ({ ...a, enabled: !!a.enabled, config: a.id === ADDON_IDS.LLM_PARSING ? maskLlmAddonConfig(a.config ?? {}) : (a.config ?? {}), })), ...providers.map((p) => ({ id: p.id, name: p.name, description: p.description, type: 'photo_provider', icon: p.icon, enabled: !!p.enabled, config: getPhotoProviderConfig(p.id!), fields: (fieldsByProvider.get(p.id!) || []).map((f) => ({ key: f.field_key, label: f.label, input_type: f.input_type, placeholder: f.placeholder || '', required: !!f.required, secret: !!f.secret, settings_key: f.settings_key || null, payload_key: f.payload_key || null, sort_order: f.sort_order, })), sort_order: p.sort_order, })), ...docProviders.map((p) => ({ id: p.id, name: p.name, description: p.description, type: 'document_provider', icon: p.icon, enabled: !!p.enabled, config: {}, fields: [], sort_order: p.sort_order, })), ]; } async updateAddon(id: string, data: { enabled?: boolean; config?: Record }) { const addon = await this.addonsRepo.findById(id); const provider = await this.photoProviders.findById(id); const docProvider = await this.documentProviders.findById(id); if (!addon && !provider && !docProvider) throw new DomainError(404, 'Addon not found'); // The whole addon, not just its config: on a centrally administered install // the operator owns the endpoint, the model and the per-document cost, so // there is nothing here for an instance admin to set — including whether it // runs at all. listAddons hides the row; this closes the route behind it. if (readEnv().managed.enabled && (id === ADDON_IDS.LLM_PARSING || id === ADDON_IDS.AI_ASSISTANT)) { throw new DomainError(403, MANAGED_FORBIDDEN_ERROR.error); } // Photo providers are Journey's shelf rows — their whole UI lives inside // journeys, so enabling one under a disabled journey addon would only // advertise an integration nothing can reach. if (provider && data.enabled === true && !(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) { throw new DomainError(409, 'Enable the Journey addon first'); } // Same rule one shelf down: a document provider only exists to serve the // file manager, so switching one on under a disabled Documents addon would // advertise a sync nothing can reach. if (docProvider && data.enabled === true && !(await this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS))) { throw new DomainError(409, 'Enable the Documents addon first'); } await this.uow.transactional(async () => { if (addon) { if (data.enabled !== undefined) { await this.addonsRepo.setEnabled(id, !!data.enabled); // Journey off takes its providers with it: a row left enabled would // resurface the moment journey returns, which nobody switched on. if (id === ADDON_IDS.JOURNEY && !data.enabled) await this.photoProviders.disableAll(); // Documents off takes its providers with it, for the reason above: a // row left enabled would resurface the moment the addon returns. if (id === ADDON_IDS.DOCUMENTS && !data.enabled) await this.documentProviders.disableAll(); } if (data.config !== undefined) { // The AI-parsing addon holds an API key — encrypt it at rest and preserve // the stored key when the client echoes the mask sentinel (see llmConfig.ts). const configToStore = id === ADDON_IDS.LLM_PARSING ? prepareLlmAddonConfigForWrite(data.config, addon.config ?? {}) : data.config; await this.addonsRepo.setConfig(id, configToStore as AddonConfig); } } else if (provider) { if (data.enabled !== undefined) await this.photoProviders.setEnabled(id, data.enabled ? 1 : 0); } else { if (data.enabled !== undefined) await this.documentProviders.setEnabled(id, data.enabled ? 1 : 0); } }); const updatedAddon = await this.addonsRepo.findById(id); const updatedProvider = await this.photoProviders.findById(id); const updated = updatedAddon ? { ...updatedAddon, enabled: !!updatedAddon.enabled, config: updatedAddon.id === ADDON_IDS.LLM_PARSING ? maskLlmAddonConfig(updatedAddon.config ?? {}) : (updatedAddon.config ?? {}), } : updatedProvider ? { id: updatedProvider.id, name: updatedProvider.name, description: updatedProvider.description, type: 'photo_provider', icon: updatedProvider.icon, enabled: !!updatedProvider.enabled, config: getPhotoProviderConfig(updatedProvider.id!), sort_order: updatedProvider.sort_order, } : null; // Only addons that gate MCP registration matter here — and only a real // enabled-flip changes what a session would register. Config-only saves, // photo providers and MCP-irrelevant addons must not tear down every live // session (#1414). // // The list lives beside ADDON_IDS and is held to the gates by a parity test. // As a copy kept here it had drifted: airtrail and collections gate tools // and were missing, so their write tools stayed callable on an open session // after an admin switched them off, while the REST half answered 404 for the // same user in the same moment. const MCP_RELEVANT_ADDONS = new Set(MCP_GATED_ADDON_IDS); // `addon.enabled` is a JS boolean now (the repository's `AddonRow.enabled` // shape, not the raw stored int the legacy `SELECT *` returned) — comparing // two booleans for a real flip is the same predicate the legacy int // comparison (`(data.enabled ? 1 : 0) !== addon.enabled`) expressed. const enabledChanged = !!addon && data.enabled !== undefined && !!data.enabled !== addon.enabled; return { addon: updated, mcpAffected: enabledChanged && MCP_RELEVANT_ADDONS.has(id), auditDetails: { enabled: data.enabled !== undefined ? !!data.enabled : undefined, config_changed: data.config !== undefined, }, }; } // ── JWT Rotation ─────────────────────────────────────────────────────────── rotateJwtSecret(): void { const newSecret = crypto.randomBytes(32).toString('hex'); const { dataDir, jwtSecretFile: secretFile } = this.dataPaths; try { if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); fs.writeFileSync(secretFile, newSecret, { mode: 0o600 }); } catch { throw new DomainError(500, 'Failed to persist new JWT secret to disk'); } updateJwtSecret(newSecret); } invalidateMcpSessions() { invalidateMcpSessions(); } // ── Settings + notification preference helpers (non-admin-service modules) ── }