Files
stack/apps/gateway/src/preferences/preferences.service.ts
Jarvis 774b76447d
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline failed
fix: rename all packages from @mosaic/* to @mosaicstack/*
- Updated all package.json name fields and dependency references
- Updated all TypeScript/JavaScript imports
- Updated .woodpecker/publish.yml filters and registry paths
- Updated tools/install.sh scope default
- Updated .npmrc registry paths (worktree + host)
- Enhanced update-checker.ts with checkForAllUpdates() multi-package support
- Updated CLI update command to show table of all packages
- Added KNOWN_PACKAGES, formatAllPackagesTable, getInstallAllCommand
- Marked checkForUpdate() with @deprecated JSDoc

Closes #391
2026-04-04 21:43:23 -05:00

119 lines
3.8 KiB
TypeScript

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<string, unknown> = {
'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<string>(['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<Record<string, unknown>> {
const userPrefs = await this.getUserPrefs(userId);
const result: Record<string, unknown> = { ...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<Record<string, unknown>> {
const rows = await this.db
.select({ key: preferencesTable.key, value: preferencesTable.value })
.from(preferencesTable)
.where(eq(preferencesTable.userId, userId));
const result: Record<string, unknown> = {};
for (const row of rows) {
result[row.key] = row.value;
}
return result;
}
private async upsertPref(userId: string, key: string, value: unknown): Promise<void> {
// 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<void> {
await this.db
.delete(preferencesTable)
.where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key)));
this.logger.debug(`Deleted preference "${key}" for user ${userId}`);
}
}