@actuate-media/cms-core 0.41.0 → 0.43.0

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 (101) hide show
  1. package/dist/__tests__/ai/ai.test.d.ts +2 -0
  2. package/dist/__tests__/ai/ai.test.d.ts.map +1 -0
  3. package/dist/__tests__/ai/ai.test.js +261 -0
  4. package/dist/__tests__/ai/ai.test.js.map +1 -0
  5. package/dist/__tests__/seo/settings-defaults.test.d.ts +2 -0
  6. package/dist/__tests__/seo/settings-defaults.test.d.ts.map +1 -0
  7. package/dist/__tests__/seo/settings-defaults.test.js +189 -0
  8. package/dist/__tests__/seo/settings-defaults.test.js.map +1 -0
  9. package/dist/__tests__/seo/sitemap-notify.test.d.ts +2 -0
  10. package/dist/__tests__/seo/sitemap-notify.test.d.ts.map +1 -0
  11. package/dist/__tests__/seo/sitemap-notify.test.js +191 -0
  12. package/dist/__tests__/seo/sitemap-notify.test.js.map +1 -0
  13. package/dist/actions.d.ts.map +1 -1
  14. package/dist/actions.js +80 -3
  15. package/dist/actions.js.map +1 -1
  16. package/dist/ai/catalog.d.ts +29 -0
  17. package/dist/ai/catalog.d.ts.map +1 -0
  18. package/dist/ai/catalog.js +340 -0
  19. package/dist/ai/catalog.js.map +1 -0
  20. package/dist/ai/config-store.d.ts +55 -0
  21. package/dist/ai/config-store.d.ts.map +1 -0
  22. package/dist/ai/config-store.js +177 -0
  23. package/dist/ai/config-store.js.map +1 -0
  24. package/dist/ai/feature-defaults.d.ts +20 -0
  25. package/dist/ai/feature-defaults.d.ts.map +1 -0
  26. package/dist/ai/feature-defaults.js +104 -0
  27. package/dist/ai/feature-defaults.js.map +1 -0
  28. package/dist/ai/index.d.ts +13 -0
  29. package/dist/ai/index.d.ts.map +1 -0
  30. package/dist/ai/index.js +13 -0
  31. package/dist/ai/index.js.map +1 -0
  32. package/dist/ai/models.d.ts +37 -0
  33. package/dist/ai/models.d.ts.map +1 -0
  34. package/dist/ai/models.js +126 -0
  35. package/dist/ai/models.js.map +1 -0
  36. package/dist/ai/providers/anthropic.d.ts +9 -0
  37. package/dist/ai/providers/anthropic.d.ts.map +1 -0
  38. package/dist/ai/providers/anthropic.js +36 -0
  39. package/dist/ai/providers/anthropic.js.map +1 -0
  40. package/dist/ai/providers/custom.d.ts +11 -0
  41. package/dist/ai/providers/custom.d.ts.map +1 -0
  42. package/dist/ai/providers/custom.js +70 -0
  43. package/dist/ai/providers/custom.js.map +1 -0
  44. package/dist/ai/providers/http.d.ts +32 -0
  45. package/dist/ai/providers/http.d.ts.map +1 -0
  46. package/dist/ai/providers/http.js +90 -0
  47. package/dist/ai/providers/http.js.map +1 -0
  48. package/dist/ai/providers/index.d.ts +16 -0
  49. package/dist/ai/providers/index.d.ts.map +1 -0
  50. package/dist/ai/providers/index.js +25 -0
  51. package/dist/ai/providers/index.js.map +1 -0
  52. package/dist/ai/providers/openai.d.ts +10 -0
  53. package/dist/ai/providers/openai.d.ts.map +1 -0
  54. package/dist/ai/providers/openai.js +41 -0
  55. package/dist/ai/providers/openai.js.map +1 -0
  56. package/dist/ai/providers/xai.d.ts +9 -0
  57. package/dist/ai/providers/xai.d.ts.map +1 -0
  58. package/dist/ai/providers/xai.js +35 -0
  59. package/dist/ai/providers/xai.js.map +1 -0
  60. package/dist/ai/types.d.ts +161 -0
  61. package/dist/ai/types.d.ts.map +1 -0
  62. package/dist/ai/types.js +11 -0
  63. package/dist/ai/types.js.map +1 -0
  64. package/dist/ai/usage.d.ts +18 -0
  65. package/dist/ai/usage.d.ts.map +1 -0
  66. package/dist/ai/usage.js +70 -0
  67. package/dist/ai/usage.js.map +1 -0
  68. package/dist/api/handlers.d.ts.map +1 -1
  69. package/dist/api/handlers.js +539 -10
  70. package/dist/api/handlers.js.map +1 -1
  71. package/dist/config/types.d.ts +49 -0
  72. package/dist/config/types.d.ts.map +1 -1
  73. package/dist/index.d.ts +3 -0
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +4 -0
  76. package/dist/index.js.map +1 -1
  77. package/dist/page-builder/schema.d.ts +2 -2
  78. package/dist/seo/config-store.d.ts +2 -0
  79. package/dist/seo/config-store.d.ts.map +1 -1
  80. package/dist/seo/config-store.js +2 -0
  81. package/dist/seo/config-store.js.map +1 -1
  82. package/dist/seo/index.d.ts +6 -4
  83. package/dist/seo/index.d.ts.map +1 -1
  84. package/dist/seo/index.js +3 -2
  85. package/dist/seo/index.js.map +1 -1
  86. package/dist/seo/meta-tags.d.ts +36 -0
  87. package/dist/seo/meta-tags.d.ts.map +1 -1
  88. package/dist/seo/meta-tags.js +78 -0
  89. package/dist/seo/meta-tags.js.map +1 -1
  90. package/dist/seo/page-meta.d.ts.map +1 -1
  91. package/dist/seo/page-meta.js +47 -8
  92. package/dist/seo/page-meta.js.map +1 -1
  93. package/dist/seo/sitemap-notify.d.ts +85 -0
  94. package/dist/seo/sitemap-notify.d.ts.map +1 -0
  95. package/dist/seo/sitemap-notify.js +159 -0
  96. package/dist/seo/sitemap-notify.js.map +1 -0
  97. package/dist/seo/title-templates.d.ts +26 -0
  98. package/dist/seo/title-templates.d.ts.map +1 -1
  99. package/dist/seo/title-templates.js +83 -0
  100. package/dist/seo/title-templates.js.map +1 -1
  101. package/package.json +1 -1
