@actuate-media/cms-core 0.13.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 (55) 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__/security/api-key-enhanced.test.d.ts +2 -0
  7. package/dist/__tests__/security/api-key-enhanced.test.d.ts.map +1 -0
  8. package/dist/__tests__/security/api-key-enhanced.test.js +110 -0
  9. package/dist/__tests__/security/api-key-enhanced.test.js.map +1 -0
  10. package/dist/api/handler-factory.d.ts.map +1 -1
  11. package/dist/api/handler-factory.js +19 -2
  12. package/dist/api/handler-factory.js.map +1 -1
  13. package/dist/api/handlers.d.ts.map +1 -1
  14. package/dist/api/handlers.js +427 -30
  15. package/dist/api/handlers.js.map +1 -1
  16. package/dist/security/api-key-enhanced.d.ts +48 -5
  17. package/dist/security/api-key-enhanced.d.ts.map +1 -1
  18. package/dist/security/api-key-enhanced.js +60 -9
  19. package/dist/security/api-key-enhanced.js.map +1 -1
  20. package/generated/browser.ts +109 -0
  21. package/generated/client.ts +133 -0
  22. package/generated/commonInputTypes.ts +709 -0
  23. package/generated/enums.ts +125 -0
  24. package/generated/internal/class.ts +376 -0
  25. package/generated/internal/prismaNamespace.ts +2617 -0
  26. package/generated/internal/prismaNamespaceBrowser.ts +611 -0
  27. package/generated/models/ApiKey.ts +1550 -0
  28. package/generated/models/AuditLog.ts +1206 -0
  29. package/generated/models/BackupRecord.ts +1250 -0
  30. package/generated/models/ContentLock.ts +1472 -0
  31. package/generated/models/ContentTemplate.ts +1416 -0
  32. package/generated/models/Document.ts +3005 -0
  33. package/generated/models/Folder.ts +1904 -0
  34. package/generated/models/FormSubmission.ts +1200 -0
  35. package/generated/models/InAppNotification.ts +1457 -0
  36. package/generated/models/Media.ts +2340 -0
  37. package/generated/models/MediaUsage.ts +1472 -0
  38. package/generated/models/OAuthAccount.ts +1463 -0
  39. package/generated/models/Redirect.ts +1284 -0
  40. package/generated/models/Session.ts +1492 -0
  41. package/generated/models/Site.ts +1206 -0
  42. package/generated/models/User.ts +3513 -0
  43. package/generated/models/Version.ts +1511 -0
  44. package/generated/models/WorkflowState.ts +1514 -0
  45. package/generated/models.ts +29 -0
  46. package/package.json +1 -1
  47. package/prisma/cms-schema.prisma +306 -306
  48. package/prisma/migrations/0001_init/migration.sql +384 -384
  49. package/prisma/migrations/0002_folders/migration.sql +39 -39
  50. package/prisma/migrations/0003_search_and_webhooks/migration.sql +50 -50
  51. package/prisma/migrations/0004_script_tags/migration.sql +21 -21
  52. package/prisma/migrations/0005_password_reset_tokens/migration.sql +20 -20
  53. package/prisma/migrations/0006_page_builder/migration.sql +38 -38
  54. package/prisma/migrations/migration_lock.toml +3 -3
  55. package/prisma/schema.prisma +549 -549
@@ -33,6 +33,7 @@ import { validateMimeType, checkMagicBytes } from '../security/upload.js';
33
33
  import { sanitizeHtml } from '../security/sanitize.js';
34
34
  import { getActuateConfig, getActuateCoreVersion } from '../config/runtime.js';
35
35
  import { validateEnvShape } from '../diagnostics/env.js';
36
+ import { generateApiKey, hashApiKey, looksLikeApiKey, validateApiKeyScope, validateApiKeyGlobalScope, validateApiKeyMediaScope, validateApiKeyPageBuilderScope, validateApiKeyIp, } from '../security/api-key-enhanced.js';
36
37
  // Opaque dynamic import so Turbopack/webpack won't statically analyze the specifier.
37
38
  // Returns { put, del, ... } from @vercel/blob when available.
