@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.
Files changed (118) hide show
  1. package/dist/__tests__/api/stats-seo-cache.test.js +1 -1
  2. package/dist/__tests__/api/stats-seo-cache.test.js.map +1 -1
  3. package/dist/api/guarded-db.d.ts +13 -0
  4. package/dist/api/guarded-db.d.ts.map +1 -0
  5. package/dist/api/guarded-db.js +58 -0
  6. package/dist/api/guarded-db.js.map +1 -0
  7. package/dist/api/handler-factory.d.ts.map +1 -1
  8. package/dist/api/handler-factory.js +2 -1
  9. package/dist/api/handler-factory.js.map +1 -1
  10. package/dist/api/handlers.d.ts +5 -3
  11. package/dist/api/handlers.d.ts.map +1 -1
  12. package/dist/api/handlers.js +54 -13068
  13. package/dist/api/handlers.js.map +1 -1
  14. package/dist/api/route-helpers.d.ts +320 -0
  15. package/dist/api/route-helpers.d.ts.map +1 -0
  16. package/dist/api/route-helpers.js +1307 -0
  17. package/dist/api/route-helpers.js.map +1 -0
  18. package/dist/api/routes/ai-seo.d.ts +3 -0
  19. package/dist/api/routes/ai-seo.d.ts.map +1 -0
  20. package/dist/api/routes/ai-seo.js +377 -0
  21. package/dist/api/routes/ai-seo.js.map +1 -0
  22. package/dist/api/routes/ai-settings.d.ts +3 -0
  23. package/dist/api/routes/ai-settings.d.ts.map +1 -0
  24. package/dist/api/routes/ai-settings.js +764 -0
  25. package/dist/api/routes/ai-settings.js.map +1 -0
  26. package/dist/api/routes/auth.d.ts +3 -0
  27. package/dist/api/routes/auth.d.ts.map +1 -0
  28. package/dist/api/routes/auth.js +787 -0
  29. package/dist/api/routes/auth.js.map +1 -0
  30. package/dist/api/routes/collab.d.ts +3 -0
  31. package/dist/api/routes/collab.d.ts.map +1 -0
  32. package/dist/api/routes/collab.js +426 -0
  33. package/dist/api/routes/collab.js.map +1 -0
  34. package/dist/api/routes/dashboard.d.ts +7 -0
  35. package/dist/api/routes/dashboard.d.ts.map +1 -0
  36. package/dist/api/routes/dashboard.js +540 -0
  37. package/dist/api/routes/dashboard.js.map +1 -0
  38. package/dist/api/routes/documents.d.ts +3 -0
  39. package/dist/api/routes/documents.d.ts.map +1 -0
  40. package/dist/api/routes/documents.js +180 -0
  41. package/dist/api/routes/documents.js.map +1 -0
  42. package/dist/api/routes/folders.d.ts +3 -0
  43. package/dist/api/routes/folders.d.ts.map +1 -0
  44. package/dist/api/routes/folders.js +195 -0
  45. package/dist/api/routes/folders.js.map +1 -0
  46. package/dist/api/routes/forms.d.ts +3 -0
  47. package/dist/api/routes/forms.d.ts.map +1 -0
  48. package/dist/api/routes/forms.js +661 -0
  49. package/dist/api/routes/forms.js.map +1 -0
  50. package/dist/api/routes/globals.d.ts +3 -0
  51. package/dist/api/routes/globals.d.ts.map +1 -0
  52. package/dist/api/routes/globals.js +131 -0
  53. package/dist/api/routes/globals.js.map +1 -0
  54. package/dist/api/routes/media.d.ts +3 -0
  55. package/dist/api/routes/media.d.ts.map +1 -0
  56. package/dist/api/routes/media.js +521 -0
  57. package/dist/api/routes/media.js.map +1 -0
  58. package/dist/api/routes/meta.d.ts +3 -0
  59. package/dist/api/routes/meta.d.ts.map +1 -0
  60. package/dist/api/routes/meta.js +96 -0
  61. package/dist/api/routes/meta.js.map +1 -0
  62. package/dist/api/routes/page-templates.d.ts +3 -0
  63. package/dist/api/routes/page-templates.d.ts.map +1 -0
  64. package/dist/api/routes/page-templates.js +265 -0
  65. package/dist/api/routes/page-templates.js.map +1 -0
  66. package/dist/api/routes/presence.d.ts +3 -0
  67. package/dist/api/routes/presence.d.ts.map +1 -0
  68. package/dist/api/routes/presence.js +35 -0
  69. package/dist/api/routes/presence.js.map +1 -0
  70. package/dist/api/routes/preview.d.ts +3 -0
  71. package/dist/api/routes/preview.d.ts.map +1 -0
  72. package/dist/api/routes/preview.js +111 -0
  73. package/dist/api/routes/preview.js.map +1 -0
  74. package/dist/api/routes/redirects.d.ts +3 -0
  75. package/dist/api/routes/redirects.d.ts.map +1 -0
  76. package/dist/api/routes/redirects.js +790 -0
  77. package/dist/api/routes/redirects.js.map +1 -0
  78. package/dist/api/routes/saved-sections.d.ts +3 -0
  79. package/dist/api/routes/saved-sections.d.ts.map +1 -0
  80. package/dist/api/routes/saved-sections.js +1036 -0
  81. package/dist/api/routes/saved-sections.js.map +1 -0
  82. package/dist/api/routes/scheduling.d.ts +3 -0
  83. package/dist/api/routes/scheduling.d.ts.map +1 -0
  84. package/dist/api/routes/scheduling.js +373 -0
  85. package/dist/api/routes/scheduling.js.map +1 -0
  86. package/dist/api/routes/sections.d.ts +3 -0
  87. package/dist/api/routes/sections.d.ts.map +1 -0
  88. package/dist/api/routes/sections.js +500 -0
  89. package/dist/api/routes/sections.js.map +1 -0
  90. package/dist/api/routes/security-center.d.ts +3 -0
  91. package/dist/api/routes/security-center.d.ts.map +1 -0
  92. package/dist/api/routes/security-center.js +71 -0
  93. package/dist/api/routes/security-center.js.map +1 -0
  94. package/dist/api/routes/seo-public.d.ts +14 -0
  95. package/dist/api/routes/seo-public.d.ts.map +1 -0
  96. package/dist/api/routes/seo-public.js +930 -0
  97. package/dist/api/routes/seo-public.js.map +1 -0
  98. package/dist/api/routes/seo.d.ts +8 -0
  99. package/dist/api/routes/seo.d.ts.map +1 -0
  100. package/dist/api/routes/seo.js +848 -0
  101. package/dist/api/routes/seo.js.map +1 -0
  102. package/dist/api/routes/system.d.ts +3 -0
  103. package/dist/api/routes/system.d.ts.map +1 -0
  104. package/dist/api/routes/system.js +340 -0
  105. package/dist/api/routes/system.js.map +1 -0
  106. package/dist/api/routes/url-resolution.d.ts +3 -0
  107. package/dist/api/routes/url-resolution.d.ts.map +1 -0
  108. package/dist/api/routes/url-resolution.js +457 -0
  109. package/dist/api/routes/url-resolution.js.map +1 -0
  110. package/dist/api/routes/users.d.ts +3 -0
  111. package/dist/api/routes/users.d.ts.map +1 -0
  112. package/dist/api/routes/users.js +1162 -0
  113. package/dist/api/routes/users.js.map +1 -0
  114. package/dist/api/routes/webhooks.d.ts +3 -0
  115. package/dist/api/routes/webhooks.d.ts.map +1 -0
  116. package/dist/api/routes/webhooks.js +338 -0
  117. package/dist/api/routes/webhooks.js.map +1 -0
  118. 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