@actuate-media/cms-core 0.57.0 → 0.57.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/api/stats-seo-cache.test.js +1 -1
- package/dist/__tests__/api/stats-seo-cache.test.js.map +1 -1
- package/dist/api/guarded-db.d.ts +13 -0
- package/dist/api/guarded-db.d.ts.map +1 -0
- package/dist/api/guarded-db.js +58 -0
- package/dist/api/guarded-db.js.map +1 -0
- package/dist/api/handler-factory.d.ts.map +1 -1
- package/dist/api/handler-factory.js +2 -1
- package/dist/api/handler-factory.js.map +1 -1
- package/dist/api/handlers.d.ts +5 -3
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +54 -13068
- package/dist/api/handlers.js.map +1 -1
- package/dist/api/route-helpers.d.ts +320 -0
- package/dist/api/route-helpers.d.ts.map +1 -0
- package/dist/api/route-helpers.js +1307 -0
- package/dist/api/route-helpers.js.map +1 -0
- package/dist/api/routes/ai-seo.d.ts +3 -0
- package/dist/api/routes/ai-seo.d.ts.map +1 -0
- package/dist/api/routes/ai-seo.js +377 -0
- package/dist/api/routes/ai-seo.js.map +1 -0
- package/dist/api/routes/ai-settings.d.ts +3 -0
- package/dist/api/routes/ai-settings.d.ts.map +1 -0
- package/dist/api/routes/ai-settings.js +764 -0
- package/dist/api/routes/ai-settings.js.map +1 -0
- package/dist/api/routes/auth.d.ts +3 -0
- package/dist/api/routes/auth.d.ts.map +1 -0
- package/dist/api/routes/auth.js +787 -0
- package/dist/api/routes/auth.js.map +1 -0
- package/dist/api/routes/collab.d.ts +3 -0
- package/dist/api/routes/collab.d.ts.map +1 -0
- package/dist/api/routes/collab.js +426 -0
- package/dist/api/routes/collab.js.map +1 -0
- package/dist/api/routes/dashboard.d.ts +7 -0
- package/dist/api/routes/dashboard.d.ts.map +1 -0
- package/dist/api/routes/dashboard.js +540 -0
- package/dist/api/routes/dashboard.js.map +1 -0
- package/dist/api/routes/documents.d.ts +3 -0
- package/dist/api/routes/documents.d.ts.map +1 -0
- package/dist/api/routes/documents.js +180 -0
- package/dist/api/routes/documents.js.map +1 -0
- package/dist/api/routes/folders.d.ts +3 -0
- package/dist/api/routes/folders.d.ts.map +1 -0
- package/dist/api/routes/folders.js +195 -0
- package/dist/api/routes/folders.js.map +1 -0
- package/dist/api/routes/forms.d.ts +3 -0
- package/dist/api/routes/forms.d.ts.map +1 -0
- package/dist/api/routes/forms.js +661 -0
- package/dist/api/routes/forms.js.map +1 -0
- package/dist/api/routes/globals.d.ts +3 -0
- package/dist/api/routes/globals.d.ts.map +1 -0
- package/dist/api/routes/globals.js +131 -0
- package/dist/api/routes/globals.js.map +1 -0
- package/dist/api/routes/media.d.ts +3 -0
- package/dist/api/routes/media.d.ts.map +1 -0
- package/dist/api/routes/media.js +521 -0
- package/dist/api/routes/media.js.map +1 -0
- package/dist/api/routes/meta.d.ts +3 -0
- package/dist/api/routes/meta.d.ts.map +1 -0
- package/dist/api/routes/meta.js +96 -0
- package/dist/api/routes/meta.js.map +1 -0
- package/dist/api/routes/page-templates.d.ts +3 -0
- package/dist/api/routes/page-templates.d.ts.map +1 -0
- package/dist/api/routes/page-templates.js +265 -0
- package/dist/api/routes/page-templates.js.map +1 -0
- package/dist/api/routes/presence.d.ts +3 -0
- package/dist/api/routes/presence.d.ts.map +1 -0
- package/dist/api/routes/presence.js +35 -0
- package/dist/api/routes/presence.js.map +1 -0
- package/dist/api/routes/preview.d.ts +3 -0
- package/dist/api/routes/preview.d.ts.map +1 -0
- package/dist/api/routes/preview.js +111 -0
- package/dist/api/routes/preview.js.map +1 -0
- package/dist/api/routes/redirects.d.ts +3 -0
- package/dist/api/routes/redirects.d.ts.map +1 -0
- package/dist/api/routes/redirects.js +790 -0
- package/dist/api/routes/redirects.js.map +1 -0
- package/dist/api/routes/saved-sections.d.ts +3 -0
- package/dist/api/routes/saved-sections.d.ts.map +1 -0
- package/dist/api/routes/saved-sections.js +1036 -0
- package/dist/api/routes/saved-sections.js.map +1 -0
- package/dist/api/routes/scheduling.d.ts +3 -0
- package/dist/api/routes/scheduling.d.ts.map +1 -0
- package/dist/api/routes/scheduling.js +373 -0
- package/dist/api/routes/scheduling.js.map +1 -0
- package/dist/api/routes/sections.d.ts +3 -0
- package/dist/api/routes/sections.d.ts.map +1 -0
- package/dist/api/routes/sections.js +500 -0
- package/dist/api/routes/sections.js.map +1 -0
- package/dist/api/routes/security-center.d.ts +3 -0
- package/dist/api/routes/security-center.d.ts.map +1 -0
- package/dist/api/routes/security-center.js +71 -0
- package/dist/api/routes/security-center.js.map +1 -0
- package/dist/api/routes/seo-public.d.ts +14 -0
- package/dist/api/routes/seo-public.d.ts.map +1 -0
- package/dist/api/routes/seo-public.js +930 -0
- package/dist/api/routes/seo-public.js.map +1 -0
- package/dist/api/routes/seo.d.ts +8 -0
- package/dist/api/routes/seo.d.ts.map +1 -0
- package/dist/api/routes/seo.js +848 -0
- package/dist/api/routes/seo.js.map +1 -0
- package/dist/api/routes/system.d.ts +3 -0
- package/dist/api/routes/system.d.ts.map +1 -0
- package/dist/api/routes/system.js +340 -0
- package/dist/api/routes/system.js.map +1 -0
- package/dist/api/routes/url-resolution.d.ts +3 -0
- package/dist/api/routes/url-resolution.d.ts.map +1 -0
- package/dist/api/routes/url-resolution.js +457 -0
- package/dist/api/routes/url-resolution.js.map +1 -0
- package/dist/api/routes/users.d.ts +3 -0
- package/dist/api/routes/users.d.ts.map +1 -0
- package/dist/api/routes/users.js +1162 -0
- package/dist/api/routes/users.js.map +1 -0
- package/dist/api/routes/webhooks.d.ts +3 -0
- package/dist/api/routes/webhooks.d.ts.map +1 -0
- package/dist/api/routes/webhooks.js +338 -0
- package/dist/api/routes/webhooks.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
import { getGlobal, updateGlobal } from '../../actions.js';
|
|
2
|
+
import { logEvent } from '../../security/audit.js';
|
|
3
|
+
import { ACTUATE_TAG_ROOT, actuateTag, cachedRead } from '../../cache/http.js';
|
|
4
|
+
import { getClientIp } from '../../security/client-ip.js';
|
|
5
|
+
import { getActuateConfig } from '../../config/runtime.js';
|
|
6
|
+
import { guardedDb } from '../guarded-db.js';
|
|
7
|
+
import { json, jsonWithCache, errorResponse, internalError, hasModel, requireAuth, requireGlobalScope, requireAdminScope, buildActionContext, ADMIN_ROLES, requireRole, diffScalarKeys, } from '../route-helpers.js';
|
|
8
|
+
export function registerAiSettingsRoutes(router) {
|
|
9
|
+
const db = guardedDb;
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Settings > AI — provider connections, dynamic model catalog, feature flags.
|
|
12
|
+
// Backed by the `__ai_config__` config document (see `../ai/config-store.ts`).
|
|
13
|
+
// ADMIN-gated. Provider API keys are encrypted at rest and NEVER returned.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
const AI_PROVIDER_KEYS = ['openai', 'anthropic', 'xai', 'custom'];
|
|
16
|
+
function isAiProviderKey(value) {
|
|
17
|
+
return typeof value === 'string' && AI_PROVIDER_KEYS.includes(value);
|
|
18
|
+
}
|
|
19
|
+
function defaultProviderName(provider) {
|
|
20
|
+
switch (provider) {
|
|
21
|
+
case 'openai':
|
|
22
|
+
return 'OpenAI';
|
|
23
|
+
case 'anthropic':
|
|
24
|
+
return 'Anthropic';
|
|
25
|
+
case 'xai':
|
|
26
|
+
return 'xAI (Grok)';
|
|
27
|
+
default:
|
|
28
|
+
return 'Custom provider';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
router.get('/ai/settings', async (request) => {
|
|
32
|
+
try {
|
|
33
|
+
const auth = await requireAuth(request);
|
|
34
|
+
if (auth.error)
|
|
35
|
+
return auth.error;
|
|
36
|
+
const adminErr = requireAdminScope(auth.session);
|
|
37
|
+
if (adminErr)
|
|
38
|
+
return adminErr;
|
|
39
|
+
const { getAIConfig, toPublicConfig, getModels, resolveModelOrUnavailable } = await import('../../ai/index.js');
|
|
40
|
+
const config = await getAIConfig(db());
|
|
41
|
+
const pub = toPublicConfig(config);
|
|
42
|
+
const catalog = await getModels(db());
|
|
43
|
+
const defaultModel = resolveModelOrUnavailable(config.settings.defaultModelId, catalog);
|
|
44
|
+
return json({
|
|
45
|
+
data: {
|
|
46
|
+
settings: pub.settings,
|
|
47
|
+
providers: pub.providers,
|
|
48
|
+
defaultModel,
|
|
49
|
+
updatedAt: config.updatedAt ?? null,
|
|
50
|
+
updatedBy: config.updatedBy ?? null,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
return internalError(err, 'ai/settings GET');
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
router.put('/ai/settings', async (request) => {
|
|
59
|
+
try {
|
|
60
|
+
const auth = await requireAuth(request);
|
|
61
|
+
if (auth.error)
|
|
62
|
+
return auth.error;
|
|
63
|
+
const adminErr = requireAdminScope(auth.session);
|
|
64
|
+
if (adminErr)
|
|
65
|
+
return adminErr;
|
|
66
|
+
const body = (await request.json().catch(() => null));
|
|
67
|
+
if (!body || typeof body !== 'object') {
|
|
68
|
+
return errorResponse('Request body must be an object', 400);
|
|
69
|
+
}
|
|
70
|
+
const { getAIConfig, putAIConfig, getModels, normalizeBrandVoice } = await import('../../ai/index.js');
|
|
71
|
+
const config = await getAIConfig(db());
|
|
72
|
+
const catalog = await getModels(db());
|
|
73
|
+
// Validate the selected default provider exists.
|
|
74
|
+
if (body.defaultProviderId &&
|
|
75
|
+
!config.providers.some((p) => p.id === body.defaultProviderId)) {
|
|
76
|
+
return errorResponse('Selected provider is not connected', 400);
|
|
77
|
+
}
|
|
78
|
+
// Validate the selected default model is in the catalog and available.
|
|
79
|
+
if (body.defaultModelId) {
|
|
80
|
+
const model = catalog.find((m) => m.id === body.defaultModelId || m.providerModelId === body.defaultModelId);
|
|
81
|
+
if (!model)
|
|
82
|
+
return errorResponse('Selected model is not in the catalog', 400);
|
|
83
|
+
if (model.status === 'unavailable') {
|
|
84
|
+
return errorResponse('Selected model is currently unavailable', 400);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Validate budget.
|
|
88
|
+
let monthlyTokenLimit = config.settings.budget.monthlyTokenLimit;
|
|
89
|
+
if (body.budget && 'monthlyTokenLimit' in body.budget) {
|
|
90
|
+
const v = body.budget.monthlyTokenLimit;
|
|
91
|
+
if (v === null)
|
|
92
|
+
monthlyTokenLimit = undefined;
|
|
93
|
+
else if (typeof v === 'number' && Number.isFinite(v) && v >= 0)
|
|
94
|
+
monthlyTokenLimit = v;
|
|
95
|
+
else
|
|
96
|
+
return errorResponse('monthlyTokenLimit must be a non-negative number or null', 400);
|
|
97
|
+
}
|
|
98
|
+
// Validate per-feature model/provider routing overrides. Empty string and
|
|
99
|
+
// null both clear the override (revert to the default model).
|
|
100
|
+
const featurePatches = body.features ?? [];
|
|
101
|
+
for (const patch of featurePatches) {
|
|
102
|
+
if (patch.modelId != null && patch.modelId !== '') {
|
|
103
|
+
const m = catalog.find((x) => x.id === patch.modelId || x.providerModelId === patch.modelId);
|
|
104
|
+
if (!m)
|
|
105
|
+
return errorResponse(`Model "${patch.modelId}" is not in the catalog`, 400);
|
|
106
|
+
if (m.status === 'unavailable') {
|
|
107
|
+
return errorResponse(`Model "${patch.modelId}" is currently unavailable`, 400);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (patch.providerId != null &&
|
|
111
|
+
patch.providerId !== '' &&
|
|
112
|
+
!config.providers.some((p) => p.id === patch.providerId)) {
|
|
113
|
+
return errorResponse(`Provider "${patch.providerId}" is not connected`, 400);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Apply feature patches by key (ignore unknown keys). Each patch may toggle
|
|
117
|
+
// `enabled` and/or set the per-feature model/provider routing override.
|
|
118
|
+
const patchByKey = new Map(featurePatches.map((f) => [f.key, f]));
|
|
119
|
+
const clearOverride = (v) => v == null || v === '' ? undefined : v;
|
|
120
|
+
const nextFeatures = config.settings.features.map((f) => {
|
|
121
|
+
const patch = patchByKey.get(f.key);
|
|
122
|
+
if (!patch)
|
|
123
|
+
return f;
|
|
124
|
+
const next = { ...f };
|
|
125
|
+
if (typeof patch.enabled === 'boolean')
|
|
126
|
+
next.enabled = patch.enabled;
|
|
127
|
+
if ('modelId' in patch)
|
|
128
|
+
next.modelId = clearOverride(patch.modelId);
|
|
129
|
+
if ('providerId' in patch)
|
|
130
|
+
next.providerId = clearOverride(patch.providerId);
|
|
131
|
+
return next;
|
|
132
|
+
});
|
|
133
|
+
const changedFeatures = config.settings.features
|
|
134
|
+
.map((f) => {
|
|
135
|
+
const patch = patchByKey.get(f.key);
|
|
136
|
+
if (!patch)
|
|
137
|
+
return null;
|
|
138
|
+
const enabledChanged = typeof patch.enabled === 'boolean' && patch.enabled !== f.enabled;
|
|
139
|
+
const nextModelId = 'modelId' in patch ? clearOverride(patch.modelId) : f.modelId;
|
|
140
|
+
const modelChanged = 'modelId' in patch && (nextModelId ?? null) !== (f.modelId ?? null);
|
|
141
|
+
if (!enabledChanged && !modelChanged)
|
|
142
|
+
return null;
|
|
143
|
+
return {
|
|
144
|
+
key: f.key,
|
|
145
|
+
...(enabledChanged ? { enabledFrom: f.enabled, enabledTo: patch.enabled } : {}),
|
|
146
|
+
...(modelChanged ? { modelFrom: f.modelId ?? null, modelTo: nextModelId ?? null } : {}),
|
|
147
|
+
};
|
|
148
|
+
})
|
|
149
|
+
.filter((x) => x !== null);
|
|
150
|
+
// Brand voice: `null` clears it, an object replaces it (normalized +
|
|
151
|
+
// length-clamped), omitted leaves the stored profile untouched.
|
|
152
|
+
let nextBrandVoice = config.settings.brandVoice;
|
|
153
|
+
if ('brandVoice' in body) {
|
|
154
|
+
if (body.brandVoice === null)
|
|
155
|
+
nextBrandVoice = undefined;
|
|
156
|
+
else if (body.brandVoice && typeof body.brandVoice === 'object') {
|
|
157
|
+
nextBrandVoice = {
|
|
158
|
+
...normalizeBrandVoice(body.brandVoice),
|
|
159
|
+
updatedAt: new Date().toISOString(),
|
|
160
|
+
updatedBy: auth.session.userId,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
return errorResponse('brandVoice must be an object or null', 400);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const nextSettings = {
|
|
168
|
+
...config.settings,
|
|
169
|
+
defaultProviderId: body.defaultProviderId === null
|
|
170
|
+
? undefined
|
|
171
|
+
: (body.defaultProviderId ?? config.settings.defaultProviderId),
|
|
172
|
+
defaultModelId: body.defaultModelId === null
|
|
173
|
+
? undefined
|
|
174
|
+
: (body.defaultModelId ?? config.settings.defaultModelId),
|
|
175
|
+
features: nextFeatures,
|
|
176
|
+
budget: { ...config.settings.budget, monthlyTokenLimit },
|
|
177
|
+
brandVoice: nextBrandVoice,
|
|
178
|
+
};
|
|
179
|
+
const saved = await putAIConfig(db(), { ...config, settings: nextSettings }, auth.session.userId);
|
|
180
|
+
try {
|
|
181
|
+
await logEvent({
|
|
182
|
+
event: 'update_ai_settings',
|
|
183
|
+
userId: auth.session.userId,
|
|
184
|
+
details: {
|
|
185
|
+
defaultProviderId: nextSettings.defaultProviderId ?? null,
|
|
186
|
+
defaultModelId: nextSettings.defaultModelId ?? null,
|
|
187
|
+
monthlyTokenLimit: monthlyTokenLimit ?? null,
|
|
188
|
+
changedFeatures,
|
|
189
|
+
brandVoiceEnabled: nextBrandVoice?.enabled ?? false,
|
|
190
|
+
source: 'settings.ai',
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// Audit must never block the write.
|
|
196
|
+
}
|
|
197
|
+
const { toPublicConfig } = await import('../../ai/index.js');
|
|
198
|
+
return json({ data: toPublicConfig(saved) });
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
return internalError(err, 'ai/settings PUT');
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
router.post('/ai/providers', async (request) => {
|
|
205
|
+
try {
|
|
206
|
+
const auth = await requireAuth(request);
|
|
207
|
+
if (auth.error)
|
|
208
|
+
return auth.error;
|
|
209
|
+
const adminErr = requireAdminScope(auth.session);
|
|
210
|
+
if (adminErr)
|
|
211
|
+
return adminErr;
|
|
212
|
+
const body = (await request.json().catch(() => null));
|
|
213
|
+
if (!body || !isAiProviderKey(body.provider)) {
|
|
214
|
+
return errorResponse('A valid `provider` is required', 400);
|
|
215
|
+
}
|
|
216
|
+
const provider = body.provider;
|
|
217
|
+
if (provider === 'custom') {
|
|
218
|
+
if (!body.baseUrl || typeof body.baseUrl !== 'string') {
|
|
219
|
+
return errorResponse('A `baseUrl` is required for a custom provider', 400);
|
|
220
|
+
}
|
|
221
|
+
const { validateWebhookUrl } = await import('../../security/webhook.js');
|
|
222
|
+
const check = validateWebhookUrl(body.baseUrl);
|
|
223
|
+
if (!check.valid)
|
|
224
|
+
return errorResponse(`Invalid baseUrl: ${check.error ?? 'unsafe'}`, 400);
|
|
225
|
+
}
|
|
226
|
+
const { getAIConfig, putAIConfig, encryptProviderKey, getEnvApiKey, resolveProviderKey, toPublicProvider, } = await import('../../ai/index.js');
|
|
227
|
+
const { testConnection } = await import('../../ai/providers/index.js');
|
|
228
|
+
const config = await getAIConfig(db());
|
|
229
|
+
const now = new Date().toISOString();
|
|
230
|
+
const envManaged = Boolean(getEnvApiKey(provider));
|
|
231
|
+
let apiKeyEncrypted;
|
|
232
|
+
let apiKeyLast4;
|
|
233
|
+
if (!envManaged && typeof body.apiKey === 'string' && body.apiKey.trim()) {
|
|
234
|
+
const enc = await encryptProviderKey(body.apiKey.trim());
|
|
235
|
+
apiKeyEncrypted = enc.apiKeyEncrypted;
|
|
236
|
+
apiKeyLast4 = enc.apiKeyLast4;
|
|
237
|
+
}
|
|
238
|
+
const connection = {
|
|
239
|
+
id: crypto.randomUUID(),
|
|
240
|
+
provider,
|
|
241
|
+
displayName: (typeof body.displayName === 'string' && body.displayName.trim()) ||
|
|
242
|
+
defaultProviderName(provider),
|
|
243
|
+
enabled: body.enabled !== false,
|
|
244
|
+
status: 'unknown',
|
|
245
|
+
apiKeyEncrypted,
|
|
246
|
+
apiKeyLast4,
|
|
247
|
+
baseUrl: provider === 'custom' ? body.baseUrl : undefined,
|
|
248
|
+
organizationId: body.organizationId?.trim() || undefined,
|
|
249
|
+
projectId: body.projectId?.trim() || undefined,
|
|
250
|
+
lastTestedAt: undefined,
|
|
251
|
+
createdAt: now,
|
|
252
|
+
updatedAt: now,
|
|
253
|
+
updatedBy: auth.session.userId,
|
|
254
|
+
};
|
|
255
|
+
// Optional test-before-save.
|
|
256
|
+
if (body.test) {
|
|
257
|
+
const key = await resolveProviderKey(connection);
|
|
258
|
+
const result = await testConnection(provider, {
|
|
259
|
+
apiKey: key ?? '',
|
|
260
|
+
baseUrl: connection.baseUrl,
|
|
261
|
+
organizationId: connection.organizationId,
|
|
262
|
+
projectId: connection.projectId,
|
|
263
|
+
});
|
|
264
|
+
connection.status = result.status;
|
|
265
|
+
connection.lastTestedAt = result.testedAt;
|
|
266
|
+
}
|
|
267
|
+
const saved = await putAIConfig(db(), { ...config, providers: [...config.providers, connection] }, auth.session.userId);
|
|
268
|
+
try {
|
|
269
|
+
await logEvent({
|
|
270
|
+
event: 'create_ai_provider',
|
|
271
|
+
userId: auth.session.userId,
|
|
272
|
+
details: {
|
|
273
|
+
providerId: connection.id,
|
|
274
|
+
provider,
|
|
275
|
+
envManaged,
|
|
276
|
+
tested: Boolean(body.test),
|
|
277
|
+
status: connection.status,
|
|
278
|
+
source: 'settings.ai',
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
/* never block on audit */
|
|
284
|
+
}
|
|
285
|
+
const created = saved.providers.find((p) => p.id === connection.id);
|
|
286
|
+
return json({ data: toPublicProvider(created) }, 201);
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
return internalError(err, 'ai/providers POST');
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
router.put('/ai/providers/:id', async (request, params) => {
|
|
293
|
+
try {
|
|
294
|
+
const auth = await requireAuth(request);
|
|
295
|
+
if (auth.error)
|
|
296
|
+
return auth.error;
|
|
297
|
+
const adminErr = requireAdminScope(auth.session);
|
|
298
|
+
if (adminErr)
|
|
299
|
+
return adminErr;
|
|
300
|
+
const body = (await request.json().catch(() => null));
|
|
301
|
+
if (!body || typeof body !== 'object') {
|
|
302
|
+
return errorResponse('Request body must be an object', 400);
|
|
303
|
+
}
|
|
304
|
+
const { getAIConfig, putAIConfig, encryptProviderKey, getEnvApiKey, toPublicProvider } = await import('../../ai/index.js');
|
|
305
|
+
const config = await getAIConfig(db());
|
|
306
|
+
const idx = config.providers.findIndex((p) => p.id === params.id);
|
|
307
|
+
if (idx === -1)
|
|
308
|
+
return errorResponse('Provider connection not found', 404);
|
|
309
|
+
const prev = config.providers[idx];
|
|
310
|
+
if (prev.provider === 'custom' && typeof body.baseUrl === 'string') {
|
|
311
|
+
const { validateWebhookUrl } = await import('../../security/webhook.js');
|
|
312
|
+
const check = validateWebhookUrl(body.baseUrl);
|
|
313
|
+
if (!check.valid)
|
|
314
|
+
return errorResponse(`Invalid baseUrl: ${check.error ?? 'unsafe'}`, 400);
|
|
315
|
+
}
|
|
316
|
+
const envManaged = Boolean(getEnvApiKey(prev.provider));
|
|
317
|
+
let { apiKeyEncrypted, apiKeyLast4 } = prev;
|
|
318
|
+
let keyChanged = false;
|
|
319
|
+
if (!envManaged && body.apiKey !== undefined) {
|
|
320
|
+
if (body.apiKey === null || body.apiKey === '') {
|
|
321
|
+
apiKeyEncrypted = undefined;
|
|
322
|
+
apiKeyLast4 = undefined;
|
|
323
|
+
keyChanged = true;
|
|
324
|
+
}
|
|
325
|
+
else if (typeof body.apiKey === 'string' && body.apiKey.trim()) {
|
|
326
|
+
const enc = await encryptProviderKey(body.apiKey.trim());
|
|
327
|
+
apiKeyEncrypted = enc.apiKeyEncrypted;
|
|
328
|
+
apiKeyLast4 = enc.apiKeyLast4;
|
|
329
|
+
keyChanged = true;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const updated = {
|
|
333
|
+
...prev,
|
|
334
|
+
displayName: typeof body.displayName === 'string' && body.displayName.trim()
|
|
335
|
+
? body.displayName.trim()
|
|
336
|
+
: prev.displayName,
|
|
337
|
+
enabled: typeof body.enabled === 'boolean' ? body.enabled : prev.enabled,
|
|
338
|
+
baseUrl: prev.provider === 'custom' && typeof body.baseUrl === 'string'
|
|
339
|
+
? body.baseUrl
|
|
340
|
+
: prev.baseUrl,
|
|
341
|
+
organizationId: body.organizationId === null
|
|
342
|
+
? undefined
|
|
343
|
+
: (body.organizationId?.trim() ?? prev.organizationId),
|
|
344
|
+
projectId: body.projectId === null ? undefined : (body.projectId?.trim() ?? prev.projectId),
|
|
345
|
+
apiKeyEncrypted,
|
|
346
|
+
apiKeyLast4,
|
|
347
|
+
// A new key invalidates the previous test result.
|
|
348
|
+
status: keyChanged ? 'unknown' : prev.status,
|
|
349
|
+
lastTestedAt: keyChanged ? undefined : prev.lastTestedAt,
|
|
350
|
+
updatedAt: new Date().toISOString(),
|
|
351
|
+
updatedBy: auth.session.userId,
|
|
352
|
+
};
|
|
353
|
+
const nextProviders = [...config.providers];
|
|
354
|
+
nextProviders[idx] = updated;
|
|
355
|
+
const saved = await putAIConfig(db(), { ...config, providers: nextProviders }, auth.session.userId);
|
|
356
|
+
try {
|
|
357
|
+
await logEvent({
|
|
358
|
+
event: 'update_ai_provider',
|
|
359
|
+
userId: auth.session.userId,
|
|
360
|
+
details: {
|
|
361
|
+
providerId: prev.id,
|
|
362
|
+
provider: prev.provider,
|
|
363
|
+
keyChanged,
|
|
364
|
+
source: 'settings.ai',
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
/* never block on audit */
|
|
370
|
+
}
|
|
371
|
+
const out = saved.providers.find((p) => p.id === prev.id);
|
|
372
|
+
return json({ data: toPublicProvider(out) });
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
return internalError(err, 'ai/providers PUT');
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
router.delete('/ai/providers/:id', async (request, params) => {
|
|
379
|
+
try {
|
|
380
|
+
const auth = await requireAuth(request);
|
|
381
|
+
if (auth.error)
|
|
382
|
+
return auth.error;
|
|
383
|
+
const adminErr = requireAdminScope(auth.session);
|
|
384
|
+
if (adminErr)
|
|
385
|
+
return adminErr;
|
|
386
|
+
const id = params.id;
|
|
387
|
+
if (!id)
|
|
388
|
+
return errorResponse('Provider connection not found', 404);
|
|
389
|
+
const { getAIConfig, putAIConfig } = await import('../../ai/index.js');
|
|
390
|
+
const config = await getAIConfig(db());
|
|
391
|
+
const exists = config.providers.some((p) => p.id === id);
|
|
392
|
+
if (!exists)
|
|
393
|
+
return errorResponse('Provider connection not found', 404);
|
|
394
|
+
const nextCatalog = { ...(config.catalog ?? {}) };
|
|
395
|
+
delete nextCatalog[id];
|
|
396
|
+
// Clear default selectors that pointed at the removed provider.
|
|
397
|
+
const clearedProvider = config.settings.defaultProviderId === id;
|
|
398
|
+
const nextSettings = clearedProvider
|
|
399
|
+
? { ...config.settings, defaultProviderId: undefined, defaultModelId: undefined }
|
|
400
|
+
: config.settings;
|
|
401
|
+
await putAIConfig(db(), {
|
|
402
|
+
...config,
|
|
403
|
+
providers: config.providers.filter((p) => p.id !== id),
|
|
404
|
+
catalog: nextCatalog,
|
|
405
|
+
settings: nextSettings,
|
|
406
|
+
}, auth.session.userId);
|
|
407
|
+
try {
|
|
408
|
+
await logEvent({
|
|
409
|
+
event: 'delete_ai_provider',
|
|
410
|
+
userId: auth.session.userId,
|
|
411
|
+
details: { providerId: id, source: 'settings.ai' },
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
/* never block on audit */
|
|
416
|
+
}
|
|
417
|
+
return json({ data: { id: params.id, deleted: true } });
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
return internalError(err, 'ai/providers DELETE');
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
router.post('/ai/providers/:id/test', async (request, params) => {
|
|
424
|
+
try {
|
|
425
|
+
const auth = await requireAuth(request);
|
|
426
|
+
if (auth.error)
|
|
427
|
+
return auth.error;
|
|
428
|
+
const adminErr = requireAdminScope(auth.session);
|
|
429
|
+
if (adminErr)
|
|
430
|
+
return adminErr;
|
|
431
|
+
const { getAIConfig, putAIConfig, resolveProviderKey, toPublicProvider } = await import('../../ai/index.js');
|
|
432
|
+
const { testConnection } = await import('../../ai/providers/index.js');
|
|
433
|
+
const config = await getAIConfig(db());
|
|
434
|
+
const idx = config.providers.findIndex((p) => p.id === params.id);
|
|
435
|
+
if (idx === -1)
|
|
436
|
+
return errorResponse('Provider connection not found', 404);
|
|
437
|
+
const conn = config.providers[idx];
|
|
438
|
+
const key = await resolveProviderKey(conn);
|
|
439
|
+
const result = await testConnection(conn.provider, {
|
|
440
|
+
apiKey: key ?? '',
|
|
441
|
+
baseUrl: conn.baseUrl,
|
|
442
|
+
organizationId: conn.organizationId,
|
|
443
|
+
projectId: conn.projectId,
|
|
444
|
+
});
|
|
445
|
+
const updated = { ...conn, status: result.status, lastTestedAt: result.testedAt };
|
|
446
|
+
const nextProviders = [...config.providers];
|
|
447
|
+
nextProviders[idx] = updated;
|
|
448
|
+
const saved = await putAIConfig(db(), { ...config, providers: nextProviders }, auth.session.userId);
|
|
449
|
+
try {
|
|
450
|
+
await logEvent({
|
|
451
|
+
event: 'test_ai_provider',
|
|
452
|
+
userId: auth.session.userId,
|
|
453
|
+
details: {
|
|
454
|
+
providerId: conn.id,
|
|
455
|
+
provider: conn.provider,
|
|
456
|
+
status: result.status,
|
|
457
|
+
source: 'settings.ai',
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
/* never block on audit */
|
|
463
|
+
}
|
|
464
|
+
const out = saved.providers.find((p) => p.id === conn.id);
|
|
465
|
+
return json({ data: { provider: toPublicProvider(out), result } });
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
return internalError(err, 'ai/providers test');
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
router.post('/ai/providers/:id/sync-models', async (request, params) => {
|
|
472
|
+
try {
|
|
473
|
+
const auth = await requireAuth(request);
|
|
474
|
+
if (auth.error)
|
|
475
|
+
return auth.error;
|
|
476
|
+
const adminErr = requireAdminScope(auth.session);
|
|
477
|
+
if (adminErr)
|
|
478
|
+
return adminErr;
|
|
479
|
+
const id = params.id;
|
|
480
|
+
if (!id)
|
|
481
|
+
return errorResponse('Provider connection not found', 404);
|
|
482
|
+
const { syncModels } = await import('../../ai/index.js');
|
|
483
|
+
const result = await syncModels(db(), id).catch((err) => {
|
|
484
|
+
if (err instanceof Error && /not found/i.test(err.message))
|
|
485
|
+
return null;
|
|
486
|
+
throw err;
|
|
487
|
+
});
|
|
488
|
+
if (!result)
|
|
489
|
+
return errorResponse('Provider connection not found', 404);
|
|
490
|
+
try {
|
|
491
|
+
await logEvent({
|
|
492
|
+
event: 'sync_ai_models',
|
|
493
|
+
userId: auth.session.userId,
|
|
494
|
+
details: {
|
|
495
|
+
providerId: id,
|
|
496
|
+
modelCount: result.models.length,
|
|
497
|
+
fromFallback: result.fromFallback,
|
|
498
|
+
source: 'settings.ai',
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
/* never block on audit */
|
|
504
|
+
}
|
|
505
|
+
return json({
|
|
506
|
+
data: {
|
|
507
|
+
models: result.models,
|
|
508
|
+
fromFallback: result.fromFallback,
|
|
509
|
+
warning: result.warning ?? null,
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
catch (err) {
|
|
514
|
+
return internalError(err, 'ai/providers sync-models');
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
router.get('/ai/models', async (request) => {
|
|
518
|
+
try {
|
|
519
|
+
const auth = await requireAuth(request);
|
|
520
|
+
if (auth.error)
|
|
521
|
+
return auth.error;
|
|
522
|
+
const adminErr = requireAdminScope(auth.session);
|
|
523
|
+
if (adminErr)
|
|
524
|
+
return adminErr;
|
|
525
|
+
const url = new URL(request.url);
|
|
526
|
+
const connectionId = url.searchParams.get('connectionId') ?? undefined;
|
|
527
|
+
const provider = url.searchParams.get('provider') ?? undefined;
|
|
528
|
+
const { getModels } = await import('../../ai/index.js');
|
|
529
|
+
const models = await getModels(db(), { connectionId, provider });
|
|
530
|
+
return json({ data: { models } });
|
|
531
|
+
}
|
|
532
|
+
catch (err) {
|
|
533
|
+
return internalError(err, 'ai/models GET');
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
router.get('/ai/usage/summary', async (request) => {
|
|
537
|
+
try {
|
|
538
|
+
const auth = await requireAuth(request);
|
|
539
|
+
if (auth.error)
|
|
540
|
+
return auth.error;
|
|
541
|
+
const adminErr = requireAdminScope(auth.session);
|
|
542
|
+
if (adminErr)
|
|
543
|
+
return adminErr;
|
|
544
|
+
const { getUsageSummary } = await import('../../ai/index.js');
|
|
545
|
+
const summary = await getUsageSummary(db());
|
|
546
|
+
return json({ data: summary });
|
|
547
|
+
}
|
|
548
|
+
catch (err) {
|
|
549
|
+
return internalError(err, 'ai/usage/summary GET');
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
router.put('/security', async (request) => {
|
|
553
|
+
try {
|
|
554
|
+
const auth = await requireAuth(request);
|
|
555
|
+
if (auth.error)
|
|
556
|
+
return auth.error;
|
|
557
|
+
const adminErr = requireAdminScope(auth.session);
|
|
558
|
+
if (adminErr)
|
|
559
|
+
return adminErr;
|
|
560
|
+
const body = (await request.json());
|
|
561
|
+
const { parseSecuritySettings, validateSecuritySettings, mfaEnableLockoutError } = await import('../../security/center/settings.js');
|
|
562
|
+
const { getStoredSecuritySettings, getSecurityCenterData } = await import('../../security/center/gather.js');
|
|
563
|
+
const incoming = parseSecuritySettings({ security: body });
|
|
564
|
+
const issues = validateSecuritySettings(incoming);
|
|
565
|
+
const errors = issues.filter((i) => i.severity === 'error');
|
|
566
|
+
if (errors.length > 0) {
|
|
567
|
+
return json({ error: errors[0].message, issues }, 400);
|
|
568
|
+
}
|
|
569
|
+
const d = db();
|
|
570
|
+
const current = await getStoredSecuritySettings(d);
|
|
571
|
+
// Lockout-safe: enabling org-wide MFA requires the acting admin to have
|
|
572
|
+
// their own MFA configured. Enforced server-side — never trust the client.
|
|
573
|
+
const enabling = incoming.mfa.required && !current.mfa.required;
|
|
574
|
+
if (enabling) {
|
|
575
|
+
let viewerHasMfa = false;
|
|
576
|
+
let totalAdmins = 0;
|
|
577
|
+
if (hasModel(d, 'user')) {
|
|
578
|
+
const viewer = await d.user
|
|
579
|
+
.findUnique({ where: { id: auth.session.userId }, select: { totpEnabled: true } })
|
|
580
|
+
.catch(() => null);
|
|
581
|
+
viewerHasMfa = Boolean(viewer?.totpEnabled);
|
|
582
|
+
totalAdmins = await d.user
|
|
583
|
+
.count({ where: { role: 'ADMIN', isActive: true } })
|
|
584
|
+
.catch(() => 0);
|
|
585
|
+
}
|
|
586
|
+
const lockErr = mfaEnableLockoutError({ enabling: true, viewerHasMfa, totalAdmins });
|
|
587
|
+
if (lockErr) {
|
|
588
|
+
return json({ error: lockErr, code: 'MFA_LOCKOUT_RISK' }, 409);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
// Stamp `requiredAt` when MFA requirement flips on (drives the grace
|
|
592
|
+
// window); preserve it across saves while it stays on; clear when off.
|
|
593
|
+
const requiredAt = incoming.mfa.required
|
|
594
|
+
? current.mfa.required
|
|
595
|
+
? current.mfa.requiredAt
|
|
596
|
+
: new Date().toISOString()
|
|
597
|
+
: undefined;
|
|
598
|
+
const securityBlob = {
|
|
599
|
+
mfa: {
|
|
600
|
+
required: incoming.mfa.required,
|
|
601
|
+
gracePeriodDays: incoming.mfa.gracePeriodDays,
|
|
602
|
+
...(requiredAt ? { requiredAt } : {}),
|
|
603
|
+
},
|
|
604
|
+
session: { maxConcurrentSessions: incoming.session.maxConcurrentSessions },
|
|
605
|
+
updatedAt: new Date().toISOString(),
|
|
606
|
+
updatedBy: auth.session.userId,
|
|
607
|
+
};
|
|
608
|
+
const ctx = buildActionContext(auth.session, d);
|
|
609
|
+
const prevGlobal = (await getGlobal('settings', ctx).catch(() => ({}))) ?? {};
|
|
610
|
+
await updateGlobal('settings', { ...prevGlobal, security: securityBlob }, ctx);
|
|
611
|
+
const changes = [];
|
|
612
|
+
if (current.mfa.required !== incoming.mfa.required)
|
|
613
|
+
changes.push({ key: 'mfa.required', from: current.mfa.required, to: incoming.mfa.required });
|
|
614
|
+
if (current.mfa.gracePeriodDays !== incoming.mfa.gracePeriodDays)
|
|
615
|
+
changes.push({
|
|
616
|
+
key: 'mfa.gracePeriodDays',
|
|
617
|
+
from: current.mfa.gracePeriodDays,
|
|
618
|
+
to: incoming.mfa.gracePeriodDays,
|
|
619
|
+
});
|
|
620
|
+
if (current.session.maxConcurrentSessions !== incoming.session.maxConcurrentSessions)
|
|
621
|
+
changes.push({
|
|
622
|
+
key: 'session.maxConcurrentSessions',
|
|
623
|
+
from: current.session.maxConcurrentSessions,
|
|
624
|
+
to: incoming.session.maxConcurrentSessions,
|
|
625
|
+
});
|
|
626
|
+
if (changes.length > 0) {
|
|
627
|
+
void logEvent({
|
|
628
|
+
event: 'security_settings_changed',
|
|
629
|
+
userId: auth.session.userId,
|
|
630
|
+
ipAddress: getClientIp(request),
|
|
631
|
+
details: {
|
|
632
|
+
source: 'database',
|
|
633
|
+
environment: process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? null,
|
|
634
|
+
changes,
|
|
635
|
+
},
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
const data = await getSecurityCenterData({
|
|
639
|
+
db: d,
|
|
640
|
+
userId: auth.session.userId,
|
|
641
|
+
currentIp: getClientIp(request),
|
|
642
|
+
});
|
|
643
|
+
return json({ data });
|
|
644
|
+
}
|
|
645
|
+
catch (err) {
|
|
646
|
+
return internalError(err, 'security PUT');
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
router.post('/security/sessions/revoke-all', async (request) => {
|
|
650
|
+
try {
|
|
651
|
+
const auth = await requireAuth(request);
|
|
652
|
+
if (auth.error)
|
|
653
|
+
return auth.error;
|
|
654
|
+
const d = db();
|
|
655
|
+
if (!hasModel(d, 'session'))
|
|
656
|
+
return json({ data: { revoked: 0 } });
|
|
657
|
+
// Revoke every OTHER active session for the caller — their current
|
|
658
|
+
// session stays valid so they aren't logged out of the admin they're in.
|
|
659
|
+
const result = await d.session.updateMany({
|
|
660
|
+
where: {
|
|
661
|
+
userId: auth.session.userId,
|
|
662
|
+
id: { not: auth.session.sessionId },
|
|
663
|
+
revokedAt: null,
|
|
664
|
+
},
|
|
665
|
+
data: { revokedAt: new Date() },
|
|
666
|
+
});
|
|
667
|
+
const revoked = typeof result?.count === 'number' ? result.count : 0;
|
|
668
|
+
void logEvent({
|
|
669
|
+
event: 'sessions_revoked_all',
|
|
670
|
+
userId: auth.session.userId,
|
|
671
|
+
ipAddress: getClientIp(request),
|
|
672
|
+
details: { count: revoked, scope: 'self_other' },
|
|
673
|
+
});
|
|
674
|
+
return json({ data: { revoked } });
|
|
675
|
+
}
|
|
676
|
+
catch (err) {
|
|
677
|
+
return internalError(err, 'security/sessions/revoke-all');
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
router.get('/globals/:slug', async (request, params) => {
|
|
681
|
+
try {
|
|
682
|
+
const auth = await requireAuth(request);
|
|
683
|
+
if (auth.error)
|
|
684
|
+
return auth.error;
|
|
685
|
+
const scopeErr = requireGlobalScope(auth.session, params.slug);
|
|
686
|
+
if (scopeErr)
|
|
687
|
+
return scopeErr;
|
|
688
|
+
return cachedRead(request, {
|
|
689
|
+
cache: getActuateConfig()?.cache,
|
|
690
|
+
tags: [ACTUATE_TAG_ROOT, actuateTag('global', params.slug)],
|
|
691
|
+
wrap: jsonWithCache,
|
|
692
|
+
build: async () => {
|
|
693
|
+
const ctx = buildActionContext(auth.session, db());
|
|
694
|
+
const global = await getGlobal(params.slug, ctx);
|
|
695
|
+
// Globals are upsert-on-write: a slug with no row yet (fresh install,
|
|
696
|
+
// never-saved settings) is a valid EMPTY state, not an error. Return
|
|
697
|
+
// {} so the admin editor (e.g. the Settings page) loads cleanly before
|
|
698
|
+
// the first save, and the next PUT creates the document. The public
|
|
699
|
+
// `/public/globals/:slug` route keeps its config-gated 404 for
|
|
700
|
+
// genuinely undeclared globals.
|
|
701
|
+
return { data: global ?? {} };
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
catch (err) {
|
|
706
|
+
return internalError(err);
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
router.put('/globals/:slug', async (request, params) => {
|
|
710
|
+
try {
|
|
711
|
+
const auth = await requireAuth(request);
|
|
712
|
+
if (auth.error)
|
|
713
|
+
return auth.error;
|
|
714
|
+
const scopeErr = requireGlobalScope(auth.session, params.slug);
|
|
715
|
+
if (scopeErr)
|
|
716
|
+
return scopeErr;
|
|
717
|
+
if (!auth.session.apiKey) {
|
|
718
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
719
|
+
if (roleErr)
|
|
720
|
+
return roleErr;
|
|
721
|
+
}
|
|
722
|
+
const body = (await request.json());
|
|
723
|
+
const ctx = buildActionContext(auth.session, db());
|
|
724
|
+
const previous = await getGlobal(params.slug, ctx).catch(() => null);
|
|
725
|
+
const global = await updateGlobal(params.slug, body, ctx);
|
|
726
|
+
// Audit settings changes (title/tagline/URL/locale/timezone/etc.) with a
|
|
727
|
+
// per-key before/after diff so the audit trail records what changed and
|
|
728
|
+
// who changed it. Scalar values only — we never log nested secret blobs.
|
|
729
|
+
// Nested config blobs (`appearance`, `brand`) are logged as a changed
|
|
730
|
+
// key (not their contents) so appearance/branding edits are auditable
|
|
731
|
+
// without dumping asset IDs or theme objects into the log.
|
|
732
|
+
try {
|
|
733
|
+
const changes = diffScalarKeys(previous ?? {}, body);
|
|
734
|
+
const prev = (previous ?? {});
|
|
735
|
+
for (const key of ['appearance', 'brand', 'security']) {
|
|
736
|
+
if (key in body && JSON.stringify(prev[key]) !== JSON.stringify(body[key])) {
|
|
737
|
+
changes.push({ key, from: '[changed]', to: '[changed]' });
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (changes.length > 0) {
|
|
741
|
+
void logEvent({
|
|
742
|
+
event: 'settings_changed',
|
|
743
|
+
userId: auth.session.userId,
|
|
744
|
+
ipAddress: getClientIp(request),
|
|
745
|
+
details: {
|
|
746
|
+
global: params.slug,
|
|
747
|
+
source: 'database',
|
|
748
|
+
environment: process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? null,
|
|
749
|
+
changes,
|
|
750
|
+
},
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
catch {
|
|
755
|
+
// Audit failures must never block the write.
|
|
756
|
+
}
|
|
757
|
+
return json({ data: global });
|
|
758
|
+
}
|
|
759
|
+
catch (err) {
|
|
760
|
+
return internalError(err);
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
//# sourceMappingURL=ai-settings.js.map
|