@actuate-media/cms-core 0.12.0 → 0.14.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/LICENSE +21 -21
- package/dist/__tests__/api/api-key-auth.test.d.ts +2 -0
- package/dist/__tests__/api/api-key-auth.test.d.ts.map +1 -0
- package/dist/__tests__/api/api-key-auth.test.js +217 -0
- package/dist/__tests__/api/api-key-auth.test.js.map +1 -0
- package/dist/__tests__/api/health.test.d.ts +2 -0
- package/dist/__tests__/api/health.test.d.ts.map +1 -0
- package/dist/__tests__/api/health.test.js +140 -0
- package/dist/__tests__/api/health.test.js.map +1 -0
- package/dist/__tests__/auth/oauth.test.d.ts +2 -0
- package/dist/__tests__/auth/oauth.test.d.ts.map +1 -0
- package/dist/__tests__/auth/oauth.test.js +406 -0
- package/dist/__tests__/auth/oauth.test.js.map +1 -0
- package/dist/__tests__/auth/reset.test.d.ts +2 -0
- package/dist/__tests__/auth/reset.test.d.ts.map +1 -0
- package/dist/__tests__/auth/reset.test.js +303 -0
- package/dist/__tests__/auth/reset.test.js.map +1 -0
- package/dist/__tests__/diagnostics/env.test.d.ts +2 -0
- package/dist/__tests__/diagnostics/env.test.d.ts.map +1 -0
- package/dist/__tests__/diagnostics/env.test.js +119 -0
- package/dist/__tests__/diagnostics/env.test.js.map +1 -0
- package/dist/__tests__/diagnostics/logger.test.d.ts +2 -0
- package/dist/__tests__/diagnostics/logger.test.d.ts.map +1 -0
- package/dist/__tests__/diagnostics/logger.test.js +111 -0
- package/dist/__tests__/diagnostics/logger.test.js.map +1 -0
- package/dist/__tests__/security/api-key-enhanced.test.d.ts +2 -0
- package/dist/__tests__/security/api-key-enhanced.test.d.ts.map +1 -0
- package/dist/__tests__/security/api-key-enhanced.test.js +110 -0
- package/dist/__tests__/security/api-key-enhanced.test.js.map +1 -0
- package/dist/__tests__/security/rate-limit.test.js +42 -0
- package/dist/__tests__/security/rate-limit.test.js.map +1 -1
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +7 -6
- package/dist/actions.js.map +1 -1
- package/dist/api/handler-factory.d.ts.map +1 -1
- package/dist/api/handler-factory.js +31 -8
- package/dist/api/handler-factory.js.map +1 -1
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +508 -55
- package/dist/api/handlers.js.map +1 -1
- package/dist/auth/oauth.d.ts.map +1 -1
- package/dist/auth/oauth.js +5 -1
- package/dist/auth/oauth.js.map +1 -1
- package/dist/auth/reset.d.ts.map +1 -1
- package/dist/auth/reset.js +2 -1
- package/dist/auth/reset.js.map +1 -1
- package/dist/config/runtime.d.ts +99 -0
- package/dist/config/runtime.d.ts.map +1 -0
- package/dist/config/runtime.js +43 -0
- package/dist/config/runtime.js.map +1 -0
- package/dist/config/types.d.ts +21 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/diagnostics/env.d.ts +44 -0
- package/dist/diagnostics/env.d.ts.map +1 -0
- package/dist/diagnostics/env.js +293 -0
- package/dist/diagnostics/env.js.map +1 -0
- package/dist/diagnostics/logger.d.ts +38 -0
- package/dist/diagnostics/logger.d.ts.map +1 -0
- package/dist/diagnostics/logger.js +89 -0
- package/dist/diagnostics/logger.js.map +1 -0
- package/dist/page-builder/blocks.d.ts.map +1 -1
- package/dist/page-builder/blocks.js +6 -1
- package/dist/page-builder/blocks.js.map +1 -1
- package/dist/security/api-key-enhanced.d.ts +48 -5
- package/dist/security/api-key-enhanced.d.ts.map +1 -1
- package/dist/security/api-key-enhanced.js +60 -9
- package/dist/security/api-key-enhanced.js.map +1 -1
- package/dist/security/audit.d.ts.map +1 -1
- package/dist/security/audit.js +3 -1
- package/dist/security/audit.js.map +1 -1
- package/dist/security/rate-limit.d.ts +8 -0
- package/dist/security/rate-limit.d.ts.map +1 -1
- package/dist/security/rate-limit.js +81 -3
- package/dist/security/rate-limit.js.map +1 -1
- package/generated/browser.ts +109 -0
- package/generated/client.ts +133 -0
- package/generated/commonInputTypes.ts +709 -0
- package/generated/enums.ts +125 -0
- package/generated/internal/class.ts +376 -0
- package/generated/internal/prismaNamespace.ts +2617 -0
- package/generated/internal/prismaNamespaceBrowser.ts +611 -0
- package/generated/models/ApiKey.ts +1550 -0
- package/generated/models/AuditLog.ts +1206 -0
- package/generated/models/BackupRecord.ts +1250 -0
- package/generated/models/ContentLock.ts +1472 -0
- package/generated/models/ContentTemplate.ts +1416 -0
- package/generated/models/Document.ts +3005 -0
- package/generated/models/Folder.ts +1904 -0
- package/generated/models/FormSubmission.ts +1200 -0
- package/generated/models/InAppNotification.ts +1457 -0
- package/generated/models/Media.ts +2340 -0
- package/generated/models/MediaUsage.ts +1472 -0
- package/generated/models/OAuthAccount.ts +1463 -0
- package/generated/models/Redirect.ts +1284 -0
- package/generated/models/Session.ts +1492 -0
- package/generated/models/Site.ts +1206 -0
- package/generated/models/User.ts +3513 -0
- package/generated/models/Version.ts +1511 -0
- package/generated/models/WorkflowState.ts +1514 -0
- package/generated/models.ts +29 -0
- package/package.json +1 -1
- package/prisma/cms-schema.prisma +306 -306
- package/prisma/migrations/0001_init/migration.sql +384 -384
- package/prisma/migrations/0002_folders/migration.sql +39 -39
- package/prisma/migrations/0003_search_and_webhooks/migration.sql +50 -50
- package/prisma/migrations/0004_script_tags/migration.sql +21 -21
- package/prisma/migrations/0005_password_reset_tokens/migration.sql +20 -20
- package/prisma/migrations/0006_page_builder/migration.sql +38 -38
- package/prisma/migrations/migration_lock.toml +3 -3
- package/prisma/schema.prisma +549 -549
package/dist/api/handlers.js
CHANGED
|
@@ -31,6 +31,9 @@ import { enforceSessionLimits } from '../security/session-limits.js';
|
|
|
31
31
|
import { verifyReauth } from '../security/reauth.js';
|
|
32
32
|
import { validateMimeType, checkMagicBytes } from '../security/upload.js';
|
|
33
33
|
import { sanitizeHtml } from '../security/sanitize.js';
|
|
34
|
+
import { getActuateConfig, getActuateCoreVersion } from '../config/runtime.js';
|
|
35
|
+
import { validateEnvShape } from '../diagnostics/env.js';
|
|
36
|
+
import { generateApiKey, hashApiKey, looksLikeApiKey, validateApiKeyScope, validateApiKeyGlobalScope, validateApiKeyMediaScope, validateApiKeyPageBuilderScope, validateApiKeyIp, } from '../security/api-key-enhanced.js';
|
|
34
37
|
// Opaque dynamic import so Turbopack/webpack won't statically analyze the specifier.
|
|
35
38
|
// Returns { put, del, ... } from @vercel/blob when available.
|
|
36
39
|
async function importBlobStorage() {
|
|
@@ -244,9 +247,7 @@ const ALLOWED_SORT_FIELDS = new Set([
|
|
|
244
247
|
let _secretMissing = false;
|
|
245
248
|
let _secretWarningLogged = false;
|
|
246
249
|
function getSessionSecret() {
|
|
247
|
-
const secret = process.env.CMS_SECRET ??
|
|
248
|
-
process.env.CMS_SESSION_SECRET ??
|
|
249
|
-
globalThis.__actuateConfig?.secret;
|
|
250
|
+
const secret = process.env.CMS_SECRET ?? process.env.CMS_SESSION_SECRET ?? getActuateConfig()?.secret;
|
|
250
251
|
if (!secret) {
|
|
251
252
|
_secretMissing = true;
|
|
252
253
|
if (!_secretWarningLogged) {
|
|
@@ -293,6 +294,49 @@ async function extractSession(request) {
|
|
|
293
294
|
}
|
|
294
295
|
if (!token)
|
|
295
296
|
return null;
|
|
297
|
+
// API key path. Keys are recognized by the `act_sk_` prefix and looked up
|
|
298
|
+
// by SHA-256 hash. We never JWT-verify these tokens — they are opaque
|
|
299
|
+
// random secrets stored as hashes in `actuate_api_keys`.
|
|
300
|
+
if (looksLikeApiKey(token)) {
|
|
301
|
+
try {
|
|
302
|
+
const d = getDB();
|
|
303
|
+
if (!hasModel(d, 'apiKey'))
|
|
304
|
+
return null;
|
|
305
|
+
const hash = await hashApiKey(token);
|
|
306
|
+
const apiKey = await d.apiKey.findUnique({ where: { keyHash: hash } });
|
|
307
|
+
if (!apiKey)
|
|
308
|
+
return null;
|
|
309
|
+
if (apiKey.revokedAt)
|
|
310
|
+
return null;
|
|
311
|
+
if (apiKey.expiresAt && new Date(apiKey.expiresAt).getTime() < Date.now())
|
|
312
|
+
return null;
|
|
313
|
+
const ipRestrictions = Array.isArray(apiKey.ipRestrictions)
|
|
314
|
+
? apiKey.ipRestrictions
|
|
315
|
+
: apiKey.ipRestrictions
|
|
316
|
+
? apiKey.ipRestrictions.allow ?? null
|
|
317
|
+
: null;
|
|
318
|
+
if (ipRestrictions && ipRestrictions.length > 0) {
|
|
319
|
+
const ip = getClientIp(request);
|
|
320
|
+
if (!validateApiKeyIp(ipRestrictions, ip))
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
const scopes = apiKey.scopes ?? {};
|
|
324
|
+
// Fire-and-forget lastUsedAt update; never block the request on it.
|
|
325
|
+
void d.apiKey
|
|
326
|
+
.update({ where: { id: apiKey.id }, data: { lastUsedAt: new Date() } })
|
|
327
|
+
.catch(() => { });
|
|
328
|
+
return {
|
|
329
|
+
userId: apiKey.userId,
|
|
330
|
+
role: scopes.admin ? 'ADMIN' : 'API_KEY',
|
|
331
|
+
sessionId: apiKey.id,
|
|
332
|
+
apiKey: { id: apiKey.id, scopes },
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// Session JWT path.
|
|
296
340
|
try {
|
|
297
341
|
const payload = await verifySession(token, { secret: getSessionSecret() });
|
|
298
342
|
const d = getDB();
|
|
@@ -365,6 +409,57 @@ async function requireAuth(request) {
|
|
|
365
409
|
}
|
|
366
410
|
return { session };
|
|
367
411
|
}
|
|
412
|
+
/**
|
|
413
|
+
* Check that the request's auth context permits `action` on `collection`.
|
|
414
|
+
* - Session-authenticated requests fall through to `requireRole` semantics:
|
|
415
|
+
* write actions require WRITE_ROLES.
|
|
416
|
+
* - API-key-authenticated requests must have an explicit scope match.
|
|
417
|
+
*/
|
|
418
|
+
function requireCollectionScope(session, collection, action) {
|
|
419
|
+
if (session.apiKey) {
|
|
420
|
+
if (!validateApiKeyScope(session.apiKey.scopes, collection, action)) {
|
|
421
|
+
return errorResponse(`API key does not have permission to ${action} on collection "${collection}"`, 403);
|
|
422
|
+
}
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
if (action === 'read')
|
|
426
|
+
return null;
|
|
427
|
+
return requireRole(session.role, WRITE_ROLES);
|
|
428
|
+
}
|
|
429
|
+
function requireGlobalScope(session, slug) {
|
|
430
|
+
if (session.apiKey) {
|
|
431
|
+
if (!validateApiKeyGlobalScope(session.apiKey.scopes, slug)) {
|
|
432
|
+
return errorResponse(`API key does not have permission for global "${slug}"`, 403);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
function requireMediaScope(session) {
|
|
438
|
+
if (session.apiKey) {
|
|
439
|
+
if (!validateApiKeyMediaScope(session.apiKey.scopes)) {
|
|
440
|
+
return errorResponse('API key does not have media scope', 403);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
function requirePageBuilderScope(session) {
|
|
446
|
+
if (session.apiKey) {
|
|
447
|
+
if (!validateApiKeyPageBuilderScope(session.apiKey.scopes)) {
|
|
448
|
+
return errorResponse('API key does not have pageBuilder scope', 403);
|
|
449
|
+
}
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
return requireRole(session.role, ADMIN_ROLES);
|
|
453
|
+
}
|
|
454
|
+
function requireAdminScope(session) {
|
|
455
|
+
if (session.apiKey) {
|
|
456
|
+
if (!session.apiKey.scopes.admin) {
|
|
457
|
+
return errorResponse('API key does not have admin scope', 403);
|
|
458
|
+
}
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
return requireRole(session.role, ADMIN_ROLES);
|
|
462
|
+
}
|
|
368
463
|
function buildActionContext(session, db, locale) {
|
|
369
464
|
return {
|
|
370
465
|
userId: session.userId,
|
|
@@ -417,9 +512,9 @@ const MAX_CONCURRENT_SESSIONS = 5;
|
|
|
417
512
|
async function enforceSessionLimitsForUser(d, userId) {
|
|
418
513
|
if (!hasModel(d, 'session'))
|
|
419
514
|
return;
|
|
420
|
-
const
|
|
421
|
-
const max = typeof
|
|
422
|
-
?
|
|
515
|
+
const auth = getActuateConfig()?.auth;
|
|
516
|
+
const max = typeof auth?.maxConcurrentSessions === 'number' && auth.maxConcurrentSessions > 0
|
|
517
|
+
? auth.maxConcurrentSessions
|
|
423
518
|
: MAX_CONCURRENT_SESSIONS;
|
|
424
519
|
const active = await d.session.findMany({
|
|
425
520
|
where: { userId, revokedAt: null, expiresAt: { gt: new Date() } },
|
|
@@ -434,7 +529,7 @@ async function enforceSessionLimitsForUser(d, userId) {
|
|
|
434
529
|
}
|
|
435
530
|
}
|
|
436
531
|
function getAdminPath() {
|
|
437
|
-
return
|
|
532
|
+
return process.env.ACTUATE_ADMIN_PATH ?? getActuateConfig()?.admin?.path ?? '/admin';
|
|
438
533
|
}
|
|
439
534
|
class ModelNotAvailableError extends Error {
|
|
440
535
|
model;
|
|
@@ -505,7 +600,7 @@ export function registerCMSRoutes(router) {
|
|
|
505
600
|
// OpenAPI spec
|
|
506
601
|
// ---------------------------------------------------------------------------
|
|
507
602
|
router.get('/openapi.json', async () => {
|
|
508
|
-
const config =
|
|
603
|
+
const config = getActuateConfig();
|
|
509
604
|
if (!config)
|
|
510
605
|
return errorResponse('CMS not configured', 500);
|
|
511
606
|
const spec = generateOpenAPISpec(config);
|
|
@@ -696,7 +791,7 @@ export function registerCMSRoutes(router) {
|
|
|
696
791
|
if (!hasModel(d, 'user'))
|
|
697
792
|
return modelNotAvailable('user');
|
|
698
793
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? new URL(request.url).origin;
|
|
699
|
-
const cmsConfig =
|
|
794
|
+
const cmsConfig = getActuateConfig();
|
|
700
795
|
await createPasswordReset(d, email.toLowerCase().trim(), {
|
|
701
796
|
siteUrl,
|
|
702
797
|
platform: cmsConfig?.platform,
|
|
@@ -1077,7 +1172,7 @@ export function registerCMSRoutes(router) {
|
|
|
1077
1172
|
};
|
|
1078
1173
|
const cookies = parseCookieHeader(request.headers.get('cookie') ?? '');
|
|
1079
1174
|
const expectedNonce = cookies['actuate_oauth_nonce'] ?? null;
|
|
1080
|
-
const cmsConfig =
|
|
1175
|
+
const cmsConfig = getActuateConfig();
|
|
1081
1176
|
const allowSelfSignup = cmsConfig?.auth?.oauth?.allowSelfSignup === true;
|
|
1082
1177
|
const result = await handleOAuthCallback(provider, code, stateToken, oauthProviders, secret, db(), { expectedNonce, allowSelfSignup });
|
|
1083
1178
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
@@ -1118,6 +1213,9 @@ export function registerCMSRoutes(router) {
|
|
|
1118
1213
|
const auth = await requireAuth(request);
|
|
1119
1214
|
if (auth.error)
|
|
1120
1215
|
return auth.error;
|
|
1216
|
+
const scopeErr = requireCollectionScope(auth.session, params.slug, 'read');
|
|
1217
|
+
if (scopeErr)
|
|
1218
|
+
return scopeErr;
|
|
1121
1219
|
const url = new URL(request.url);
|
|
1122
1220
|
const ctx = buildActionContext(auth.session, db(), url.searchParams.get('locale') ?? undefined);
|
|
1123
1221
|
const result = await listDocuments({
|
|
@@ -1130,7 +1228,7 @@ export function registerCMSRoutes(router) {
|
|
|
1130
1228
|
locale: url.searchParams.get('locale') ?? undefined,
|
|
1131
1229
|
folderId: url.searchParams.get('folderId') ?? undefined,
|
|
1132
1230
|
}, ctx);
|
|
1133
|
-
const collectionConfig =
|
|
1231
|
+
const collectionConfig = getActuateConfig()?.collections?.[params.slug];
|
|
1134
1232
|
const fields = collectionConfig?.fields;
|
|
1135
1233
|
if (fields && result.docs.length > 0) {
|
|
1136
1234
|
const user = { id: auth.session.userId, role: auth.session.role };
|
|
@@ -1155,6 +1253,9 @@ export function registerCMSRoutes(router) {
|
|
|
1155
1253
|
const auth = await requireAuth(request);
|
|
1156
1254
|
if (auth.error)
|
|
1157
1255
|
return auth.error;
|
|
1256
|
+
const scopeErr = requireCollectionScope(auth.session, params.slug, 'read');
|
|
1257
|
+
if (scopeErr)
|
|
1258
|
+
return scopeErr;
|
|
1158
1259
|
const ctx = buildActionContext(auth.session, db());
|
|
1159
1260
|
const doc = await getDocument(params.slug, params.id, ctx);
|
|
1160
1261
|
if (!doc) {
|
|
@@ -1175,9 +1276,9 @@ export function registerCMSRoutes(router) {
|
|
|
1175
1276
|
const auth = await requireAuth(request);
|
|
1176
1277
|
if (auth.error)
|
|
1177
1278
|
return auth.error;
|
|
1178
|
-
const
|
|
1179
|
-
if (
|
|
1180
|
-
return
|
|
1279
|
+
const scopeErr = requireCollectionScope(auth.session, params.slug, 'create');
|
|
1280
|
+
if (scopeErr)
|
|
1281
|
+
return scopeErr;
|
|
1181
1282
|
const body = (await request.json());
|
|
1182
1283
|
const ctx = buildActionContext(auth.session, db());
|
|
1183
1284
|
const doc = await createDocument(params.slug, body, ctx);
|
|
@@ -1197,9 +1298,9 @@ export function registerCMSRoutes(router) {
|
|
|
1197
1298
|
const auth = await requireAuth(request);
|
|
1198
1299
|
if (auth.error)
|
|
1199
1300
|
return auth.error;
|
|
1200
|
-
const
|
|
1201
|
-
if (
|
|
1202
|
-
return
|
|
1301
|
+
const scopeErr = requireCollectionScope(auth.session, params.slug, 'update');
|
|
1302
|
+
if (scopeErr)
|
|
1303
|
+
return scopeErr;
|
|
1203
1304
|
const body = (await request.json());
|
|
1204
1305
|
const ctx = buildActionContext(auth.session, db());
|
|
1205
1306
|
const doc = await updateDocument(params.slug, params.id, body, ctx);
|
|
@@ -1219,9 +1320,9 @@ export function registerCMSRoutes(router) {
|
|
|
1219
1320
|
const auth = await requireAuth(request);
|
|
1220
1321
|
if (auth.error)
|
|
1221
1322
|
return auth.error;
|
|
1222
|
-
const
|
|
1223
|
-
if (
|
|
1224
|
-
return
|
|
1323
|
+
const scopeErr = requireCollectionScope(auth.session, params.slug, 'delete');
|
|
1324
|
+
if (scopeErr)
|
|
1325
|
+
return scopeErr;
|
|
1225
1326
|
const ctx = buildActionContext(auth.session, db());
|
|
1226
1327
|
await deleteDocument(params.slug, params.id, ctx);
|
|
1227
1328
|
await logEvent({
|
|
@@ -1286,6 +1387,9 @@ export function registerCMSRoutes(router) {
|
|
|
1286
1387
|
const auth = await requireAuth(request);
|
|
1287
1388
|
if (auth.error)
|
|
1288
1389
|
return auth.error;
|
|
1390
|
+
const scopeErr = requireMediaScope(auth.session);
|
|
1391
|
+
if (scopeErr)
|
|
1392
|
+
return scopeErr;
|
|
1289
1393
|
const body = (await request.json());
|
|
1290
1394
|
if (!body.filename || !body.contentType) {
|
|
1291
1395
|
return errorResponse('filename and contentType are required', 400);
|
|
@@ -1330,7 +1434,24 @@ export function registerCMSRoutes(router) {
|
|
|
1330
1434
|
const auth = await requireAuth(request);
|
|
1331
1435
|
if (auth.error)
|
|
1332
1436
|
return auth.error;
|
|
1333
|
-
const
|
|
1437
|
+
const scopeErr = requireMediaScope(auth.session);
|
|
1438
|
+
if (scopeErr)
|
|
1439
|
+
return scopeErr;
|
|
1440
|
+
// Reject *before* buffering the body. We require a valid content-length
|
|
1441
|
+
// header so that chunked / no-length requests (which would otherwise
|
|
1442
|
+
// bypass this gate and allow `request.formData()` to buffer unbounded
|
|
1443
|
+
// memory) are rejected up front. The multipart envelope is always
|
|
1444
|
+
// larger than the underlying file, so checking against MAX_UPLOAD_BYTES
|
|
1445
|
+
// here is a safe upper bound — a 50 MB file cannot fit inside a 50 MB
|
|
1446
|
+
// request body.
|
|
1447
|
+
const contentLengthHeader = request.headers.get('content-length');
|
|
1448
|
+
if (!contentLengthHeader) {
|
|
1449
|
+
return errorResponse('Content-Length header is required for uploads (chunked encoding is not supported)', 411);
|
|
1450
|
+
}
|
|
1451
|
+
const contentLength = parseInt(contentLengthHeader, 10);
|
|
1452
|
+
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
|
1453
|
+
return errorResponse('Invalid Content-Length header', 400);
|
|
1454
|
+
}
|
|
1334
1455
|
if (contentLength > MAX_UPLOAD_BYTES) {
|
|
1335
1456
|
return errorResponse('File exceeds maximum size of 50MB', 413);
|
|
1336
1457
|
}
|
|
@@ -1343,7 +1464,11 @@ export function registerCMSRoutes(router) {
|
|
|
1343
1464
|
const originalFilename = file.name;
|
|
1344
1465
|
const contentType = file.type;
|
|
1345
1466
|
const originalSize = file.size;
|
|
1346
|
-
|
|
1467
|
+
// Belt-and-braces: even with the header check above, re-validate the
|
|
1468
|
+
// *actual* file size after parsing in case the multipart envelope
|
|
1469
|
+
// disagreed with the header. This must come BEFORE any further
|
|
1470
|
+
// processing (magic byte read, SVG sanitization, blob upload).
|
|
1471
|
+
if (originalSize > MAX_UPLOAD_BYTES) {
|
|
1347
1472
|
return errorResponse('File exceeds maximum size of 50MB', 413);
|
|
1348
1473
|
}
|
|
1349
1474
|
// 1. Block file types that aren't on our allowlist outright.
|
|
@@ -1643,9 +1768,14 @@ export function registerCMSRoutes(router) {
|
|
|
1643
1768
|
const auth = await requireAuth(request);
|
|
1644
1769
|
if (auth.error)
|
|
1645
1770
|
return auth.error;
|
|
1646
|
-
const
|
|
1647
|
-
if (
|
|
1648
|
-
return
|
|
1771
|
+
const scopeErr = requireMediaScope(auth.session);
|
|
1772
|
+
if (scopeErr)
|
|
1773
|
+
return scopeErr;
|
|
1774
|
+
if (!auth.session.apiKey) {
|
|
1775
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1776
|
+
if (roleErr)
|
|
1777
|
+
return roleErr;
|
|
1778
|
+
}
|
|
1649
1779
|
const body = (await request.json());
|
|
1650
1780
|
const updated = await db().media.update({
|
|
1651
1781
|
where: { id: params.id },
|
|
@@ -1668,9 +1798,14 @@ export function registerCMSRoutes(router) {
|
|
|
1668
1798
|
const auth = await requireAuth(request);
|
|
1669
1799
|
if (auth.error)
|
|
1670
1800
|
return auth.error;
|
|
1671
|
-
const
|
|
1672
|
-
if (
|
|
1673
|
-
return
|
|
1801
|
+
const scopeErr = requireMediaScope(auth.session);
|
|
1802
|
+
if (scopeErr)
|
|
1803
|
+
return scopeErr;
|
|
1804
|
+
if (!auth.session.apiKey) {
|
|
1805
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1806
|
+
if (roleErr)
|
|
1807
|
+
return roleErr;
|
|
1808
|
+
}
|
|
1674
1809
|
const media = await db().media.findUnique({ where: { id: params.id } });
|
|
1675
1810
|
if (!media) {
|
|
1676
1811
|
return errorResponse('Media not found', 404);
|
|
@@ -1728,7 +1863,7 @@ export function registerCMSRoutes(router) {
|
|
|
1728
1863
|
const session = await extractSession(request);
|
|
1729
1864
|
if (!session)
|
|
1730
1865
|
return errorResponse('Unauthorized', 401);
|
|
1731
|
-
const coreVersion =
|
|
1866
|
+
const coreVersion = getActuateCoreVersion() ?? '0.1.0';
|
|
1732
1867
|
const info = await checkForUpdates(coreVersion);
|
|
1733
1868
|
const saved = await getUpdateConfig();
|
|
1734
1869
|
return json({
|
|
@@ -1831,7 +1966,7 @@ export function registerCMSRoutes(router) {
|
|
|
1831
1966
|
if (!owner || !repo) {
|
|
1832
1967
|
return errorResponse('GitHub repository not configured. Go to Settings > Updates to add one.', 400);
|
|
1833
1968
|
}
|
|
1834
|
-
const coreVersion =
|
|
1969
|
+
const coreVersion = getActuateCoreVersion() ?? '0.1.0';
|
|
1835
1970
|
const result = await createUpgradePR({
|
|
1836
1971
|
owner,
|
|
1837
1972
|
repo,
|
|
@@ -1916,8 +2051,43 @@ export function registerCMSRoutes(router) {
|
|
|
1916
2051
|
'webhookDeliveryLog',
|
|
1917
2052
|
];
|
|
1918
2053
|
router.get('/health', async () => {
|
|
1919
|
-
const cmsVersion =
|
|
2054
|
+
const cmsVersion = getActuateCoreVersion() ?? '0.0.0';
|
|
1920
2055
|
const models = {};
|
|
2056
|
+
// M6: validate the *shape* of every well-known env var, not just presence.
|
|
2057
|
+
// A 31-char CMS_SECRET or a placeholder CMS_ENCRYPTION_KEY now reports as
|
|
2058
|
+
// a hard error here instead of crashing at the first runtime use.
|
|
2059
|
+
//
|
|
2060
|
+
// Bugbot review (PR #43): CMS_SECRET has *three* legitimate sources —
|
|
2061
|
+
// `process.env.CMS_SECRET`, the legacy `process.env.CMS_SESSION_SECRET`,
|
|
2062
|
+
// and `getActuateConfig()?.secret` (config-file path documented in the
|
|
2063
|
+
// missing-secret warning message itself). `getSessionSecret()` checks all
|
|
2064
|
+
// three; `isSecretMissing()` consequently returns false when any of them
|
|
2065
|
+
// is set. If `validateEnvShape()` only inspected `process.env.CMS_SECRET`,
|
|
2066
|
+
// a config-file deploy would get `secretConfigured: true` AND
|
|
2067
|
+
// `status: "unhealthy"` simultaneously — a contradiction that would trip
|
|
2068
|
+
// monitoring / load-balancer health probes for no real fault. We pass a
|
|
2069
|
+
// wrapped `EnvSource` so the validator sees what the runtime would
|
|
2070
|
+
// actually resolve.
|
|
2071
|
+
const env = validateEnvShape({
|
|
2072
|
+
get(name) {
|
|
2073
|
+
if (name === 'CMS_SECRET') {
|
|
2074
|
+
return (process.env.CMS_SECRET ?? process.env.CMS_SESSION_SECRET ?? getActuateConfig()?.secret);
|
|
2075
|
+
}
|
|
2076
|
+
return process.env[name];
|
|
2077
|
+
},
|
|
2078
|
+
});
|
|
2079
|
+
// Derive overall status from env validation + model availability + DB
|
|
2080
|
+
// connection. `env.errorCount > 0` outranks every model/DB issue because
|
|
2081
|
+
// it represents a *deployment* misconfig the operator must fix before any
|
|
2082
|
+
// request will succeed — pretending the deploy is "degraded" when secrets
|
|
2083
|
+
// are malformed defeats the M6 goal of catching that at /health time.
|
|
2084
|
+
function deriveStatus(dbConnected, allModelsAvailable) {
|
|
2085
|
+
if (env.errorCount > 0)
|
|
2086
|
+
return 'unhealthy';
|
|
2087
|
+
if (allModelsAvailable && dbConnected)
|
|
2088
|
+
return 'healthy';
|
|
2089
|
+
return 'degraded';
|
|
2090
|
+
}
|
|
1921
2091
|
let d;
|
|
1922
2092
|
try {
|
|
1923
2093
|
d = db();
|
|
@@ -1927,9 +2097,10 @@ export function registerCMSRoutes(router) {
|
|
|
1927
2097
|
models[m] = false;
|
|
1928
2098
|
return json({
|
|
1929
2099
|
data: {
|
|
1930
|
-
status:
|
|
2100
|
+
status: deriveStatus(false, false),
|
|
1931
2101
|
version: cmsVersion,
|
|
1932
2102
|
secretConfigured: !isSecretMissing(),
|
|
2103
|
+
env,
|
|
1933
2104
|
models,
|
|
1934
2105
|
databaseConnected: false,
|
|
1935
2106
|
},
|
|
@@ -1955,11 +2126,13 @@ export function registerCMSRoutes(router) {
|
|
|
1955
2126
|
}
|
|
1956
2127
|
}
|
|
1957
2128
|
const allAvailable = Object.values(models).every(Boolean);
|
|
2129
|
+
const status = deriveStatus(dbConnected, allAvailable);
|
|
1958
2130
|
return json({
|
|
1959
2131
|
data: {
|
|
1960
|
-
status
|
|
2132
|
+
status,
|
|
1961
2133
|
version: cmsVersion,
|
|
1962
2134
|
secretConfigured: !isSecretMissing(),
|
|
2135
|
+
env,
|
|
1963
2136
|
models,
|
|
1964
2137
|
databaseConnected: dbConnected,
|
|
1965
2138
|
},
|
|
@@ -2162,6 +2335,163 @@ export function registerCMSRoutes(router) {
|
|
|
2162
2335
|
}
|
|
2163
2336
|
});
|
|
2164
2337
|
// ---------------------------------------------------------------------------
|
|
2338
|
+
// API key management (admin-only). Keys are created here, hashed in the DB,
|
|
2339
|
+
// and shown to the caller exactly once. They authenticate programmatic
|
|
2340
|
+
// clients (AI agents, CI, integrations) against the same REST surface as a
|
|
2341
|
+
// user session, but skip CSRF and use scope-based authorization.
|
|
2342
|
+
// ---------------------------------------------------------------------------
|
|
2343
|
+
router.get('/api-keys', async (request) => {
|
|
2344
|
+
try {
|
|
2345
|
+
const auth = await requireAuth(request);
|
|
2346
|
+
if (auth.error)
|
|
2347
|
+
return auth.error;
|
|
2348
|
+
const adminErr = requireAdminScope(auth.session);
|
|
2349
|
+
if (adminErr)
|
|
2350
|
+
return adminErr;
|
|
2351
|
+
const d = db();
|
|
2352
|
+
if (!hasModel(d, 'apiKey'))
|
|
2353
|
+
return json({ data: [] });
|
|
2354
|
+
const keys = await d.apiKey.findMany({
|
|
2355
|
+
orderBy: { createdAt: 'desc' },
|
|
2356
|
+
select: {
|
|
2357
|
+
id: true,
|
|
2358
|
+
name: true,
|
|
2359
|
+
keyPrefix: true,
|
|
2360
|
+
scopes: true,
|
|
2361
|
+
ipRestrictions: true,
|
|
2362
|
+
expiresAt: true,
|
|
2363
|
+
lastUsedAt: true,
|
|
2364
|
+
revokedAt: true,
|
|
2365
|
+
createdAt: true,
|
|
2366
|
+
user: { select: { id: true, name: true, email: true } },
|
|
2367
|
+
},
|
|
2368
|
+
});
|
|
2369
|
+
return json({ data: keys });
|
|
2370
|
+
}
|
|
2371
|
+
catch (err) {
|
|
2372
|
+
return internalError(err, 'api-keys/list');
|
|
2373
|
+
}
|
|
2374
|
+
});
|
|
2375
|
+
router.post('/api-keys', async (request) => {
|
|
2376
|
+
try {
|
|
2377
|
+
const auth = await requireAuth(request);
|
|
2378
|
+
if (auth.error)
|
|
2379
|
+
return auth.error;
|
|
2380
|
+
const adminErr = requireAdminScope(auth.session);
|
|
2381
|
+
if (adminErr)
|
|
2382
|
+
return adminErr;
|
|
2383
|
+
// Issuing a new credential is a sensitive action — require reauth for
|
|
2384
|
+
// session-authenticated callers. API-key-authenticated requests are
|
|
2385
|
+
// already proving possession of a long-lived credential.
|
|
2386
|
+
if (!auth.session.apiKey) {
|
|
2387
|
+
const reauthErr = await requirePasswordReauth(request, auth.session.userId);
|
|
2388
|
+
if (reauthErr)
|
|
2389
|
+
return reauthErr;
|
|
2390
|
+
}
|
|
2391
|
+
const body = (await request.json());
|
|
2392
|
+
if (!body.name || typeof body.name !== 'string' || body.name.trim().length === 0) {
|
|
2393
|
+
return errorResponse('name is required', 400);
|
|
2394
|
+
}
|
|
2395
|
+
if (body.name.length > 100) {
|
|
2396
|
+
return errorResponse('name must be 100 characters or fewer', 400);
|
|
2397
|
+
}
|
|
2398
|
+
const scopes = body.scopes ?? {};
|
|
2399
|
+
// Sanity check the scope shape so we don't store garbage that fails
|
|
2400
|
+
// every authorization check at runtime.
|
|
2401
|
+
if (!scopes.admin &&
|
|
2402
|
+
!scopes.media &&
|
|
2403
|
+
!scopes.pageBuilder &&
|
|
2404
|
+
(!scopes.collections || scopes.collections.length === 0) &&
|
|
2405
|
+
(!scopes.globals || scopes.globals.length === 0)) {
|
|
2406
|
+
return errorResponse('scopes must grant at least one capability (admin, media, pageBuilder, collections, or globals)', 400);
|
|
2407
|
+
}
|
|
2408
|
+
let expiresAt;
|
|
2409
|
+
if (body.expiresAt) {
|
|
2410
|
+
const parsed = new Date(body.expiresAt);
|
|
2411
|
+
if (isNaN(parsed.getTime()))
|
|
2412
|
+
return errorResponse('Invalid expiresAt date', 400);
|
|
2413
|
+
if (parsed.getTime() < Date.now())
|
|
2414
|
+
return errorResponse('expiresAt must be in the future', 400);
|
|
2415
|
+
expiresAt = parsed;
|
|
2416
|
+
}
|
|
2417
|
+
const { key, keyHash, keyPrefix } = await generateApiKey({
|
|
2418
|
+
prefix: 'act_sk',
|
|
2419
|
+
scopes,
|
|
2420
|
+
expiresAt,
|
|
2421
|
+
});
|
|
2422
|
+
const d = db();
|
|
2423
|
+
const record = await d.apiKey.create({
|
|
2424
|
+
data: {
|
|
2425
|
+
name: body.name.trim(),
|
|
2426
|
+
keyHash,
|
|
2427
|
+
keyPrefix,
|
|
2428
|
+
userId: auth.session.userId,
|
|
2429
|
+
scopes: scopes,
|
|
2430
|
+
ipRestrictions: body.ipRestrictions?.length ? body.ipRestrictions : null,
|
|
2431
|
+
expiresAt: expiresAt ?? null,
|
|
2432
|
+
},
|
|
2433
|
+
select: {
|
|
2434
|
+
id: true,
|
|
2435
|
+
name: true,
|
|
2436
|
+
keyPrefix: true,
|
|
2437
|
+
scopes: true,
|
|
2438
|
+
ipRestrictions: true,
|
|
2439
|
+
expiresAt: true,
|
|
2440
|
+
createdAt: true,
|
|
2441
|
+
},
|
|
2442
|
+
});
|
|
2443
|
+
await logEvent({
|
|
2444
|
+
event: 'api_key_created',
|
|
2445
|
+
userId: auth.session.userId,
|
|
2446
|
+
ipAddress: clientIp(request),
|
|
2447
|
+
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
2448
|
+
details: { apiKeyId: record.id, name: record.name, scopes },
|
|
2449
|
+
});
|
|
2450
|
+
// The raw `key` is the only thing the caller will ever see; we never
|
|
2451
|
+
// store it in plaintext. Document this in the response shape.
|
|
2452
|
+
return json({ data: { ...record, key } }, 201);
|
|
2453
|
+
}
|
|
2454
|
+
catch (err) {
|
|
2455
|
+
return internalError(err, 'api-keys/create');
|
|
2456
|
+
}
|
|
2457
|
+
});
|
|
2458
|
+
router.delete('/api-keys/:id', async (request, params) => {
|
|
2459
|
+
try {
|
|
2460
|
+
const auth = await requireAuth(request);
|
|
2461
|
+
if (auth.error)
|
|
2462
|
+
return auth.error;
|
|
2463
|
+
const adminErr = requireAdminScope(auth.session);
|
|
2464
|
+
if (adminErr)
|
|
2465
|
+
return adminErr;
|
|
2466
|
+
// Same reauth gate as creation — revocation is irreversible and
|
|
2467
|
+
// affects every dependent client.
|
|
2468
|
+
if (!auth.session.apiKey) {
|
|
2469
|
+
const reauthErr = await requirePasswordReauth(request, auth.session.userId);
|
|
2470
|
+
if (reauthErr)
|
|
2471
|
+
return reauthErr;
|
|
2472
|
+
}
|
|
2473
|
+
const d = db();
|
|
2474
|
+
const existing = await d.apiKey.findUnique({ where: { id: params.id } });
|
|
2475
|
+
if (!existing)
|
|
2476
|
+
return errorResponse('API key not found', 404);
|
|
2477
|
+
await d.apiKey.update({
|
|
2478
|
+
where: { id: params.id },
|
|
2479
|
+
data: { revokedAt: new Date() },
|
|
2480
|
+
});
|
|
2481
|
+
await logEvent({
|
|
2482
|
+
event: 'api_key_revoked',
|
|
2483
|
+
userId: auth.session.userId,
|
|
2484
|
+
ipAddress: clientIp(request),
|
|
2485
|
+
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
2486
|
+
details: { apiKeyId: existing.id, name: existing.name },
|
|
2487
|
+
});
|
|
2488
|
+
return json({ data: { revoked: true } });
|
|
2489
|
+
}
|
|
2490
|
+
catch (err) {
|
|
2491
|
+
return internalError(err, 'api-keys/revoke');
|
|
2492
|
+
}
|
|
2493
|
+
});
|
|
2494
|
+
// ---------------------------------------------------------------------------
|
|
2165
2495
|
// Users route
|
|
2166
2496
|
// ---------------------------------------------------------------------------
|
|
2167
2497
|
router.get('/users', async (request) => {
|
|
@@ -2330,9 +2660,9 @@ export function registerCMSRoutes(router) {
|
|
|
2330
2660
|
});
|
|
2331
2661
|
(async () => {
|
|
2332
2662
|
try {
|
|
2333
|
-
const config =
|
|
2334
|
-
const hooks =
|
|
2335
|
-
const formHooks = hooks.filter((h) => h
|
|
2663
|
+
const config = getActuateConfig();
|
|
2664
|
+
const hooks = (config?._pluginHooks ?? []);
|
|
2665
|
+
const formHooks = hooks.filter((h) => h?.event === 'afterCreate:form-submissions');
|
|
2336
2666
|
for (const hook of formHooks) {
|
|
2337
2667
|
await hook.handler({ formId, data: body.fields });
|
|
2338
2668
|
}
|
|
@@ -2405,7 +2735,7 @@ export function registerCMSRoutes(router) {
|
|
|
2405
2735
|
if (!['http:', 'https:'].includes(destUrl.protocol)) {
|
|
2406
2736
|
return errorResponse('Invalid destination URL', 400);
|
|
2407
2737
|
}
|
|
2408
|
-
const cmsConfig =
|
|
2738
|
+
const cmsConfig = getActuateConfig();
|
|
2409
2739
|
const allowed = new Set([
|
|
2410
2740
|
...(Array.isArray(cmsConfig?.redirects?.allowedExternalHosts)
|
|
2411
2741
|
? cmsConfig.redirects.allowedExternalHosts.map((h) => h.toLowerCase())
|
|
@@ -2967,7 +3297,7 @@ export function registerCMSRoutes(router) {
|
|
|
2967
3297
|
// ---------------------------------------------------------------------------
|
|
2968
3298
|
const MAX_RESOLVE_DEPTH = 10;
|
|
2969
3299
|
async function resolveLayout(path, docData, matchedCollection) {
|
|
2970
|
-
const config =
|
|
3300
|
+
const config = getActuateConfig();
|
|
2971
3301
|
const layoutConfig = config?.layout;
|
|
2972
3302
|
if (!layoutConfig?.regions)
|
|
2973
3303
|
return {};
|
|
@@ -3063,7 +3393,7 @@ export function registerCMSRoutes(router) {
|
|
|
3063
3393
|
.replace(/^\/|\/$/g, '')
|
|
3064
3394
|
.split('/')
|
|
3065
3395
|
.filter(Boolean);
|
|
3066
|
-
const configCollections =
|
|
3396
|
+
const configCollections = getActuateConfig()?.collections ?? {};
|
|
3067
3397
|
const collectionDefs = Object.values(configCollections);
|
|
3068
3398
|
let matchedCollection = null;
|
|
3069
3399
|
let docSlug = null;
|
|
@@ -3579,7 +3909,7 @@ export function registerCMSRoutes(router) {
|
|
|
3579
3909
|
router.get('/public/globals/:slug', async (_request, params) => {
|
|
3580
3910
|
try {
|
|
3581
3911
|
const slug = params.slug;
|
|
3582
|
-
const globalConfig =
|
|
3912
|
+
const globalConfig = getActuateConfig()?.globals?.[slug];
|
|
3583
3913
|
if (!globalConfig) {
|
|
3584
3914
|
return errorResponse('Global not found', 404);
|
|
3585
3915
|
}
|
|
@@ -3611,6 +3941,9 @@ export function registerCMSRoutes(router) {
|
|
|
3611
3941
|
const auth = await requireAuth(request);
|
|
3612
3942
|
if (auth.error)
|
|
3613
3943
|
return auth.error;
|
|
3944
|
+
const scopeErr = requireGlobalScope(auth.session, params.slug);
|
|
3945
|
+
if (scopeErr)
|
|
3946
|
+
return scopeErr;
|
|
3614
3947
|
const ctx = buildActionContext(auth.session, db());
|
|
3615
3948
|
const global = await getGlobal(params.slug, ctx);
|
|
3616
3949
|
if (!global) {
|
|
@@ -3627,9 +3960,14 @@ export function registerCMSRoutes(router) {
|
|
|
3627
3960
|
const auth = await requireAuth(request);
|
|
3628
3961
|
if (auth.error)
|
|
3629
3962
|
return auth.error;
|
|
3630
|
-
const
|
|
3631
|
-
if (
|
|
3632
|
-
return
|
|
3963
|
+
const scopeErr = requireGlobalScope(auth.session, params.slug);
|
|
3964
|
+
if (scopeErr)
|
|
3965
|
+
return scopeErr;
|
|
3966
|
+
if (!auth.session.apiKey) {
|
|
3967
|
+
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
3968
|
+
if (roleErr)
|
|
3969
|
+
return roleErr;
|
|
3970
|
+
}
|
|
3633
3971
|
const body = (await request.json());
|
|
3634
3972
|
const ctx = buildActionContext(auth.session, db());
|
|
3635
3973
|
const global = await updateGlobal(params.slug, body, ctx);
|
|
@@ -4407,9 +4745,9 @@ export function registerCMSRoutes(router) {
|
|
|
4407
4745
|
const auth = await requireAuth(request);
|
|
4408
4746
|
if (auth.error)
|
|
4409
4747
|
return auth.error;
|
|
4410
|
-
const
|
|
4411
|
-
if (
|
|
4412
|
-
return
|
|
4748
|
+
const scopeErr = requirePageBuilderScope(auth.session);
|
|
4749
|
+
if (scopeErr)
|
|
4750
|
+
return scopeErr;
|
|
4413
4751
|
// Per-user rate limit. AI generation is the single most expensive
|
|
4414
4752
|
// operation in the CMS — without this, a compromised admin account
|
|
4415
4753
|
// can drain a provider key in minutes.
|
|
@@ -4479,9 +4817,9 @@ export function registerCMSRoutes(router) {
|
|
|
4479
4817
|
const auth = await requireAuth(request);
|
|
4480
4818
|
if (auth.error)
|
|
4481
4819
|
return auth.error;
|
|
4482
|
-
const
|
|
4483
|
-
if (
|
|
4484
|
-
return
|
|
4820
|
+
const scopeErr = requirePageBuilderScope(auth.session);
|
|
4821
|
+
if (scopeErr)
|
|
4822
|
+
return scopeErr;
|
|
4485
4823
|
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-block:${auth.session.userId}`))) {
|
|
4486
4824
|
return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
|
|
4487
4825
|
}
|
|
@@ -4512,14 +4850,129 @@ export function registerCMSRoutes(router) {
|
|
|
4512
4850
|
return internalError(err, 'page-builder generate-block');
|
|
4513
4851
|
}
|
|
4514
4852
|
});
|
|
4853
|
+
/**
|
|
4854
|
+
* One-shot page creation: run the AI page generator and persist the result
|
|
4855
|
+
* as a new document in a single call. Designed for AI agents that want to
|
|
4856
|
+
* create a complete page from a prompt without orchestrating two requests
|
|
4857
|
+
* (generate, then create). Defaults to status=DRAFT so the human reviewer
|
|
4858
|
+
* can polish before publishing.
|
|
4859
|
+
*/
|
|
4860
|
+
router.post('/page-builder/create', async (request) => {
|
|
4861
|
+
try {
|
|
4862
|
+
const auth = await requireAuth(request);
|
|
4863
|
+
if (auth.error)
|
|
4864
|
+
return auth.error;
|
|
4865
|
+
const scopeErr = requirePageBuilderScope(auth.session);
|
|
4866
|
+
if (scopeErr)
|
|
4867
|
+
return scopeErr;
|
|
4868
|
+
// The create path also writes to a collection, so the API key must hold
|
|
4869
|
+
// create scope on the destination collection (defaults to 'pages').
|
|
4870
|
+
const body = (await request.json());
|
|
4871
|
+
const targetCollection = body.collection ?? 'pages';
|
|
4872
|
+
const collectionScopeErr = requireCollectionScope(auth.session, targetCollection, 'create');
|
|
4873
|
+
if (collectionScopeErr)
|
|
4874
|
+
return collectionScopeErr;
|
|
4875
|
+
if (!body.prompt || typeof body.prompt !== 'string') {
|
|
4876
|
+
return errorResponse('prompt is required', 400);
|
|
4877
|
+
}
|
|
4878
|
+
if (body.prompt.length > AI_PROMPT_MAX_CHARS) {
|
|
4879
|
+
return errorResponse(`prompt exceeds ${AI_PROMPT_MAX_CHARS} character limit`, 400);
|
|
4880
|
+
}
|
|
4881
|
+
// Same rate-limit bucket as /generate — one create == one expensive LLM
|
|
4882
|
+
// run, so it should count against the same hourly cap.
|
|
4883
|
+
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-gen:${auth.session.userId}`))) {
|
|
4884
|
+
return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
|
|
4885
|
+
}
|
|
4886
|
+
const steps = Array.isArray(body.steps) && body.steps.length > 0
|
|
4887
|
+
? body.steps
|
|
4888
|
+
: ['structure', 'content', 'seo', 'accessibility'];
|
|
4889
|
+
const validSteps = ['structure', 'content', 'seo', 'accessibility'];
|
|
4890
|
+
for (const s of steps) {
|
|
4891
|
+
if (!validSteps.includes(s)) {
|
|
4892
|
+
return errorResponse(`Invalid step: ${s}. Valid steps: ${validSteps.join(', ')}`, 400);
|
|
4893
|
+
}
|
|
4894
|
+
}
|
|
4895
|
+
let generatePage = null;
|
|
4896
|
+
try {
|
|
4897
|
+
const aiModule = await importAIPlugin();
|
|
4898
|
+
generatePage = aiModule.generatePage;
|
|
4899
|
+
}
|
|
4900
|
+
catch {
|
|
4901
|
+
return errorResponse('AI plugin is not installed. Install @actuate-media/plugin-ai to use page generation.', 501);
|
|
4902
|
+
}
|
|
4903
|
+
const result = await generatePage({
|
|
4904
|
+
prompt: body.prompt,
|
|
4905
|
+
template: body.template,
|
|
4906
|
+
context: body.context,
|
|
4907
|
+
steps,
|
|
4908
|
+
tone: body.tone,
|
|
4909
|
+
});
|
|
4910
|
+
const tree = result?.tree;
|
|
4911
|
+
if (!tree) {
|
|
4912
|
+
return errorResponse('Page generation returned no tree', 502);
|
|
4913
|
+
}
|
|
4914
|
+
// Pull SEO metadata out of the generator output so we can store it on
|
|
4915
|
+
// the document alongside the layout tree.
|
|
4916
|
+
const seoStep = (result?.steps ?? []).find((s) => s.step === 'seo');
|
|
4917
|
+
const meta = seoStep?.data ?? {};
|
|
4918
|
+
const title = body.title ?? meta.title ?? meta.metaTitle ?? 'Untitled';
|
|
4919
|
+
const slug = body.slug ??
|
|
4920
|
+
title
|
|
4921
|
+
.toLowerCase()
|
|
4922
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
4923
|
+
.replace(/^-|-$/g, '');
|
|
4924
|
+
// Determine final status — explicit body.status wins, then publish: true,
|
|
4925
|
+
// then DRAFT.
|
|
4926
|
+
const status = body.status === 'PUBLISHED' || body.publish === true ? 'PUBLISHED' : 'DRAFT';
|
|
4927
|
+
const docPayload = {
|
|
4928
|
+
title,
|
|
4929
|
+
slug,
|
|
4930
|
+
status,
|
|
4931
|
+
layout: tree,
|
|
4932
|
+
pageSettings: {
|
|
4933
|
+
metaTitle: meta.metaTitle ?? meta.title ?? title,
|
|
4934
|
+
metaDescription: meta.metaDescription ?? meta.description ?? '',
|
|
4935
|
+
...(meta.canonical ? { canonical: meta.canonical } : {}),
|
|
4936
|
+
...(meta.schemaType ? { schemaType: meta.schemaType } : {}),
|
|
4937
|
+
},
|
|
4938
|
+
};
|
|
4939
|
+
const ctx = buildActionContext(auth.session, db());
|
|
4940
|
+
const doc = await createDocument(targetCollection, docPayload, ctx);
|
|
4941
|
+
await logEvent({
|
|
4942
|
+
event: 'settings_changed',
|
|
4943
|
+
userId: auth.session.userId,
|
|
4944
|
+
details: {
|
|
4945
|
+
action: 'page_create_from_prompt',
|
|
4946
|
+
collection: targetCollection,
|
|
4947
|
+
documentId: doc?.id,
|
|
4948
|
+
prompt: redactSecrets(body.prompt).slice(0, 500),
|
|
4949
|
+
totalTokensUsed: result.totalTokensUsed,
|
|
4950
|
+
totalDurationMs: result.totalDurationMs,
|
|
4951
|
+
},
|
|
4952
|
+
});
|
|
4953
|
+
return json({
|
|
4954
|
+
data: {
|
|
4955
|
+
document: doc,
|
|
4956
|
+
generation: {
|
|
4957
|
+
steps: result.steps,
|
|
4958
|
+
totalTokensUsed: result.totalTokensUsed,
|
|
4959
|
+
totalDurationMs: result.totalDurationMs,
|
|
4960
|
+
},
|
|
4961
|
+
},
|
|
4962
|
+
}, 201);
|
|
4963
|
+
}
|
|
4964
|
+
catch (err) {
|
|
4965
|
+
return internalError(err, 'page-builder create');
|
|
4966
|
+
}
|
|
4967
|
+
});
|
|
4515
4968
|
router.post('/page-builder/audit-a11y', async (request) => {
|
|
4516
4969
|
try {
|
|
4517
4970
|
const auth = await requireAuth(request);
|
|
4518
4971
|
if (auth.error)
|
|
4519
4972
|
return auth.error;
|
|
4520
|
-
const
|
|
4521
|
-
if (
|
|
4522
|
-
return
|
|
4973
|
+
const scopeErr = requirePageBuilderScope(auth.session);
|
|
4974
|
+
if (scopeErr)
|
|
4975
|
+
return scopeErr;
|
|
4523
4976
|
const body = await request.json();
|
|
4524
4977
|
const tree = body.tree;
|
|
4525
4978
|
if (!tree || tree.type !== 'page') {
|
|
@@ -4537,9 +4990,9 @@ export function registerCMSRoutes(router) {
|
|
|
4537
4990
|
const auth = await requireAuth(request);
|
|
4538
4991
|
if (auth.error)
|
|
4539
4992
|
return auth.error;
|
|
4540
|
-
const
|
|
4541
|
-
if (
|
|
4542
|
-
return
|
|
4993
|
+
const scopeErr = requirePageBuilderScope(auth.session);
|
|
4994
|
+
if (scopeErr)
|
|
4995
|
+
return scopeErr;
|
|
4543
4996
|
const body = await request.json();
|
|
4544
4997
|
const tree = body.tree;
|
|
4545
4998
|
if (!tree || tree.type !== 'page') {
|