@actuate-media/cms-core 0.10.4 → 0.11.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 (125) hide show
  1. package/dist/__tests__/api/admin-contracts.test.js +1 -0
  2. package/dist/__tests__/api/admin-contracts.test.js.map +1 -1
  3. package/dist/__tests__/api/public-globals.test.js +8 -4
  4. package/dist/__tests__/api/public-globals.test.js.map +1 -1
  5. package/dist/__tests__/security/audit.test.d.ts +2 -0
  6. package/dist/__tests__/security/audit.test.d.ts.map +1 -0
  7. package/dist/__tests__/security/audit.test.js +50 -0
  8. package/dist/__tests__/security/audit.test.js.map +1 -0
  9. package/dist/__tests__/security/client-ip.test.d.ts +2 -0
  10. package/dist/__tests__/security/client-ip.test.d.ts.map +1 -0
  11. package/dist/__tests__/security/client-ip.test.js +37 -0
  12. package/dist/__tests__/security/client-ip.test.js.map +1 -0
  13. package/dist/__tests__/security/ip-allowlist.test.d.ts +2 -0
  14. package/dist/__tests__/security/ip-allowlist.test.d.ts.map +1 -0
  15. package/dist/__tests__/security/ip-allowlist.test.js +40 -0
  16. package/dist/__tests__/security/ip-allowlist.test.js.map +1 -0
  17. package/dist/__tests__/security/redact.test.d.ts +2 -0
  18. package/dist/__tests__/security/redact.test.d.ts.map +1 -0
  19. package/dist/__tests__/security/redact.test.js +31 -0
  20. package/dist/__tests__/security/redact.test.js.map +1 -0
  21. package/dist/__tests__/security/secret-storage.test.d.ts +2 -0
  22. package/dist/__tests__/security/secret-storage.test.d.ts.map +1 -0
  23. package/dist/__tests__/security/secret-storage.test.js +42 -0
  24. package/dist/__tests__/security/secret-storage.test.js.map +1 -0
  25. package/dist/__tests__/security/upload-magic.test.d.ts +2 -0
  26. package/dist/__tests__/security/upload-magic.test.d.ts.map +1 -0
  27. package/dist/__tests__/security/upload-magic.test.js +55 -0
  28. package/dist/__tests__/security/upload-magic.test.js.map +1 -0
  29. package/dist/__tests__/server-site.test.d.ts +2 -0
  30. package/dist/__tests__/server-site.test.d.ts.map +1 -0
  31. package/dist/__tests__/server-site.test.js +123 -0
  32. package/dist/__tests__/server-site.test.js.map +1 -0
  33. package/dist/actions.d.ts.map +1 -1
  34. package/dist/actions.js +170 -34
  35. package/dist/actions.js.map +1 -1
  36. package/dist/api/handler-factory.d.ts.map +1 -1
  37. package/dist/api/handler-factory.js +64 -9
  38. package/dist/api/handler-factory.js.map +1 -1
  39. package/dist/api/handlers.d.ts.map +1 -1
  40. package/dist/api/handlers.js +673 -116
  41. package/dist/api/handlers.js.map +1 -1
  42. package/dist/api/openapi.d.ts.map +1 -1
  43. package/dist/api/openapi.js +38 -0
  44. package/dist/api/openapi.js.map +1 -1
  45. package/dist/auth/mfa-pending.d.ts +24 -0
  46. package/dist/auth/mfa-pending.d.ts.map +1 -0
  47. package/dist/auth/mfa-pending.js +38 -0
  48. package/dist/auth/mfa-pending.js.map +1 -0
  49. package/dist/auth/oauth.d.ts +25 -3
  50. package/dist/auth/oauth.d.ts.map +1 -1
  51. package/dist/auth/oauth.js +109 -20
  52. package/dist/auth/oauth.js.map +1 -1
  53. package/dist/auth/reset.d.ts.map +1 -1
  54. package/dist/auth/reset.js +26 -2
  55. package/dist/auth/reset.js.map +1 -1
  56. package/dist/auth/session.d.ts +9 -2
  57. package/dist/auth/session.d.ts.map +1 -1
  58. package/dist/auth/session.js +20 -2
  59. package/dist/auth/session.js.map +1 -1
  60. package/dist/index.d.ts +2 -0
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +2 -0
  63. package/dist/index.js.map +1 -1
  64. package/dist/middleware.d.ts.map +1 -1
  65. package/dist/middleware.js +21 -34
  66. package/dist/middleware.js.map +1 -1
  67. package/dist/page-builder/__tests__/blocks.test.js +104 -1
  68. package/dist/page-builder/__tests__/blocks.test.js.map +1 -1
  69. package/dist/page-builder/blocks.d.ts +18 -1
  70. package/dist/page-builder/blocks.d.ts.map +1 -1
  71. package/dist/page-builder/blocks.js +22 -2
  72. package/dist/page-builder/blocks.js.map +1 -1
  73. package/dist/security/audit.d.ts.map +1 -1
  74. package/dist/security/audit.js +8 -4
  75. package/dist/security/audit.js.map +1 -1
  76. package/dist/security/client-ip.d.ts +33 -0
  77. package/dist/security/client-ip.d.ts.map +1 -0
  78. package/dist/security/client-ip.js +39 -0
  79. package/dist/security/client-ip.js.map +1 -0
  80. package/dist/security/index.d.ts +7 -0
  81. package/dist/security/index.d.ts.map +1 -1
  82. package/dist/security/index.js +5 -0
  83. package/dist/security/index.js.map +1 -1
  84. package/dist/security/internal-keys.d.ts +15 -0
  85. package/dist/security/internal-keys.d.ts.map +1 -0
  86. package/dist/security/internal-keys.js +33 -0
  87. package/dist/security/internal-keys.js.map +1 -0
  88. package/dist/security/ip-allowlist.d.ts +13 -1
  89. package/dist/security/ip-allowlist.d.ts.map +1 -1
  90. package/dist/security/ip-allowlist.js +120 -12
  91. package/dist/security/ip-allowlist.js.map +1 -1
  92. package/dist/security/rate-limit.d.ts.map +1 -1
  93. package/dist/security/rate-limit.js +49 -17
  94. package/dist/security/rate-limit.js.map +1 -1
  95. package/dist/security/redact.d.ts +12 -0
  96. package/dist/security/redact.d.ts.map +1 -0
  97. package/dist/security/redact.js +41 -0
  98. package/dist/security/redact.js.map +1 -0
  99. package/dist/security/safe-fetch.d.ts +35 -0
  100. package/dist/security/safe-fetch.d.ts.map +1 -0
  101. package/dist/security/safe-fetch.js +45 -0
  102. package/dist/security/safe-fetch.js.map +1 -0
  103. package/dist/security/secret-storage.d.ts +22 -0
  104. package/dist/security/secret-storage.d.ts.map +1 -0
  105. package/dist/security/secret-storage.js +75 -0
  106. package/dist/security/secret-storage.js.map +1 -0
  107. package/dist/security/upload.d.ts +23 -4
  108. package/dist/security/upload.d.ts.map +1 -1
  109. package/dist/security/upload.js +110 -21
  110. package/dist/security/upload.js.map +1 -1
  111. package/dist/server-site.d.ts +54 -0
  112. package/dist/server-site.d.ts.map +1 -0
  113. package/dist/server-site.js +149 -0
  114. package/dist/server-site.js.map +1 -0
  115. package/dist/site.d.ts.map +1 -1
  116. package/dist/site.js +19 -1
  117. package/dist/site.js.map +1 -1
  118. package/dist/storage/index.d.ts +20 -10
  119. package/dist/storage/index.d.ts.map +1 -1
  120. package/dist/storage/index.js +6 -3
  121. package/dist/storage/index.js.map +1 -1
  122. package/dist/webhooks/index.d.ts.map +1 -1
  123. package/dist/webhooks/index.js +20 -9
  124. package/dist/webhooks/index.js.map +1 -1
  125. package/package.json +1 -1
@@ -4,7 +4,8 @@ import { createSession, verifySession, revokeSession } from '../auth/session.js'
4
4
  import { createPasswordReset, executePasswordReset } from '../auth/reset.js';
5
5
  import { checkSetupRequired, createInitialAdmin } from '../setup/index.js';
6
6
  import { getDB } from '../db.js';
7
- import { generateCodeVerifier, generateCodeChallenge, generateState, getAuthorizationUrl, handleOAuthCallback, } from '../auth/oauth.js';
7
+ import { generateCodeVerifier, generateCodeChallenge, generateState, generateOAuthNonce, getAuthorizationUrl, handleOAuthCallback, } from '../auth/oauth.js';
8
+ import { createMfaPendingToken, verifyMfaPendingToken, computeRequestFingerprint, } from '../auth/mfa-pending.js';
8
9
  import { optimizeImage, formatBytes } from '../media/optimize.js';
9
10
  import { generateToken as generateCsrfToken } from '../security/csrf.js';
10
11
  import { logEvent } from '../security/audit.js';
@@ -15,12 +16,20 @@ import { verifyCaptcha, getCaptchaConfig } from '../security/captcha.js';
15
16
  import { checkForUpdates } from '../upgrade/version-check.js';
