@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.
- package/dist/__tests__/ai/ai.test.d.ts +2 -0
- package/dist/__tests__/ai/ai.test.d.ts.map +1 -0
- package/dist/__tests__/ai/ai.test.js +261 -0
- package/dist/__tests__/ai/ai.test.js.map +1 -0
- package/dist/__tests__/seo/settings-defaults.test.d.ts +2 -0
- package/dist/__tests__/seo/settings-defaults.test.d.ts.map +1 -0
- package/dist/__tests__/seo/settings-defaults.test.js +189 -0
- package/dist/__tests__/seo/settings-defaults.test.js.map +1 -0
- package/dist/__tests__/seo/sitemap-notify.test.d.ts +2 -0
- package/dist/__tests__/seo/sitemap-notify.test.d.ts.map +1 -0
- package/dist/__tests__/seo/sitemap-notify.test.js +191 -0
- package/dist/__tests__/seo/sitemap-notify.test.js.map +1 -0
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +80 -3
- package/dist/actions.js.map +1 -1
- package/dist/ai/catalog.d.ts +29 -0
- package/dist/ai/catalog.d.ts.map +1 -0
- package/dist/ai/catalog.js +340 -0
- package/dist/ai/catalog.js.map +1 -0
- package/dist/ai/config-store.d.ts +55 -0
- package/dist/ai/config-store.d.ts.map +1 -0
- package/dist/ai/config-store.js +177 -0
- package/dist/ai/config-store.js.map +1 -0
- package/dist/ai/feature-defaults.d.ts +20 -0
- package/dist/ai/feature-defaults.d.ts.map +1 -0
- package/dist/ai/feature-defaults.js +104 -0
- package/dist/ai/feature-defaults.js.map +1 -0
- package/dist/ai/index.d.ts +13 -0
- package/dist/ai/index.d.ts.map +1 -0
- package/dist/ai/index.js +13 -0
- package/dist/ai/index.js.map +1 -0
- package/dist/ai/models.d.ts +37 -0
- package/dist/ai/models.d.ts.map +1 -0
- package/dist/ai/models.js +126 -0
- package/dist/ai/models.js.map +1 -0
- package/dist/ai/providers/anthropic.d.ts +9 -0
- package/dist/ai/providers/anthropic.d.ts.map +1 -0
- package/dist/ai/providers/anthropic.js +36 -0
- package/dist/ai/providers/anthropic.js.map +1 -0
- package/dist/ai/providers/custom.d.ts +11 -0
- package/dist/ai/providers/custom.d.ts.map +1 -0
- package/dist/ai/providers/custom.js +70 -0
- package/dist/ai/providers/custom.js.map +1 -0
- package/dist/ai/providers/http.d.ts +32 -0
- package/dist/ai/providers/http.d.ts.map +1 -0
- package/dist/ai/providers/http.js +90 -0
- package/dist/ai/providers/http.js.map +1 -0
- package/dist/ai/providers/index.d.ts +16 -0
- package/dist/ai/providers/index.d.ts.map +1 -0
- package/dist/ai/providers/index.js +25 -0
- package/dist/ai/providers/index.js.map +1 -0
- package/dist/ai/providers/openai.d.ts +10 -0
- package/dist/ai/providers/openai.d.ts.map +1 -0
- package/dist/ai/providers/openai.js +41 -0
- package/dist/ai/providers/openai.js.map +1 -0
- package/dist/ai/providers/xai.d.ts +9 -0
- package/dist/ai/providers/xai.d.ts.map +1 -0
- package/dist/ai/providers/xai.js +35 -0
- package/dist/ai/providers/xai.js.map +1 -0
- package/dist/ai/types.d.ts +161 -0
- package/dist/ai/types.d.ts.map +1 -0
- package/dist/ai/types.js +11 -0
- package/dist/ai/types.js.map +1 -0
- package/dist/ai/usage.d.ts +18 -0
- package/dist/ai/usage.d.ts.map +1 -0
- package/dist/ai/usage.js +70 -0
- package/dist/ai/usage.js.map +1 -0
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +539 -10
- package/dist/api/handlers.js.map +1 -1
- package/dist/config/types.d.ts +49 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/page-builder/schema.d.ts +2 -2
- package/dist/seo/config-store.d.ts +2 -0
- package/dist/seo/config-store.d.ts.map +1 -1
- package/dist/seo/config-store.js +2 -0
- package/dist/seo/config-store.js.map +1 -1
- package/dist/seo/index.d.ts +6 -4
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +3 -2
- package/dist/seo/index.js.map +1 -1
- package/dist/seo/meta-tags.d.ts +36 -0
- package/dist/seo/meta-tags.d.ts.map +1 -1
- package/dist/seo/meta-tags.js +78 -0
- package/dist/seo/meta-tags.js.map +1 -1
- package/dist/seo/page-meta.d.ts.map +1 -1
- package/dist/seo/page-meta.js +47 -8
- package/dist/seo/page-meta.js.map +1 -1
- package/dist/seo/sitemap-notify.d.ts +85 -0
- package/dist/seo/sitemap-notify.d.ts.map +1 -0
- package/dist/seo/sitemap-notify.js +159 -0
- package/dist/seo/sitemap-notify.js.map +1 -0
- package/dist/seo/title-templates.d.ts +26 -0
- package/dist/seo/title-templates.d.ts.map +1 -1
- package/dist/seo/title-templates.js +83 -0
- package/dist/seo/title-templates.js.map +1 -1
- package/package.json +1 -1
package/dist/api/handlers.js
CHANGED
|
@@ -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
|
|
8149
|
-
|
|
8150
|
-
|
|
8151
|
-
|
|
8152
|
-
|
|
8153
|
-
|
|
8154
|
-
|
|
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: {
|
|
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);
|