@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.
Files changed (110) hide show
  1. package/LICENSE +21 -21
  2. package/dist/__tests__/api/api-key-auth.test.d.ts +2 -0
  3. package/dist/__tests__/api/api-key-auth.test.d.ts.map +1 -0
  4. package/dist/__tests__/api/api-key-auth.test.js +217 -0
  5. package/dist/__tests__/api/api-key-auth.test.js.map +1 -0
  6. package/dist/__tests__/api/health.test.d.ts +2 -0
  7. package/dist/__tests__/api/health.test.d.ts.map +1 -0
  8. package/dist/__tests__/api/health.test.js +140 -0
  9. package/dist/__tests__/api/health.test.js.map +1 -0
  10. package/dist/__tests__/auth/oauth.test.d.ts +2 -0
  11. package/dist/__tests__/auth/oauth.test.d.ts.map +1 -0
  12. package/dist/__tests__/auth/oauth.test.js +406 -0
  13. package/dist/__tests__/auth/oauth.test.js.map +1 -0
  14. package/dist/__tests__/auth/reset.test.d.ts +2 -0
  15. package/dist/__tests__/auth/reset.test.d.ts.map +1 -0
  16. package/dist/__tests__/auth/reset.test.js +303 -0
  17. package/dist/__tests__/auth/reset.test.js.map +1 -0
  18. package/dist/__tests__/diagnostics/env.test.d.ts +2 -0
  19. package/dist/__tests__/diagnostics/env.test.d.ts.map +1 -0
  20. package/dist/__tests__/diagnostics/env.test.js +119 -0
  21. package/dist/__tests__/diagnostics/env.test.js.map +1 -0
  22. package/dist/__tests__/diagnostics/logger.test.d.ts +2 -0
  23. package/dist/__tests__/diagnostics/logger.test.d.ts.map +1 -0
  24. package/dist/__tests__/diagnostics/logger.test.js +111 -0
  25. package/dist/__tests__/diagnostics/logger.test.js.map +1 -0
  26. package/dist/__tests__/security/api-key-enhanced.test.d.ts +2 -0
  27. package/dist/__tests__/security/api-key-enhanced.test.d.ts.map +1 -0
  28. package/dist/__tests__/security/api-key-enhanced.test.js +110 -0
  29. package/dist/__tests__/security/api-key-enhanced.test.js.map +1 -0
  30. package/dist/__tests__/security/rate-limit.test.js +42 -0
  31. package/dist/__tests__/security/rate-limit.test.js.map +1 -1
  32. package/dist/actions.d.ts.map +1 -1
  33. package/dist/actions.js +7 -6
  34. package/dist/actions.js.map +1 -1
  35. package/dist/api/handler-factory.d.ts.map +1 -1
  36. package/dist/api/handler-factory.js +31 -8
  37. package/dist/api/handler-factory.js.map +1 -1
  38. package/dist/api/handlers.d.ts.map +1 -1
  39. package/dist/api/handlers.js +508 -55
  40. package/dist/api/handlers.js.map +1 -1
  41. package/dist/auth/oauth.d.ts.map +1 -1
  42. package/dist/auth/oauth.js +5 -1
  43. package/dist/auth/oauth.js.map +1 -1
  44. package/dist/auth/reset.d.ts.map +1 -1
  45. package/dist/auth/reset.js +2 -1
  46. package/dist/auth/reset.js.map +1 -1
  47. package/dist/config/runtime.d.ts +99 -0
  48. package/dist/config/runtime.d.ts.map +1 -0
  49. package/dist/config/runtime.js +43 -0
  50. package/dist/config/runtime.js.map +1 -0
  51. package/dist/config/types.d.ts +21 -0
  52. package/dist/config/types.d.ts.map +1 -1
  53. package/dist/diagnostics/env.d.ts +44 -0
  54. package/dist/diagnostics/env.d.ts.map +1 -0
  55. package/dist/diagnostics/env.js +293 -0
  56. package/dist/diagnostics/env.js.map +1 -0
  57. package/dist/diagnostics/logger.d.ts +38 -0
  58. package/dist/diagnostics/logger.d.ts.map +1 -0
  59. package/dist/diagnostics/logger.js +89 -0
  60. package/dist/diagnostics/logger.js.map +1 -0
  61. package/dist/page-builder/blocks.d.ts.map +1 -1
  62. package/dist/page-builder/blocks.js +6 -1
  63. package/dist/page-builder/blocks.js.map +1 -1
  64. package/dist/security/api-key-enhanced.d.ts +48 -5
  65. package/dist/security/api-key-enhanced.d.ts.map +1 -1
  66. package/dist/security/api-key-enhanced.js +60 -9
  67. package/dist/security/api-key-enhanced.js.map +1 -1
  68. package/dist/security/audit.d.ts.map +1 -1
  69. package/dist/security/audit.js +3 -1
  70. package/dist/security/audit.js.map +1 -1
  71. package/dist/security/rate-limit.d.ts +8 -0
  72. package/dist/security/rate-limit.d.ts.map +1 -1
  73. package/dist/security/rate-limit.js +81 -3
  74. package/dist/security/rate-limit.js.map +1 -1
  75. package/generated/browser.ts +109 -0
  76. package/generated/client.ts +133 -0
  77. package/generated/commonInputTypes.ts +709 -0
  78. package/generated/enums.ts +125 -0
  79. package/generated/internal/class.ts +376 -0
  80. package/generated/internal/prismaNamespace.ts +2617 -0
  81. package/generated/internal/prismaNamespaceBrowser.ts +611 -0
  82. package/generated/models/ApiKey.ts +1550 -0
  83. package/generated/models/AuditLog.ts +1206 -0
  84. package/generated/models/BackupRecord.ts +1250 -0
  85. package/generated/models/ContentLock.ts +1472 -0
  86. package/generated/models/ContentTemplate.ts +1416 -0
  87. package/generated/models/Document.ts +3005 -0
  88. package/generated/models/Folder.ts +1904 -0
  89. package/generated/models/FormSubmission.ts +1200 -0
  90. package/generated/models/InAppNotification.ts +1457 -0
  91. package/generated/models/Media.ts +2340 -0
  92. package/generated/models/MediaUsage.ts +1472 -0
  93. package/generated/models/OAuthAccount.ts +1463 -0
  94. package/generated/models/Redirect.ts +1284 -0
  95. package/generated/models/Session.ts +1492 -0
  96. package/generated/models/Site.ts +1206 -0
  97. package/generated/models/User.ts +3513 -0
  98. package/generated/models/Version.ts +1511 -0
  99. package/generated/models/WorkflowState.ts +1514 -0
  100. package/generated/models.ts +29 -0
  101. package/package.json +1 -1
  102. package/prisma/cms-schema.prisma +306 -306
  103. package/prisma/migrations/0001_init/migration.sql +384 -384
  104. package/prisma/migrations/0002_folders/migration.sql +39 -39
  105. package/prisma/migrations/0003_search_and_webhooks/migration.sql +50 -50
  106. package/prisma/migrations/0004_script_tags/migration.sql +21 -21
  107. package/prisma/migrations/0005_password_reset_tokens/migration.sql +20 -20
  108. package/prisma/migrations/0006_page_builder/migration.sql +38 -38
  109. package/prisma/migrations/migration_lock.toml +3 -3
  110. package/prisma/schema.prisma +549 -549