16
17
  import { createUpgradePR } from '../upgrade/upgrade-pr.js';
17
18
  import { encryptField, decryptField } from '../security/encrypted-fields.js';
19
+ import { encryptSecret, decryptSecret, encryptStringArray } from '../security/secret-storage.js';
18
20
  import { createRateLimiter } from '../security/rate-limit.js';
19
21
  import { generateOpenAPISpec } from './openapi.js';
20
22
  import { createSSEPresenceAdapter } from '../presence/index.js';
21
23
  import { BUILT_IN_TEMPLATES } from '../page-builder/templates.js';
22
24
  import { validateTree } from '../page-builder/validate.js';
23
25
  import { auditAccessibility, fixAccessibility } from '../page-builder/a11y-fix.js';
26
+ import { getClientIp, isResolvedIp } from '../security/client-ip.js';
27
+ import { safeFetch, SsrfBlockedError } from '../security/safe-fetch.js';
28
+ import { redactSecrets } from '../security/redact.js';
29
+ import { enforceSessionLimits } from '../security/session-limits.js';
30
+ import { verifyReauth } from '../security/reauth.js';
31
+ import { validateMimeType, checkMagicBytes } from '../security/upload.js';
32
+ import { sanitizeHtml } from '../security/sanitize.js';
24
33
  // Opaque dynamic import so Turbopack/webpack won't statically analyze the specifier.
25
34
  // Returns { put, del, ... } from @vercel/blob when available.
26
35
  async function importBlobStorage() {
@@ -302,6 +311,36 @@ export function parseCookieHeader(cookieHeader) {
302
311
  }
303
312
  return cookies;
304
313
  }
