import type { User } from '../../types'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; import { AuditService } from '../audit/audit.service'; import { getClientIp } from '../audit/client-ip'; import { AdminGuard } from '../auth-core/admin.guard'; import { CurrentUser } from '../auth-core/current-user.decorator'; import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; import { isDemoLockedSetting, withoutDemoLockedSettings } from '../common/demo-write'; import { isManagedLockedKey, splitManagedKeys } from '../common/managed'; import { ResponseContract } from '../common/response-contract'; import { SettingsWritesService } from './settings-writes.service'; import { AdminDefaultUserSettingsDto, SettingUpsertDto, SettingsBulkDto } from './settings.dto'; import { SettingsService, isAdminOnlyEndpointSetting } from './settings.service'; import { Body, Controller, Get, Headers, HttpCode, HttpException, Post, Put, Req, UseGuards } from '@nestjs/common'; import { MASKED_SETTING_VALUE, PLANNER_TRIP_VIEWS_KEY, adminDefaultUserSettingsResponseSchema, plannerTripViewsSchema, settingUpsertResponseSchema, settingsBulkResponseSchema, settingsListResponseSchema, } from '@trek/shared'; import type { Request } from 'express'; /** * /api/settings — per-user key/value preferences: get-all, single upsert * (no-op on the masked sentinel), and bulk upsert (500 on a write error). All * answer 200. Bodies validate against the @trek/shared settings schemas via * the DTO classes in settings.dto.ts + the global ZodValidationPipe (400 with * the standard `{ error }` envelope on mismatch — this replaced the legacy * bespoke 'Key is required' / 'Settings object is required' checks). * * One key group is authorized rather than merely validated: the LLM endpoint * settings answer 403 here for everyone, admins included, because they are * instance configuration and live on their own admin routes (#1772). */ @Controller('api/settings') @UseGuards(JwtAuthGuard) export class SettingsController { constructor( private readonly settings: SettingsService, private readonly env: RuntimeEnvService, // The writes, which tell the user's other tabs which keys changed. private readonly writes: SettingsWritesService, ) {} /** * #1772: the write half of the rule that the endpoint is instance * configuration. There is no key allow-list on this route, so anyone could * otherwise park an `llm_base_url` (or `llm_provider: 'local'`) in their own * settings row. The resolver ignores such a row anyway; refusing it here * means the caller finds out instead of silently saving something that never * takes effect. * * Admins are not exempt: they set the instance endpoint on the addon, or via * PUT /api/admin/default-user-settings, both of which are separate routes * behind AdminGuard. A personal row would be a second, invisible place for * the same value. */ private assertMayWriteInstanceEndpoint(settings: Record) { for (const [key, value] of Object.entries(settings)) { if (isAdminOnlyEndpointSetting(key, value)) { throw new HttpException({ error: 'Admin access required' }, 403); } } } /** * The one value with a shape the route checks (#2639): the planner's view * switches per trip. Clearing it stays open, like every other key. */ private assertValidValues(settings: Record) { const value = settings[PLANNER_TRIP_VIEWS_KEY]; if (value === undefined || value === null) return; const parsed = plannerTripViewsSchema.safeParse(value); if (!parsed.success) { throw new HttpException( { error: `Invalid value for ${PLANNER_TRIP_VIEWS_KEY}: ${parsed.error.issues[0]?.message ?? 'not accepted'}` }, 400, ); } } @Get() @ResponseContract(settingsListResponseSchema) async list(@CurrentUser() user: User) { return { settings: await this.settings.getUserSettings(user.id) }; } @Put() @ResponseContract(settingUpsertResponseSchema) async upsert(@CurrentUser() user: User, @Body() body: SettingUpsertDto, @Headers('x-socket-id') socketId?: string) { this.assertMayWriteInstanceEndpoint({ [body.key]: body.value }); this.assertValidValues({ [body.key]: body.value }); // assertMayWriteInstanceEndpoint only covers llm_base_url and provider 'local'. // llm_api_key and llm_model are writable by every user, and on a managed // install both cost the operator money, so the key list decides here. if (isManagedLockedKey(body.key) && this.env.isManaged()) { return { success: true, key: body.key, managed: true }; } // The demo account keeps its language: every visitor sees the same demo. if (isDemoLockedSetting(user, body.key)) { return { success: true, key: body.key, unchanged: true }; } // The client echoes a redacted secret back unchanged — treat as a no-op. if (body.value === MASKED_SETTING_VALUE) { return { success: true, key: body.key, unchanged: true }; } await this.writes.saveOne(user.id, body.key, body.value, socketId); return { success: true, key: body.key, value: body.value }; } @Post('bulk') @HttpCode(200) // Express answers bulk with res.json (200), not the POST-default 201. @ResponseContract(settingsBulkResponseSchema) async bulk(@CurrentUser() user: User, @Body() body: SettingsBulkDto, @Headers('x-socket-id') socketId?: string) { this.assertMayWriteInstanceEndpoint(body.settings); this.assertValidValues(body.settings); const { allowed, blocked } = splitManagedKeys(withoutDemoLockedSettings(user, body.settings), this.env.isManaged()); try { const updated = await this.writes.saveMany(user.id, allowed, socketId); return { success: true, updated, ...(blocked.length ? { managed_keys: blocked } : {}) }; } catch (err) { console.error('Error saving settings:', err); throw new HttpException({ error: 'Error saving settings' }, 500); } } } /** * /api/admin/default-user-settings — the defaults a new account starts with. * * These two routes sat on AdminController and reached SettingsService through a pair * of pass-through methods on AdminService that did nothing but forward. The owner is * here, so the routes are too; the path and both response shapes are unchanged. */ @Controller('api/admin/default-user-settings') @UseGuards(JwtAuthGuard, AdminGuard) export class AdminDefaultUserSettingsController { constructor( private readonly settings: SettingsService, private readonly audit: AuditService, private readonly env: RuntimeEnvService, ) {} @Get() @ResponseContract(adminDefaultUserSettingsResponseSchema) async get() { return this.settings.getAdminUserDefaults(); } @Put() @ResponseContract(adminDefaultUserSettingsResponseSchema) async update(@CurrentUser() user: User, @Body() body: AdminDefaultUserSettingsDto, @Req() req: Request) { try { // Deliberately no managed_keys in the response here: the route answers // with the raw defaults map the admin panel renders from, so an extra // field would sit inside the key namespace. The audit row below and the // client's capability filter carry the message instead. const { allowed } = splitManagedKeys(body as unknown as Record, this.env.isManaged()); await this.settings.setAdminUserDefaults(allowed); await this.audit.writeAudit({ userId: user.id, action: 'admin.default_user_settings_update', ip: getClientIp(req), details: body as Record, }); // Answer with the stored defaults, not the request body: the service normalises // and drops unknown keys, and the admin panel renders straight from this. return await this.settings.getAdminUserDefaults(); } catch (err) { throw new HttpException({ error: err instanceof Error ? err.message : String(err) }, 400); } } }