Files
TREK/server/src/nest/settings/settings.controller.ts
T
Maurice eae74c5605 feat(demo): the shared demo account keeps its keys, account and language
Every visitor of a demo instance signs in as the same account, so one
visitor's API key, MCP token, OAuth client, passkey or new email address
became everybody's. These writes now answer 403 for the demo account through
refuseForDemoAccount in demo-write.ts: creating API keys, creating and
deleting MCP tokens, registering, rotating and deleting OAuth clients,
approving an app and ending a connected app, changing the name, address or
picture, and enrolling, renaming or deleting passkeys. Password, 2FA,
account deletion, sessions and the own AI connection were refused already.

The demo account's language is fixed to English: a single settings write of
the language answers unchanged, a bulk save drops it and keeps the rest. The
MCP settings tool is held back for the demo account by the tool gate, as
every write tool is. The admin of a demo instance and every real instance
are untouched.
2026-10-24 23:22:15 +00:00

178 lines
7.9 KiB
TypeScript

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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>, 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<string, unknown>,
});
// 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);
}
}
}