38
39
  async function importBlobStorage() {
@@ -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,
@@ -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({
@@ -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,6 +1434,9 @@ export function registerCMSRoutes(router) {
1330
1434
  const auth = await requireAuth(request);
1331
1435
  if (auth.error)
1332
1436
  return auth.error;
1437
+ const scopeErr = requireMediaScope(auth.session);
1438
+ if (scopeErr)
1439
+ return scopeErr;
1333
1440
  // Reject *before* buffering the body. We require a valid content-length
1334
1441
  // header so that chunked / no-length requests (which would otherwise
1335
1442
  // bypass this gate and allow `request.formData()` to buffer unbounded
@@ -1661,9 +1768,14 @@ export function registerCMSRoutes(router) {
1661
1768
  const auth = await requireAuth(request);
1662
1769
  if (auth.error)
1663
1770
  return auth.error;
1664
- const roleErr = requireRole(auth.session.role, WRITE_ROLES);
1665
- if (roleErr)
1666
- 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
+ }
1667
1779
  const body = (await request.json());
1668
1780
  const updated = await db().media.update({
1669
1781
  where: { id: params.id },
@@ -1686,9 +1798,14 @@ export function registerCMSRoutes(router) {
1686
1798
  const auth = await requireAuth(request);
1687
1799
  if (auth.error)
1688
1800
  return auth.error;
1689
- const roleErr = requireRole(auth.session.role, WRITE_ROLES);
1690
- if (roleErr)
1691
- 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
+ }
1692
1809
  const media = await db().media.findUnique({ where: { id: params.id } });
1693
1810
  if (!media) {
1694
1811
  return errorResponse('Media not found', 404);
@@ -2218,6 +2335,163 @@ export function registerCMSRoutes(router) {
2218
2335
  }
2219
2336
  });
2220
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
+ // ---------------------------------------------------------------------------
2221
2495
  // Users route
2222
2496
  // ---------------------------------------------------------------------------
2223
2497
  router.get('/users', async (request) => {
@@ -3667,6 +3941,9 @@ export function registerCMSRoutes(router) {
3667
3941
  const auth = await requireAuth(request);
3668
3942
  if (auth.error)
3669
3943
  return auth.error;
3944
+ const scopeErr = requireGlobalScope(auth.session, params.slug);
3945
+ if (scopeErr)
3946
+ return scopeErr;
3670
3947
  const ctx = buildActionContext(auth.session, db());
3671
3948
  const global = await getGlobal(params.slug, ctx);
3672
3949
  if (!global) {
@@ -3683,9 +3960,14 @@ export function registerCMSRoutes(router) {
3683
3960
  const auth = await requireAuth(request);
3684
3961
  if (auth.error)
3685
3962
  return auth.error;
3686
- const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
3687
- if (roleErr)
3688
- 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
+ }
3689
3971
  const body = (await request.json());
3690
3972
  const ctx = buildActionContext(auth.session, db());
3691
3973
  const global = await updateGlobal(params.slug, body, ctx);
@@ -4463,9 +4745,9 @@ export function registerCMSRoutes(router) {
4463
4745
  const auth = await requireAuth(request);
4464
4746
  if (auth.error)
4465
4747
  return auth.error;
4466
- const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4467
- if (roleErr)
4468
- return roleErr;
4748
+ const scopeErr = requirePageBuilderScope(auth.session);
4749
+ if (scopeErr)
4750
+ return scopeErr;
4469
4751
  // Per-user rate limit. AI generation is the single most expensive
4470
4752
  // operation in the CMS — without this, a compromised admin account
4471
4753
  // can drain a provider key in minutes.
@@ -4535,9 +4817,9 @@ export function registerCMSRoutes(router) {
4535
4817
  const auth = await requireAuth(request);
4536
4818
  if (auth.error)
4537
4819
  return auth.error;
4538
- const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4539
- if (roleErr)
4540
- return roleErr;
4820
+ const scopeErr = requirePageBuilderScope(auth.session);
4821
+ if (scopeErr)
4822
+ return scopeErr;
4541
4823
  if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-block:${auth.session.userId}`))) {
4542
4824
  return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
4543
4825
  }
@@ -4568,14 +4850,129 @@ export function registerCMSRoutes(router) {
4568
4850
  return internalError(err, 'page-builder generate-block');
4569
4851
  }
4570
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
+ });
4571
4968
  router.post('/page-builder/audit-a11y', async (request) => {
4572
4969
  try {
4573
4970
  const auth = await requireAuth(request);
4574
4971
  if (auth.error)
4575
4972
  return auth.error;
4576
- const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4577
- if (roleErr)
4578
- return roleErr;
4973
+ const scopeErr = requirePageBuilderScope(auth.session);
4974
+ if (scopeErr)
4975
+ return scopeErr;
4579
4976
  const body = await request.json();
4580
4977
  const tree = body.tree;
4581
4978
  if (!tree || tree.type !== 'page') {
@@ -4593,9 +4990,9 @@ export function registerCMSRoutes(router) {
4593
4990
  const auth = await requireAuth(request);
4594
4991
  if (auth.error)
4595
4992
  return auth.error;
4596
- const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4597
- if (roleErr)
4598
- return roleErr;
4993
+ const scopeErr = requirePageBuilderScope(auth.session);
4994
+ if (scopeErr)
4995
+ return scopeErr;
4599
4996
  const body = await request.json();
4600
4997
  const tree = body.tree;
4601
4998
  if (!tree || tree.type !== 'page') {