@@ -8128,6 +8128,15 @@ export function registerCMSRoutes(router) {
8128
8128
  if (site === undefined && collections === undefined) {
8129
8129
  return errorResponse('Body must include at least one of `site` or `collections`', 400);
8130
8130
  }
8131
+ // Validate the title template server-side so an unsupported token can't be
8132
+ // persisted and then silently break public title rendering.
8133
+ if (site && typeof site.titleTemplate === 'string') {
8134
+ const { validateTitleTemplate } = await import('../seo/title-templates.js');
8135
+ const result = validateTitleTemplate(site.titleTemplate);
8136
+ if (!result.valid) {
8137
+ return errorResponse(result.errors.join(' '), 400);
8138
+ }
8139
+ }
8131
8140
  const { putSeoOverrides, getSeoOverrides } = await import('../seo/config-store.js');
8132
8141
  // Merge with whatever's currently stored so the UI can PATCH a single
8133
8142
  // section without wiping the other — `PUT /seo/config { site: {...} }`
@@ -8143,16 +8152,39 @@ export function registerCMSRoutes(router) {
8143
8152
  module: current.module,
8144
8153
  };
8145
8154
  const saved = await putSeoOverrides(db(), next, auth.session.userId);
8146
- // Audit so changes to public SEO surface are traceable.
8155
+ // Audit so changes to public SEO surface are traceable. We log a
8156
+ // field-level diff (previous → new) for the site defaults, but redact
8157
+ // verification token VALUES — only record which providers changed, never
8158
+ // the token itself, since those land in public <meta> tags and shouldn't
8159
+ // be duplicated into the audit trail.
8147
8160
  try {
8148
- await db().auditLog?.create?.({
8149
- data: {
8150
- action: 'update_seo_config',
8151
- actorId: auth.session.userId,
8152
- metadata: {
8153
- siteKeys: Object.keys(site ?? {}),
8154
- collectionKeys: Object.keys(collections ?? {}),
8155
- },
8161
+ const { resolveSeoEnvironment } = await import('../seo/robots.js');
8162
+ const prevSite = (current.site ?? {});
8163
+ const nextSite = (site ?? prevSite);
8164
+ const changedFields = [];
8165
+ for (const key of new Set([...Object.keys(prevSite), ...Object.keys(nextSite)])) {
8166
+ if (key === 'verification')
8167
+ continue;
8168
+ if (JSON.stringify(prevSite[key]) !== JSON.stringify(nextSite[key])) {
8169
+ changedFields.push({
8170
+ field: key,
8171
+ from: prevSite[key] ?? null,
8172
+ to: nextSite[key] ?? null,
8173
+ });
8174
+ }
8175
+ }
8176
+ const prevVer = (prevSite.verification ?? {});
8177
+ const nextVer = (nextSite.verification ?? {});
8178
+ const verificationChanged = Object.keys({ ...prevVer, ...nextVer }).filter((k) => JSON.stringify(prevVer[k]) !== JSON.stringify(nextVer[k]));
8179
+ await logEvent({
8180
+ event: 'update_seo_config',
8181
+ userId: auth.session.userId,
8182
+ details: {
8183
+ changedFields,
8184
+ verificationChanged,
8185
+ collectionKeys: Object.keys(collections ?? {}),
8186
+ environment: resolveSeoEnvironment(),
8187
+ source: 'settings.seo',
8156
8188
  },
8157
8189
  });
8158
8190
  }
@@ -8316,6 +8348,9 @@ export function registerCMSRoutes(router) {
8316
8348
  includePostsInSitemap: s.includePostsInSitemap,
8317
8349
  noindexPaginatedArchives: s.noindexPaginatedArchives,
8318
8350
  noindexTagPages: s.noindexTagPages,
8351
+ sitemapEnabled: s.sitemapEnabled,
8352
+ autoRegenerateSitemapOnPublish: s.autoRegenerateSitemapOnPublish,
8353
+ pingSearchEnginesOnPublish: s.pingSearchEnginesOnPublish,
8319
8354
  },
8320
8355
  });
8321
8356
  }
@@ -8341,6 +8376,9 @@ export function registerCMSRoutes(router) {
8341
8376
  'includePostsInSitemap',
8342
8377
  'noindexPaginatedArchives',
8343
8378
  'noindexTagPages',
8379
+ 'sitemapEnabled',
8380
+ 'autoRegenerateSitemapOnPublish',
8381
+ 'pingSearchEnginesOnPublish',
8344
8382
  ];
8345
8383
  const patch = {};
8346
8384
  for (const key of allowed) {
@@ -8350,12 +8388,26 @@ export function registerCMSRoutes(router) {
8350
8388
  if (Object.keys(patch).length === 0) {
8351
8389
  return errorResponse('No valid crawl settings provided', 400);
8352
8390
  }
8391
+ // Snapshot current values so the audit trail records prev → new.
8392
+ const { getSeoOverrides, resolveSeoModuleSettings } = await import('../seo/config-store.js');
8393
+ const { resolveSeoEnvironment } = await import('../seo/robots.js');
8394
+ const before = resolveSeoModuleSettings(await getSeoOverrides(db()));
8353
8395
  await patchSeoModule(patch, auth.session.userId);
8354
8396
  try {
8397
+ const changedFields = Object.keys(patch).map((key) => ({
8398
+ field: key,
8399
+ from: before[key] ?? null,
8400
+ to: patch[key],
8401
+ }));
8355
8402
  await logEvent({
8356
8403
  event: 'update_seo_config',
8357
8404
  userId: auth.session.userId,
8358
- details: { section: 'crawl-settings', keys: Object.keys(patch) },
8405
+ details: {
8406
+ section: 'crawl-settings',
8407
+ changedFields,
8408
+ environment: resolveSeoEnvironment(),
8409
+ source: 'settings.seo',
8410
+ },
8359
8411
  });
8360
8412
  }
8361
8413
  catch {
@@ -10021,6 +10073,483 @@ export function registerCMSRoutes(router) {
10021
10073
  return internalError(err, 'ai status');
10022
10074
  }
10023
10075
  });
10076
+ // ---------------------------------------------------------------------------
10077
+ // Settings > AI — provider connections, dynamic model catalog, feature flags.
10078
+ // Backed by the `__ai_config__` config document (see `../ai/config-store.ts`).
10079
+ // ADMIN-gated. Provider API keys are encrypted at rest and NEVER returned.
10080
+ // ---------------------------------------------------------------------------
10081
+ const AI_PROVIDER_KEYS = ['openai', 'anthropic', 'xai', 'custom'];
10082
+ function isAiProviderKey(value) {
10083
+ return typeof value === 'string' && AI_PROVIDER_KEYS.includes(value);
10084
+ }
10085
+ function defaultProviderName(provider) {
10086
+ switch (provider) {
10087
+ case 'openai':
10088
+ return 'OpenAI';
10089
+ case 'anthropic':
10090
+ return 'Anthropic';
10091
+ case 'xai':
10092
+ return 'xAI (Grok)';
10093
+ default:
10094
+ return 'Custom provider';
10095
+ }
10096
+ }
10097
+ router.get('/ai/settings', async (request) => {
10098
+ try {
10099
+ const auth = await requireAuth(request);
10100
+ if (auth.error)
10101
+ return auth.error;
10102
+ const adminErr = requireAdminScope(auth.session);
10103
+ if (adminErr)
10104
+ return adminErr;
10105
+ const { getAIConfig, toPublicConfig, getModels, resolveModelOrUnavailable } = await import('../ai/index.js');
10106
+ const config = await getAIConfig(db());
10107
+ const pub = toPublicConfig(config);
10108
+ const catalog = await getModels(db());
10109
+ const defaultModel = resolveModelOrUnavailable(config.settings.defaultModelId, catalog);
10110
+ return json({
10111
+ data: {
10112
+ settings: pub.settings,
10113
+ providers: pub.providers,
10114
+ defaultModel,
10115
+ updatedAt: config.updatedAt ?? null,
10116
+ updatedBy: config.updatedBy ?? null,
10117
+ },
10118
+ });
10119
+ }
10120
+ catch (err) {
10121
+ return internalError(err, 'ai/settings GET');
10122
+ }
10123
+ });
10124
+ router.put('/ai/settings', async (request) => {
10125
+ try {
10126
+ const auth = await requireAuth(request);
10127
+ if (auth.error)
10128
+ return auth.error;
10129
+ const adminErr = requireAdminScope(auth.session);
10130
+ if (adminErr)
10131
+ return adminErr;
10132
+ const body = (await request.json().catch(() => null));
10133
+ if (!body || typeof body !== 'object') {
10134
+ return errorResponse('Request body must be an object', 400);
10135
+ }
10136
+ const { getAIConfig, putAIConfig, getModels } = await import('../ai/index.js');
10137
+ const config = await getAIConfig(db());
10138
+ const catalog = await getModels(db());
10139
+ // Validate the selected default provider exists.
10140
+ if (body.defaultProviderId &&
10141
+ !config.providers.some((p) => p.id === body.defaultProviderId)) {
10142
+ return errorResponse('Selected provider is not connected', 400);
10143
+ }
10144
+ // Validate the selected default model is in the catalog and available.
10145
+ if (body.defaultModelId) {
10146
+ const model = catalog.find((m) => m.id === body.defaultModelId || m.providerModelId === body.defaultModelId);
10147
+ if (!model)
10148
+ return errorResponse('Selected model is not in the catalog', 400);
10149
+ if (model.status === 'unavailable') {
10150
+ return errorResponse('Selected model is currently unavailable', 400);
10151
+ }
10152
+ }
10153
+ // Validate budget.
10154
+ let monthlyTokenLimit = config.settings.budget.monthlyTokenLimit;
10155
+ if (body.budget && 'monthlyTokenLimit' in body.budget) {
10156
+ const v = body.budget.monthlyTokenLimit;
10157
+ if (v === null)
10158
+ monthlyTokenLimit = undefined;
10159
+ else if (typeof v === 'number' && Number.isFinite(v) && v >= 0)
10160
+ monthlyTokenLimit = v;
10161
+ else
10162
+ return errorResponse('monthlyTokenLimit must be a non-negative number or null', 400);
10163
+ }
10164
+ // Apply feature toggles by key (ignore unknown keys).
10165
+ const toggles = new Map((body.features ?? []).map((f) => [f.key, Boolean(f.enabled)]));
10166
+ const nextFeatures = config.settings.features.map((f) => toggles.has(f.key) ? { ...f, enabled: toggles.get(f.key) } : f);
10167
+ const changedFeatures = config.settings.features
10168
+ .filter((f) => toggles.has(f.key) && toggles.get(f.key) !== f.enabled)
10169
+ .map((f) => ({ key: f.key, from: f.enabled, to: toggles.get(f.key) }));
10170
+ const nextSettings = {
10171
+ ...config.settings,
10172
+ defaultProviderId: body.defaultProviderId === null
10173
+ ? undefined
10174
+ : (body.defaultProviderId ?? config.settings.defaultProviderId),
10175
+ defaultModelId: body.defaultModelId === null
10176
+ ? undefined
10177
+ : (body.defaultModelId ?? config.settings.defaultModelId),
10178
+ features: nextFeatures,
10179
+ budget: { ...config.settings.budget, monthlyTokenLimit },
10180
+ };
10181
+ const saved = await putAIConfig(db(), { ...config, settings: nextSettings }, auth.session.userId);
10182
+ try {
10183
+ await logEvent({
10184
+ event: 'update_ai_settings',
10185
+ userId: auth.session.userId,
10186
+ details: {
10187
+ defaultProviderId: nextSettings.defaultProviderId ?? null,
10188
+ defaultModelId: nextSettings.defaultModelId ?? null,
10189
+ monthlyTokenLimit: monthlyTokenLimit ?? null,
10190
+ changedFeatures,
10191
+ source: 'settings.ai',
10192
+ },
10193
+ });
10194
+ }
10195
+ catch {
10196
+ // Audit must never block the write.
10197
+ }
10198
+ const { toPublicConfig } = await import('../ai/index.js');
10199
+ return json({ data: toPublicConfig(saved) });
10200
+ }
10201
+ catch (err) {
10202
+ return internalError(err, 'ai/settings PUT');
10203
+ }
10204
+ });
10205
+ router.post('/ai/providers', async (request) => {
10206
+ try {
10207
+ const auth = await requireAuth(request);
10208
+ if (auth.error)
10209
+ return auth.error;
10210
+ const adminErr = requireAdminScope(auth.session);
10211
+ if (adminErr)
10212
+ return adminErr;
10213
+ const body = (await request.json().catch(() => null));
10214
+ if (!body || !isAiProviderKey(body.provider)) {
10215
+ return errorResponse('A valid `provider` is required', 400);
10216
+ }
10217
+ const provider = body.provider;
10218
+ if (provider === 'custom') {
10219
+ if (!body.baseUrl || typeof body.baseUrl !== 'string') {
10220
+ return errorResponse('A `baseUrl` is required for a custom provider', 400);
10221
+ }
10222
+ const { validateWebhookUrl } = await import('../security/webhook.js');
10223
+ const check = validateWebhookUrl(body.baseUrl);
10224
+ if (!check.valid)
10225
+ return errorResponse(`Invalid baseUrl: ${check.error ?? 'unsafe'}`, 400);
10226
+ }
10227
+ const { getAIConfig, putAIConfig, encryptProviderKey, getEnvApiKey, resolveProviderKey, toPublicProvider, } = await import('../ai/index.js');
10228
+ const { testConnection } = await import('../ai/providers/index.js');
10229
+ const config = await getAIConfig(db());
10230
+ const now = new Date().toISOString();
10231
+ const envManaged = Boolean(getEnvApiKey(provider));
10232
+ let apiKeyEncrypted;
10233
+ let apiKeyLast4;
10234
+ if (!envManaged && typeof body.apiKey === 'string' && body.apiKey.trim()) {
10235
+ const enc = await encryptProviderKey(body.apiKey.trim());
10236
+ apiKeyEncrypted = enc.apiKeyEncrypted;
10237
+ apiKeyLast4 = enc.apiKeyLast4;
10238
+ }
10239
+ const connection = {
10240
+ id: crypto.randomUUID(),
10241
+ provider,
10242
+ displayName: (typeof body.displayName === 'string' && body.displayName.trim()) ||
10243
+ defaultProviderName(provider),
10244
+ enabled: body.enabled !== false,
10245
+ status: 'unknown',
10246
+ apiKeyEncrypted,
10247
+ apiKeyLast4,
10248
+ baseUrl: provider === 'custom' ? body.baseUrl : undefined,
10249
+ organizationId: body.organizationId?.trim() || undefined,
10250
+ projectId: body.projectId?.trim() || undefined,
10251
+ lastTestedAt: undefined,
10252
+ createdAt: now,
10253
+ updatedAt: now,
10254
+ updatedBy: auth.session.userId,
10255
+ };
10256
+ // Optional test-before-save.
10257
+ if (body.test) {
10258
+ const key = await resolveProviderKey(connection);
10259
+ const result = await testConnection(provider, {
10260
+ apiKey: key ?? '',
10261
+ baseUrl: connection.baseUrl,
10262
+ organizationId: connection.organizationId,
10263
+ projectId: connection.projectId,
10264
+ });
10265
+ connection.status = result.status;
10266
+ connection.lastTestedAt = result.testedAt;
10267
+ }
10268
+ const saved = await putAIConfig(db(), { ...config, providers: [...config.providers, connection] }, auth.session.userId);
10269
+ try {
10270
+ await logEvent({
10271
+ event: 'create_ai_provider',
10272
+ userId: auth.session.userId,
10273
+ details: {
10274
+ providerId: connection.id,
10275
+ provider,
10276
+ envManaged,
10277
+ tested: Boolean(body.test),
10278
+ status: connection.status,
10279
+ source: 'settings.ai',
10280
+ },
10281
+ });
10282
+ }
10283
+ catch {
10284
+ /* never block on audit */
10285
+ }
10286
+ const created = saved.providers.find((p) => p.id === connection.id);
10287
+ return json({ data: toPublicProvider(created) }, 201);
10288
+ }
10289
+ catch (err) {
10290
+ return internalError(err, 'ai/providers POST');
10291
+ }
10292
+ });
10293
+ router.put('/ai/providers/:id', async (request, params) => {
10294
+ try {
10295
+ const auth = await requireAuth(request);
10296
+ if (auth.error)
10297
+ return auth.error;
10298
+ const adminErr = requireAdminScope(auth.session);
10299
+ if (adminErr)
10300
+ return adminErr;
10301
+ const body = (await request.json().catch(() => null));
10302
+ if (!body || typeof body !== 'object') {
10303
+ return errorResponse('Request body must be an object', 400);
10304
+ }
10305
+ const { getAIConfig, putAIConfig, encryptProviderKey, getEnvApiKey, toPublicProvider } = await import('../ai/index.js');
10306
+ const config = await getAIConfig(db());
10307
+ const idx = config.providers.findIndex((p) => p.id === params.id);
10308
+ if (idx === -1)
10309
+ return errorResponse('Provider connection not found', 404);
10310
+ const prev = config.providers[idx];
10311
+ if (prev.provider === 'custom' && typeof body.baseUrl === 'string') {
10312
+ const { validateWebhookUrl } = await import('../security/webhook.js');
10313
+ const check = validateWebhookUrl(body.baseUrl);
10314
+ if (!check.valid)
10315
+ return errorResponse(`Invalid baseUrl: ${check.error ?? 'unsafe'}`, 400);
10316
+ }
10317
+ const envManaged = Boolean(getEnvApiKey(prev.provider));
10318
+ let { apiKeyEncrypted, apiKeyLast4 } = prev;
10319
+ let keyChanged = false;
10320
+ if (!envManaged && body.apiKey !== undefined) {
10321
+ if (body.apiKey === null || body.apiKey === '') {
10322
+ apiKeyEncrypted = undefined;
10323
+ apiKeyLast4 = undefined;
10324
+ keyChanged = true;
10325
+ }
10326
+ else if (typeof body.apiKey === 'string' && body.apiKey.trim()) {
10327
+ const enc = await encryptProviderKey(body.apiKey.trim());
10328
+ apiKeyEncrypted = enc.apiKeyEncrypted;
10329
+ apiKeyLast4 = enc.apiKeyLast4;
10330
+ keyChanged = true;
10331
+ }
10332
+ }
10333
+ const updated = {
10334
+ ...prev,
10335
+ displayName: typeof body.displayName === 'string' && body.displayName.trim()
10336
+ ? body.displayName.trim()
10337
+ : prev.displayName,
10338
+ enabled: typeof body.enabled === 'boolean' ? body.enabled : prev.enabled,
10339
+ baseUrl: prev.provider === 'custom' && typeof body.baseUrl === 'string'
10340
+ ? body.baseUrl
10341
+ : prev.baseUrl,
10342
+ organizationId: body.organizationId === null
10343
+ ? undefined
10344
+ : (body.organizationId?.trim() ?? prev.organizationId),
10345
+ projectId: body.projectId === null ? undefined : (body.projectId?.trim() ?? prev.projectId),
10346
+ apiKeyEncrypted,
10347
+ apiKeyLast4,
10348
+ // A new key invalidates the previous test result.
10349
+ status: keyChanged ? 'unknown' : prev.status,
10350
+ lastTestedAt: keyChanged ? undefined : prev.lastTestedAt,
10351
+ updatedAt: new Date().toISOString(),
10352
+ updatedBy: auth.session.userId,
10353
+ };
10354
+ const nextProviders = [...config.providers];
10355
+ nextProviders[idx] = updated;
10356
+ const saved = await putAIConfig(db(), { ...config, providers: nextProviders }, auth.session.userId);
10357
+ try {
10358
+ await logEvent({
10359
+ event: 'update_ai_provider',
10360
+ userId: auth.session.userId,
10361
+ details: {
10362
+ providerId: prev.id,
10363
+ provider: prev.provider,
10364
+ keyChanged,
10365
+ source: 'settings.ai',
10366
+ },
10367
+ });
10368
+ }
10369
+ catch {
10370
+ /* never block on audit */
10371
+ }
10372
+ const out = saved.providers.find((p) => p.id === prev.id);
10373
+ return json({ data: toPublicProvider(out) });
10374
+ }
10375
+ catch (err) {
10376
+ return internalError(err, 'ai/providers PUT');
10377
+ }
10378
+ });
10379
+ router.delete('/ai/providers/:id', async (request, params) => {
10380
+ try {
10381
+ const auth = await requireAuth(request);
10382
+ if (auth.error)
10383
+ return auth.error;
10384
+ const adminErr = requireAdminScope(auth.session);
10385
+ if (adminErr)
10386
+ return adminErr;
10387
+ const id = params.id;
10388
+ if (!id)
10389
+ return errorResponse('Provider connection not found', 404);
10390
+ const { getAIConfig, putAIConfig } = await import('../ai/index.js');
10391
+ const config = await getAIConfig(db());
10392
+ const exists = config.providers.some((p) => p.id === id);
10393
+ if (!exists)
10394
+ return errorResponse('Provider connection not found', 404);
10395
+ const nextCatalog = { ...(config.catalog ?? {}) };
10396
+ delete nextCatalog[id];
10397
+ // Clear default selectors that pointed at the removed provider.
10398
+ const clearedProvider = config.settings.defaultProviderId === id;
10399
+ const nextSettings = clearedProvider
10400
+ ? { ...config.settings, defaultProviderId: undefined, defaultModelId: undefined }
10401
+ : config.settings;
10402
+ await putAIConfig(db(), {
10403
+ ...config,
10404
+ providers: config.providers.filter((p) => p.id !== id),
10405
+ catalog: nextCatalog,
10406
+ settings: nextSettings,
10407
+ }, auth.session.userId);
10408
+ try {
10409
+ await logEvent({
10410
+ event: 'delete_ai_provider',
10411
+ userId: auth.session.userId,
10412
+ details: { providerId: id, source: 'settings.ai' },
10413
+ });
10414
+ }
10415
+ catch {
10416
+ /* never block on audit */
10417
+ }
10418
+ return json({ data: { id: params.id, deleted: true } });
10419
+ }
10420
+ catch (err) {
10421
+ return internalError(err, 'ai/providers DELETE');
10422
+ }
10423
+ });
10424
+ router.post('/ai/providers/:id/test', async (request, params) => {
10425
+ try {
10426
+ const auth = await requireAuth(request);
10427
+ if (auth.error)
10428
+ return auth.error;
10429
+ const adminErr = requireAdminScope(auth.session);
10430
+ if (adminErr)
10431
+ return adminErr;
10432
+ const { getAIConfig, putAIConfig, resolveProviderKey, toPublicProvider } = await import('../ai/index.js');
10433
+ const { testConnection } = await import('../ai/providers/index.js');
10434
+ const config = await getAIConfig(db());
10435
+ const idx = config.providers.findIndex((p) => p.id === params.id);
10436
+ if (idx === -1)
10437
+ return errorResponse('Provider connection not found', 404);
10438
+ const conn = config.providers[idx];
10439
+ const key = await resolveProviderKey(conn);
10440
+ const result = await testConnection(conn.provider, {
10441
+ apiKey: key ?? '',
10442
+ baseUrl: conn.baseUrl,
10443
+ organizationId: conn.organizationId,
10444
+ projectId: conn.projectId,
10445
+ });
10446
+ const updated = { ...conn, status: result.status, lastTestedAt: result.testedAt };
10447
+ const nextProviders = [...config.providers];
10448
+ nextProviders[idx] = updated;
10449
+ const saved = await putAIConfig(db(), { ...config, providers: nextProviders }, auth.session.userId);
10450
+ try {
10451
+ await logEvent({
10452
+ event: 'test_ai_provider',
10453
+ userId: auth.session.userId,
10454
+ details: {
10455
+ providerId: conn.id,
10456
+ provider: conn.provider,
10457
+ status: result.status,
10458
+ source: 'settings.ai',
10459
+ },
10460
+ });
10461
+ }
10462
+ catch {
10463
+ /* never block on audit */
10464
+ }
10465
+ const out = saved.providers.find((p) => p.id === conn.id);
10466
+ return json({ data: { provider: toPublicProvider(out), result } });
10467
+ }
10468
+ catch (err) {
10469
+ return internalError(err, 'ai/providers test');
10470
+ }
10471
+ });
10472
+ router.post('/ai/providers/:id/sync-models', async (request, params) => {
10473
+ try {
10474
+ const auth = await requireAuth(request);
10475
+ if (auth.error)
10476
+ return auth.error;
10477
+ const adminErr = requireAdminScope(auth.session);
10478
+ if (adminErr)
10479
+ return adminErr;
10480
+ const id = params.id;
10481
+ if (!id)
10482
+ return errorResponse('Provider connection not found', 404);
10483
+ const { syncModels } = await import('../ai/index.js');
10484
+ const result = await syncModels(db(), id).catch((err) => {
10485
+ if (err instanceof Error && /not found/i.test(err.message))
10486
+ return null;
10487
+ throw err;
10488
+ });
10489
+ if (!result)
10490
+ return errorResponse('Provider connection not found', 404);
10491
+ try {
10492
+ await logEvent({
10493
+ event: 'sync_ai_models',
10494
+ userId: auth.session.userId,
10495
+ details: {
10496
+ providerId: id,
10497
+ modelCount: result.models.length,
10498
+ fromFallback: result.fromFallback,
10499
+ source: 'settings.ai',
10500
+ },
10501
+ });
10502
+ }
10503
+ catch {
10504
+ /* never block on audit */
10505
+ }
10506
+ return json({
10507
+ data: {
10508
+ models: result.models,
10509
+ fromFallback: result.fromFallback,
10510
+ warning: result.warning ?? null,
10511
+ },
10512
+ });
10513
+ }
10514
+ catch (err) {
10515
+ return internalError(err, 'ai/providers sync-models');
10516
+ }
10517
+ });
10518
+ router.get('/ai/models', async (request) => {
10519
+ try {
10520
+ const auth = await requireAuth(request);
10521
+ if (auth.error)
10522
+ return auth.error;
10523
+ const adminErr = requireAdminScope(auth.session);
10524
+ if (adminErr)
10525
+ return adminErr;
10526
+ const url = new URL(request.url);
10527
+ const connectionId = url.searchParams.get('connectionId') ?? undefined;
10528
+ const provider = url.searchParams.get('provider') ?? undefined;
10529
+ const { getModels } = await import('../ai/index.js');
10530
+ const models = await getModels(db(), { connectionId, provider });
10531
+ return json({ data: { models } });
10532
+ }
10533
+ catch (err) {
10534
+ return internalError(err, 'ai/models GET');
10535
+ }
10536
+ });
10537
+ router.get('/ai/usage/summary', async (request) => {
10538
+ try {
10539
+ const auth = await requireAuth(request);
10540
+ if (auth.error)
10541
+ return auth.error;
10542
+ const adminErr = requireAdminScope(auth.session);
10543
+ if (adminErr)
10544
+ return adminErr;
10545
+ const { getUsageSummary } = await import('../ai/index.js');
10546
+ const summary = await getUsageSummary(db());
10547
+ return json({ data: summary });
10548
+ }
10549
+ catch (err) {
10550
+ return internalError(err, 'ai/usage/summary GET');
10551
+ }
10552
+ });
10024
10553
  router.put('/security', async (request) => {
10025
10554
  try {
10026
10555
  const auth = await requireAuth(request);