@actuate-media/cms-core 0.13.0 → 0.15.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 (73) 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 +254 -0
  5. package/dist/__tests__/api/api-key-auth.test.js.map +1 -0
  6. package/dist/__tests__/api/public-seo.test.d.ts +2 -0
  7. package/dist/__tests__/api/public-seo.test.d.ts.map +1 -0
  8. package/dist/__tests__/api/public-seo.test.js +341 -0
  9. package/dist/__tests__/api/public-seo.test.js.map +1 -0
  10. package/dist/__tests__/security/api-key-enhanced.test.d.ts +2 -0
  11. package/dist/__tests__/security/api-key-enhanced.test.d.ts.map +1 -0
  12. package/dist/__tests__/security/api-key-enhanced.test.js +110 -0
  13. package/dist/__tests__/security/api-key-enhanced.test.js.map +1 -0
  14. package/dist/__tests__/seo/page-meta.test.d.ts +2 -0
  15. package/dist/__tests__/seo/page-meta.test.d.ts.map +1 -0
  16. package/dist/__tests__/seo/page-meta.test.js +204 -0
  17. package/dist/__tests__/seo/page-meta.test.js.map +1 -0
  18. package/dist/api/handler-factory.d.ts.map +1 -1
  19. package/dist/api/handler-factory.js +20 -2
  20. package/dist/api/handler-factory.js.map +1 -1
  21. package/dist/api/handlers.d.ts.map +1 -1
  22. package/dist/api/handlers.js +764 -31
  23. package/dist/api/handlers.js.map +1 -1
  24. package/dist/config/types.d.ts +75 -0
  25. package/dist/config/types.d.ts.map +1 -1
  26. package/dist/security/api-key-enhanced.d.ts +48 -5
  27. package/dist/security/api-key-enhanced.d.ts.map +1 -1
  28. package/dist/security/api-key-enhanced.js +60 -9
  29. package/dist/security/api-key-enhanced.js.map +1 -1
  30. package/dist/seo/index.d.ts +2 -0
  31. package/dist/seo/index.d.ts.map +1 -1
  32. package/dist/seo/index.js +1 -0
  33. package/dist/seo/index.js.map +1 -1
  34. package/dist/seo/page-meta.d.ts +79 -0
  35. package/dist/seo/page-meta.d.ts.map +1 -0
  36. package/dist/seo/page-meta.js +209 -0
  37. package/dist/seo/page-meta.js.map +1 -0
  38. package/generated/browser.ts +109 -0
  39. package/generated/client.ts +133 -0
  40. package/generated/commonInputTypes.ts +709 -0
  41. package/generated/enums.ts +125 -0
  42. package/generated/internal/class.ts +376 -0
  43. package/generated/internal/prismaNamespace.ts +2617 -0
  44. package/generated/internal/prismaNamespaceBrowser.ts +611 -0
  45. package/generated/models/ApiKey.ts +1550 -0
  46. package/generated/models/AuditLog.ts +1206 -0
  47. package/generated/models/BackupRecord.ts +1250 -0
  48. package/generated/models/ContentLock.ts +1472 -0
  49. package/generated/models/ContentTemplate.ts +1416 -0
  50. package/generated/models/Document.ts +3005 -0
  51. package/generated/models/Folder.ts +1904 -0
  52. package/generated/models/FormSubmission.ts +1200 -0
  53. package/generated/models/InAppNotification.ts +1457 -0
  54. package/generated/models/Media.ts +2340 -0
  55. package/generated/models/MediaUsage.ts +1472 -0
  56. package/generated/models/OAuthAccount.ts +1463 -0
  57. package/generated/models/Redirect.ts +1284 -0
  58. package/generated/models/Session.ts +1492 -0
  59. package/generated/models/Site.ts +1206 -0
  60. package/generated/models/User.ts +3513 -0
  61. package/generated/models/Version.ts +1511 -0
  62. package/generated/models/WorkflowState.ts +1514 -0
  63. package/generated/models.ts +29 -0
  64. package/package.json +1 -1
  65. package/prisma/cms-schema.prisma +306 -306
  66. package/prisma/migrations/0001_init/migration.sql +384 -384
  67. package/prisma/migrations/0002_folders/migration.sql +39 -39
  68. package/prisma/migrations/0003_search_and_webhooks/migration.sql +50 -50
  69. package/prisma/migrations/0004_script_tags/migration.sql +21 -21
  70. package/prisma/migrations/0005_password_reset_tokens/migration.sql +20 -20
  71. package/prisma/migrations/0006_page_builder/migration.sql +38 -38
  72. package/prisma/migrations/migration_lock.toml +3 -3
  73. 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() {
@@ -176,6 +177,79 @@ function modelNotAvailable(name) {
176
177
  'Run `actuate db:init` for new schemas, or carefully update the existing Actuate block, create/apply a Prisma migration, then regenerate Prisma Client. ' +
177
178
  'See https://actuatecms.dev/docs/database-setup for required models.', 501);
178
179
  }
