import { Inject, Injectable, Logger } from '@nestjs/common'; import { eq, and, sql, type Db, preferences as preferencesTable } from '@mosaicstack/db'; import { DB } from '../database/database.module.js'; export const PLATFORM_DEFAULTS: Record = { 'agent.defaultModel': null, 'agent.thinkingLevel': 'auto', 'agent.streamingEnabled': true, 'response.language': 'auto', 'response.codeAnnotations': true, 'safety.confirmDestructiveTools': true, 'session.autoCompactThreshold': 0.8, 'session.autoCompactEnabled': true, 'limits.maxThinkingLevel': null, 'limits.rateLimit': null, }; export const IMMUTABLE_KEYS = new Set(['limits.maxThinkingLevel', 'limits.rateLimit']); @Injectable() export class PreferencesService { private readonly logger = new Logger(PreferencesService.name); constructor(@Inject(DB) private readonly db: Db) {} /** * Returns the effective preference set for a user: * Platform defaults → user overrides (mutable keys only) → enforcements re-applied last */ async getEffective(userId: string): Promise> { const userPrefs = await this.getUserPrefs(userId); const result: Record = { ...PLATFORM_DEFAULTS }; for (const [key, value] of Object.entries(userPrefs)) { if (!IMMUTABLE_KEYS.has(key)) { result[key] = value; } } // Re-apply immutable keys (enforcements always win) for (const key of IMMUTABLE_KEYS) { result[key] = PLATFORM_DEFAULTS[key]; } return result; } async set( userId: string, key: string, value: unknown, ): Promise<{ success: boolean; message: string }> { if (IMMUTABLE_KEYS.has(key)) { return { success: false, message: `Cannot override "${key}" — this is a platform enforcement. Contact your admin.`, }; } await this.upsertPref(userId, key, value); return { success: true, message: `Preference "${key}" set to ${JSON.stringify(value)}.` }; } async reset(userId: string, key: string): Promise<{ success: boolean; message: string }> { if (IMMUTABLE_KEYS.has(key)) { return { success: false, message: `Cannot reset "${key}" — it is a platform enforcement.` }; } await this.deletePref(userId, key); const defaultVal = PLATFORM_DEFAULTS[key]; return { success: true, message: `Preference "${key}" reset to default: ${JSON.stringify(defaultVal)}.`, }; } private async getUserPrefs(userId: string): Promise> { const rows = await this.db .select({ key: preferencesTable.key, value: preferencesTable.value }) .from(preferencesTable) .where(eq(preferencesTable.userId, userId)); const result: Record = {}; for (const row of rows) { result[row.key] = row.value; } return result; } private async upsertPref(userId: string, key: string, value: unknown): Promise { // Single-round-trip upsert using INSERT … ON CONFLICT DO UPDATE. // Previously this was two queries (SELECT + INSERT/UPDATE), which doubled // the DB round-trips and introduced a TOCTOU window under concurrent writes. await this.db .insert(preferencesTable) .values({ userId, key, value: value as never, mutable: true, }) .onConflictDoUpdate({ target: [preferencesTable.userId, preferencesTable.key], set: { value: sql`excluded.value`, updatedAt: sql`now()`, }, }); this.logger.debug(`Upserted preference "${key}" for user ${userId}`); } private async deletePref(userId: string, key: string): Promise { await this.db .delete(preferencesTable) .where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key))); this.logger.debug(`Deleted preference "${key}" for user ${userId}`); } }