314
+ /**
315
+ * Verify a password (or "current password") posted in the X-Reauth-Password
316
+ * header against the authenticated user. Returns an error Response when the
317
+ * header is missing or the password is wrong; returns null on success.
318
+ *
319
+ * Use this for high-impact account changes (TOTP enable/disable, role change,
320
+ * delete user, etc.) so a stolen session alone cannot perform them.
321
+ */
322
+ async function requirePasswordReauth(request, userId) {
323
+ const password = request.headers.get('x-reauth-password');
324
+ if (!password) {
325
+ return new Response(JSON.stringify({
326
+ error: 'Re-authentication required for this action.',
327
+ code: 'REAUTH_REQUIRED',
328
+ method: 'password',
329
+ }), { status: 401, headers: { ...SECURITY_HEADERS } });
330
+ }
331
+ try {
332
+ const { getDB } = await import('../db.js');
333
+ const ok = await verifyReauth(userId, password, 'password', getDB());
334
+ if (!ok) {
335
+ return errorResponse('Re-authentication failed.', 401);
336
+ }
337
+ return null;
338
+ }
339
+ catch (err) {
340
+ console.error('[actuate][reauth] verify failed:', err instanceof Error ? err.message : err);
341
+ return errorResponse('Re-authentication failed.', 401);
342
+ }
343
+ }
305
344
  async function requireAuth(request) {
306
345
  if (isSecretMissing()) {
307
346
  return {
@@ -334,11 +373,49 @@ function requireRole(role, allowedRoles) {
334
373
  return null;
335
374
  }
336
375
  const loginLimiter = createRateLimiter({ maxRequests: 5, windowMs: 15 * 60 * 1000 });
376
+ const totpLimiter = createRateLimiter({ maxRequests: 10, windowMs: 15 * 60 * 1000 });
337
377
  const formLimiterGlobal = createRateLimiter({ maxRequests: 10, windowMs: 60_000 });
378
+ const aiGenerateLimiter = createRateLimiter({ maxRequests: 20, windowMs: 60 * 60 * 1000 });
379
+ const linkHealthLimiter = createRateLimiter({ maxRequests: 4, windowMs: 60 * 60 * 1000 });
338
380
  async function checkRateLimitAsync(limiter, key) {
339
381
  const result = await limiter.check(key);
340
382
  return result.allowed;
341
383
  }
384
+ /**
385
+ * Resolve the client IP from trusted-proxy headers (Vercel first, then x-real-ip,
386
+ * then x-forwarded-for only when ACTUATE_TRUST_PROXY=1). Returns 'unknown' when
387
+ * no trustworthy source is available — callers MUST treat that case as a hard
388
+ * failure for security-sensitive decisions like rate-limit keys and IP allowlists.
389
+ */
390
+ function clientIp(request) {
391
+ return getClientIp(request);
392
+ }
393
+ const MAX_CONCURRENT_SESSIONS = 5;
394
+ /**
395
+ * After a successful primary-credential check, prune the user's active sessions
396
+ * down to the configured concurrent maximum (default 5; configurable via
397
+ * `auth.maxConcurrentSessions`). Strategy is "revoke oldest" so a user can
398
+ * always sign in.
399
+ */
400
+ async function enforceSessionLimitsForUser(d, userId) {
401
+ if (!hasModel(d, 'session'))
402
+ return;
403
+ const config = globalThis.__actuateConfig?.auth ?? {};
404
+ const max = typeof config.maxConcurrentSessions === 'number' && config.maxConcurrentSessions > 0
405
+ ? config.maxConcurrentSessions
406
+ : MAX_CONCURRENT_SESSIONS;
407
+ const active = await d.session.findMany({
408
+ where: { userId, revokedAt: null, expiresAt: { gt: new Date() } },
409
+ select: { id: true, createdAt: true },
410
+ });
411
+ const decision = enforceSessionLimits(active.map((s) => ({ sessionId: s.id, userId, createdAt: s.createdAt })), { maxConcurrentSessions: max, strategy: 'revoke_oldest' });
412
+ if (decision.sessionsToRevoke.length > 0) {
413
+ await d.session.updateMany({
414
+ where: { id: { in: decision.sessionsToRevoke } },
415
+ data: { revokedAt: new Date() },
416
+ });
417
+ }
418
+ }
342
419
  function getAdminPath() {
343
420
  return process.env.ACTUATE_ADMIN_PATH
344
421
  ?? globalThis.__actuateConfig?.admin?.path
@@ -451,13 +528,18 @@ export function registerCMSRoutes(router) {
451
528
  if (!email || !password) {
452
529
  return errorResponse('Email and password are required', 400);
453
530
  }
454
- const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
455
- if (!(await checkRateLimitAsync(loginLimiter, `login:${clientIp}`))) {
531
+ const ip = clientIp(request);
532
+ const userAgent = request.headers.get('user-agent');
533
+ // Bucket by IP when we have a trustworthy one; otherwise fall back to a
534
+ // global bucket. We deliberately avoid keying on the email alone — that
535
+ // would let attackers lock out legitimate users by spamming logins.
536
+ const rateLimitKey = isResolvedIp(ip) ? `login:${ip}` : 'login:unknown';
537
+ if (!(await checkRateLimitAsync(loginLimiter, rateLimitKey))) {
456
538
  return errorResponse('Too many login attempts. Please try again later.', 429);
457
539
  }
458
540
  const captchaConfig = getCaptchaConfig();
459
541
  if (captchaConfig.provider !== 'none') {
460
- const captchaResult = await verifyCaptcha(body.captchaToken ?? '', captchaConfig, clientIp);
542
+ const captchaResult = await verifyCaptcha(body.captchaToken ?? '', captchaConfig, ip);
461
543
  if (!captchaResult.success) {
462
544
  return errorResponse('CAPTCHA verification failed. Please try again.', 403);
463
545
  }
@@ -479,8 +561,8 @@ export function registerCMSRoutes(router) {
479
561
  await logEvent({
480
562
  event: 'login_failed',
481
563
  userId: user.id,
482
- ipAddress: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? undefined,
483
- userAgent: request.headers.get('user-agent') ?? undefined,
564
+ ipAddress: isResolvedIp(ip) ? ip : undefined,
565
+ userAgent: userAgent ?? undefined,
484
566
  });
485
567
  return errorResponse('Invalid email or password', 401);
486
568
  }
@@ -488,8 +570,14 @@ export function registerCMSRoutes(router) {
488
570
  return errorResponse('Account is deactivated', 403);
489
571
  }
490
572
  if (user.totpEnabled) {
491
- return json({ data: { requiresTOTP: true, userId: user.id } });
573
+ // Hand back an opaque short-lived token instead of the raw userId.
574
+ // The /auth/totp/login endpoint will verify both this token and a
575
+ // stable browser fingerprint before checking the TOTP code.
576
+ const fingerprint = await computeRequestFingerprint(ip, userAgent);
577
+ const mfaPendingToken = await createMfaPendingToken({ userId: user.id, fingerprint }, getSessionSecret());
578
+ return json({ data: { requiresTOTP: true, mfaPendingToken } });
492
579
  }
580
+ await enforceSessionLimitsForUser(d, user.id);
493
581
  const tempSessionId = crypto.randomUUID();
494
582
  const token = await createSession({ userId: user.id, role: user.role, sessionId: tempSessionId }, { secret: getSessionSecret() });
495
583
  await db().session.create({
@@ -498,8 +586,8 @@ export function registerCMSRoutes(router) {
498
586
  userId: user.id,
499
587
  token,
500
588
  expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
501
- ipAddress: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? null,
502
- userAgent: request.headers.get('user-agent') ?? null,
589
+ ipAddress: isResolvedIp(ip) ? ip : null,
590
+ userAgent: userAgent ?? null,
503
591
  },
504
592
  });
505
593
  const response = json({
@@ -532,8 +620,8 @@ export function registerCMSRoutes(router) {
532
620
  await logEvent({
533
621
  event: 'login_success',
534
622
  userId: user.id,
535
- ipAddress: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? undefined,
536
- userAgent: request.headers.get('user-agent') ?? undefined,
623
+ ipAddress: isResolvedIp(ip) ? ip : undefined,
624
+ userAgent: userAgent ?? undefined,
537
625
  });
538
626
  return response;
539
627
  }
@@ -547,10 +635,11 @@ export function registerCMSRoutes(router) {
547
635
  if (auth.error)
548
636
  return auth.error;
549
637
  await revokeSession(auth.session.sessionId, db());
638
+ const ip = clientIp(request);
550
639
  await logEvent({
551
640
  event: 'logout',
552
641
  userId: auth.session.userId,
553
- ipAddress: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? undefined,
642
+ ipAddress: isResolvedIp(ip) ? ip : undefined,
554
643
  });
555
644
  const response = json({ data: { success: true } });
556
645
  response.headers.set('Set-Cookie', 'actuate_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
@@ -567,8 +656,9 @@ export function registerCMSRoutes(router) {
567
656
  if (!email) {
568
657
  return errorResponse('Email is required', 400);
569
658
  }
570
- const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
571
- if (!(await checkRateLimitAsync(loginLimiter, `forgot:${clientIp}`))) {
659
+ const ip = clientIp(request);
660
+ const rateLimitKey = isResolvedIp(ip) ? `forgot:${ip}` : 'forgot:unknown';
661
+ if (!(await checkRateLimitAsync(loginLimiter, rateLimitKey))) {
572
662
  return errorResponse('Too many requests. Please try again later.', 429);
573
663
  }
574
664
  const d = db();
@@ -582,7 +672,7 @@ export function registerCMSRoutes(router) {
582
672
  });
583
673
  await logEvent({
584
674
  event: 'password_reset_request',
585
- ipAddress: clientIp,
675
+ ipAddress: isResolvedIp(ip) ? ip : undefined,
586
676
  userAgent: request.headers.get('user-agent') ?? undefined,
587
677
  details: { email: email.toLowerCase().trim() },
588
678
  });
@@ -602,8 +692,9 @@ export function registerCMSRoutes(router) {
602
692
  if (password.length < 8) {
603
693
  return errorResponse('Password must be at least 8 characters', 400);
604
694
  }
605
- const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
606
- if (!(await checkRateLimitAsync(loginLimiter, `reset:${clientIp}`))) {
695
+ const ip = clientIp(request);
696
+ const rateLimitKey = isResolvedIp(ip) ? `reset:${ip}` : 'reset:unknown';
697
+ if (!(await checkRateLimitAsync(loginLimiter, rateLimitKey))) {
607
698
  return errorResponse('Too many requests. Please try again later.', 429);
608
699
  }
609
700
  const d = db();
@@ -615,7 +706,7 @@ export function registerCMSRoutes(router) {
615
706
  }
616
707
  await logEvent({
617
708
  event: 'password_reset_complete',
618
- ipAddress: clientIp,
709
+ ipAddress: isResolvedIp(ip) ? ip : undefined,
619
710
  userAgent: request.headers.get('user-agent') ?? undefined,
620
711
  });
621
712
  return json({ data: { success: true } });
@@ -636,7 +727,24 @@ export function registerCMSRoutes(router) {
636
727
  if (!user) {
637
728
  return errorResponse('User not found', 404);
638
729
  }
639
- return json({ data: user });
730
+ const response = json({ data: user });
731
+ // Bootstrap (or refresh) the double-submit CSRF cookie. Without this
732
+ // the admin app's first non-GET after a hard reload races to populate
733
+ // it; some users hit "Invalid CSRF token" on the very first save.
734
+ const cookies = parseCookieHeader(request.headers.get('cookie') ?? '');
735
+ if (!cookies['actuate_csrf']) {
736
+ const csrfToken = await generateCsrfToken();
737
+ const isProduction = process.env.NODE_ENV === 'production';
738
+ const csrfCookie = [
739
+ `actuate_csrf=${csrfToken}`,
740
+ 'Path=/',
741
+ 'SameSite=Lax',
742
+ 'Max-Age=86400',
743
+ ...(isProduction ? ['Secure'] : []),
744
+ ].join('; ');
745
+ response.headers.append('Set-Cookie', csrfCookie);
746
+ }
747
+ return response;
640
748
  }
641
749
  catch (err) {
642
750
  return internalError(err, 'auth/me');
@@ -650,6 +758,11 @@ export function registerCMSRoutes(router) {
650
758
  const auth = await requireAuth(request);
651
759
  if (auth.error)
652
760
  return auth.error;
761
+ // Sensitive operation — require recent password reauth so a stolen
762
+ // session can't silently rotate someone's TOTP secret.
763
+ const reauthErr = await requirePasswordReauth(request, auth.session.userId);
764
+ if (reauthErr)
765
+ return reauthErr;
653
766
  const { generateTOTPSecret, generateTOTPUri, generateBackupCodes } = await import('../auth/totp.js');
654
767
  const user = await db().user.findUnique({ where: { id: auth.session.userId }, select: { email: true, totpEnabled: true } });
655
768
  if (!user)
@@ -659,7 +772,14 @@ export function registerCMSRoutes(router) {
659
772
  const secret = generateTOTPSecret();
660
773
  const uri = generateTOTPUri(secret, user.email);
661
774
  const backups = generateBackupCodes();
662
- await db().user.update({ where: { id: auth.session.userId }, data: { totpSecret: secret, backupCodes: backups } });
775
+ // Persist encrypted; the plaintext is only returned to the user once
776
+ // (so they can scan the QR / save backup codes).
777
+ const encryptedSecret = await encryptSecret(secret);
778
+ const encryptedBackups = await encryptStringArray(backups);
779
+ await db().user.update({
780
+ where: { id: auth.session.userId },
781
+ data: { totpSecret: encryptedSecret, backupCodes: encryptedBackups },
782
+ });
663
783
  return json({ data: { secret, uri, backupCodes: backups } });
664
784
  }
665
785
  catch (err) {
@@ -678,10 +798,12 @@ export function registerCMSRoutes(router) {
678
798
  const user = await db().user.findUnique({ where: { id: auth.session.userId }, select: { totpSecret: true } });
679
799
  if (!user?.totpSecret)
680
800
  return errorResponse('TOTP not set up', 400);
681
- const valid = verifyTOTP(body.code, user.totpSecret);
801
+ const secret = await decryptSecret(user.totpSecret);
802
+ const valid = verifyTOTP(body.code, secret);
682
803
  if (!valid)
683
804
  return errorResponse('Invalid code', 400);
684
805
  await db().user.update({ where: { id: auth.session.userId }, data: { totpEnabled: true } });
806
+ await logEvent({ event: 'totp_enabled', userId: auth.session.userId });
685
807
  return json({ data: { enabled: true } });
686
808
  }
687
809
  catch (err) {
@@ -693,7 +815,28 @@ export function registerCMSRoutes(router) {
693
815
  const auth = await requireAuth(request);
694
816
  if (auth.error)
695
817
  return auth.error;
696
- await db().user.update({ where: { id: auth.session.userId }, data: { totpEnabled: false, totpSecret: null, backupCodes: null } });
818
+ // Disabling MFA is a high-impact security change. Require recent password
819
+ // reauth and revoke every other session so a compromised cookie loses
820
+ // access immediately after MFA goes away.
821
+ const reauthErr = await requirePasswordReauth(request, auth.session.userId);
822
+ if (reauthErr)
823
+ return reauthErr;
824
+ const d = db();
825
+ await d.user.update({
826
+ where: { id: auth.session.userId },
827
+ data: { totpEnabled: false, totpSecret: null, backupCodes: null },
828
+ });
829
+ if (hasModel(d, 'session')) {
830
+ await d.session.updateMany({
831
+ where: {
832
+ userId: auth.session.userId,
833
+ id: { not: auth.session.sessionId },
834
+ revokedAt: null,
835
+ },
836
+ data: { revokedAt: new Date() },
837
+ });
838
+ }
839
+ await logEvent({ event: 'totp_disabled', userId: auth.session.userId });
697
840
  return json({ data: { enabled: false } });
698
841
  }
699
842
  catch (err) {
@@ -703,21 +846,107 @@ export function registerCMSRoutes(router) {
703
846
  router.post('/auth/totp/login', async (request) => {
704
847
  try {
705
848
  const body = await request.json();
706
- if (!body.userId || !body.code)
707
- return errorResponse('userId and code are required', 400);
849
+ if (!body.mfaPendingToken || !body.code) {
850
+ return errorResponse('mfaPendingToken and code are required', 400);
851
+ }
852
+ const ip = clientIp(request);
853
+ const userAgent = request.headers.get('user-agent');
854
+ // Per-IP and per-token rate limits. The per-token limit is the actual
855
+ // brute-force defence: even with IP rotation, an attacker has to obtain
856
+ // a fresh mfaPendingToken (which requires the password) for every
857
+ // window. The per-IP limit guards the IP-aware case and limits log noise.
858
+ const ipBucket = isResolvedIp(ip) ? `totp-ip:${ip}` : 'totp-ip:unknown';
859
+ if (!(await checkRateLimitAsync(totpLimiter, ipBucket))) {
860
+ return errorResponse('Too many TOTP attempts. Please try again later.', 429);
861
+ }
862
+ let pending;
863
+ try {
864
+ pending = await verifyMfaPendingToken(body.mfaPendingToken, getSessionSecret());
865
+ }
866
+ catch {
867
+ return errorResponse('Session expired. Please sign in again.', 401);
868
+ }
869
+ // Validate the request comes from the same browser that completed the
870
+ // password step. This frustrates attempts to ship a captured pending
871
+ // token to a different host.
872
+ const fingerprint = await computeRequestFingerprint(ip, userAgent);
873
+ if (fingerprint !== pending.fingerprint) {
874
+ return errorResponse('Session fingerprint mismatch. Please sign in again.', 401);
875
+ }
876
+ // Per-userId bucket is what actually caps brute-force. With the default
877
+ // 10 attempts / 15 min, an attacker would need ~190 years to cover the
878
+ // 1M code space even with unbounded IPs.
879
+ const userBucket = `totp-user:${pending.userId}`;
880
+ if (!(await checkRateLimitAsync(totpLimiter, userBucket))) {
881
+ return errorResponse('Too many TOTP attempts. Please try again later.', 429);
882
+ }
708
883
  const { verifyTOTP } = await import('../auth/totp.js');
709
- const user = await db().user.findUnique({ where: { id: body.userId }, select: { id: true, email: true, role: true, totpSecret: true, totpEnabled: true, isActive: true } });
710
- if (!user || !user.isActive || !user.totpEnabled || !user.totpSecret)
884
+ const user = await db().user.findUnique({
885
+ where: { id: pending.userId },
886
+ select: { id: true, email: true, name: true, role: true, totpSecret: true, totpEnabled: true, isActive: true },
887
+ });
888
+ if (!user || !user.isActive || !user.totpEnabled || !user.totpSecret) {
711
889
  return errorResponse('Invalid request', 400);
712
- const valid = verifyTOTP(body.code, user.totpSecret);
713
- if (!valid)
890
+ }
891
+ const decryptedSecret = await decryptSecret(user.totpSecret);
892
+ const valid = verifyTOTP(body.code, decryptedSecret);
893
+ if (!valid) {
894
+ await logEvent({
895
+ event: 'login_failed',
896
+ userId: user.id,
897
+ ipAddress: isResolvedIp(ip) ? ip : undefined,
898
+ userAgent: userAgent ?? undefined,
899
+ details: { reason: 'totp_invalid' },
900
+ });
714
901
  return errorResponse('Invalid code', 401);
902
+ }
903
+ const d = db();
904
+ await enforceSessionLimitsForUser(d, user.id);
715
905
  const tempSessionId = crypto.randomUUID();
716
906
  const token = await createSession({ userId: user.id, role: user.role, sessionId: tempSessionId }, { secret: getSessionSecret() });
717
- await db().session.create({ data: { id: tempSessionId, userId: user.id, token, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) } });
907
+ await d.session.create({
908
+ data: {
909
+ id: tempSessionId,
910
+ userId: user.id,
911
+ token,
912
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
913
+ ipAddress: isResolvedIp(ip) ? ip : null,
914
+ userAgent: userAgent ?? null,
915
+ },
916
+ });
917
+ await logEvent({
918
+ event: 'login_success',
919
+ userId: user.id,
920
+ ipAddress: isResolvedIp(ip) ? ip : undefined,
921
+ userAgent: userAgent ?? undefined,
922
+ details: { mfa: 'totp' },
923
+ });
718
924
  const isProduction = process.env.NODE_ENV === 'production';
719
- const sessionCookie = [`actuate_session=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${7 * 24 * 3600}`, ...(isProduction ? ['Secure'] : [])].join('; ');
720
- return new Response(JSON.stringify({ data: { token, user: { id: user.id, email: user.email, role: user.role } } }), { status: 200, headers: { ...SECURITY_HEADERS, 'Set-Cookie': sessionCookie } });
925
+ const sessionCookie = [
926
+ `actuate_session=${token}`,
927
+ 'Path=/',
928
+ 'HttpOnly',
929
+ 'SameSite=Lax',
930
+ `Max-Age=${7 * 24 * 3600}`,
931
+ ...(isProduction ? ['Secure'] : []),
932
+ ].join('; ');
933
+ const csrfToken = await generateCsrfToken();
934
+ const csrfCookie = [
935
+ `actuate_csrf=${csrfToken}`,
936
+ 'Path=/',
937
+ 'SameSite=Lax',
938
+ 'Max-Age=86400',
939
+ ...(isProduction ? ['Secure'] : []),
940
+ ].join('; ');
941
+ const response = new Response(JSON.stringify({
942
+ data: {
943
+ token,
944
+ user: { id: user.id, email: user.email, name: user.name, role: user.role },
945
+ },
946
+ }), { status: 200, headers: { ...SECURITY_HEADERS } });
947
+ response.headers.append('Set-Cookie', sessionCookie);
948
+ response.headers.append('Set-Cookie', csrfCookie);
949
+ return response;
721
950
  }
722
951
  catch (err) {
723
952
  return internalError(err);
@@ -748,9 +977,25 @@ export function registerCMSRoutes(router) {
748
977
  };
749
978
  const codeVerifier = generateCodeVerifier();
750
979
  const codeChallenge = await generateCodeChallenge(codeVerifier);
751
- const state = await generateState(provider, codeVerifier, getAdminPath(), secret);
980
+ // Generate a one-time nonce, embed it in the signed state, and also set
981
+ // it on a host-only cookie. The callback will require both to match —
982
+ // this binds the OAuth flow to the browser that started it and prevents
983
+ // an attacker from delivering a state token to a victim's browser.
984
+ const nonce = generateOAuthNonce();
985
+ const state = await generateState(provider, codeVerifier, getAdminPath(), secret, nonce);
752
986
  const url = getAuthorizationUrl(provider, oauthProviders[provider], state, codeChallenge);
753
- return json({ data: { url } });
987
+ const isProduction = process.env.NODE_ENV === 'production';
988
+ const nonceCookie = [
989
+ `actuate_oauth_nonce=${nonce}`,
990
+ 'Path=/',
991
+ 'HttpOnly',
992
+ 'SameSite=Lax',
993
+ 'Max-Age=600',
994
+ ...(isProduction ? ['Secure'] : []),
995
+ ].join('; ');
996
+ const response = json({ data: { url } });
997
+ response.headers.append('Set-Cookie', nonceCookie);
998
+ return response;
754
999
  }
755
1000
  catch (err) {
756
1001
  return internalError(err);
@@ -785,31 +1030,39 @@ export function registerCMSRoutes(router) {
785
1030
  clientSecret: process.env[`OAUTH_${envPrefix}_CLIENT_SECRET`] ?? '',
786
1031
  redirectUri: `${siteUrl}/api/cms/auth/oauth/${provider}/callback`,
787
1032
  };
788
- const result = await handleOAuthCallback(provider, code, stateToken, oauthProviders, secret, db());
789
- const cookieFlags = [
1033
+ const cookies = parseCookieHeader(request.headers.get('cookie') ?? '');
1034
+ const expectedNonce = cookies['actuate_oauth_nonce'] ?? null;
1035
+ const cmsConfig = globalThis.__actuateConfig;
1036
+ const allowSelfSignup = cmsConfig?.auth?.oauth?.allowSelfSignup === true;
1037
+ const result = await handleOAuthCallback(provider, code, stateToken, oauthProviders, secret, db(), { expectedNonce, allowSelfSignup });
1038
+ const isProduction = process.env.NODE_ENV === 'production';
1039
+ const sessionCookieFlags = [
790
1040
  `actuate_session=${result.token}`,
791
1041
  'Path=/',
792
1042
  'HttpOnly',
793
1043
  'SameSite=Lax',
794
1044
  'Max-Age=604800',
795
1045
  ];
796
- if (siteUrl.startsWith('https')) {
797
- cookieFlags.push('Secure');
1046
+ if (siteUrl.startsWith('https') || isProduction) {
1047
+ sessionCookieFlags.push('Secure');
798
1048
  }
799
- return new Response(null, {
1049
+ const response = new Response(null, {
800
1050
  status: 302,
801
- headers: {
802
- Location: getAdminPath(),
803
- 'Set-Cookie': cookieFlags.join('; '),
804
- },
1051
+ headers: { Location: getAdminPath() },
805
1052
  });
1053
+ response.headers.append('Set-Cookie', sessionCookieFlags.join('; '));
1054
+ // Clear the one-time nonce cookie regardless of outcome.
1055
+ response.headers.append('Set-Cookie', 'actuate_oauth_nonce=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
1056
+ return response;
806
1057
  }
807
1058
  catch (err) {
808
1059
  const message = err instanceof Error ? err.message : 'OAuth callback failed';
809
- return new Response(null, {
1060
+ const response = new Response(null, {
810
1061
  status: 302,
811
1062
  headers: { Location: `${getAdminPath()}?error=${encodeURIComponent(message)}` },
812
1063
  });
1064
+ response.headers.append('Set-Cookie', 'actuate_oauth_nonce=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
1065
+ return response;
813
1066
  }
814
1067
  });
815
1068
  // ---------------------------------------------------------------------------
@@ -862,17 +1115,10 @@ export function registerCMSRoutes(router) {
862
1115
  if (!doc) {
863
1116
  return errorResponse('Document not found', 404);
864
1117
  }
865
- const collectionConfig = globalThis.__actuateConfig?.collections?.[params.slug];
866
- const fields = collectionConfig?.fields;
867
- if (fields && doc.data && typeof doc.data === 'object') {
868
- const user = { id: auth.session.userId, role: auth.session.role };
869
- return json({
870
- data: {
871
- ...doc,
872
- data: await applyFieldAccess('read', fields, doc.data, user),
873
- },
874
- });
875
- }
1118
+ // `getDocument` already lifts `_layout` / `_pageSettings` to the
1119
+ // top-level page builder envelope and strips internal keys from
1120
+ // `data`. Field-level access has been applied as well — we don't
1121
+ // need to re-apply it here.
876
1122
  return json({ data: doc });
877
1123
  }
878
1124
  catch (err) {
@@ -984,6 +1230,12 @@ export function registerCMSRoutes(router) {
984
1230
  return internalError(err);
985
1231
  }
986
1232
  });
1233
+ // The /media/presign endpoint returns advisory upload metadata; the actual
1234
+ // upload still goes through /media/upload (which performs validation).
1235
+ // Returning a presigned URL pointing at our own non-presigned endpoint was
1236
+ // misleading, so we deprecated this in favour of just using /media/upload
1237
+ // directly. We keep the route for backward compatibility but it now just
1238
+ // returns a hint to use the upload endpoint.
987
1239
  router.post('/media/presign', async (request) => {
988
1240
  try {
989
1241
  const auth = await requireAuth(request);
@@ -993,12 +1245,13 @@ export function registerCMSRoutes(router) {
993
1245
  if (!body.filename || !body.contentType) {
994
1246
  return errorResponse('filename and contentType are required', 400);
995
1247
  }
996
- const storageKey = `actuate/media/${Date.now()}-${body.filename.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
997
1248
  return json({
998
1249
  data: {
999
- storageKey,
1000
- uploadUrl: `/api/cms/media/upload`,
1001
- fields: { storageKey, contentType: body.contentType },
1250
+ uploadUrl: '/api/cms/media/upload',
1251
+ method: 'POST',
1252
+ field: 'file',
1253
+ deprecated: true,
1254
+ message: 'Direct multipart upload to /media/upload is preferred; this endpoint will be removed in a future release.',
1002
1255
  },
1003
1256
  });
1004
1257
  }
@@ -1007,6 +1260,26 @@ export function registerCMSRoutes(router) {
1007
1260
  }
1008
1261
  });
1009
1262
  const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
1263
+ /**
1264
+ * Allowlist of accepted upload mime types. Storing a file outside this set
1265
+ * would let an attacker upload e.g. `.html`/`.js` and serve it from the same
1266
+ * origin as the admin (cookie-leaking XSS), or `.svg` with embedded
1267
+ * `<script>` (also XSS). Add to this list deliberately.
1268
+ */
1269
+ const ALLOWED_UPLOAD_MIME_TYPES = [
1270
+ 'image/jpeg',
1271
+ 'image/png',
1272
+ 'image/gif',
1273
+ 'image/webp',
1274
+ 'image/avif',
1275
+ 'image/svg+xml',
1276
+ 'application/pdf',
1277
+ 'video/mp4',
1278
+ 'video/webm',
1279
+ 'audio/mpeg',
1280
+ 'audio/ogg',
1281
+ 'audio/wav',
1282
+ ];
1010
1283
  router.post('/media/upload', async (request) => {
1011
1284
  try {
1012
1285
  const auth = await requireAuth(request);
@@ -1028,7 +1301,21 @@ export function registerCMSRoutes(router) {
1028
1301
  if (originalSize > 50 * 1024 * 1024) {
1029
1302
  return errorResponse('File exceeds maximum size of 50MB', 413);
1030
1303
  }
1304
+ // 1. Block file types that aren't on our allowlist outright.
1305
+ if (!validateMimeType(contentType, ALLOWED_UPLOAD_MIME_TYPES)) {
1306
+ return errorResponse(`Unsupported file type "${contentType || 'unknown'}". Allowed types: ${ALLOWED_UPLOAD_MIME_TYPES.join(', ')}`, 415);
1307
+ }
1031
1308
  const arrayBuffer = await file.arrayBuffer();
1309
+ // 2. Verify the file's actual bytes match the claimed mime type. Without
1310
+ // this, an attacker can upload a `.exe` with `Content-Type: image/png`
1311
+ // and have it served from our origin.
1312
+ const magicCheck = checkMagicBytes(arrayBuffer, contentType);
1313
+ if (!magicCheck.valid) {
1314
+ return errorResponse(`File contents do not match declared type "${contentType}".`, 415);
1315
+ }
1316
+ // 3. SVGs need an extra pass — even when the mime type is correct, the
1317
+ // XML body can contain `<script>` or event-handler attributes that
1318
+ // execute when an admin previews the file. Sanitize before storing.
1032
1319
  let uploadBuffer;
1033
1320
  let finalFilename = originalFilename;
1034
1321
  let finalMimeType = contentType;
@@ -1037,7 +1324,17 @@ export function registerCMSRoutes(router) {
1037
1324
  let height = null;
1038
1325
  let blurHash = null;
1039
1326
  let savings = 0;
1040
- if (!skipOptimize && contentType.startsWith('image/')) {
1327
+ if (contentType === 'image/svg+xml') {
1328
+ // Strip <script>, on*, javascript: URLs, foreignObject, etc.
1329
+ const xml = new TextDecoder('utf-8', { fatal: false }).decode(arrayBuffer);
1330
+ const sanitized = sanitizeHtml(xml, {
1331
+ allowedTags: ['svg', 'g', 'path', 'circle', 'ellipse', 'line', 'polygon', 'polyline', 'rect', 'text', 'tspan', 'defs', 'use', 'symbol', 'title', 'desc', 'style', 'linearGradient', 'radialGradient', 'stop', 'mask', 'clipPath', 'pattern', 'filter', 'feGaussianBlur', 'feColorMatrix', 'feOffset', 'feBlend', 'feFlood', 'feComposite', 'feMerge', 'feMergeNode'],
1332
+ allowedAttributes: { '*': ['id', 'class', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'opacity', 'transform', 'd', 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'width', 'height', 'viewBox', 'xmlns', 'xmlns:xlink', 'preserveAspectRatio', 'points', 'points', 'offset', 'stop-color', 'stop-opacity', 'gradientUnits', 'gradientTransform', 'href', 'xlink:href', 'fill-rule', 'clip-rule', 'mask', 'clip-path', 'filter', 'patternUnits', 'patternContentUnits', 'in', 'in2', 'result', 'mode', 'values', 'type', 'stdDeviation', 'dx', 'dy'] },
1333
+ });
1334
+ uploadBuffer = Buffer.from(sanitized, 'utf-8');
1335
+ finalSize = uploadBuffer.byteLength;
1336
+ }
1337
+ else if (!skipOptimize && contentType.startsWith('image/')) {
1041
1338
  const result = await optimizeImage(arrayBuffer, originalFilename, contentType);
1042
1339
  uploadBuffer = result.buffer;
1043
1340
  finalFilename = result.filename;
@@ -1054,16 +1351,35 @@ export function registerCMSRoutes(router) {
1054
1351
  const sanitizedName = finalFilename.replace(/[^a-zA-Z0-9._-]/g, '_');
1055
1352
  const storageKey = `actuate/media/${Date.now()}-${sanitizedName}`;
1056
1353
  let publicUrl = '';
1057
- try {
1058
- const blob = await importBlobStorage();
1059
- const result = await blob.put(storageKey, uploadBuffer, {
1060
- access: 'public',
1061
- contentType: finalMimeType,
1062
- });
1063
- publicUrl = result.url;
1354
+ // Prefer the configured platform storage adapter (e.g. platform-vercel,
1355
+ // platform-aws, or a consumer-provided one). Falling through to
1356
+ // @vercel/blob via dynamic import preserves the legacy behavior for
1357
+ // installs that haven't wired up a platform package yet.
1358
+ const { getStorageAdapter } = await import('../storage/index.js');
1359
+ const storage = getStorageAdapter();
1360
+ if (storage) {
1361
+ try {
1362
+ publicUrl = await storage.upload(storageKey, uploadBuffer, finalMimeType);
1363
+ }
1364
+ catch (err) {
1365
+ if (process.env.NODE_ENV !== 'test') {
1366
+ console.error('[Actuate CMS] Storage adapter upload failed:', err);
1367
+ }
1368
+ return errorResponse('Storage upload failed', 500);
1369
+ }
1064
1370
  }
1065
- catch {
1066
- publicUrl = `/api/cms/media/file/${storageKey}`;
1371
+ else {
1372
+ try {
1373
+ const blob = await importBlobStorage();
1374
+ const result = await blob.put(storageKey, uploadBuffer, {
1375
+ access: 'public',
1376
+ contentType: finalMimeType,
1377
+ });
1378
+ publicUrl = result.url;
1379
+ }
1380
+ catch {
1381
+ publicUrl = `/api/cms/media/file/${storageKey}`;
1382
+ }
1067
1383
  }
1068
1384
  const media = await db().media.create({
1069
1385
  data: {
@@ -1643,6 +1959,10 @@ export function registerCMSRoutes(router) {
1643
1959
  if (!q)
1644
1960
  return json({ data: { documents: [], media: [], users: [] } });
1645
1961
  const d = db();
1962
+ // Documents and media are visible to all signed-in users; the user
1963
+ // directory is gated to EDITOR+ so a CLIENT can't enumerate the team
1964
+ // (or harvest emails for spear-phishing).
1965
+ const canSeeUserDirectory = auth.session.role === 'ADMIN' || auth.session.role === 'EDITOR';
1646
1966
  const [documents, media, users] = await Promise.all([
1647
1967
  safeFindMany(d.document, {
1648
1968
  where: { deletedAt: null, OR: [{ title: { contains: q, mode: 'insensitive' } }, { plainText: { contains: q, mode: 'insensitive' } }] },
@@ -1656,11 +1976,19 @@ export function registerCMSRoutes(router) {
1656
1976
  orderBy: { createdAt: 'desc' },
1657
1977
  select: { id: true, filename: true, altText: true, mimeType: true, storageKey: true },
1658
1978
  }),
1659
- safeFindMany(d.user, {
1660
- where: { isActive: true, OR: [{ name: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }] },
1661
- take: 5,
1662
- select: { id: true, name: true, email: true, role: true },
1663
- }),
1979
+ canSeeUserDirectory
1980
+ ? safeFindMany(d.user, {
1981
+ where: {
1982
+ isActive: true,
1983
+ OR: [
1984
+ { name: { contains: q, mode: 'insensitive' } },
1985
+ { email: { contains: q, mode: 'insensitive' } },
1986
+ ],
1987
+ },
1988
+ take: 5,
1989
+ select: { id: true, name: true, email: true, role: true },
1990
+ })
1991
+ : Promise.resolve([]),
1664
1992
  ]);
1665
1993
  return json({ data: { documents, media, users } });
1666
1994
  }
@@ -1729,17 +2057,37 @@ export function registerCMSRoutes(router) {
1729
2057
  const auth = await requireAuth(request);
1730
2058
  if (auth.error)
1731
2059
  return auth.error;
2060
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
2061
+ if (roleErr)
2062
+ return roleErr;
1732
2063
  const d = db();
1733
2064
  const forms = await d.document.findMany({
1734
2065
  where: { collection: 'forms', deletedAt: null },
1735
2066
  orderBy: { createdAt: 'desc' },
1736
2067
  });
1737
- const normalized = await Promise.all(forms.map(async (form) => {
1738
- const submissions = hasModel(d, 'formSubmission')
1739
- ? await d.formSubmission.count({ where: { formId: form.id } })
1740
- : 0;
1741
- return normalizeFormDocument(form, submissions);
1742
- }));
2068
+ // Single grouped count is far cheaper than N+1 count() per form once
2069
+ // the dashboard grows past a handful of forms. Fall back to per-form
2070
+ // .count() when the consumer's Prisma client doesn't expose groupBy
2071
+ // (older schemas, custom adapters).
2072
+ let submissionCounts = new Map();
2073
+ if (hasModel(d, 'formSubmission') && forms.length > 0) {
2074
+ const ids = forms.map((f) => f.id);
2075
+ if (typeof d.formSubmission.groupBy === 'function') {
2076
+ const grouped = await d.formSubmission.groupBy({
2077
+ by: ['formId'],
2078
+ where: { formId: { in: ids } },
2079
+ _count: { _all: true },
2080
+ });
2081
+ submissionCounts = new Map(grouped.map((g) => [g.formId, g._count._all]));
2082
+ }
2083
+ else {
2084
+ for (const id of ids) {
2085
+ const count = await d.formSubmission.count({ where: { formId: id } });
2086
+ submissionCounts.set(id, count);
2087
+ }
2088
+ }
2089
+ }
2090
+ const normalized = forms.map((form) => normalizeFormDocument(form, submissionCounts.get(form.id) ?? 0));
1743
2091
  return json({ data: normalized });
1744
2092
  }
1745
2093
  catch (err) {
@@ -1751,6 +2099,9 @@ export function registerCMSRoutes(router) {
1751
2099
  const auth = await requireAuth(request);
1752
2100
  if (auth.error)
1753
2101
  return auth.error;
2102
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
2103
+ if (roleErr)
2104
+ return roleErr;
1754
2105
  const url = new URL(request.url);
1755
2106
  const page = Number(url.searchParams.get('page')) || 1;
1756
2107
  const pageSize = clampPageSize(url.searchParams.get('pageSize'));
@@ -1876,16 +2227,42 @@ export function registerCMSRoutes(router) {
1876
2227
  if (!source || !destination) {
1877
2228
  return errorResponse('source and destination are required', 400);
1878
2229
  }
1879
- if (destination.startsWith('http') && !destination.startsWith(process.env.NEXT_PUBLIC_SITE_URL ?? 'https://')) {
2230
+ // Open-redirect defence: relative destinations (`/foo`) are always
2231
+ // allowed; absolute destinations must point at an explicitly trusted
2232
+ // host. We compare on parsed origins (not string `startsWith`) so
2233
+ // `https://attacker.com.example.com` no longer passes a `startsWith`
2234
+ // check on `https://example.com`. Configure the allowlist via
2235
+ // `redirects.allowedExternalHosts` (string[] of hostnames).
2236
+ if (destination.startsWith('http://') || destination.startsWith('https://')) {
2237
+ let destUrl;
1880
2238
  try {
1881
- const destUrl = new URL(destination);
1882
- if (!['http:', 'https:'].includes(destUrl.protocol)) {
1883
- return errorResponse('Invalid destination URL', 400);
1884
- }
2239
+ destUrl = new URL(destination);
1885
2240
  }
1886
2241
  catch {
1887
2242
  return errorResponse('Invalid destination URL', 400);
1888
2243
  }
2244
+ if (!['http:', 'https:'].includes(destUrl.protocol)) {
2245
+ return errorResponse('Invalid destination URL', 400);
2246
+ }
2247
+ const cmsConfig = globalThis.__actuateConfig;
2248
+ const allowed = new Set([
2249
+ ...(Array.isArray(cmsConfig?.redirects?.allowedExternalHosts)
2250
+ ? cmsConfig.redirects.allowedExternalHosts.map((h) => h.toLowerCase())
2251
+ : []),
2252
+ ]);
2253
+ const siteUrl = process.env.NEXT_PUBLIC_SITE_URL;
2254
+ if (siteUrl) {
2255
+ try {
2256
+ allowed.add(new URL(siteUrl).hostname.toLowerCase());
2257
+ }
2258
+ catch { /* noop */ }
2259
+ }
2260
+ if (!allowed.has(destUrl.hostname.toLowerCase())) {
2261
+ return errorResponse('External redirect destinations must be to an allowlisted host. Add the host to `redirects.allowedExternalHosts` in your CMS config.', 400);
2262
+ }
2263
+ }
2264
+ else if (!destination.startsWith('/')) {
2265
+ return errorResponse('Destination must be an absolute URL or a path beginning with /', 400);
1889
2266
  }
1890
2267
  const redirect = await db().redirect.create({
1891
2268
  data: {
@@ -1946,48 +2323,96 @@ export function registerCMSRoutes(router) {
1946
2323
  const auth = await requireAuth(request);
1947
2324
  if (auth.error)
1948
2325
  return auth.error;
2326
+ // EDITOR+ only — this endpoint hits arbitrary URLs and can be expensive.
2327
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
2328
+ if (roleErr)
2329
+ return roleErr;
2330
+ // Tight rate limit because each call fans out to many outbound requests.
2331
+ const ip = clientIp(request);
2332
+ const rateKey = isResolvedIp(ip)
2333
+ ? `link-health:${ip}`
2334
+ : `link-health-user:${auth.session.userId}`;
2335
+ if (!(await checkRateLimitAsync(linkHealthLimiter, rateKey))) {
2336
+ return errorResponse('Too many link-health scans. Please try again later.', 429);
2337
+ }
2338
+ const MAX_LINKS_PER_PAGE = 50;
2339
+ const MAX_TOTAL_LINKS = 500;
2340
+ const PER_LINK_TIMEOUT_MS = 4000;
2341
+ const CONCURRENCY = 8;
1949
2342
  const docs = await db().document.findMany({
1950
2343
  where: { deletedAt: null, status: 'PUBLISHED' },
1951
2344
  select: { id: true, title: true, data: true, collection: true },
1952
2345
  });
1953
- const linkResults = [];
1954
2346
  const urlRegex = /https?:\/\/[^\s"'<>]+/g;
1955
2347
  const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? '';
1956
- for (const doc of docs) {
2348
+ const queue = [];
2349
+ const seenGlobal = new Set();
2350
+ outer: for (const doc of docs) {
1957
2351
  const pageTitle = doc.title ?? doc.data?.title ?? doc.id;
1958
2352
  const content = JSON.stringify(doc.data ?? {});
1959
2353
  const urls = content.match(urlRegex) ?? [];
1960
- const seen = new Set();
2354
+ const seenInPage = new Set();
2355
+ let countInPage = 0;
1961
2356
  for (const url of urls) {
1962
2357
  const clean = url.replace(/[",;)}\]]+$/, '');
1963
- if (seen.has(clean))
2358
+ if (seenInPage.has(clean))
2359
+ continue;
2360
+ seenInPage.add(clean);
2361
+ if (seenGlobal.has(clean))
1964
2362
  continue;
1965
- seen.add(clean);
1966
- const isInternal = siteUrl && clean.startsWith(siteUrl);
2363
+ seenGlobal.add(clean);
2364
+ if (countInPage >= MAX_LINKS_PER_PAGE)
2365
+ break;
2366
+ if (queue.length >= MAX_TOTAL_LINKS)
2367
+ break outer;
2368
+ const isInternal = !!siteUrl && clean.startsWith(siteUrl);
2369
+ queue.push({ docId: doc.id, pageTitle, clean, isInternal });
2370
+ countInPage++;
2371
+ }
2372
+ }
2373
+ const linkResults = [];
2374
+ let cursor = 0;
2375
+ const worker = async () => {
2376
+ while (cursor < queue.length) {
2377
+ const idx = cursor++;
2378
+ const job = queue[idx];
2379
+ let status = 0;
1967
2380
  try {
1968
- const resp = await fetch(clean, { method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(5000) });
1969
- if (resp.status >= 400 || (resp.status >= 300 && resp.status < 400)) {
1970
- linkResults.push({
1971
- id: `${doc.id}-${linkResults.length}`,
1972
- page: pageTitle,
1973
- url: clean,
1974
- status: resp.status,
1975
- type: isInternal ? 'internal' : 'external',
1976
- });
2381
+ // safeFetch rejects private/loopback IPs and disables redirect
2382
+ // following so 302->internal can't smuggle the scanner past SSRF.
2383
+ const resp = await safeFetch(job.clean, {
2384
+ method: 'HEAD',
2385
+ timeoutMs: PER_LINK_TIMEOUT_MS,
2386
+ });
2387
+ status = resp.status;
2388
+ // Drain the body so the connection can be reused.
2389
+ try {
2390
+ await resp.body?.cancel();
1977
2391
  }
2392
+ catch { /* noop */ }
2393
+ }
2394
+ catch (err) {
2395
+ status = err instanceof SsrfBlockedError ? -1 : 0;
1978
2396
  }
1979
- catch {
2397
+ if (status === -1 || status === 0 || status >= 300) {
1980
2398
  linkResults.push({
1981
- id: `${doc.id}-${linkResults.length}`,
1982
- page: pageTitle,
1983
- url: clean,
1984
- status: 0,
1985
- type: isInternal ? 'internal' : 'external',
2399
+ id: `${job.docId}-${idx}`,
2400
+ page: job.pageTitle,
2401
+ url: job.clean,
2402
+ status: status === -1 ? 0 : status,
2403
+ type: job.isInternal ? 'internal' : 'external',
1986
2404
  });
1987
2405
  }
1988
2406
  }
1989
- }
1990
- return json({ data: linkResults });
2407
+ };
2408
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
2409
+ return json({
2410
+ data: {
2411
+ truncated: queue.length >= MAX_TOTAL_LINKS,
2412
+ checked: queue.length,
2413
+ issues: linkResults,
2414
+ },
2415
+ });
1991
2416
  }
1992
2417
  catch (err) {
1993
2418
  return internalError(err);
@@ -2047,7 +2472,12 @@ export function registerCMSRoutes(router) {
2047
2472
  // ---------------------------------------------------------------------------
2048
2473
  router.get('/seo/analysis/:documentId', async (request, params) => {
2049
2474
  try {
2050
- const doc = await db().document.findUnique({ where: { id: params.documentId } });
2475
+ const auth = await requireAuth(request);
2476
+ if (auth.error)
2477
+ return auth.error;
2478
+ const doc = await db().document.findFirst({
2479
+ where: { id: params.documentId, deletedAt: null },
2480
+ });
2051
2481
  if (!doc)
2052
2482
  return errorResponse('Not found', 404);
2053
2483
  const { analyzeContent } = await import('../seo/analysis.js');
@@ -2076,7 +2506,12 @@ export function registerCMSRoutes(router) {
2076
2506
  });
2077
2507
  router.get('/seo/readability/:documentId', async (request, params) => {
2078
2508
  try {
2079
- const doc = await db().document.findUnique({ where: { id: params.documentId } });
2509
+ const auth = await requireAuth(request);
2510
+ if (auth.error)
2511
+ return auth.error;
2512
+ const doc = await db().document.findFirst({
2513
+ where: { id: params.documentId, deletedAt: null },
2514
+ });
2080
2515
  if (!doc)
2081
2516
  return errorResponse('Not found', 404);
2082
2517
  const { calculateReadability, stripHtmlTags } = await import('../seo/analysis.js');
@@ -2094,7 +2529,12 @@ export function registerCMSRoutes(router) {
2094
2529
  });
2095
2530
  router.get('/seo/internal-links/:documentId', async (request, params) => {
2096
2531
  try {
2097
- const doc = await db().document.findUnique({ where: { id: params.documentId } });
2532
+ const auth = await requireAuth(request);
2533
+ if (auth.error)
2534
+ return auth.error;
2535
+ const doc = await db().document.findFirst({
2536
+ where: { id: params.documentId, deletedAt: null },
2537
+ });
2098
2538
  if (!doc)
2099
2539
  return errorResponse('Not found', 404);
2100
2540
  const data = doc.data || {};
@@ -2110,6 +2550,7 @@ export function registerCMSRoutes(router) {
2110
2550
  where: {
2111
2551
  id: { not: params.documentId },
2112
2552
  status: 'PUBLISHED',
2553
+ deletedAt: null,
2113
2554
  OR: keywords.map((kw) => ({
2114
2555
  title: { contains: kw, mode: 'insensitive' },
2115
2556
  })),
@@ -2165,7 +2606,12 @@ export function registerCMSRoutes(router) {
2165
2606
  });
2166
2607
  router.get('/seo/schema/:documentId', async (request, params) => {
2167
2608
  try {
2168
- const doc = await db().document.findUnique({ where: { id: params.documentId } });
2609
+ const auth = await requireAuth(request);
2610
+ if (auth.error)
2611
+ return auth.error;
2612
+ const doc = await db().document.findFirst({
2613
+ where: { id: params.documentId, deletedAt: null },
2614
+ });
2169
2615
  if (!doc)
2170
2616
  return errorResponse('Not found', 404);
2171
2617
  const { buildSchemaGraph } = await import('../content/structured-data.js');
@@ -2194,7 +2640,12 @@ export function registerCMSRoutes(router) {
2194
2640
  });
2195
2641
  router.get('/seo/meta/:documentId', async (request, params) => {
2196
2642
  try {
2197
- const doc = await db().document.findUnique({ where: { id: params.documentId } });
2643
+ const auth = await requireAuth(request);
2644
+ if (auth.error)
2645
+ return auth.error;
2646
+ const doc = await db().document.findFirst({
2647
+ where: { id: params.documentId, deletedAt: null },
2648
+ });
2198
2649
  if (!doc)
2199
2650
  return errorResponse('Not found', 404);
2200
2651
  const { generateMetaTags } = await import('../seo/meta-tags.js');
@@ -2531,6 +2982,17 @@ export function registerCMSRoutes(router) {
2531
2982
  const body = await request.json();
2532
2983
  if (!body.name || !body.scope)
2533
2984
  return errorResponse('name and scope are required', 400);
2985
+ // A child folder MUST live in the same scope as its parent — without
2986
+ // this check, a 'documents' folder could be reparented under a 'media'
2987
+ // folder, hiding it from both UIs.
2988
+ if (body.parentId) {
2989
+ const parent = await db().folder.findUnique({ where: { id: body.parentId } });
2990
+ if (!parent)
2991
+ return errorResponse('Parent folder not found', 404);
2992
+ if (parent.scope !== body.scope) {
2993
+ return errorResponse('Parent folder is in a different scope', 400);
2994
+ }
2995
+ }
2534
2996
  const folder = await db().folder.create({
2535
2997
  data: {
2536
2998
  name: body.name,
@@ -2553,6 +3015,22 @@ export function registerCMSRoutes(router) {
2553
3015
  if (roleErr)
2554
3016
  return roleErr;
2555
3017
  const body = await request.json();
3018
+ const existing = await db().folder.findUnique({ where: { id: params.id } });
3019
+ if (!existing)
3020
+ return errorResponse('Folder not found', 404);
3021
+ // Prevent reparenting into a different scope, and prevent making a folder
3022
+ // a descendant of itself (which would create a cycle).
3023
+ if (body.parentId !== undefined && body.parentId !== null) {
3024
+ if (body.parentId === params.id) {
3025
+ return errorResponse('Folder cannot be its own parent', 400);
3026
+ }
3027
+ const parent = await db().folder.findUnique({ where: { id: body.parentId } });
3028
+ if (!parent)
3029
+ return errorResponse('Parent folder not found', 404);
3030
+ if (parent.scope !== existing.scope) {
3031
+ return errorResponse('Parent folder is in a different scope', 400);
3032
+ }
3033
+ }
2556
3034
  const data = {};
2557
3035
  if (body.name !== undefined)
2558
3036
  data.name = body.name;
@@ -2578,7 +3056,22 @@ export function registerCMSRoutes(router) {
2578
3056
  const roleErr = requireRole(auth.session.role, WRITE_ROLES);
2579
3057
  if (roleErr)
2580
3058
  return roleErr;
2581
- await db().folder.delete({ where: { id: params.id } });
3059
+ // We don't cascade-delete the contents they would silently vanish.
3060
+ // Instead, refuse to delete a folder that still has documents, media,
3061
+ // or sub-folders inside it.
3062
+ const d = db();
3063
+ const folder = await d.folder.findUnique({ where: { id: params.id } });
3064
+ if (!folder)
3065
+ return errorResponse('Folder not found', 404);
3066
+ const [docCount, mediaCount, childCount] = await Promise.all([
3067
+ hasModel(d, 'document') ? d.document.count({ where: { folderId: params.id, deletedAt: null } }) : 0,
3068
+ hasModel(d, 'media') ? d.media.count({ where: { folderId: params.id } }) : 0,
3069
+ d.folder.count({ where: { parentId: params.id } }),
3070
+ ]);
3071
+ if (docCount + mediaCount + childCount > 0) {
3072
+ return errorResponse(`Folder is not empty (${docCount} documents, ${mediaCount} media, ${childCount} sub-folders). Move or delete its contents first.`, 409);
3073
+ }
3074
+ await d.folder.delete({ where: { id: params.id } });
2582
3075
  return json({ data: { success: true } });
2583
3076
  }
2584
3077
  catch (err) {
@@ -2594,6 +3087,15 @@ export function registerCMSRoutes(router) {
2594
3087
  if (roleErr)
2595
3088
  return roleErr;
2596
3089
  const body = await request.json();
3090
+ // Confirm the target folder is in the documents scope before moving in.
3091
+ if (body.folderId) {
3092
+ const target = await db().folder.findUnique({ where: { id: body.folderId } });
3093
+ if (!target)
3094
+ return errorResponse('Folder not found', 404);
3095
+ if (target.scope !== 'documents' && target.scope !== 'collections') {
3096
+ return errorResponse('Target folder is not a documents folder', 400);
3097
+ }
3098
+ }
2597
3099
  await db().document.update({
2598
3100
  where: { id: params.id },
2599
3101
  data: { folderId: body.folderId ?? null },
@@ -2613,6 +3115,14 @@ export function registerCMSRoutes(router) {
2613
3115
  if (roleErr)
2614
3116
  return roleErr;
2615
3117
  const body = await request.json();
3118
+ if (body.folderId) {
3119
+ const target = await db().folder.findUnique({ where: { id: body.folderId } });
3120
+ if (!target)
3121
+ return errorResponse('Folder not found', 404);
3122
+ if (target.scope !== 'media') {
3123
+ return errorResponse('Target folder is not a media folder', 400);
3124
+ }
3125
+ }
2616
3126
  await db().media.update({
2617
3127
  where: { id: params.id },
2618
3128
  data: { folderId: body.folderId ?? null },
@@ -2821,10 +3331,14 @@ export function registerCMSRoutes(router) {
2821
3331
  if (!doc) {
2822
3332
  return errorResponse('Global not found', 404);
2823
3333
  }
3334
+ // Globals routed through `/public/globals/:slug` are by definition
3335
+ // public site data — when no `access.read` is set we default to
3336
+ // allowed. Integrators that want to gate a global must set
3337
+ // `access.read` explicitly (returning `false` for public).
2824
3338
  const readAccess = globalConfig.access?.read;
2825
3339
  const allowed = readAccess
2826
3340
  ? await readAccess({ user: null, doc })
2827
- : false;
3341
+ : true;
2828
3342
  if (!allowed) {
2829
3343
  return errorResponse('Forbidden', 403);
2830
3344
  }
@@ -3040,7 +3554,15 @@ export function registerCMSRoutes(router) {
3040
3554
  bucket.push(tag.code);
3041
3555
  }
3042
3556
  }
3043
- return json(grouped);
3557
+ // Public endpoint that fans out to many page renders. Add a short
3558
+ // edge cache so it doesn't become a per-request DB hit.
3559
+ const response = new Response(JSON.stringify(grouped), {
3560
+ status: 200,
3561
+ headers: { ...SECURITY_HEADERS, 'Content-Type': 'application/json' },
3562
+ });
3563
+ response.headers.set('Cache-Control', 'public, max-age=60, s-maxage=60, stale-while-revalidate=300');
3564
+ response.headers.set('Vary', 'path');
3565
+ return response;
3044
3566
  }
3045
3567
  catch (err) {
3046
3568
  return internalError(err, 'script-tags/resolve');
@@ -3621,6 +4143,11 @@ export function registerCMSRoutes(router) {
3621
4143
  }
3622
4144
  });
3623
4145
  // ─── Page Builder AI Generation ─────────────────────────────────────
4146
+ // Hard caps for AI input to keep token cost bounded and to limit the
4147
+ // surface area for prompt injection. Adjust via the `ai.limits` block in
4148
+ // the CMS config if you want stricter values; never raise past 8k.
4149
+ const AI_PROMPT_MAX_CHARS = 4000;
4150
+ const AI_CONTEXT_MAX_CHARS = 8000;
3624
4151
  router.post('/page-builder/generate', async (request) => {
3625
4152
  try {
3626
4153
  const auth = await requireAuth(request);
@@ -3629,11 +4156,23 @@ export function registerCMSRoutes(router) {
3629
4156
  const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
3630
4157
  if (roleErr)
3631
4158
  return roleErr;
4159
+ // Per-user rate limit. AI generation is the single most expensive
4160
+ // operation in the CMS — without this, a compromised admin account
4161
+ // can drain a provider key in minutes.
4162
+ if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-gen:${auth.session.userId}`))) {
4163
+ return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
4164
+ }
3632
4165
  const body = await request.json();
3633
4166
  const { prompt, template, context, steps, tone } = body;
3634
4167
  if (!prompt || typeof prompt !== 'string') {
3635
4168
  return errorResponse('prompt is required', 400);
3636
4169
  }
4170
+ if (prompt.length > AI_PROMPT_MAX_CHARS) {
4171
+ return errorResponse(`prompt exceeds ${AI_PROMPT_MAX_CHARS} character limit`, 400);
4172
+ }
4173
+ if (typeof context === 'string' && context.length > AI_CONTEXT_MAX_CHARS) {
4174
+ return errorResponse(`context exceeds ${AI_CONTEXT_MAX_CHARS} character limit`, 400);
4175
+ }
3637
4176
  if (!steps || !Array.isArray(steps) || steps.length === 0) {
3638
4177
  return errorResponse('steps array is required', 400);
3639
4178
  }
@@ -3646,7 +4185,15 @@ export function registerCMSRoutes(router) {
3646
4185
  await logEvent({
3647
4186
  event: 'settings_changed',
3648
4187
  userId: auth.session.userId,
3649
- details: { action: 'page_generation_started', prompt, steps, template },
4188
+ details: {
4189
+ action: 'page_generation_started',
4190
+ // Redact secrets from the prompt before persisting to the audit log.
4191
+ // Even an admin pasting a key into a prompt by mistake shouldn't
4192
+ // result in that key being mirrored into permanent storage.
4193
+ prompt: redactSecrets(prompt).slice(0, 500),
4194
+ steps,
4195
+ template,
4196
+ },
3650
4197
  });
3651
4198
  let generatePage = null;
3652
4199
  try {
@@ -3681,11 +4228,21 @@ export function registerCMSRoutes(router) {
3681
4228
  const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
3682
4229
  if (roleErr)
3683
4230
  return roleErr;
4231
+ if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-block:${auth.session.userId}`))) {
4232
+ return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
4233
+ }
3684
4234
  const body = await request.json();
3685
4235
  const { blockType, variant, pageContext, tone } = body;
3686
4236
  if (!blockType || typeof blockType !== 'string') {
3687
4237
  return errorResponse('blockType is required', 400);
3688
4238
  }
4239
+ // Limit caller-supplied context that flows directly into the prompt.
4240
+ if (pageContext) {
4241
+ const total = JSON.stringify(pageContext).length;
4242
+ if (total > AI_CONTEXT_MAX_CHARS) {
4243
+ return errorResponse(`pageContext exceeds ${AI_CONTEXT_MAX_CHARS} character limit`, 400);
4244
+ }
4245
+ }
3689
4246
  let generateBlockContent = null;
3690
4247
  try {
3691
4248
  const aiModule = await importAIPlugin();