180
+ /**
181
+ * XML-escape a value for safe inclusion in sitemap content. Sitemaps reject
182
+ * unescaped ampersands and angle brackets; URLs frequently contain `&` query
183
+ * separators, so this is non-optional.
184
+ */
185
+ function escapeXml(value) {
186
+ return value
187
+ .replace(/&/g, '&')
188
+ .replace(/</g, '&lt;')
189
+ .replace(/>/g, '&gt;')
190
+ .replace(/"/g, '&quot;')
191
+ .replace(/'/g, '&apos;');
192
+ }
193
+ /**
194
+ * Renders a 1200x630 SVG suitable for og:image. We don't pull in Satori/resvg
195
+ * because most integrators don't need PNG — major crawlers (Facebook, Twitter,
196
+ * LinkedIn, Slack, Discord) handle image/svg+xml correctly. Sites that
197
+ * specifically need PNG can override with their own /og endpoint via
198
+ * @vercel/og and point `seo.defaultOgImage` at it.
199
+ */
200
+ function renderOgSvg(opts) {
201
+ const { title, description, siteName, bg, fg, muted } = opts;
202
+ // Naive line wrapping at ~22 chars (large font). Good enough for an OG card;
203
+ // anything longer than ~3 lines gets truncated with an ellipsis.
204
+ const wrap = (text, maxChars, maxLines) => {
205
+ const words = text.split(/\s+/);
206
+ const lines = [];
207
+ let current = '';
208
+ for (const w of words) {
209
+ if (lines.length >= maxLines)
210
+ break;
211
+ const next = current ? `${current} ${w}` : w;
212
+ if (next.length > maxChars) {
213
+ if (current)
214
+ lines.push(current);
215
+ current = w;
216
+ if (lines.length === maxLines - 1 && words.indexOf(w) < words.length - 1) {
217
+ lines.push(current.length > maxChars ? current.slice(0, maxChars - 1) + '…' : current + '…');
218
+ current = '';
219
+ break;
220
+ }
221
+ }
222
+ else {
223
+ current = next;
224
+ }
225
+ }
226
+ if (current && lines.length < maxLines)
227
+ lines.push(current);
228
+ return lines;
229
+ };
230
+ const escapeSvg = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
231
+ const titleLines = wrap(title, 28, 3);
232
+ const descLines = description ? wrap(description, 60, 2) : [];
233
+ const titleEls = titleLines
234
+ .map((line, i) => `<tspan x="60" dy="${i === 0 ? 0 : 78}">${escapeSvg(line)}</tspan>`)
235
+ .join('');
236
+ const descEls = descLines
237
+ .map((line, i) => `<tspan x="60" dy="${i === 0 ? 0 : 36}">${escapeSvg(line)}</tspan>`)
238
+ .join('');
239
+ // Layout: site name top-left, title bottom-left, description below title.
240
+ // Coordinates are roughly aligned to the 1200x630 spec used by every major
241
+ // social platform.
242
+ const titleY = descLines.length > 0 ? 360 : 420;
243
+ const descY = titleY + 80 * titleLines.length;
244
+ return `<?xml version="1.0" encoding="UTF-8"?>
245
+ <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
246
+ <rect width="1200" height="630" fill="${bg}"/>
247
+ ${siteName ? `<text x="60" y="100" font-family="system-ui, -apple-system, Segoe UI, Arial, sans-serif" font-size="28" font-weight="500" fill="${muted}">${escapeSvg(siteName)}</text>` : ''}
248
+ <text x="60" y="${titleY}" font-family="system-ui, -apple-system, Segoe UI, Arial, sans-serif" font-size="68" font-weight="700" fill="${fg}">${titleEls}</text>
249
+ ${descLines.length > 0 ? `<text x="60" y="${descY}" font-family="system-ui, -apple-system, Segoe UI, Arial, sans-serif" font-size="30" fill="${muted}">${descEls}</text>` : ''}
250
+ <rect x="60" y="560" width="60" height="6" fill="${fg}" rx="3"/>
251
+ </svg>`;
252
+ }
179
253
  async function safeCount(model, where) {
180
254
  try {
181
255
  if (!model || typeof model !== 'object')
@@ -293,6 +367,49 @@ async function extractSession(request) {
293
367
  }
294
368
  if (!token)
295
369
  return null;
370
+ // API key path. Keys are recognized by the `act_sk_` prefix and looked up
371
+ // by SHA-256 hash. We never JWT-verify these tokens — they are opaque
372
+ // random secrets stored as hashes in `actuate_api_keys`.
373
+ if (looksLikeApiKey(token)) {
374
+ try {
375
+ const d = getDB();
376
+ if (!hasModel(d, 'apiKey'))
377
+ return null;
378
+ const hash = await hashApiKey(token);
379
+ const apiKey = await d.apiKey.findUnique({ where: { keyHash: hash } });
380
+ if (!apiKey)
381
+ return null;
382
+ if (apiKey.revokedAt)
383
+ return null;
384
+ if (apiKey.expiresAt && new Date(apiKey.expiresAt).getTime() < Date.now())
385
+ return null;
386
+ const ipRestrictions = Array.isArray(apiKey.ipRestrictions)
387
+ ? apiKey.ipRestrictions
388
+ : apiKey.ipRestrictions
389
+ ? (apiKey.ipRestrictions.allow ?? null)
390
+ : null;
391
+ if (ipRestrictions && ipRestrictions.length > 0) {
392
+ const ip = getClientIp(request);
393
+ if (!validateApiKeyIp(ipRestrictions, ip))
394
+ return null;
395
+ }
396
+ const scopes = apiKey.scopes ?? {};
397
+ // Fire-and-forget lastUsedAt update; never block the request on it.
398
+ void d.apiKey
399
+ .update({ where: { id: apiKey.id }, data: { lastUsedAt: new Date() } })
400
+ .catch(() => { });
401
+ return {
402
+ userId: apiKey.userId,
403
+ role: scopes.admin ? 'ADMIN' : 'API_KEY',
404
+ sessionId: apiKey.id,
405
+ apiKey: { id: apiKey.id, scopes },
406
+ };
407
+ }
408
+ catch {
409
+ return null;
410
+ }
411
+ }
412
+ // Session JWT path.
296
413
  try {
297
414
  const payload = await verifySession(token, { secret: getSessionSecret() });
298
415
  const d = getDB();
@@ -365,6 +482,57 @@ async function requireAuth(request) {
365
482
  }
366
483
  return { session };
367
484
  }
485
+ /**
486
+ * Check that the request's auth context permits `action` on `collection`.
487
+ * - Session-authenticated requests fall through to `requireRole` semantics:
488
+ * write actions require WRITE_ROLES.
489
+ * - API-key-authenticated requests must have an explicit scope match.
490
+ */
491
+ function requireCollectionScope(session, collection, action) {
492
+ if (session.apiKey) {
493
+ if (!validateApiKeyScope(session.apiKey.scopes, collection, action)) {
494
+ return errorResponse(`API key does not have permission to ${action} on collection "${collection}"`, 403);
495
+ }
496
+ return null;
497
+ }
498
+ if (action === 'read')
499
+ return null;
500
+ return requireRole(session.role, WRITE_ROLES);
501
+ }
502
+ function requireGlobalScope(session, slug) {
503
+ if (session.apiKey) {
504
+ if (!validateApiKeyGlobalScope(session.apiKey.scopes, slug)) {
505
+ return errorResponse(`API key does not have permission for global "${slug}"`, 403);
506
+ }
507
+ }
508
+ return null;
509
+ }
510
+ function requireMediaScope(session) {
511
+ if (session.apiKey) {
512
+ if (!validateApiKeyMediaScope(session.apiKey.scopes)) {
513
+ return errorResponse('API key does not have media scope', 403);
514
+ }
515
+ }
516
+ return null;
517
+ }
518
+ function requirePageBuilderScope(session) {
519
+ if (session.apiKey) {
520
+ if (!validateApiKeyPageBuilderScope(session.apiKey.scopes)) {
521
+ return errorResponse('API key does not have pageBuilder scope', 403);
522
+ }
523
+ return null;
524
+ }
525
+ return requireRole(session.role, ADMIN_ROLES);
526
+ }
527
+ function requireAdminScope(session) {
528
+ if (session.apiKey) {
529
+ if (!session.apiKey.scopes.admin) {
530
+ return errorResponse('API key does not have admin scope', 403);
531
+ }
532
+ return null;
533
+ }
534
+ return requireRole(session.role, ADMIN_ROLES);
535
+ }
368
536
  function buildActionContext(session, db, locale) {
369
537
  return {
370
538
  userId: session.userId,
@@ -1118,6 +1286,9 @@ export function registerCMSRoutes(router) {
1118
1286
  const auth = await requireAuth(request);
1119
1287
  if (auth.error)
1120
1288
  return auth.error;
1289
+ const scopeErr = requireCollectionScope(auth.session, params.slug, 'read');
1290
+ if (scopeErr)
1291
+ return scopeErr;
1121
1292
  const url = new URL(request.url);
1122
1293
  const ctx = buildActionContext(auth.session, db(), url.searchParams.get('locale') ?? undefined);
1123
1294
  const result = await listDocuments({
@@ -1155,6 +1326,9 @@ export function registerCMSRoutes(router) {
1155
1326
  const auth = await requireAuth(request);
1156
1327
  if (auth.error)
1157
1328
  return auth.error;
1329
+ const scopeErr = requireCollectionScope(auth.session, params.slug, 'read');
1330
+ if (scopeErr)
1331
+ return scopeErr;
1158
1332
  const ctx = buildActionContext(auth.session, db());
1159
1333
  const doc = await getDocument(params.slug, params.id, ctx);
1160
1334
  if (!doc) {
@@ -1175,9 +1349,9 @@ export function registerCMSRoutes(router) {
1175
1349
  const auth = await requireAuth(request);
1176
1350
  if (auth.error)
1177
1351
  return auth.error;
1178
- const roleErr = requireRole(auth.session.role, WRITE_ROLES);
1179
- if (roleErr)
1180
- return roleErr;
1352
+ const scopeErr = requireCollectionScope(auth.session, params.slug, 'create');
1353
+ if (scopeErr)
1354
+ return scopeErr;
1181
1355
  const body = (await request.json());
1182
1356
  const ctx = buildActionContext(auth.session, db());
1183
1357
  const doc = await createDocument(params.slug, body, ctx);
@@ -1197,9 +1371,9 @@ export function registerCMSRoutes(router) {
1197
1371
  const auth = await requireAuth(request);
1198
1372
  if (auth.error)
1199
1373
  return auth.error;
1200
- const roleErr = requireRole(auth.session.role, WRITE_ROLES);
1201
- if (roleErr)
1202
- return roleErr;
1374
+ const scopeErr = requireCollectionScope(auth.session, params.slug, 'update');
1375
+ if (scopeErr)
1376
+ return scopeErr;
1203
1377
  const body = (await request.json());
1204
1378
  const ctx = buildActionContext(auth.session, db());
1205
1379
  const doc = await updateDocument(params.slug, params.id, body, ctx);
@@ -1219,9 +1393,9 @@ export function registerCMSRoutes(router) {
1219
1393
  const auth = await requireAuth(request);
1220
1394
  if (auth.error)
1221
1395
  return auth.error;
1222
- const roleErr = requireRole(auth.session.role, WRITE_ROLES);
1223
- if (roleErr)
1224
- return roleErr;
1396
+ const scopeErr = requireCollectionScope(auth.session, params.slug, 'delete');
1397
+ if (scopeErr)
1398
+ return scopeErr;
1225
1399
  const ctx = buildActionContext(auth.session, db());
1226
1400
  await deleteDocument(params.slug, params.id, ctx);
1227
1401
  await logEvent({
@@ -1286,6 +1460,9 @@ export function registerCMSRoutes(router) {
1286
1460
  const auth = await requireAuth(request);
1287
1461
  if (auth.error)
1288
1462
  return auth.error;
1463
+ const scopeErr = requireMediaScope(auth.session);
1464
+ if (scopeErr)
1465
+ return scopeErr;
1289
1466
  const body = (await request.json());
1290
1467
  if (!body.filename || !body.contentType) {
1291
1468
  return errorResponse('filename and contentType are required', 400);
@@ -1330,6 +1507,9 @@ export function registerCMSRoutes(router) {
1330
1507
  const auth = await requireAuth(request);
1331
1508
  if (auth.error)
1332
1509
  return auth.error;
1510
+ const scopeErr = requireMediaScope(auth.session);
1511
+ if (scopeErr)
1512
+ return scopeErr;
1333
1513
  // Reject *before* buffering the body. We require a valid content-length
1334
1514
  // header so that chunked / no-length requests (which would otherwise
1335
1515
  // bypass this gate and allow `request.formData()` to buffer unbounded
@@ -1661,9 +1841,14 @@ export function registerCMSRoutes(router) {
1661
1841
  const auth = await requireAuth(request);
1662
1842
  if (auth.error)
1663
1843
  return auth.error;
1664
- const roleErr = requireRole(auth.session.role, WRITE_ROLES);
1665
- if (roleErr)
1666
- return roleErr;
1844
+ const scopeErr = requireMediaScope(auth.session);
1845
+ if (scopeErr)
1846
+ return scopeErr;
1847
+ if (!auth.session.apiKey) {
1848
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
1849
+ if (roleErr)
1850
+ return roleErr;
1851
+ }
1667
1852
  const body = (await request.json());
1668
1853
  const updated = await db().media.update({
1669
1854
  where: { id: params.id },
@@ -1686,9 +1871,14 @@ export function registerCMSRoutes(router) {
1686
1871
  const auth = await requireAuth(request);
1687
1872
  if (auth.error)
1688
1873
  return auth.error;
1689
- const roleErr = requireRole(auth.session.role, WRITE_ROLES);
1690
- if (roleErr)
1691
- return roleErr;
1874
+ const scopeErr = requireMediaScope(auth.session);
1875
+ if (scopeErr)
1876
+ return scopeErr;
1877
+ if (!auth.session.apiKey) {
1878
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
1879
+ if (roleErr)
1880
+ return roleErr;
1881
+ }
1692
1882
  const media = await db().media.findUnique({ where: { id: params.id } });
1693
1883
  if (!media) {
1694
1884
  return errorResponse('Media not found', 404);
@@ -2218,6 +2408,163 @@ export function registerCMSRoutes(router) {
2218
2408
  }
2219
2409
  });
2220
2410
  // ---------------------------------------------------------------------------
2411
+ // API key management (admin-only). Keys are created here, hashed in the DB,
2412
+ // and shown to the caller exactly once. They authenticate programmatic
2413
+ // clients (AI agents, CI, integrations) against the same REST surface as a
2414
+ // user session, but skip CSRF and use scope-based authorization.
2415
+ // ---------------------------------------------------------------------------
2416
+ router.get('/api-keys', async (request) => {
2417
+ try {
2418
+ const auth = await requireAuth(request);
2419
+ if (auth.error)
2420
+ return auth.error;
2421
+ const adminErr = requireAdminScope(auth.session);
2422
+ if (adminErr)
2423
+ return adminErr;
2424
+ const d = db();
2425
+ if (!hasModel(d, 'apiKey'))
2426
+ return json({ data: [] });
2427
+ const keys = await d.apiKey.findMany({
2428
+ orderBy: { createdAt: 'desc' },
2429
+ select: {
2430
+ id: true,
2431
+ name: true,
2432
+ keyPrefix: true,
2433
+ scopes: true,
2434
+ ipRestrictions: true,
2435
+ expiresAt: true,
2436
+ lastUsedAt: true,
2437
+ revokedAt: true,
2438
+ createdAt: true,
2439
+ user: { select: { id: true, name: true, email: true } },
2440
+ },
2441
+ });
2442
+ return json({ data: keys });
2443
+ }
2444
+ catch (err) {
2445
+ return internalError(err, 'api-keys/list');
2446
+ }
2447
+ });
2448
+ router.post('/api-keys', async (request) => {
2449
+ try {
2450
+ const auth = await requireAuth(request);
2451
+ if (auth.error)
2452
+ return auth.error;
2453
+ const adminErr = requireAdminScope(auth.session);
2454
+ if (adminErr)
2455
+ return adminErr;
2456
+ // Issuing a new credential is a sensitive action — require reauth for
2457
+ // session-authenticated callers. API-key-authenticated requests are
2458
+ // already proving possession of a long-lived credential.
2459
+ if (!auth.session.apiKey) {
2460
+ const reauthErr = await requirePasswordReauth(request, auth.session.userId);
2461
+ if (reauthErr)
2462
+ return reauthErr;
2463
+ }
2464
+ const body = (await request.json());
2465
+ if (!body.name || typeof body.name !== 'string' || body.name.trim().length === 0) {
2466
+ return errorResponse('name is required', 400);
2467
+ }
2468
+ if (body.name.length > 100) {
2469
+ return errorResponse('name must be 100 characters or fewer', 400);
2470
+ }
2471
+ const scopes = body.scopes ?? {};
2472
+ // Sanity check the scope shape so we don't store garbage that fails
2473
+ // every authorization check at runtime.
2474
+ if (!scopes.admin &&
2475
+ !scopes.media &&
2476
+ !scopes.pageBuilder &&
2477
+ (!scopes.collections || scopes.collections.length === 0) &&
2478
+ (!scopes.globals || scopes.globals.length === 0)) {
2479
+ return errorResponse('scopes must grant at least one capability (admin, media, pageBuilder, collections, or globals)', 400);
2480
+ }
2481
+ let expiresAt;
2482
+ if (body.expiresAt) {
2483
+ const parsed = new Date(body.expiresAt);
2484
+ if (isNaN(parsed.getTime()))
2485
+ return errorResponse('Invalid expiresAt date', 400);
2486
+ if (parsed.getTime() < Date.now())
2487
+ return errorResponse('expiresAt must be in the future', 400);
2488
+ expiresAt = parsed;
2489
+ }
2490
+ const { key, keyHash, keyPrefix } = await generateApiKey({
2491
+ prefix: 'act_sk',
2492
+ scopes,
2493
+ expiresAt,
2494
+ });
2495
+ const d = db();
2496
+ const record = await d.apiKey.create({
2497
+ data: {
2498
+ name: body.name.trim(),
2499
+ keyHash,
2500
+ keyPrefix,
2501
+ userId: auth.session.userId,
2502
+ scopes: scopes,
2503
+ ipRestrictions: body.ipRestrictions?.length ? body.ipRestrictions : null,
2504
+ expiresAt: expiresAt ?? null,
2505
+ },
2506
+ select: {
2507
+ id: true,
2508
+ name: true,
2509
+ keyPrefix: true,
2510
+ scopes: true,
2511
+ ipRestrictions: true,
2512
+ expiresAt: true,
2513
+ createdAt: true,
2514
+ },
2515
+ });
2516
+ await logEvent({
2517
+ event: 'api_key_created',
2518
+ userId: auth.session.userId,
2519
+ ipAddress: clientIp(request),
2520
+ userAgent: request.headers.get('user-agent') ?? undefined,
2521
+ details: { apiKeyId: record.id, name: record.name, scopes },
2522
+ });
2523
+ // The raw `key` is the only thing the caller will ever see; we never
2524
+ // store it in plaintext. Document this in the response shape.
2525
+ return json({ data: { ...record, key } }, 201);
2526
+ }
2527
+ catch (err) {
2528
+ return internalError(err, 'api-keys/create');
2529
+ }
2530
+ });
2531
+ router.delete('/api-keys/:id', async (request, params) => {
2532
+ try {
2533
+ const auth = await requireAuth(request);
2534
+ if (auth.error)
2535
+ return auth.error;
2536
+ const adminErr = requireAdminScope(auth.session);
2537
+ if (adminErr)
2538
+ return adminErr;
2539
+ // Same reauth gate as creation — revocation is irreversible and
2540
+ // affects every dependent client.
2541
+ if (!auth.session.apiKey) {
2542
+ const reauthErr = await requirePasswordReauth(request, auth.session.userId);
2543
+ if (reauthErr)
2544
+ return reauthErr;
2545
+ }
2546
+ const d = db();
2547
+ const existing = await d.apiKey.findUnique({ where: { id: params.id } });
2548
+ if (!existing)
2549
+ return errorResponse('API key not found', 404);
2550
+ await d.apiKey.update({
2551
+ where: { id: params.id },
2552
+ data: { revokedAt: new Date() },
2553
+ });
2554
+ await logEvent({
2555
+ event: 'api_key_revoked',
2556
+ userId: auth.session.userId,
2557
+ ipAddress: clientIp(request),
2558
+ userAgent: request.headers.get('user-agent') ?? undefined,
2559
+ details: { apiKeyId: existing.id, name: existing.name },
2560
+ });
2561
+ return json({ data: { revoked: true } });
2562
+ }
2563
+ catch (err) {
2564
+ return internalError(err, 'api-keys/revoke');
2565
+ }
2566
+ });
2567
+ // ---------------------------------------------------------------------------
2221
2568
  // Users route
2222
2569
  // ---------------------------------------------------------------------------
2223
2570
  router.get('/users', async (request) => {
@@ -2844,6 +3191,237 @@ export function registerCMSRoutes(router) {
2844
3191
  return internalError(err, 'llms.txt');
2845
3192
  }
2846
3193
  });
3194
+ // ---------------------------------------------------------------------------
3195
+ // Public SEO surfaces: sitemap.xml, per-collection sitemaps, robots.txt,
3196
+ // and the dynamic /og.png OG-image endpoint. These are unauthenticated by
3197
+ // design — search engines and social crawlers will fetch them.
3198
+ // ---------------------------------------------------------------------------
3199
+ function siteUrlFromRequest(request) {
3200
+ const cfg = getActuateConfig()?.seo;
3201
+ if (cfg?.siteUrl)
3202
+ return cfg.siteUrl.replace(/\/+$/, '');
3203
+ // Fall back to the request origin so the routes work on preview deploys
3204
+ // before the integrator has configured siteUrl.
3205
+ try {
3206
+ const u = new URL(request.url);
3207
+ return `${u.protocol}//${u.host}`;
3208
+ }
3209
+ catch {
3210
+ return '';
3211
+ }
3212
+ }
3213
+ function sitemapEligibleCollections() {
3214
+ const cfg = getActuateConfig();
3215
+ if (!cfg)
3216
+ return [];
3217
+ const excluded = new Set(cfg.seo?.sitemap?.excludeCollections ?? []);
3218
+ const out = [];
3219
+ for (const [slug, col] of Object.entries(cfg.collections ?? {})) {
3220
+ if (excluded.has(slug))
3221
+ continue;
3222
+ if (col.seo?.excludeFromSitemap)
3223
+ continue;
3224
+ out.push({ slug, urlPrefix: col.urlPrefix, type: col.type, seo: col.seo });
3225
+ }
3226
+ return out;
3227
+ }
3228
+ router.get('/sitemap.xml', async (request) => {
3229
+ try {
3230
+ const cfg = getActuateConfig();
3231
+ if (cfg?.seo?.sitemap?.disabled)
3232
+ return errorResponse('Sitemap disabled', 404);
3233
+ const base = siteUrlFromRequest(request);
3234
+ const cols = sitemapEligibleCollections();
3235
+ // Sitemap index points at /sitemaps/:slug.xml for each collection.
3236
+ const sitemaps = cols
3237
+ .map((c) => [
3238
+ ' <sitemap>',
3239
+ ` <loc>${base}/api/cms/sitemaps/${c.slug}.xml</loc>`,
3240
+ ` <lastmod>${new Date().toISOString()}</lastmod>`,
3241
+ ' </sitemap>',
3242
+ ].join('\n'))
3243
+ .join('\n');
3244
+ const xml = [
3245
+ '<?xml version="1.0" encoding="UTF-8"?>',
3246
+ '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
3247
+ sitemaps,
3248
+ '</sitemapindex>',
3249
+ ].join('\n');
3250
+ return new Response(xml, {
3251
+ status: 200,
3252
+ headers: {
3253
+ 'Content-Type': 'application/xml; charset=utf-8',
3254
+ 'Cache-Control': 'public, max-age=300, s-maxage=600',
3255
+ },
3256
+ });
3257
+ }
3258
+ catch (err) {
3259
+ return internalError(err, 'sitemap.xml');
3260
+ }
3261
+ });
3262
+ // The router's path-param syntax (`:name`) greedily matches everything up
3263
+ // to the next `/`, so registering as `/sitemaps/:slug.xml` would capture
3264
+ // `slug.xml` into a single param. We register `/sitemaps/:slug` instead and
3265
+ // require the `.xml` suffix in the handler.
3266
+ router.get('/sitemaps/:slug', async (request, params) => {
3267
+ try {
3268
+ const cfg = getActuateConfig();
3269
+ if (cfg?.seo?.sitemap?.disabled)
3270
+ return errorResponse('Sitemap disabled', 404);
3271
+ const slugParam = params.slug ?? '';
3272
+ if (!/\.xml$/i.test(slugParam))
3273
+ return errorResponse('Sitemap not found', 404);
3274
+ const rawSlug = slugParam.replace(/\.xml$/i, '');
3275
+ const collection = cfg?.collections?.[rawSlug];
3276
+ if (!collection)
3277
+ return errorResponse('Collection not found', 404);
3278
+ if (collection.seo?.excludeFromSitemap)
3279
+ return errorResponse('Collection excluded from sitemap', 404);
3280
+ const base = siteUrlFromRequest(request);
3281
+ const docs = await db().document.findMany({
3282
+ where: { collection: rawSlug, deletedAt: null, status: 'PUBLISHED' },
3283
+ select: { slug: true, updatedAt: true, data: true },
3284
+ orderBy: { updatedAt: 'desc' },
3285
+ take: 5000,
3286
+ });
3287
+ const prefix = (collection.urlPrefix ?? '').replace(/^\/|\/$/g, '');
3288
+ const defaultPriority = collection.seo?.sitemapPriority ??
3289
+ cfg?.seo?.sitemap?.defaultPriority ??
3290
+ (collection.type === 'page' ? 0.8 : 0.6);
3291
+ const changefreq = collection.seo?.sitemapChangeFreq ?? cfg?.seo?.sitemap?.defaultChangeFreq ?? 'weekly';
3292
+ const urls = [];
3293
+ // Archive page first (e.g. /blog) for post-type collections.
3294
+ if (collection.seo?.archivePath && collection.type === 'post') {
3295
+ const archive = collection.seo.archivePath.startsWith('http')
3296
+ ? collection.seo.archivePath
3297
+ : `${base}${collection.seo.archivePath}`;
3298
+ urls.push([
3299
+ ' <url>',
3300
+ ` <loc>${escapeXml(archive)}</loc>`,
3301
+ ` <lastmod>${new Date().toISOString()}</lastmod>`,
3302
+ ` <changefreq>${changefreq}</changefreq>`,
3303
+ ' <priority>0.6</priority>',
3304
+ ' </url>',
3305
+ ].join('\n'));
3306
+ }
3307
+ for (const d of docs) {
3308
+ const data = d.data || {};
3309
+ const slug = d.slug ?? data.slug;
3310
+ if (!slug)
3311
+ continue;
3312
+ const loc = prefix ? `${base}/${prefix}/${slug}` : `${base}/${slug}`;
3313
+ urls.push([
3314
+ ' <url>',
3315
+ ` <loc>${escapeXml(loc)}</loc>`,
3316
+ ` <lastmod>${d.updatedAt?.toISOString() ?? new Date().toISOString()}</lastmod>`,
3317
+ ` <changefreq>${changefreq}</changefreq>`,
3318
+ ` <priority>${defaultPriority.toFixed(1)}</priority>`,
3319
+ ' </url>',
3320
+ ].join('\n'));
3321
+ }
3322
+ const xml = [
3323
+ '<?xml version="1.0" encoding="UTF-8"?>',
3324
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
3325
+ urls.join('\n'),
3326
+ '</urlset>',
3327
+ ].join('\n');
3328
+ return new Response(xml, {
3329
+ status: 200,
3330
+ headers: {
3331
+ 'Content-Type': 'application/xml; charset=utf-8',
3332
+ 'Cache-Control': 'public, max-age=300, s-maxage=600',
3333
+ },
3334
+ });
3335
+ }
3336
+ catch (err) {
3337
+ return internalError(err, 'sitemaps/:slug.xml');
3338
+ }
3339
+ });
3340
+ router.get('/robots.txt', async (request) => {
3341
+ try {
3342
+ const cfg = getActuateConfig();
3343
+ const seo = cfg?.seo;
3344
+ if (seo?.robots?.disabled)
3345
+ return errorResponse('robots.txt disabled', 404);
3346
+ const base = siteUrlFromRequest(request);
3347
+ const sitemapUrl = seo?.sitemap?.disabled ? undefined : `${base}/api/cms/sitemap.xml`;
3348
+ const lines = [
3349
+ 'User-agent: *',
3350
+ 'Allow: /',
3351
+ 'Disallow: /admin',
3352
+ 'Disallow: /api/',
3353
+ '',
3354
+ ];
3355
+ for (const rule of seo?.robots?.additionalRules ?? []) {
3356
+ lines.push(`User-agent: ${rule.userAgent}`);
3357
+ for (const allow of rule.allow ?? [])
3358
+ lines.push(`Allow: ${allow}`);
3359
+ for (const dis of rule.disallow ?? [])
3360
+ lines.push(`Disallow: ${dis}`);
3361
+ lines.push('');
3362
+ }
3363
+ if (seo?.robots?.blockAIBots) {
3364
+ const bots = [
3365
+ 'GPTBot',
3366
+ 'ChatGPT-User',
3367
+ 'ClaudeBot',
3368
+ 'Claude-Web',
3369
+ 'anthropic-ai',
3370
+ 'Bytespider',
3371
+ 'CCBot',
3372
+ 'Google-Extended',
3373
+ ];
3374
+ for (const bot of bots) {
3375
+ lines.push(`User-agent: ${bot}`);
3376
+ lines.push('Disallow: /');
3377
+ lines.push('');
3378
+ }
3379
+ }
3380
+ if (sitemapUrl)
3381
+ lines.push(`Sitemap: ${sitemapUrl}`, '');
3382
+ return new Response(lines.join('\n'), {
3383
+ status: 200,
3384
+ headers: {
3385
+ 'Content-Type': 'text/plain; charset=utf-8',
3386
+ 'Cache-Control': 'public, max-age=3600, s-maxage=3600',
3387
+ },
3388
+ });
3389
+ }
3390
+ catch (err) {
3391
+ return internalError(err, 'robots.txt');
3392
+ }
3393
+ });
3394
+ router.get('/og.png', async (request) => {
3395
+ try {
3396
+ const cfg = getActuateConfig();
3397
+ if (cfg?.seo?.ogImage?.disabled)
3398
+ return errorResponse('og.png disabled', 404);
3399
+ const url = new URL(request.url);
3400
+ const title = url.searchParams.get('title') ?? cfg?.seo?.siteName ?? 'Untitled';
3401
+ const description = url.searchParams.get('description') ?? undefined;
3402
+ const siteName = url.searchParams.get('siteName') ?? cfg?.seo?.siteName;
3403
+ const theme = url.searchParams.get('theme') ?? cfg?.seo?.ogImage?.theme ?? 'light';
3404
+ const bg = theme === 'dark' ? '#0a0a0a' : '#ffffff';
3405
+ const fg = theme === 'dark' ? '#fafafa' : '#0a0a0a';
3406
+ const muted = theme === 'dark' ? '#a1a1aa' : '#71717a';
3407
+ // SVG OG image. Crawlers (Facebook, Twitter/X, LinkedIn, Slack, Discord)
3408
+ // all accept image/svg+xml when served with the right Content-Type. We
3409
+ // chose SVG over PNG to avoid forcing a Satori/resvg dependency on
3410
+ // every integrator; sites that need PNG can override with `og:image`
3411
+ // pointing at their own /og endpoint built with @vercel/og.
3412
+ const svg = renderOgSvg({ title, description, siteName, theme, bg, fg, muted });
3413
+ return new Response(svg, {
3414
+ status: 200,
3415
+ headers: {
3416
+ 'Content-Type': 'image/svg+xml; charset=utf-8',
3417
+ 'Cache-Control': 'public, max-age=86400, s-maxage=86400',
3418
+ },
3419
+ });
3420
+ }
3421
+ catch (err) {
3422
+ return internalError(err, 'og.png');
3423
+ }
3424
+ });
2847
3425
  router.get('/seo/schema/:documentId', async (request, params) => {
2848
3426
  try {
2849
3427
  const auth = await requireAuth(request);
@@ -3180,6 +3758,28 @@ export function registerCMSRoutes(router) {
3180
3758
  const docData = doc.data && typeof doc.data === 'object' ? doc.data : {};
3181
3759
  const layout = await resolveLayout(pathParam, docData, matchedCollection);
3182
3760
  const { _layout: _omit, ...cleanData } = docData;
3761
+ // Compose page meta + JSON-LD up front so client renderers (Next.js
3762
+ // generateMetadata, plain SSR, MCP agents) don't have to re-derive
3763
+ // schema, OG tags, and canonical URLs from raw doc data. Reads the
3764
+ // collection's SEO config and the site-wide SEO defaults.
3765
+ const cfg = getActuateConfig();
3766
+ const collectionDef = cfg?.collections?.[matchedCollection] ?? null;
3767
+ const { composePageMeta } = await import('../seo/page-meta.js');
3768
+ const composed = composePageMeta({
3769
+ doc: {
3770
+ id: doc.id,
3771
+ collection: doc.collection,
3772
+ slug: doc.slug ?? cleanData.slug ?? null,
3773
+ data: cleanData,
3774
+ publishedAt: doc.publishedAt,
3775
+ updatedAt: doc.updatedAt,
3776
+ structuredData: doc.structuredData ?? null,
3777
+ pageSettings: cleanData.pageSettings ?? null,
3778
+ },
3779
+ collection: collectionDef,
3780
+ config: cfg ?? null,
3781
+ siteUrl: siteUrlFromRequest(request),
3782
+ });
3183
3783
  return json({
3184
3784
  data: {
3185
3785
  id: doc.id,
@@ -3187,8 +3787,18 @@ export function registerCMSRoutes(router) {
3187
3787
  data: cleanData,
3188
3788
  status: doc.status,
3189
3789
  publishedAt: doc.publishedAt,
3190
- structuredData: doc.structuredData,
3790
+ structuredData: composed.jsonLd ?? doc.structuredData,
3791
+ },
3792
+ meta: {
3793
+ title: composed.title,
3794
+ description: composed.description,
3795
+ canonical: composed.canonical,
3796
+ url: composed.url,
3797
+ tags: composed.meta,
3798
+ html: composed.metaHtml,
3191
3799
  },
3800
+ jsonLd: composed.jsonLd,
3801
+ jsonLdHtml: composed.jsonLdHtml,
3192
3802
  ...(Object.keys(layout).length > 0 ? { layout } : {}),
3193
3803
  });
3194
3804
  }
@@ -3667,6 +4277,9 @@ export function registerCMSRoutes(router) {
3667
4277
  const auth = await requireAuth(request);
3668
4278
  if (auth.error)
3669
4279
  return auth.error;
4280
+ const scopeErr = requireGlobalScope(auth.session, params.slug);
4281
+ if (scopeErr)
4282
+ return scopeErr;
3670
4283
  const ctx = buildActionContext(auth.session, db());
3671
4284
  const global = await getGlobal(params.slug, ctx);
3672
4285
  if (!global) {
@@ -3683,9 +4296,14 @@ export function registerCMSRoutes(router) {
3683
4296
  const auth = await requireAuth(request);
3684
4297
  if (auth.error)
3685
4298
  return auth.error;
3686
- const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
3687
- if (roleErr)
3688
- return roleErr;
4299
+ const scopeErr = requireGlobalScope(auth.session, params.slug);
4300
+ if (scopeErr)
4301
+ return scopeErr;
4302
+ if (!auth.session.apiKey) {
4303
+ const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4304
+ if (roleErr)
4305
+ return roleErr;
4306
+ }
3689
4307
  const body = (await request.json());
3690
4308
  const ctx = buildActionContext(auth.session, db());
3691
4309
  const global = await updateGlobal(params.slug, body, ctx);
@@ -4463,9 +5081,9 @@ export function registerCMSRoutes(router) {
4463
5081
  const auth = await requireAuth(request);
4464
5082
  if (auth.error)
4465
5083
  return auth.error;
4466
- const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4467
- if (roleErr)
4468
- return roleErr;
5084
+ const scopeErr = requirePageBuilderScope(auth.session);
5085
+ if (scopeErr)
5086
+ return scopeErr;
4469
5087
  // Per-user rate limit. AI generation is the single most expensive
4470
5088
  // operation in the CMS — without this, a compromised admin account
4471
5089
  // can drain a provider key in minutes.
@@ -4535,9 +5153,9 @@ export function registerCMSRoutes(router) {
4535
5153
  const auth = await requireAuth(request);
4536
5154
  if (auth.error)
4537
5155
  return auth.error;
4538
- const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4539
- if (roleErr)
4540
- return roleErr;
5156
+ const scopeErr = requirePageBuilderScope(auth.session);
5157
+ if (scopeErr)
5158
+ return scopeErr;
4541
5159
  if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-block:${auth.session.userId}`))) {
4542
5160
  return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
4543
5161
  }
@@ -4568,14 +5186,129 @@ export function registerCMSRoutes(router) {
4568
5186
  return internalError(err, 'page-builder generate-block');
4569
5187
  }
4570
5188
  });
5189
+ /**
5190
+ * One-shot page creation: run the AI page generator and persist the result
5191
+ * as a new document in a single call. Designed for AI agents that want to
5192
+ * create a complete page from a prompt without orchestrating two requests
5193
+ * (generate, then create). Defaults to status=DRAFT so the human reviewer
5194
+ * can polish before publishing.
5195
+ */
5196
+ router.post('/page-builder/create', async (request) => {
5197
+ try {
5198
+ const auth = await requireAuth(request);
5199
+ if (auth.error)
5200
+ return auth.error;
5201
+ const scopeErr = requirePageBuilderScope(auth.session);
5202
+ if (scopeErr)
5203
+ return scopeErr;
5204
+ // The create path also writes to a collection, so the API key must hold
5205
+ // create scope on the destination collection (defaults to 'pages').
5206
+ const body = (await request.json());
5207
+ const targetCollection = body.collection ?? 'pages';
5208
+ const collectionScopeErr = requireCollectionScope(auth.session, targetCollection, 'create');
5209
+ if (collectionScopeErr)
5210
+ return collectionScopeErr;
5211
+ if (!body.prompt || typeof body.prompt !== 'string') {
5212
+ return errorResponse('prompt is required', 400);
5213
+ }
5214
+ if (body.prompt.length > AI_PROMPT_MAX_CHARS) {
5215
+ return errorResponse(`prompt exceeds ${AI_PROMPT_MAX_CHARS} character limit`, 400);
5216
+ }
5217
+ // Same rate-limit bucket as /generate — one create == one expensive LLM
5218
+ // run, so it should count against the same hourly cap.
5219
+ if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-gen:${auth.session.userId}`))) {
5220
+ return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
5221
+ }
5222
+ const steps = Array.isArray(body.steps) && body.steps.length > 0
5223
+ ? body.steps
5224
+ : ['structure', 'content', 'seo', 'accessibility'];
5225
+ const validSteps = ['structure', 'content', 'seo', 'accessibility'];
5226
+ for (const s of steps) {
5227
+ if (!validSteps.includes(s)) {
5228
+ return errorResponse(`Invalid step: ${s}. Valid steps: ${validSteps.join(', ')}`, 400);
5229
+ }
5230
+ }
5231
+ let generatePage = null;
5232
+ try {
5233
+ const aiModule = await importAIPlugin();
5234
+ generatePage = aiModule.generatePage;
5235
+ }
5236
+ catch {
5237
+ return errorResponse('AI plugin is not installed. Install @actuate-media/plugin-ai to use page generation.', 501);
5238
+ }
5239
+ const result = await generatePage({
5240
+ prompt: body.prompt,
5241
+ template: body.template,
5242
+ context: body.context,
5243
+ steps,
5244
+ tone: body.tone,
5245
+ });
5246
+ const tree = result?.tree;
5247
+ if (!tree) {
5248
+ return errorResponse('Page generation returned no tree', 502);
5249
+ }
5250
+ // Pull SEO metadata out of the generator output so we can store it on
5251
+ // the document alongside the layout tree.
5252
+ const seoStep = (result?.steps ?? []).find((s) => s.step === 'seo');
5253
+ const meta = seoStep?.data ?? {};
5254
+ const title = body.title ?? meta.title ?? meta.metaTitle ?? 'Untitled';
5255
+ const slug = body.slug ??
5256
+ title
5257
+ .toLowerCase()
5258
+ .replace(/[^a-z0-9]+/g, '-')
5259
+ .replace(/^-|-$/g, '');
5260
+ // Determine final status — explicit body.status wins, then publish: true,
5261
+ // then DRAFT.
5262
+ const status = body.status === 'PUBLISHED' || body.publish === true ? 'PUBLISHED' : 'DRAFT';
5263
+ const docPayload = {
5264
+ title,
5265
+ slug,
5266
+ status,
5267
+ layout: tree,
5268
+ pageSettings: {
5269
+ metaTitle: meta.metaTitle ?? meta.title ?? title,
5270
+ metaDescription: meta.metaDescription ?? meta.description ?? '',
5271
+ ...(meta.canonical ? { canonical: meta.canonical } : {}),
5272
+ ...(meta.schemaType ? { schemaType: meta.schemaType } : {}),
5273
+ },
5274
+ };
5275
+ const ctx = buildActionContext(auth.session, db());
5276
+ const doc = await createDocument(targetCollection, docPayload, ctx);
5277
+ await logEvent({
5278
+ event: 'settings_changed',
5279
+ userId: auth.session.userId,
5280
+ details: {
5281
+ action: 'page_create_from_prompt',
5282
+ collection: targetCollection,
5283
+ documentId: doc?.id,
5284
+ prompt: redactSecrets(body.prompt).slice(0, 500),
5285
+ totalTokensUsed: result.totalTokensUsed,
5286
+ totalDurationMs: result.totalDurationMs,
5287
+ },
5288
+ });
5289
+ return json({
5290
+ data: {
5291
+ document: doc,
5292
+ generation: {
5293
+ steps: result.steps,
5294
+ totalTokensUsed: result.totalTokensUsed,
5295
+ totalDurationMs: result.totalDurationMs,
5296
+ },
5297
+ },
5298
+ }, 201);
5299
+ }
5300
+ catch (err) {
5301
+ return internalError(err, 'page-builder create');
5302
+ }
5303
+ });
4571
5304
  router.post('/page-builder/audit-a11y', async (request) => {
4572
5305
  try {
4573
5306
  const auth = await requireAuth(request);
4574
5307
  if (auth.error)
4575
5308
  return auth.error;
4576
- const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4577
- if (roleErr)
4578
- return roleErr;
5309
+ const scopeErr = requirePageBuilderScope(auth.session);
5310
+ if (scopeErr)
5311
+ return scopeErr;
4579
5312
  const body = await request.json();
4580
5313
  const tree = body.tree;
4581
5314
  if (!tree || tree.type !== 'page') {
@@ -4593,9 +5326,9 @@ export function registerCMSRoutes(router) {
4593
5326
  const auth = await requireAuth(request);
4594
5327
  if (auth.error)
4595
5328
  return auth.error;
4596
- const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
4597
- if (roleErr)
4598
- return roleErr;
5329
+ const scopeErr = requirePageBuilderScope(auth.session);
5330
+ if (scopeErr)
5331
+ return scopeErr;
4599
5332
  const body = await request.json();
4600
5333
  const tree = body.tree;
4601
5334
  if (!tree || tree.type !== 'page') {