@@ -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 config = globalThis.__actuateConfig?.auth ?? {};
421
- const max = typeof config.maxConcurrentSessions === 'number' && config.maxConcurrentSessions > 0
422
- ? config.maxConcurrentSessions
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 (process.env.ACTUATE_ADMIN_PATH ?? globalThis.__actuateConfig?.admin?.path ?? '/admin');
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 = globalThis.__actuateConfig;
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 = globalThis.__actuateConfig;
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 = globalThis.__actuateConfig;
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 = globalThis.__actuateConfig?.collections?.[params.slug];
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 roleErr = requireRole(auth.session.role, WRITE_ROLES);
1179
- if (roleErr)
1180
- return roleErr;
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 roleErr = requireRole(auth.session.role, WRITE_ROLES);
1201
- if (roleErr)
1202
- return roleErr;
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 roleErr = requireRole(auth.session.role, WRITE_ROLES);
1223
- if (roleErr)
1224
- return roleErr;
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 contentLength = parseInt(request.headers.get('content-length') ?? '0', 10);
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
- if (originalSize > 50 * 1024 * 1024) {
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 roleErr = requireRole(auth.session.role, WRITE_ROLES);
1647
- if (roleErr)
1648
- return roleErr;
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 roleErr = requireRole(auth.session.role, WRITE_ROLES);
1672
- if (roleErr)
1673
- return roleErr;
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 = globalThis.__actuateCoreVersion ?? '0.1.0';
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 = globalThis.__actuateCoreVersion ?? '0.1.0';
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 = globalThis.__actuateCoreVersion ?? '0.0.0';
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: 'degraded',
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: allAvailable ? 'healthy' : 'degraded',
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 = globalThis.__actuateConfig;
2334
- const hooks = [...(config?.plugins?.forms?.hooks ?? []), ...(config?._pluginHooks ?? [])];
2335
- const formHooks = hooks.filter((h) => h.event === 'afterCreate:form-submissions');
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 = globalThis.__actuateConfig;
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 = globalThis.__actuateConfig;
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 = globalThis.__actuateConfig?.collections ?? {};
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 = globalThis.__actuateConfig?.globals?.[slug];
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 roleErr = requireRole(auth.session.role, ADMIN_ROLES);
3631
- if (roleErr)
3632
- return roleErr;
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 roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4411
- if (roleErr)
4412
- return roleErr;
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 roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4483
- if (roleErr)
4484
- return roleErr;
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 roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4521
- if (roleErr)
4522
- return roleErr;
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 roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4541
- if (roleErr)
4542
- return roleErr;
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') {