@actuate-media/cms-core 0.10.4 → 0.11.1

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 (418) hide show
  1. package/dist/__tests__/actions/document-crud.test.js +5 -1
  2. package/dist/__tests__/actions/document-crud.test.js.map +1 -1
  3. package/dist/__tests__/api/admin-contracts.test.js +1 -0
  4. package/dist/__tests__/api/admin-contracts.test.js.map +1 -1
  5. package/dist/__tests__/api/public-globals.test.js +8 -4
  6. package/dist/__tests__/api/public-globals.test.js.map +1 -1
  7. package/dist/__tests__/auth/password.test.js.map +1 -1
  8. package/dist/__tests__/auth/session.test.js.map +1 -1
  9. package/dist/__tests__/codegen/generate-types.test.js.map +1 -1
  10. package/dist/__tests__/next.test.js +1 -3
  11. package/dist/__tests__/next.test.js.map +1 -1
  12. package/dist/__tests__/scheduling/scheduling.test.js +28 -4
  13. package/dist/__tests__/scheduling/scheduling.test.js.map +1 -1
  14. package/dist/__tests__/security/access.test.js +1 -1
  15. package/dist/__tests__/security/access.test.js.map +1 -1
  16. package/dist/__tests__/security/audit.test.d.ts +2 -0
  17. package/dist/__tests__/security/audit.test.d.ts.map +1 -0
  18. package/dist/__tests__/security/audit.test.js +50 -0
  19. package/dist/__tests__/security/audit.test.js.map +1 -0
  20. package/dist/__tests__/security/client-ip.test.d.ts +2 -0
  21. package/dist/__tests__/security/client-ip.test.d.ts.map +1 -0
  22. package/dist/__tests__/security/client-ip.test.js +37 -0
  23. package/dist/__tests__/security/client-ip.test.js.map +1 -0
  24. package/dist/__tests__/security/csrf.test.js.map +1 -1
  25. package/dist/__tests__/security/ip-allowlist.test.d.ts +2 -0
  26. package/dist/__tests__/security/ip-allowlist.test.d.ts.map +1 -0
  27. package/dist/__tests__/security/ip-allowlist.test.js +40 -0
  28. package/dist/__tests__/security/ip-allowlist.test.js.map +1 -0
  29. package/dist/__tests__/security/rate-limit.test.js.map +1 -1
  30. package/dist/__tests__/security/reauth.test.js.map +1 -1
  31. package/dist/__tests__/security/redact.test.d.ts +2 -0
  32. package/dist/__tests__/security/redact.test.d.ts.map +1 -0
  33. package/dist/__tests__/security/redact.test.js +31 -0
  34. package/dist/__tests__/security/redact.test.js.map +1 -0
  35. package/dist/__tests__/security/sanitize.test.js.map +1 -1
  36. package/dist/__tests__/security/secret-storage.test.d.ts +2 -0
  37. package/dist/__tests__/security/secret-storage.test.d.ts.map +1 -0
  38. package/dist/__tests__/security/secret-storage.test.js +42 -0
  39. package/dist/__tests__/security/secret-storage.test.js.map +1 -0
  40. package/dist/__tests__/security/upload-magic.test.d.ts +2 -0
  41. package/dist/__tests__/security/upload-magic.test.d.ts.map +1 -0
  42. package/dist/__tests__/security/upload-magic.test.js +55 -0
  43. package/dist/__tests__/security/upload-magic.test.js.map +1 -0
  44. package/dist/__tests__/server-site.test.d.ts +2 -0
  45. package/dist/__tests__/server-site.test.d.ts.map +1 -0
  46. package/dist/__tests__/server-site.test.js +123 -0
  47. package/dist/__tests__/server-site.test.js.map +1 -0
  48. package/dist/__tests__/site.test.js +5 -2
  49. package/dist/__tests__/site.test.js.map +1 -1
  50. package/dist/__tests__/webhooks/webhooks.test.js.map +1 -1
  51. package/dist/a11y/index.d.ts +1 -1
  52. package/dist/a11y/index.d.ts.map +1 -1
  53. package/dist/a11y/index.js +23 -20
  54. package/dist/a11y/index.js.map +1 -1
  55. package/dist/actions.d.ts +1 -1
  56. package/dist/actions.d.ts.map +1 -1
  57. package/dist/actions.js +211 -68
  58. package/dist/actions.js.map +1 -1
  59. package/dist/api/handler-factory.d.ts.map +1 -1
  60. package/dist/api/handler-factory.js +76 -14
  61. package/dist/api/handler-factory.js.map +1 -1
  62. package/dist/api/handlers.d.ts.map +1 -1
  63. package/dist/api/handlers.js +952 -220
  64. package/dist/api/handlers.js.map +1 -1
  65. package/dist/api/index.d.ts.map +1 -1
  66. package/dist/api/index.js.map +1 -1
  67. package/dist/api/openapi.d.ts.map +1 -1
  68. package/dist/api/openapi.js +182 -23
  69. package/dist/api/openapi.js.map +1 -1
  70. package/dist/api/router.d.ts +6 -6
  71. package/dist/api/router.d.ts.map +1 -1
  72. package/dist/api/router.js +27 -10
  73. package/dist/api/router.js.map +1 -1
  74. package/dist/auth/index.d.ts +12 -12
  75. package/dist/auth/index.d.ts.map +1 -1
  76. package/dist/auth/index.js +9 -9
  77. package/dist/auth/index.js.map +1 -1
  78. package/dist/auth/mfa-pending.d.ts +24 -0
  79. package/dist/auth/mfa-pending.d.ts.map +1 -0
  80. package/dist/auth/mfa-pending.js +38 -0
  81. package/dist/auth/mfa-pending.js.map +1 -0
  82. package/dist/auth/oauth.d.ts +25 -3
  83. package/dist/auth/oauth.d.ts.map +1 -1
  84. package/dist/auth/oauth.js +118 -21
  85. package/dist/auth/oauth.js.map +1 -1
  86. package/dist/auth/password.d.ts +1 -1
  87. package/dist/auth/password.d.ts.map +1 -1
  88. package/dist/auth/password.js +14 -14
  89. package/dist/auth/password.js.map +1 -1
  90. package/dist/auth/providers/github.d.ts +1 -1
  91. package/dist/auth/providers/github.d.ts.map +1 -1
  92. package/dist/auth/providers/github.js +2 -2
  93. package/dist/auth/providers/github.js.map +1 -1
  94. package/dist/auth/providers/google.d.ts +1 -1
  95. package/dist/auth/providers/google.d.ts.map +1 -1
  96. package/dist/auth/providers/google.js +2 -2
  97. package/dist/auth/providers/google.js.map +1 -1
  98. package/dist/auth/providers/microsoft.d.ts +1 -1
  99. package/dist/auth/providers/microsoft.d.ts.map +1 -1
  100. package/dist/auth/providers/microsoft.js +2 -2
  101. package/dist/auth/providers/microsoft.js.map +1 -1
  102. package/dist/auth/reset-email.d.ts.map +1 -1
  103. package/dist/auth/reset-email.js +1 -1
  104. package/dist/auth/reset-email.js.map +1 -1
  105. package/dist/auth/reset.d.ts.map +1 -1
  106. package/dist/auth/reset.js +34 -10
  107. package/dist/auth/reset.js.map +1 -1
  108. package/dist/auth/session.d.ts +9 -2
  109. package/dist/auth/session.d.ts.map +1 -1
  110. package/dist/auth/session.js +26 -8
  111. package/dist/auth/session.js.map +1 -1
  112. package/dist/auth/totp.d.ts.map +1 -1
  113. package/dist/auth/totp.js +8 -2
  114. package/dist/auth/totp.js.map +1 -1
  115. package/dist/backup/index.d.ts +2 -2
  116. package/dist/backup/index.d.ts.map +1 -1
  117. package/dist/backup/index.js +5 -5
  118. package/dist/backup/index.js.map +1 -1
  119. package/dist/cache/index.d.ts +1 -1
  120. package/dist/cache/index.d.ts.map +1 -1
  121. package/dist/cache/index.js +1 -1
  122. package/dist/cache/index.js.map +1 -1
  123. package/dist/client.d.ts +1 -1
  124. package/dist/client.d.ts.map +1 -1
  125. package/dist/client.js +8 -8
  126. package/dist/client.js.map +1 -1
  127. package/dist/codegen/index.d.ts +1 -1
  128. package/dist/codegen/index.d.ts.map +1 -1
  129. package/dist/codegen/index.js +170 -174
  130. package/dist/codegen/index.js.map +1 -1
  131. package/dist/collections/index.d.ts +1 -1
  132. package/dist/collections/index.d.ts.map +1 -1
  133. package/dist/collections/index.js.map +1 -1
  134. package/dist/config/define.d.ts +2 -2
  135. package/dist/config/define.d.ts.map +1 -1
  136. package/dist/config/define.js +1 -1
  137. package/dist/config/define.js.map +1 -1
  138. package/dist/config/index.d.ts +3 -3
  139. package/dist/config/index.d.ts.map +1 -1
  140. package/dist/config/index.js +32 -18
  141. package/dist/config/index.js.map +1 -1
  142. package/dist/config/types.d.ts +26 -26
  143. package/dist/config/types.d.ts.map +1 -1
  144. package/dist/content/ai-api.d.ts.map +1 -1
  145. package/dist/content/ai-api.js +8 -2
  146. package/dist/content/ai-api.js.map +1 -1
  147. package/dist/content/content-graph.d.ts +1 -1
  148. package/dist/content/content-graph.d.ts.map +1 -1
  149. package/dist/content/content-graph.js +7 -7
  150. package/dist/content/content-graph.js.map +1 -1
  151. package/dist/content/extract.js +13 -13
  152. package/dist/content/extract.js.map +1 -1
  153. package/dist/content/index.d.ts +7 -7
  154. package/dist/content/index.d.ts.map +1 -1
  155. package/dist/content/index.js +4 -4
  156. package/dist/content/index.js.map +1 -1
  157. package/dist/content/structured-data.d.ts +3 -3
  158. package/dist/content/structured-data.d.ts.map +1 -1
  159. package/dist/content/structured-data.js +65 -67
  160. package/dist/content/structured-data.js.map +1 -1
  161. package/dist/db/adapters/mysql.d.ts.map +1 -1
  162. package/dist/db/adapters/mysql.js.map +1 -1
  163. package/dist/db/adapters/postgres.d.ts.map +1 -1
  164. package/dist/db/adapters/postgres.js.map +1 -1
  165. package/dist/db/adapters/sqlite.d.ts.map +1 -1
  166. package/dist/db/adapters/sqlite.js.map +1 -1
  167. package/dist/db/create-adapter.d.ts.map +1 -1
  168. package/dist/db/create-adapter.js.map +1 -1
  169. package/dist/db/index.d.ts +1 -1
  170. package/dist/db/index.d.ts.map +1 -1
  171. package/dist/db/index.js +1 -1
  172. package/dist/db/index.js.map +1 -1
  173. package/dist/db.d.ts +1 -1
  174. package/dist/db.d.ts.map +1 -1
  175. package/dist/db.js +1 -1
  176. package/dist/db.js.map +1 -1
  177. package/dist/fields/index.d.ts +2 -2
  178. package/dist/fields/index.d.ts.map +1 -1
  179. package/dist/fields/index.js +51 -47
  180. package/dist/fields/index.js.map +1 -1
  181. package/dist/forms/analytics.d.ts.map +1 -1
  182. package/dist/forms/analytics.js.map +1 -1
  183. package/dist/forms/attribution.d.ts.map +1 -1
  184. package/dist/forms/attribution.js +7 -2
  185. package/dist/forms/attribution.js.map +1 -1
  186. package/dist/forms/index.d.ts.map +1 -1
  187. package/dist/forms/index.js.map +1 -1
  188. package/dist/graphql/index.d.ts.map +1 -1
  189. package/dist/graphql/index.js.map +1 -1
  190. package/dist/graphql/resolvers.d.ts.map +1 -1
  191. package/dist/graphql/resolvers.js +17 -21
  192. package/dist/graphql/resolvers.js.map +1 -1
  193. package/dist/graphql/schema-builder.d.ts.map +1 -1
  194. package/dist/graphql/schema-builder.js.map +1 -1
  195. package/dist/health/index.d.ts +2 -2
  196. package/dist/health/index.d.ts.map +1 -1
  197. package/dist/health/index.js +9 -9
  198. package/dist/health/index.js.map +1 -1
  199. package/dist/i18n/index.d.ts +1 -1
  200. package/dist/i18n/index.d.ts.map +1 -1
  201. package/dist/i18n/index.js +2 -2
  202. package/dist/i18n/index.js.map +1 -1
  203. package/dist/index.d.ts +78 -76
  204. package/dist/index.d.ts.map +1 -1
  205. package/dist/index.js +44 -42
  206. package/dist/index.js.map +1 -1
  207. package/dist/media/index.d.ts +2 -2
  208. package/dist/media/index.d.ts.map +1 -1
  209. package/dist/media/index.js +1 -1
  210. package/dist/media/index.js.map +1 -1
  211. package/dist/media/optimize.d.ts.map +1 -1
  212. package/dist/media/optimize.js +7 -4
  213. package/dist/media/optimize.js.map +1 -1
  214. package/dist/middleware.d.ts.map +1 -1
  215. package/dist/middleware.js +21 -34
  216. package/dist/middleware.js.map +1 -1
  217. package/dist/multisite/index.d.ts.map +1 -1
  218. package/dist/multisite/index.js +4 -4
  219. package/dist/multisite/index.js.map +1 -1
  220. package/dist/next/preview.d.ts.map +1 -1
  221. package/dist/next/preview.js.map +1 -1
  222. package/dist/next.d.ts.map +1 -1
  223. package/dist/next.js +4 -5
  224. package/dist/next.js.map +1 -1
  225. package/dist/notifications/index.d.ts +1 -1
  226. package/dist/notifications/index.d.ts.map +1 -1
  227. package/dist/notifications/index.js +5 -5
  228. package/dist/notifications/index.js.map +1 -1
  229. package/dist/page-builder/__tests__/a11y-fix.test.js +1 -5
  230. package/dist/page-builder/__tests__/a11y-fix.test.js.map +1 -1
  231. package/dist/page-builder/__tests__/blocks.test.js +108 -1
  232. package/dist/page-builder/__tests__/blocks.test.js.map +1 -1
  233. package/dist/page-builder/__tests__/design-scorer.test.js +44 -11
  234. package/dist/page-builder/__tests__/design-scorer.test.js.map +1 -1
  235. package/dist/page-builder/__tests__/schema.test.js +12 -12
  236. package/dist/page-builder/__tests__/schema.test.js.map +1 -1
  237. package/dist/page-builder/__tests__/seo-analyzer.test.js +27 -13
  238. package/dist/page-builder/__tests__/seo-analyzer.test.js.map +1 -1
  239. package/dist/page-builder/ai-pipeline.d.ts.map +1 -1
  240. package/dist/page-builder/ai-pipeline.js +1 -3
  241. package/dist/page-builder/ai-pipeline.js.map +1 -1
  242. package/dist/page-builder/blocks.d.ts +18 -1
  243. package/dist/page-builder/blocks.d.ts.map +1 -1
  244. package/dist/page-builder/blocks.js +67 -11
  245. package/dist/page-builder/blocks.js.map +1 -1
  246. package/dist/page-builder/design-scorer.d.ts.map +1 -1
  247. package/dist/page-builder/design-scorer.js +249 -41
  248. package/dist/page-builder/design-scorer.js.map +1 -1
  249. package/dist/page-builder/index.d.ts +3 -3
  250. package/dist/page-builder/index.d.ts.map +1 -1
  251. package/dist/page-builder/index.js +2 -2
  252. package/dist/page-builder/index.js.map +1 -1
  253. package/dist/page-builder/seo-analyzer.d.ts.map +1 -1
  254. package/dist/page-builder/seo-analyzer.js +252 -56
  255. package/dist/page-builder/seo-analyzer.js.map +1 -1
  256. package/dist/page-builder/templates.d.ts.map +1 -1
  257. package/dist/page-builder/templates.js +45 -16
  258. package/dist/page-builder/templates.js.map +1 -1
  259. package/dist/page-builder/tree.d.ts.map +1 -1
  260. package/dist/page-builder/tree.js.map +1 -1
  261. package/dist/page-builder/validate.js.map +1 -1
  262. package/dist/presence/index.d.ts.map +1 -1
  263. package/dist/presence/index.js +2 -2
  264. package/dist/presence/index.js.map +1 -1
  265. package/dist/preview/index.d.ts.map +1 -1
  266. package/dist/preview/index.js.map +1 -1
  267. package/dist/privacy/index.d.ts +1 -1
  268. package/dist/privacy/index.d.ts.map +1 -1
  269. package/dist/privacy/index.js +3 -3
  270. package/dist/privacy/index.js.map +1 -1
  271. package/dist/relationships/index.d.ts.map +1 -1
  272. package/dist/relationships/index.js +1 -1
  273. package/dist/relationships/index.js.map +1 -1
  274. package/dist/scheduling/index.d.ts +2 -2
  275. package/dist/scheduling/index.d.ts.map +1 -1
  276. package/dist/scheduling/index.js +3 -1
  277. package/dist/scheduling/index.js.map +1 -1
  278. package/dist/search/index.d.ts.map +1 -1
  279. package/dist/search/index.js +1 -3
  280. package/dist/search/index.js.map +1 -1
  281. package/dist/security/access.d.ts +4 -4
  282. package/dist/security/access.d.ts.map +1 -1
  283. package/dist/security/access.js +11 -15
  284. package/dist/security/access.js.map +1 -1
  285. package/dist/security/anomaly-detection.d.ts.map +1 -1
  286. package/dist/security/anomaly-detection.js +5 -5
  287. package/dist/security/anomaly-detection.js.map +1 -1
  288. package/dist/security/api-key-enhanced.d.ts +2 -2
  289. package/dist/security/api-key-enhanced.d.ts.map +1 -1
  290. package/dist/security/api-key-enhanced.js +5 -5
  291. package/dist/security/api-key-enhanced.js.map +1 -1
  292. package/dist/security/audit.d.ts.map +1 -1
  293. package/dist/security/audit.js +8 -4
  294. package/dist/security/audit.js.map +1 -1
  295. package/dist/security/breach-check.js.map +1 -1
  296. package/dist/security/captcha.d.ts.map +1 -1
  297. package/dist/security/captcha.js.map +1 -1
  298. package/dist/security/client-ip.d.ts +33 -0
  299. package/dist/security/client-ip.d.ts.map +1 -0
  300. package/dist/security/client-ip.js +42 -0
  301. package/dist/security/client-ip.js.map +1 -0
  302. package/dist/security/cors.d.ts +1 -1
  303. package/dist/security/cors.d.ts.map +1 -1
  304. package/dist/security/cors.js +12 -12
  305. package/dist/security/cors.js.map +1 -1
  306. package/dist/security/csp-nonces.js +11 -11
  307. package/dist/security/csp-nonces.js.map +1 -1
  308. package/dist/security/csrf.js +2 -2
  309. package/dist/security/csrf.js.map +1 -1
  310. package/dist/security/encrypted-fields.d.ts.map +1 -1
  311. package/dist/security/encrypted-fields.js +7 -4
  312. package/dist/security/encrypted-fields.js.map +1 -1
  313. package/dist/security/headers.d.ts.map +1 -1
  314. package/dist/security/headers.js +12 -12
  315. package/dist/security/headers.js.map +1 -1
  316. package/dist/security/index.d.ts +39 -32
  317. package/dist/security/index.d.ts.map +1 -1
  318. package/dist/security/index.js +25 -20
  319. package/dist/security/index.js.map +1 -1
  320. package/dist/security/internal-keys.d.ts +15 -0
  321. package/dist/security/internal-keys.d.ts.map +1 -0
  322. package/dist/security/internal-keys.js +33 -0
  323. package/dist/security/internal-keys.js.map +1 -0
  324. package/dist/security/ip-allowlist.d.ts +13 -1
  325. package/dist/security/ip-allowlist.d.ts.map +1 -1
  326. package/dist/security/ip-allowlist.js +117 -11
  327. package/dist/security/ip-allowlist.js.map +1 -1
  328. package/dist/security/middleware.d.ts +2 -2
  329. package/dist/security/middleware.d.ts.map +1 -1
  330. package/dist/security/middleware.js +11 -11
  331. package/dist/security/middleware.js.map +1 -1
  332. package/dist/security/rate-limit.d.ts.map +1 -1
  333. package/dist/security/rate-limit.js +50 -18
  334. package/dist/security/rate-limit.js.map +1 -1
  335. package/dist/security/reauth.d.ts +1 -1
  336. package/dist/security/reauth.d.ts.map +1 -1
  337. package/dist/security/reauth.js.map +1 -1
  338. package/dist/security/redact.d.ts +12 -0
  339. package/dist/security/redact.d.ts.map +1 -0
  340. package/dist/security/redact.js +44 -0
  341. package/dist/security/redact.js.map +1 -0
  342. package/dist/security/safe-fetch.d.ts +35 -0
  343. package/dist/security/safe-fetch.d.ts.map +1 -0
  344. package/dist/security/safe-fetch.js +45 -0
  345. package/dist/security/safe-fetch.js.map +1 -0
  346. package/dist/security/sanitize.d.ts.map +1 -1
  347. package/dist/security/sanitize.js +40 -8
  348. package/dist/security/sanitize.js.map +1 -1
  349. package/dist/security/secret-storage.d.ts +22 -0
  350. package/dist/security/secret-storage.d.ts.map +1 -0
  351. package/dist/security/secret-storage.js +75 -0
  352. package/dist/security/secret-storage.js.map +1 -0
  353. package/dist/security/security-txt.d.ts.map +1 -1
  354. package/dist/security/security-txt.js +2 -2
  355. package/dist/security/security-txt.js.map +1 -1
  356. package/dist/security/session-limits.d.ts +1 -1
  357. package/dist/security/session-limits.d.ts.map +1 -1
  358. package/dist/security/session-limits.js +1 -1
  359. package/dist/security/session-limits.js.map +1 -1
  360. package/dist/security/upload.d.ts +23 -4
  361. package/dist/security/upload.d.ts.map +1 -1
  362. package/dist/security/upload.js +118 -23
  363. package/dist/security/upload.js.map +1 -1
  364. package/dist/security/webhook.d.ts.map +1 -1
  365. package/dist/security/webhook.js +12 -8
  366. package/dist/security/webhook.js.map +1 -1
  367. package/dist/seo/analysis.d.ts.map +1 -1
  368. package/dist/seo/analysis.js +25 -13
  369. package/dist/seo/analysis.js.map +1 -1
  370. package/dist/seo/index.d.ts +9 -9
  371. package/dist/seo/index.d.ts.map +1 -1
  372. package/dist/seo/index.js +4 -4
  373. package/dist/seo/index.js.map +1 -1
  374. package/dist/seo/llms-txt.js +1 -3
  375. package/dist/seo/llms-txt.js.map +1 -1
  376. package/dist/server-site.d.ts +54 -0
  377. package/dist/server-site.d.ts.map +1 -0
  378. package/dist/server-site.js +147 -0
  379. package/dist/server-site.js.map +1 -0
  380. package/dist/setup/index.d.ts.map +1 -1
  381. package/dist/setup/index.js.map +1 -1
  382. package/dist/site.d.ts.map +1 -1
  383. package/dist/site.js +26 -4
  384. package/dist/site.js.map +1 -1
  385. package/dist/storage/index.d.ts +20 -10
  386. package/dist/storage/index.d.ts.map +1 -1
  387. package/dist/storage/index.js +6 -3
  388. package/dist/storage/index.js.map +1 -1
  389. package/dist/templates/index.d.ts.map +1 -1
  390. package/dist/templates/index.js +3 -3
  391. package/dist/templates/index.js.map +1 -1
  392. package/dist/upgrade/changelog.d.ts +1 -1
  393. package/dist/upgrade/changelog.d.ts.map +1 -1
  394. package/dist/upgrade/changelog.js +12 -12
  395. package/dist/upgrade/changelog.js.map +1 -1
  396. package/dist/upgrade/index.d.ts +6 -6
  397. package/dist/upgrade/index.d.ts.map +1 -1
  398. package/dist/upgrade/index.js +3 -3
  399. package/dist/upgrade/index.js.map +1 -1
  400. package/dist/upgrade/upgrade-pr.d.ts.map +1 -1
  401. package/dist/upgrade/upgrade-pr.js +36 -36
  402. package/dist/upgrade/upgrade-pr.js.map +1 -1
  403. package/dist/upgrade/version-check.d.ts +1 -1
  404. package/dist/upgrade/version-check.d.ts.map +1 -1
  405. package/dist/upgrade/version-check.js +13 -13
  406. package/dist/upgrade/version-check.js.map +1 -1
  407. package/dist/webhooks/index.d.ts +1 -1
  408. package/dist/webhooks/index.d.ts.map +1 -1
  409. package/dist/webhooks/index.js +24 -13
  410. package/dist/webhooks/index.js.map +1 -1
  411. package/dist/workflow/index.d.ts.map +1 -1
  412. package/dist/workflow/index.js.map +1 -1
  413. package/dist/workflows/index.d.ts +1 -1
  414. package/dist/workflows/index.d.ts.map +1 -1
  415. package/dist/workflows/index.js +3 -3
  416. package/dist/workflows/index.js.map +1 -1
  417. package/package.json +1 -1
  418. package/prisma/seed.ts +31 -31
@@ -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() {
@@ -62,7 +71,9 @@ function mediaUrl(storageKey) {
62
71
  const value = String(storageKey ?? '');
63
72
  if (!value)
64
73
  return '';
65
- return value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/') ? value : '';
74
+ return value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/')
75
+ ? value
76
+ : '';
66
77
  }
67
78
  function normalizeMediaItem(media) {
68
79
  const width = typeof media.width === 'number' ? media.width : null;
@@ -158,9 +169,9 @@ function hasModel(d, name) {
158
169
  }
159
170
  }
160
171
  function modelNotAvailable(name) {
161
- return errorResponse(`The "${name}" model is not available in your Prisma schema. `
162
- + 'Run `actuate db:init` for new schemas, or carefully update the existing Actuate block, create/apply a Prisma migration, then regenerate Prisma Client. '
163
- + 'See https://actuatecms.dev/docs/database-setup for required models.', 501);
172
+ return errorResponse(`The "${name}" model is not available in your Prisma schema. ` +
173
+ 'Run `actuate db:init` for new schemas, or carefully update the existing Actuate block, create/apply a Prisma migration, then regenerate Prisma Client. ' +
174
+ 'See https://actuatecms.dev/docs/database-setup for required models.', 501);
164
175
  }
165
176
  async function safeCount(model, where) {
166
177
  try {
@@ -223,28 +234,34 @@ function isAllowedStorageUrl(url) {
223
234
  }
224
235
  }
225
236
  const ALLOWED_SORT_FIELDS = new Set([
226
- 'createdAt', 'updatedAt', 'publishedAt', 'status', 'collection',
237
+ 'createdAt',
238
+ 'updatedAt',
239
+ 'publishedAt',
240
+ 'status',
241
+ 'collection',
227
242
  ]);
228
243
  let _secretMissing = false;
229
244
  let _secretWarningLogged = false;
230
245
  function getSessionSecret() {
231
- const secret = process.env.CMS_SECRET
232
- ?? process.env.CMS_SESSION_SECRET
233
- ?? globalThis.__actuateConfig?.secret;
246
+ const secret = process.env.CMS_SECRET ??
247
+ process.env.CMS_SESSION_SECRET ??
248
+ globalThis.__actuateConfig?.secret;
234
249
  if (!secret) {
235
250
  _secretMissing = true;
236
251
  if (!_secretWarningLogged) {
237
252
  _secretWarningLogged = true;
238
- console.error('[Actuate CMS] Missing CMS secret. Set the CMS_SECRET environment variable (min 32 characters) '
239
- + 'or pass `secret` in your actuate.config.ts. '
240
- + 'Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))" '
241
- + '-- All authenticated API routes will return 503 until this is configured.');
253
+ console.error('[Actuate CMS] Missing CMS secret. Set the CMS_SECRET environment variable (min 32 characters) ' +
254
+ 'or pass `secret` in your actuate.config.ts. ' +
255
+ "Generate one with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\" " +
256
+ '-- All authenticated API routes will return 503 until this is configured.');
242
257
  }
243
258
  throw new Error('CMS secret not configured');
244
259
  }
245
260
  if (secret.length < 32) {
246
- throw new Error('[Actuate CMS] CMS secret must be at least 32 characters (got ' + secret.length + '). '
247
- + 'Generate a secure value with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"');
261
+ throw new Error('[Actuate CMS] CMS secret must be at least 32 characters (got ' +
262
+ secret.length +
263
+ '). ' +
264
+ "Generate a secure value with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"");
248
265
  }
249
266
  _secretMissing = false;
250
267
  return secret;
@@ -302,6 +319,36 @@ export function parseCookieHeader(cookieHeader) {
302
319
  }
303
320
  return cookies;
304
321
  }
322
+ /**
323
+ * Verify a password (or "current password") posted in the X-Reauth-Password
324
+ * header against the authenticated user. Returns an error Response when the
325
+ * header is missing or the password is wrong; returns null on success.
326
+ *
327
+ * Use this for high-impact account changes (TOTP enable/disable, role change,
328
+ * delete user, etc.) so a stolen session alone cannot perform them.
329
+ */
330
+ async function requirePasswordReauth(request, userId) {
331
+ const password = request.headers.get('x-reauth-password');
332
+ if (!password) {
333
+ return new Response(JSON.stringify({
334
+ error: 'Re-authentication required for this action.',
335
+ code: 'REAUTH_REQUIRED',
336
+ method: 'password',
337
+ }), { status: 401, headers: { ...SECURITY_HEADERS } });
338
+ }
339
+ try {
340
+ const { getDB } = await import('../db.js');
341
+ const ok = await verifyReauth(userId, password, 'password', getDB());
342
+ if (!ok) {
343
+ return errorResponse('Re-authentication failed.', 401);
344
+ }
345
+ return null;
346
+ }
347
+ catch (err) {
348
+ console.error('[actuate][reauth] verify failed:', err instanceof Error ? err.message : err);
349
+ return errorResponse('Re-authentication failed.', 401);
350
+ }
351
+ }
305
352
  async function requireAuth(request) {
306
353
  if (isSecretMissing()) {
307
354
  return {
@@ -334,15 +381,59 @@ function requireRole(role, allowedRoles) {
334
381
  return null;
335
382
  }
336
383
  const loginLimiter = createRateLimiter({ maxRequests: 5, windowMs: 15 * 60 * 1000 });
384
+ const totpLimiter = createRateLimiter({ maxRequests: 10, windowMs: 15 * 60 * 1000 });
337
385
  const formLimiterGlobal = createRateLimiter({ maxRequests: 10, windowMs: 60_000 });
386
+ const aiGenerateLimiter = createRateLimiter({ maxRequests: 20, windowMs: 60 * 60 * 1000 });
387
+ const linkHealthLimiter = createRateLimiter({ maxRequests: 4, windowMs: 60 * 60 * 1000 });
338
388
  async function checkRateLimitAsync(limiter, key) {
389
+ // Explicit, environment-gated bypass for test harnesses. Production never
390
+ // sets this — Vercel + the deploy guide both omit it. We deliberately do
391
+ // NOT key off `NODE_ENV === 'test'` because Next.js builds run with
392
+ // NODE_ENV=test in some CI configurations and we want rate limiting
393
+ // exercised by unit tests that explicitly opt in.
394
+ if (process.env.ACTUATE_DISABLE_RATE_LIMIT === '1') {
395
+ return true;
396
+ }
339
397
  const result = await limiter.check(key);
340
398
  return result.allowed;
341
399
  }
400
+ /**
401
+ * Resolve the client IP from trusted-proxy headers (Vercel first, then x-real-ip,
402
+ * then x-forwarded-for only when ACTUATE_TRUST_PROXY=1). Returns 'unknown' when
403
+ * no trustworthy source is available — callers MUST treat that case as a hard
404
+ * failure for security-sensitive decisions like rate-limit keys and IP allowlists.
405
+ */
406
+ function clientIp(request) {
407
+ return getClientIp(request);
408
+ }
409
+ const MAX_CONCURRENT_SESSIONS = 5;
410
+ /**
411
+ * After a successful primary-credential check, prune the user's active sessions
412
+ * down to the configured concurrent maximum (default 5; configurable via
413
+ * `auth.maxConcurrentSessions`). Strategy is "revoke oldest" so a user can
414
+ * always sign in.
415
+ */
416
+ async function enforceSessionLimitsForUser(d, userId) {
417
+ if (!hasModel(d, 'session'))
418
+ return;
419
+ const config = globalThis.__actuateConfig?.auth ?? {};
420
+ const max = typeof config.maxConcurrentSessions === 'number' && config.maxConcurrentSessions > 0
421
+ ? config.maxConcurrentSessions
422
+ : MAX_CONCURRENT_SESSIONS;
423
+ const active = await d.session.findMany({
424
+ where: { userId, revokedAt: null, expiresAt: { gt: new Date() } },
425
+ select: { id: true, createdAt: true },
426
+ });
427
+ const decision = enforceSessionLimits(active.map((s) => ({ sessionId: s.id, userId, createdAt: s.createdAt })), { maxConcurrentSessions: max, strategy: 'revoke_oldest' });
428
+ if (decision.sessionsToRevoke.length > 0) {
429
+ await d.session.updateMany({
430
+ where: { id: { in: decision.sessionsToRevoke } },
431
+ data: { revokedAt: new Date() },
432
+ });
433
+ }
434
+ }
342
435
  function getAdminPath() {
343
- return process.env.ACTUATE_ADMIN_PATH
344
- ?? globalThis.__actuateConfig?.admin?.path
345
- ?? '/admin';
436
+ return (process.env.ACTUATE_ADMIN_PATH ?? globalThis.__actuateConfig?.admin?.path ?? '/admin');
346
437
  }
347
438
  class ModelNotAvailableError extends Error {
348
439
  model;
@@ -365,7 +456,9 @@ export function registerCMSRoutes(router) {
365
456
  if (typeof prop !== 'string' || prop.startsWith('$') || prop === 'then')
366
457
  return undefined;
367
458
  return new Proxy({}, {
368
- get() { throw new ModelNotAvailableError(String(prop)); },
459
+ get() {
460
+ throw new ModelNotAvailableError(String(prop));
461
+ },
369
462
  });
370
463
  },
371
464
  });
@@ -386,7 +479,9 @@ export function registerCMSRoutes(router) {
386
479
  if (val !== undefined && val !== null)
387
480
  return val;
388
481
  return new Proxy({}, {
389
- get() { throw new ModelNotAvailableError(String(prop)); },
482
+ get() {
483
+ throw new ModelNotAvailableError(String(prop));
484
+ },
390
485
  });
391
486
  },
392
487
  });
@@ -398,12 +493,7 @@ export function registerCMSRoutes(router) {
398
493
  router.get('/auth/csrf', async () => {
399
494
  const token = await generateCsrfToken();
400
495
  const isProduction = process.env.NODE_ENV === 'production';
401
- const cookieFlags = [
402
- `actuate_csrf=${token}`,
403
- 'Path=/',
404
- 'SameSite=Lax',
405
- 'Max-Age=86400',
406
- ];
496
+ const cookieFlags = [`actuate_csrf=${token}`, 'Path=/', 'SameSite=Lax', 'Max-Age=86400'];
407
497
  if (isProduction)
408
498
  cookieFlags.push('Secure');
409
499
  const response = json({ data: { token } });
@@ -446,18 +536,23 @@ export function registerCMSRoutes(router) {
446
536
  // ---------------------------------------------------------------------------
447
537
  router.post('/auth/login', async (request) => {
448
538
  try {
449
- const body = await request.json();
539
+ const body = (await request.json());
450
540
  const { email, password } = body;
451
541
  if (!email || !password) {
452
542
  return errorResponse('Email and password are required', 400);
453
543
  }
454
- const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
455
- if (!(await checkRateLimitAsync(loginLimiter, `login:${clientIp}`))) {
544
+ const ip = clientIp(request);
545
+ const userAgent = request.headers.get('user-agent');
546
+ // Bucket by IP when we have a trustworthy one; otherwise fall back to a
547
+ // global bucket. We deliberately avoid keying on the email alone — that
548
+ // would let attackers lock out legitimate users by spamming logins.
549
+ const rateLimitKey = isResolvedIp(ip) ? `login:${ip}` : 'login:unknown';
550
+ if (!(await checkRateLimitAsync(loginLimiter, rateLimitKey))) {
456
551
  return errorResponse('Too many login attempts. Please try again later.', 429);
457
552
  }
458
553
  const captchaConfig = getCaptchaConfig();
459
554
  if (captchaConfig.provider !== 'none') {
460
- const captchaResult = await verifyCaptcha(body.captchaToken ?? '', captchaConfig, clientIp);
555
+ const captchaResult = await verifyCaptcha(body.captchaToken ?? '', captchaConfig, ip);
461
556
  if (!captchaResult.success) {
462
557
  return errorResponse('CAPTCHA verification failed. Please try again.', 403);
463
558
  }
@@ -479,8 +574,8 @@ export function registerCMSRoutes(router) {
479
574
  await logEvent({
480
575
  event: 'login_failed',
481
576
  userId: user.id,
482
- ipAddress: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? undefined,
483
- userAgent: request.headers.get('user-agent') ?? undefined,
577
+ ipAddress: isResolvedIp(ip) ? ip : undefined,
578
+ userAgent: userAgent ?? undefined,
484
579
  });
485
580
  return errorResponse('Invalid email or password', 401);
486
581
  }
@@ -488,8 +583,14 @@ export function registerCMSRoutes(router) {
488
583
  return errorResponse('Account is deactivated', 403);
489
584
  }
490
585
  if (user.totpEnabled) {
491
- return json({ data: { requiresTOTP: true, userId: user.id } });
586
+ // Hand back an opaque short-lived token instead of the raw userId.
587
+ // The /auth/totp/login endpoint will verify both this token and a
588
+ // stable browser fingerprint before checking the TOTP code.
589
+ const fingerprint = await computeRequestFingerprint(ip, userAgent);
590
+ const mfaPendingToken = await createMfaPendingToken({ userId: user.id, fingerprint }, getSessionSecret());
591
+ return json({ data: { requiresTOTP: true, mfaPendingToken } });
492
592
  }
593
+ await enforceSessionLimitsForUser(d, user.id);
493
594
  const tempSessionId = crypto.randomUUID();
494
595
  const token = await createSession({ userId: user.id, role: user.role, sessionId: tempSessionId }, { secret: getSessionSecret() });
495
596
  await db().session.create({
@@ -498,8 +599,8 @@ export function registerCMSRoutes(router) {
498
599
  userId: user.id,
499
600
  token,
500
601
  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,
602
+ ipAddress: isResolvedIp(ip) ? ip : null,
603
+ userAgent: userAgent ?? null,
503
604
  },
504
605
  });
505
606
  const response = json({
@@ -519,12 +620,7 @@ export function registerCMSRoutes(router) {
519
620
  if (isProduction)
520
621
  sessionCookie.push('Secure');
521
622
  const csrfToken = await generateCsrfToken();
522
- const csrfCookie = [
523
- `actuate_csrf=${csrfToken}`,
524
- 'Path=/',
525
- 'SameSite=Lax',
526
- 'Max-Age=86400',
527
- ];
623
+ const csrfCookie = [`actuate_csrf=${csrfToken}`, 'Path=/', 'SameSite=Lax', 'Max-Age=86400'];
528
624
  if (isProduction)
529
625
  csrfCookie.push('Secure');
530
626
  response.headers.append('Set-Cookie', sessionCookie.join('; '));
@@ -532,8 +628,8 @@ export function registerCMSRoutes(router) {
532
628
  await logEvent({
533
629
  event: 'login_success',
534
630
  userId: user.id,
535
- ipAddress: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? undefined,
536
- userAgent: request.headers.get('user-agent') ?? undefined,
631
+ ipAddress: isResolvedIp(ip) ? ip : undefined,
632
+ userAgent: userAgent ?? undefined,
537
633
  });
538
634
  return response;
539
635
  }
@@ -547,10 +643,11 @@ export function registerCMSRoutes(router) {
547
643
  if (auth.error)
548
644
  return auth.error;
549
645
  await revokeSession(auth.session.sessionId, db());
646
+ const ip = clientIp(request);
550
647
  await logEvent({
551
648
  event: 'logout',
552
649
  userId: auth.session.userId,
553
- ipAddress: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? undefined,
650
+ ipAddress: isResolvedIp(ip) ? ip : undefined,
554
651
  });
555
652
  const response = json({ data: { success: true } });
556
653
  response.headers.set('Set-Cookie', 'actuate_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
@@ -562,13 +659,14 @@ export function registerCMSRoutes(router) {
562
659
  });
563
660
  router.post('/auth/forgot-password', async (request) => {
564
661
  try {
565
- const body = await request.json();
662
+ const body = (await request.json());
566
663
  const { email } = body;
567
664
  if (!email) {
568
665
  return errorResponse('Email is required', 400);
569
666
  }
570
- const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
571
- if (!(await checkRateLimitAsync(loginLimiter, `forgot:${clientIp}`))) {
667
+ const ip = clientIp(request);
668
+ const rateLimitKey = isResolvedIp(ip) ? `forgot:${ip}` : 'forgot:unknown';
669
+ if (!(await checkRateLimitAsync(loginLimiter, rateLimitKey))) {
572
670
  return errorResponse('Too many requests. Please try again later.', 429);
573
671
  }
574
672
  const d = db();
@@ -582,7 +680,7 @@ export function registerCMSRoutes(router) {
582
680
  });
583
681
  await logEvent({
584
682
  event: 'password_reset_request',
585
- ipAddress: clientIp,
683
+ ipAddress: isResolvedIp(ip) ? ip : undefined,
586
684
  userAgent: request.headers.get('user-agent') ?? undefined,
587
685
  details: { email: email.toLowerCase().trim() },
588
686
  });
@@ -594,7 +692,7 @@ export function registerCMSRoutes(router) {
594
692
  });
595
693
  router.post('/auth/reset-password', async (request) => {
596
694
  try {
597
- const body = await request.json();
695
+ const body = (await request.json());
598
696
  const { token, password } = body;
599
697
  if (!token || !password) {
600
698
  return errorResponse('Token and new password are required', 400);
@@ -602,8 +700,9 @@ export function registerCMSRoutes(router) {
602
700
  if (password.length < 8) {
603
701
  return errorResponse('Password must be at least 8 characters', 400);
604
702
  }
605
- const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
606
- if (!(await checkRateLimitAsync(loginLimiter, `reset:${clientIp}`))) {
703
+ const ip = clientIp(request);
704
+ const rateLimitKey = isResolvedIp(ip) ? `reset:${ip}` : 'reset:unknown';
705
+ if (!(await checkRateLimitAsync(loginLimiter, rateLimitKey))) {
607
706
  return errorResponse('Too many requests. Please try again later.', 429);
608
707
  }
609
708
  const d = db();
@@ -615,7 +714,7 @@ export function registerCMSRoutes(router) {
615
714
  }
616
715
  await logEvent({
617
716
  event: 'password_reset_complete',
618
- ipAddress: clientIp,
717
+ ipAddress: isResolvedIp(ip) ? ip : undefined,
619
718
  userAgent: request.headers.get('user-agent') ?? undefined,
620
719
  });
621
720
  return json({ data: { success: true } });
@@ -636,7 +735,24 @@ export function registerCMSRoutes(router) {
636
735
  if (!user) {
637
736
  return errorResponse('User not found', 404);
638
737
  }
639
- return json({ data: user });
738
+ const response = json({ data: user });
739
+ // Bootstrap (or refresh) the double-submit CSRF cookie. Without this
740
+ // the admin app's first non-GET after a hard reload races to populate
741
+ // it; some users hit "Invalid CSRF token" on the very first save.
742
+ const cookies = parseCookieHeader(request.headers.get('cookie') ?? '');
743
+ if (!cookies['actuate_csrf']) {
744
+ const csrfToken = await generateCsrfToken();
745
+ const isProduction = process.env.NODE_ENV === 'production';
746
+ const csrfCookie = [
747
+ `actuate_csrf=${csrfToken}`,
748
+ 'Path=/',
749
+ 'SameSite=Lax',
750
+ 'Max-Age=86400',
751
+ ...(isProduction ? ['Secure'] : []),
752
+ ].join('; ');
753
+ response.headers.append('Set-Cookie', csrfCookie);
754
+ }
755
+ return response;
640
756
  }
641
757
  catch (err) {
642
758
  return internalError(err, 'auth/me');
@@ -650,8 +766,16 @@ export function registerCMSRoutes(router) {
650
766
  const auth = await requireAuth(request);
651
767
  if (auth.error)
652
768
  return auth.error;
769
+ // Sensitive operation — require recent password reauth so a stolen
770
+ // session can't silently rotate someone's TOTP secret.
771
+ const reauthErr = await requirePasswordReauth(request, auth.session.userId);
772
+ if (reauthErr)
773
+ return reauthErr;
653
774
  const { generateTOTPSecret, generateTOTPUri, generateBackupCodes } = await import('../auth/totp.js');
654
- const user = await db().user.findUnique({ where: { id: auth.session.userId }, select: { email: true, totpEnabled: true } });
775
+ const user = await db().user.findUnique({
776
+ where: { id: auth.session.userId },
777
+ select: { email: true, totpEnabled: true },
778
+ });
655
779
  if (!user)
656
780
  return errorResponse('User not found', 404);
657
781
  if (user.totpEnabled)
@@ -659,7 +783,14 @@ export function registerCMSRoutes(router) {
659
783
  const secret = generateTOTPSecret();
660
784
  const uri = generateTOTPUri(secret, user.email);
661
785
  const backups = generateBackupCodes();
662
- await db().user.update({ where: { id: auth.session.userId }, data: { totpSecret: secret, backupCodes: backups } });
786
+ // Persist encrypted; the plaintext is only returned to the user once
787
+ // (so they can scan the QR / save backup codes).
788
+ const encryptedSecret = await encryptSecret(secret);
789
+ const encryptedBackups = await encryptStringArray(backups);
790
+ await db().user.update({
791
+ where: { id: auth.session.userId },
792
+ data: { totpSecret: encryptedSecret, backupCodes: encryptedBackups },
793
+ });
663
794
  return json({ data: { secret, uri, backupCodes: backups } });
664
795
  }
665
796
  catch (err) {
@@ -671,17 +802,22 @@ export function registerCMSRoutes(router) {
671
802
  const auth = await requireAuth(request);
672
803
  if (auth.error)
673
804
  return auth.error;
674
- const body = await request.json();
805
+ const body = (await request.json());
675
806
  if (!body.code)
676
807
  return errorResponse('Code is required', 400);
677
808
  const { verifyTOTP } = await import('../auth/totp.js');
678
- const user = await db().user.findUnique({ where: { id: auth.session.userId }, select: { totpSecret: true } });
809
+ const user = await db().user.findUnique({
810
+ where: { id: auth.session.userId },
811
+ select: { totpSecret: true },
812
+ });
679
813
  if (!user?.totpSecret)
680
814
  return errorResponse('TOTP not set up', 400);
681
- const valid = verifyTOTP(body.code, user.totpSecret);
815
+ const secret = await decryptSecret(user.totpSecret);
816
+ const valid = verifyTOTP(body.code, secret);
682
817
  if (!valid)
683
818
  return errorResponse('Invalid code', 400);
684
819
  await db().user.update({ where: { id: auth.session.userId }, data: { totpEnabled: true } });
820
+ await logEvent({ event: 'totp_enabled', userId: auth.session.userId });
685
821
  return json({ data: { enabled: true } });
686
822
  }
687
823
  catch (err) {
@@ -693,7 +829,28 @@ export function registerCMSRoutes(router) {
693
829
  const auth = await requireAuth(request);
694
830
  if (auth.error)
695
831
  return auth.error;
696
- await db().user.update({ where: { id: auth.session.userId }, data: { totpEnabled: false, totpSecret: null, backupCodes: null } });
832
+ // Disabling MFA is a high-impact security change. Require recent password
833
+ // reauth and revoke every other session so a compromised cookie loses
834
+ // access immediately after MFA goes away.
835
+ const reauthErr = await requirePasswordReauth(request, auth.session.userId);
836
+ if (reauthErr)
837
+ return reauthErr;
838
+ const d = db();
839
+ await d.user.update({
840
+ where: { id: auth.session.userId },
841
+ data: { totpEnabled: false, totpSecret: null, backupCodes: null },
842
+ });
843
+ if (hasModel(d, 'session')) {
844
+ await d.session.updateMany({
845
+ where: {
846
+ userId: auth.session.userId,
847
+ id: { not: auth.session.sessionId },
848
+ revokedAt: null,
849
+ },
850
+ data: { revokedAt: new Date() },
851
+ });
852
+ }
853
+ await logEvent({ event: 'totp_disabled', userId: auth.session.userId });
697
854
  return json({ data: { enabled: false } });
698
855
  }
699
856
  catch (err) {
@@ -702,22 +859,116 @@ export function registerCMSRoutes(router) {
702
859
  });
703
860
  router.post('/auth/totp/login', async (request) => {
704
861
  try {
705
- const body = await request.json();
706
- if (!body.userId || !body.code)
707
- return errorResponse('userId and code are required', 400);
862
+ const body = (await request.json());
863
+ if (!body.mfaPendingToken || !body.code) {
864
+ return errorResponse('mfaPendingToken and code are required', 400);
865
+ }
866
+ const ip = clientIp(request);
867
+ const userAgent = request.headers.get('user-agent');
868
+ // Per-IP and per-token rate limits. The per-token limit is the actual
869
+ // brute-force defence: even with IP rotation, an attacker has to obtain
870
+ // a fresh mfaPendingToken (which requires the password) for every
871
+ // window. The per-IP limit guards the IP-aware case and limits log noise.
872
+ const ipBucket = isResolvedIp(ip) ? `totp-ip:${ip}` : 'totp-ip:unknown';
873
+ if (!(await checkRateLimitAsync(totpLimiter, ipBucket))) {
874
+ return errorResponse('Too many TOTP attempts. Please try again later.', 429);
875
+ }
876
+ let pending;
877
+ try {
878
+ pending = await verifyMfaPendingToken(body.mfaPendingToken, getSessionSecret());
879
+ }
880
+ catch {
881
+ return errorResponse('Session expired. Please sign in again.', 401);
882
+ }
883
+ // Validate the request comes from the same browser that completed the
884
+ // password step. This frustrates attempts to ship a captured pending
885
+ // token to a different host.
886
+ const fingerprint = await computeRequestFingerprint(ip, userAgent);
887
+ if (fingerprint !== pending.fingerprint) {
888
+ return errorResponse('Session fingerprint mismatch. Please sign in again.', 401);
889
+ }
890
+ // Per-userId bucket is what actually caps brute-force. With the default
891
+ // 10 attempts / 15 min, an attacker would need ~190 years to cover the
892
+ // 1M code space even with unbounded IPs.
893
+ const userBucket = `totp-user:${pending.userId}`;
894
+ if (!(await checkRateLimitAsync(totpLimiter, userBucket))) {
895
+ return errorResponse('Too many TOTP attempts. Please try again later.', 429);
896
+ }
708
897
  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)
898
+ const user = await db().user.findUnique({
899
+ where: { id: pending.userId },
900
+ select: {
901
+ id: true,
902
+ email: true,
903
+ name: true,
904
+ role: true,
905
+ totpSecret: true,
906
+ totpEnabled: true,
907
+ isActive: true,
908
+ },
909
+ });
910
+ if (!user || !user.isActive || !user.totpEnabled || !user.totpSecret) {
711
911
  return errorResponse('Invalid request', 400);
712
- const valid = verifyTOTP(body.code, user.totpSecret);
713
- if (!valid)
912
+ }
913
+ const decryptedSecret = await decryptSecret(user.totpSecret);
914
+ const valid = verifyTOTP(body.code, decryptedSecret);
915
+ if (!valid) {
916
+ await logEvent({
917
+ event: 'login_failed',
918
+ userId: user.id,
919
+ ipAddress: isResolvedIp(ip) ? ip : undefined,
920
+ userAgent: userAgent ?? undefined,
921
+ details: { reason: 'totp_invalid' },
922
+ });
714
923
  return errorResponse('Invalid code', 401);
924
+ }
925
+ const d = db();
926
+ await enforceSessionLimitsForUser(d, user.id);
715
927
  const tempSessionId = crypto.randomUUID();
716
928
  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) } });
929
+ await d.session.create({
930
+ data: {
931
+ id: tempSessionId,
932
+ userId: user.id,
933
+ token,
934
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
935
+ ipAddress: isResolvedIp(ip) ? ip : null,
936
+ userAgent: userAgent ?? null,
937
+ },
938
+ });
939
+ await logEvent({
940
+ event: 'login_success',
941
+ userId: user.id,
942
+ ipAddress: isResolvedIp(ip) ? ip : undefined,
943
+ userAgent: userAgent ?? undefined,
944
+ details: { mfa: 'totp' },
945
+ });
718
946
  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 } });
947
+ const sessionCookie = [
948
+ `actuate_session=${token}`,
949
+ 'Path=/',
950
+ 'HttpOnly',
951
+ 'SameSite=Lax',
952
+ `Max-Age=${7 * 24 * 3600}`,
953
+ ...(isProduction ? ['Secure'] : []),
954
+ ].join('; ');
955
+ const csrfToken = await generateCsrfToken();
956
+ const csrfCookie = [
957
+ `actuate_csrf=${csrfToken}`,
958
+ 'Path=/',
959
+ 'SameSite=Lax',
960
+ 'Max-Age=86400',
961
+ ...(isProduction ? ['Secure'] : []),
962
+ ].join('; ');
963
+ const response = new Response(JSON.stringify({
964
+ data: {
965
+ token,
966
+ user: { id: user.id, email: user.email, name: user.name, role: user.role },
967
+ },
968
+ }), { status: 200, headers: { ...SECURITY_HEADERS } });
969
+ response.headers.append('Set-Cookie', sessionCookie);
970
+ response.headers.append('Set-Cookie', csrfCookie);
971
+ return response;
721
972
  }
722
973
  catch (err) {
723
974
  return internalError(err);
@@ -748,9 +999,25 @@ export function registerCMSRoutes(router) {
748
999
  };
749
1000
  const codeVerifier = generateCodeVerifier();
750
1001
  const codeChallenge = await generateCodeChallenge(codeVerifier);
751
- const state = await generateState(provider, codeVerifier, getAdminPath(), secret);
1002
+ // Generate a one-time nonce, embed it in the signed state, and also set
1003
+ // it on a host-only cookie. The callback will require both to match —
1004
+ // this binds the OAuth flow to the browser that started it and prevents
1005
+ // an attacker from delivering a state token to a victim's browser.
1006
+ const nonce = generateOAuthNonce();
1007
+ const state = await generateState(provider, codeVerifier, getAdminPath(), secret, nonce);
752
1008
  const url = getAuthorizationUrl(provider, oauthProviders[provider], state, codeChallenge);
753
- return json({ data: { url } });
1009
+ const isProduction = process.env.NODE_ENV === 'production';
1010
+ const nonceCookie = [
1011
+ `actuate_oauth_nonce=${nonce}`,
1012
+ 'Path=/',
1013
+ 'HttpOnly',
1014
+ 'SameSite=Lax',
1015
+ 'Max-Age=600',
1016
+ ...(isProduction ? ['Secure'] : []),
1017
+ ].join('; ');
1018
+ const response = json({ data: { url } });
1019
+ response.headers.append('Set-Cookie', nonceCookie);
1020
+ return response;
754
1021
  }
755
1022
  catch (err) {
756
1023
  return internalError(err);
@@ -785,31 +1052,39 @@ export function registerCMSRoutes(router) {
785
1052
  clientSecret: process.env[`OAUTH_${envPrefix}_CLIENT_SECRET`] ?? '',
786
1053
  redirectUri: `${siteUrl}/api/cms/auth/oauth/${provider}/callback`,
787
1054
  };
788
- const result = await handleOAuthCallback(provider, code, stateToken, oauthProviders, secret, db());
789
- const cookieFlags = [
1055
+ const cookies = parseCookieHeader(request.headers.get('cookie') ?? '');
1056
+ const expectedNonce = cookies['actuate_oauth_nonce'] ?? null;
1057
+ const cmsConfig = globalThis.__actuateConfig;
1058
+ const allowSelfSignup = cmsConfig?.auth?.oauth?.allowSelfSignup === true;
1059
+ const result = await handleOAuthCallback(provider, code, stateToken, oauthProviders, secret, db(), { expectedNonce, allowSelfSignup });
1060
+ const isProduction = process.env.NODE_ENV === 'production';
1061
+ const sessionCookieFlags = [
790
1062
  `actuate_session=${result.token}`,
791
1063
  'Path=/',
792
1064
  'HttpOnly',
793
1065
  'SameSite=Lax',
794
1066
  'Max-Age=604800',
795
1067
  ];
796
- if (siteUrl.startsWith('https')) {
797
- cookieFlags.push('Secure');
1068
+ if (siteUrl.startsWith('https') || isProduction) {
1069
+ sessionCookieFlags.push('Secure');
798
1070
  }
799
- return new Response(null, {
1071
+ const response = new Response(null, {
800
1072
  status: 302,
801
- headers: {
802
- Location: getAdminPath(),
803
- 'Set-Cookie': cookieFlags.join('; '),
804
- },
1073
+ headers: { Location: getAdminPath() },
805
1074
  });
1075
+ response.headers.append('Set-Cookie', sessionCookieFlags.join('; '));
1076
+ // Clear the one-time nonce cookie regardless of outcome.
1077
+ response.headers.append('Set-Cookie', 'actuate_oauth_nonce=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
1078
+ return response;
806
1079
  }
807
1080
  catch (err) {
808
1081
  const message = err instanceof Error ? err.message : 'OAuth callback failed';
809
- return new Response(null, {
1082
+ const response = new Response(null, {
810
1083
  status: 302,
811
1084
  headers: { Location: `${getAdminPath()}?error=${encodeURIComponent(message)}` },
812
1085
  });
1086
+ response.headers.append('Set-Cookie', 'actuate_oauth_nonce=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
1087
+ return response;
813
1088
  }
814
1089
  });
815
1090
  // ---------------------------------------------------------------------------
@@ -862,17 +1137,10 @@ export function registerCMSRoutes(router) {
862
1137
  if (!doc) {
863
1138
  return errorResponse('Document not found', 404);
864
1139
  }
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
- }
1140
+ // `getDocument` already lifts `_layout` / `_pageSettings` to the
1141
+ // top-level page builder envelope and strips internal keys from
1142
+ // `data`. Field-level access has been applied as well — we don't
1143
+ // need to re-apply it here.
876
1144
  return json({ data: doc });
877
1145
  }
878
1146
  catch (err) {
@@ -887,7 +1155,7 @@ export function registerCMSRoutes(router) {
887
1155
  const roleErr = requireRole(auth.session.role, WRITE_ROLES);
888
1156
  if (roleErr)
889
1157
  return roleErr;
890
- const body = await request.json();
1158
+ const body = (await request.json());
891
1159
  const ctx = buildActionContext(auth.session, db());
892
1160
  const doc = await createDocument(params.slug, body, ctx);
893
1161
  await logEvent({
@@ -909,7 +1177,7 @@ export function registerCMSRoutes(router) {
909
1177
  const roleErr = requireRole(auth.session.role, WRITE_ROLES);
910
1178
  if (roleErr)
911
1179
  return roleErr;
912
- const body = await request.json();
1180
+ const body = (await request.json());
913
1181
  const ctx = buildActionContext(auth.session, db());
914
1182
  const doc = await updateDocument(params.slug, params.id, body, ctx);
915
1183
  await logEvent({
@@ -984,21 +1252,28 @@ export function registerCMSRoutes(router) {
984
1252
  return internalError(err);
985
1253
  }
986
1254
  });
1255
+ // The /media/presign endpoint returns advisory upload metadata; the actual
1256
+ // upload still goes through /media/upload (which performs validation).
1257
+ // Returning a presigned URL pointing at our own non-presigned endpoint was
1258
+ // misleading, so we deprecated this in favour of just using /media/upload
1259
+ // directly. We keep the route for backward compatibility but it now just
1260
+ // returns a hint to use the upload endpoint.
987
1261
  router.post('/media/presign', async (request) => {
988
1262
  try {
989
1263
  const auth = await requireAuth(request);
990
1264
  if (auth.error)
991
1265
  return auth.error;
992
- const body = await request.json();
1266
+ const body = (await request.json());
993
1267
  if (!body.filename || !body.contentType) {
994
1268
  return errorResponse('filename and contentType are required', 400);
995
1269
  }
996
- const storageKey = `actuate/media/${Date.now()}-${body.filename.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
997
1270
  return json({
998
1271
  data: {
999
- storageKey,
1000
- uploadUrl: `/api/cms/media/upload`,
1001
- fields: { storageKey, contentType: body.contentType },
1272
+ uploadUrl: '/api/cms/media/upload',
1273
+ method: 'POST',
1274
+ field: 'file',
1275
+ deprecated: true,
1276
+ message: 'Direct multipart upload to /media/upload is preferred; this endpoint will be removed in a future release.',
1002
1277
  },
1003
1278
  });
1004
1279
  }
@@ -1007,6 +1282,26 @@ export function registerCMSRoutes(router) {
1007
1282
  }
1008
1283
  });
1009
1284
  const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
1285
+ /**
1286
+ * Allowlist of accepted upload mime types. Storing a file outside this set
1287
+ * would let an attacker upload e.g. `.html`/`.js` and serve it from the same
1288
+ * origin as the admin (cookie-leaking XSS), or `.svg` with embedded
1289
+ * `<script>` (also XSS). Add to this list deliberately.
1290
+ */
1291
+ const ALLOWED_UPLOAD_MIME_TYPES = [
1292
+ 'image/jpeg',
1293
+ 'image/png',
1294
+ 'image/gif',
1295
+ 'image/webp',
1296
+ 'image/avif',
1297
+ 'image/svg+xml',
1298
+ 'application/pdf',
1299
+ 'video/mp4',
1300
+ 'video/webm',
1301
+ 'audio/mpeg',
1302
+ 'audio/ogg',
1303
+ 'audio/wav',
1304
+ ];
1010
1305
  router.post('/media/upload', async (request) => {
1011
1306
  try {
1012
1307
  const auth = await requireAuth(request);
@@ -1028,7 +1323,21 @@ export function registerCMSRoutes(router) {
1028
1323
  if (originalSize > 50 * 1024 * 1024) {
1029
1324
  return errorResponse('File exceeds maximum size of 50MB', 413);
1030
1325
  }
1326
+ // 1. Block file types that aren't on our allowlist outright.
1327
+ if (!validateMimeType(contentType, ALLOWED_UPLOAD_MIME_TYPES)) {
1328
+ return errorResponse(`Unsupported file type "${contentType || 'unknown'}". Allowed types: ${ALLOWED_UPLOAD_MIME_TYPES.join(', ')}`, 415);
1329
+ }
1031
1330
  const arrayBuffer = await file.arrayBuffer();
1331
+ // 2. Verify the file's actual bytes match the claimed mime type. Without
1332
+ // this, an attacker can upload a `.exe` with `Content-Type: image/png`
1333
+ // and have it served from our origin.
1334
+ const magicCheck = checkMagicBytes(arrayBuffer, contentType);
1335
+ if (!magicCheck.valid) {
1336
+ return errorResponse(`File contents do not match declared type "${contentType}".`, 415);
1337
+ }
1338
+ // 3. SVGs need an extra pass — even when the mime type is correct, the
1339
+ // XML body can contain `<script>` or event-handler attributes that
1340
+ // execute when an admin previews the file. Sanitize before storing.
1032
1341
  let uploadBuffer;
1033
1342
  let finalFilename = originalFilename;
1034
1343
  let finalMimeType = contentType;
@@ -1037,7 +1346,105 @@ export function registerCMSRoutes(router) {
1037
1346
  let height = null;
1038
1347
  let blurHash = null;
1039
1348
  let savings = 0;
1040
- if (!skipOptimize && contentType.startsWith('image/')) {
1349
+ if (contentType === 'image/svg+xml') {
1350
+ // Strip <script>, on*, javascript: URLs, foreignObject, etc.
1351
+ const xml = new TextDecoder('utf-8', { fatal: false }).decode(arrayBuffer);
1352
+ const sanitized = sanitizeHtml(xml, {
1353
+ allowedTags: [
1354
+ 'svg',
1355
+ 'g',
1356
+ 'path',
1357
+ 'circle',
1358
+ 'ellipse',
1359
+ 'line',
1360
+ 'polygon',
1361
+ 'polyline',
1362
+ 'rect',
1363
+ 'text',
1364
+ 'tspan',
1365
+ 'defs',
1366
+ 'use',
1367
+ 'symbol',
1368
+ 'title',
1369
+ 'desc',
1370
+ 'style',
1371
+ 'linearGradient',
1372
+ 'radialGradient',
1373
+ 'stop',
1374
+ 'mask',
1375
+ 'clipPath',
1376
+ 'pattern',
1377
+ 'filter',
1378
+ 'feGaussianBlur',
1379
+ 'feColorMatrix',
1380
+ 'feOffset',
1381
+ 'feBlend',
1382
+ 'feFlood',
1383
+ 'feComposite',
1384
+ 'feMerge',
1385
+ 'feMergeNode',
1386
+ ],
1387
+ allowedAttributes: {
1388
+ '*': [
1389
+ 'id',
1390
+ 'class',
1391
+ 'fill',
1392
+ 'stroke',
1393
+ 'stroke-width',
1394
+ 'stroke-linecap',
1395
+ 'stroke-linejoin',
1396
+ 'opacity',
1397
+ 'transform',
1398
+ 'd',
1399
+ 'cx',
1400
+ 'cy',
1401
+ 'r',
1402
+ 'rx',
1403
+ 'ry',
1404
+ 'x',
1405
+ 'y',
1406
+ 'x1',
1407
+ 'y1',
1408
+ 'x2',
1409
+ 'y2',
1410
+ 'width',
1411
+ 'height',
1412
+ 'viewBox',
1413
+ 'xmlns',
1414
+ 'xmlns:xlink',
1415
+ 'preserveAspectRatio',
1416
+ 'points',
1417
+ 'points',
1418
+ 'offset',
1419
+ 'stop-color',
1420
+ 'stop-opacity',
1421
+ 'gradientUnits',
1422
+ 'gradientTransform',
1423
+ 'href',
1424
+ 'xlink:href',
1425
+ 'fill-rule',
1426
+ 'clip-rule',
1427
+ 'mask',
1428
+ 'clip-path',
1429
+ 'filter',
1430
+ 'patternUnits',
1431
+ 'patternContentUnits',
1432
+ 'in',
1433
+ 'in2',
1434
+ 'result',
1435
+ 'mode',
1436
+ 'values',
1437
+ 'type',
1438
+ 'stdDeviation',
1439
+ 'dx',
1440
+ 'dy',
1441
+ ],
1442
+ },
1443
+ });
1444
+ uploadBuffer = Buffer.from(sanitized, 'utf-8');
1445
+ finalSize = uploadBuffer.byteLength;
1446
+ }
1447
+ else if (!skipOptimize && contentType.startsWith('image/')) {
1041
1448
  const result = await optimizeImage(arrayBuffer, originalFilename, contentType);
1042
1449
  uploadBuffer = result.buffer;
1043
1450
  finalFilename = result.filename;
@@ -1054,16 +1461,35 @@ export function registerCMSRoutes(router) {
1054
1461
  const sanitizedName = finalFilename.replace(/[^a-zA-Z0-9._-]/g, '_');
1055
1462
  const storageKey = `actuate/media/${Date.now()}-${sanitizedName}`;
1056
1463
  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;
1464
+ // Prefer the configured platform storage adapter (e.g. platform-vercel,
1465
+ // platform-aws, or a consumer-provided one). Falling through to
1466
+ // @vercel/blob via dynamic import preserves the legacy behavior for
1467
+ // installs that haven't wired up a platform package yet.
1468
+ const { getStorageAdapter } = await import('../storage/index.js');
1469
+ const storage = getStorageAdapter();
1470
+ if (storage) {
1471
+ try {
1472
+ publicUrl = await storage.upload(storageKey, uploadBuffer, finalMimeType);
1473
+ }
1474
+ catch (err) {
1475
+ if (process.env.NODE_ENV !== 'test') {
1476
+ console.error('[Actuate CMS] Storage adapter upload failed:', err);
1477
+ }
1478
+ return errorResponse('Storage upload failed', 500);
1479
+ }
1064
1480
  }
1065
- catch {
1066
- publicUrl = `/api/cms/media/file/${storageKey}`;
1481
+ else {
1482
+ try {
1483
+ const blob = await importBlobStorage();
1484
+ const result = await blob.put(storageKey, uploadBuffer, {
1485
+ access: 'public',
1486
+ contentType: finalMimeType,
1487
+ });
1488
+ publicUrl = result.url;
1489
+ }
1490
+ catch {
1491
+ publicUrl = `/api/cms/media/file/${storageKey}`;
1492
+ }
1067
1493
  }
1068
1494
  const media = await db().media.create({
1069
1495
  data: {
@@ -1151,7 +1577,9 @@ export function registerCMSRoutes(router) {
1151
1577
  try {
1152
1578
  await blob.del(media.storageKey);
1153
1579
  }
1154
- catch { /* best-effort */ }
1580
+ catch {
1581
+ /* best-effort */
1582
+ }
1155
1583
  }
1156
1584
  catch {
1157
1585
  newPublicUrl = `/api/cms/media/file/${newStorageKey}`;
@@ -1195,7 +1623,7 @@ export function registerCMSRoutes(router) {
1195
1623
  const roleErr = requireRole(auth.session.role, WRITE_ROLES);
1196
1624
  if (roleErr)
1197
1625
  return roleErr;
1198
- const body = await request.json();
1626
+ const body = (await request.json());
1199
1627
  const updated = await db().media.update({
1200
1628
  where: { id: params.id },
1201
1629
  data: {
@@ -1320,7 +1748,7 @@ export function registerCMSRoutes(router) {
1320
1748
  if (!encKey) {
1321
1749
  return errorResponse('CMS_ENCRYPTION_KEY is required to store encrypted credentials.', 400);
1322
1750
  }
1323
- const body = await request.json();
1751
+ const body = (await request.json());
1324
1752
  if (body.githubRepo && !/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(body.githubRepo)) {
1325
1753
  return errorResponse('Invalid repository format. Use owner/repo.', 400);
1326
1754
  }
@@ -1362,7 +1790,7 @@ export function registerCMSRoutes(router) {
1362
1790
  if (!session || session.role !== 'admin') {
1363
1791
  return errorResponse('Unauthorized — admin only', 403);
1364
1792
  }
1365
- const body = await request.json();
1793
+ const body = (await request.json());
1366
1794
  if (!body.targetVersion) {
1367
1795
  return errorResponse('targetVersion is required', 400);
1368
1796
  }
@@ -1427,7 +1855,7 @@ export function registerCMSRoutes(router) {
1427
1855
  if (!(await checkRateLimitAsync(loginLimiter, `setup:${clientIp}`))) {
1428
1856
  return errorResponse('Too many setup attempts', 429);
1429
1857
  }
1430
- const body = await request.json();
1858
+ const body = (await request.json());
1431
1859
  if (!body.name || !body.email || !body.password) {
1432
1860
  return errorResponse('Name, email, and password are required', 400);
1433
1861
  }
@@ -1452,9 +1880,17 @@ export function registerCMSRoutes(router) {
1452
1880
  // Health endpoint -- reports available models and CMS version
1453
1881
  // ---------------------------------------------------------------------------
1454
1882
  const CMS_EXPECTED_MODELS = [
1455
- 'document', 'media', 'user', 'session', 'version',
1456
- 'folder', 'redirect', 'formSubmission', 'auditLog',
1457
- 'webhookEndpoint', 'webhookDeliveryLog',
1883
+ 'document',
1884
+ 'media',
1885
+ 'user',
1886
+ 'session',
1887
+ 'version',
1888
+ 'folder',
1889
+ 'redirect',
1890
+ 'formSubmission',
1891
+ 'auditLog',
1892
+ 'webhookEndpoint',
1893
+ 'webhookDeliveryLog',
1458
1894
  ];
1459
1895
  router.get('/health', async () => {
1460
1896
  const cmsVersion = globalThis.__actuateCoreVersion ?? '0.0.0';
@@ -1573,8 +2009,12 @@ export function registerCMSRoutes(router) {
1573
2009
  orderBy: { updatedAt: 'desc' },
1574
2010
  take: 20,
1575
2011
  select: {
1576
- id: true, title: true, status: true, collection: true,
1577
- updatedAt: true, createdById: true,
2012
+ id: true,
2013
+ title: true,
2014
+ status: true,
2015
+ collection: true,
2016
+ updatedAt: true,
2017
+ createdById: true,
1578
2018
  createdBy: { select: { name: true, email: true } },
1579
2019
  },
1580
2020
  }),
@@ -1643,24 +2083,54 @@ export function registerCMSRoutes(router) {
1643
2083
  if (!q)
1644
2084
  return json({ data: { documents: [], media: [], users: [] } });
1645
2085
  const d = db();
2086
+ // Documents and media are visible to all signed-in users; the user
2087
+ // directory is gated to EDITOR+ so a CLIENT can't enumerate the team
2088
+ // (or harvest emails for spear-phishing).
2089
+ const canSeeUserDirectory = auth.session.role === 'ADMIN' || auth.session.role === 'EDITOR';
1646
2090
  const [documents, media, users] = await Promise.all([
1647
2091
  safeFindMany(d.document, {
1648
- where: { deletedAt: null, OR: [{ title: { contains: q, mode: 'insensitive' } }, { plainText: { contains: q, mode: 'insensitive' } }] },
2092
+ where: {
2093
+ deletedAt: null,
2094
+ OR: [
2095
+ { title: { contains: q, mode: 'insensitive' } },
2096
+ { plainText: { contains: q, mode: 'insensitive' } },
2097
+ ],
2098
+ },
1649
2099
  take: 10,
1650
2100
  orderBy: { updatedAt: 'desc' },
1651
- select: { id: true, title: true, slug: true, collection: true, status: true, updatedAt: true },
2101
+ select: {
2102
+ id: true,
2103
+ title: true,
2104
+ slug: true,
2105
+ collection: true,
2106
+ status: true,
2107
+ updatedAt: true,
2108
+ },
1652
2109
  }),
1653
2110
  safeFindMany(d.media, {
1654
- where: { OR: [{ filename: { contains: q, mode: 'insensitive' } }, { altText: { contains: q, mode: 'insensitive' } }] },
2111
+ where: {
2112
+ OR: [
2113
+ { filename: { contains: q, mode: 'insensitive' } },
2114
+ { altText: { contains: q, mode: 'insensitive' } },
2115
+ ],
2116
+ },
1655
2117
  take: 5,
1656
2118
  orderBy: { createdAt: 'desc' },
1657
2119
  select: { id: true, filename: true, altText: true, mimeType: true, storageKey: true },
1658
2120
  }),
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
- }),
2121
+ canSeeUserDirectory
2122
+ ? safeFindMany(d.user, {
2123
+ where: {
2124
+ isActive: true,
2125
+ OR: [
2126
+ { name: { contains: q, mode: 'insensitive' } },
2127
+ { email: { contains: q, mode: 'insensitive' } },
2128
+ ],
2129
+ },
2130
+ take: 5,
2131
+ select: { id: true, name: true, email: true, role: true },
2132
+ })
2133
+ : Promise.resolve([]),
1664
2134
  ]);
1665
2135
  return json({ data: { documents, media, users } });
1666
2136
  }
@@ -1729,17 +2199,37 @@ export function registerCMSRoutes(router) {
1729
2199
  const auth = await requireAuth(request);
1730
2200
  if (auth.error)
1731
2201
  return auth.error;
2202
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
2203
+ if (roleErr)
2204
+ return roleErr;
1732
2205
  const d = db();
1733
2206
  const forms = await d.document.findMany({
1734
2207
  where: { collection: 'forms', deletedAt: null },
1735
2208
  orderBy: { createdAt: 'desc' },
1736
2209
  });
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
- }));
2210
+ // Single grouped count is far cheaper than N+1 count() per form once
2211
+ // the dashboard grows past a handful of forms. Fall back to per-form
2212
+ // .count() when the consumer's Prisma client doesn't expose groupBy
2213
+ // (older schemas, custom adapters).
2214
+ let submissionCounts = new Map();
2215
+ if (hasModel(d, 'formSubmission') && forms.length > 0) {
2216
+ const ids = forms.map((f) => f.id);
2217
+ if (typeof d.formSubmission.groupBy === 'function') {
2218
+ const grouped = await d.formSubmission.groupBy({
2219
+ by: ['formId'],
2220
+ where: { formId: { in: ids } },
2221
+ _count: { _all: true },
2222
+ });
2223
+ submissionCounts = new Map(grouped.map((g) => [g.formId, g._count._all]));
2224
+ }
2225
+ else {
2226
+ for (const id of ids) {
2227
+ const count = await d.formSubmission.count({ where: { formId: id } });
2228
+ submissionCounts.set(id, count);
2229
+ }
2230
+ }
2231
+ }
2232
+ const normalized = forms.map((form) => normalizeFormDocument(form, submissionCounts.get(form.id) ?? 0));
1743
2233
  return json({ data: normalized });
1744
2234
  }
1745
2235
  catch (err) {
@@ -1751,6 +2241,9 @@ export function registerCMSRoutes(router) {
1751
2241
  const auth = await requireAuth(request);
1752
2242
  if (auth.error)
1753
2243
  return auth.error;
2244
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
2245
+ if (roleErr)
2246
+ return roleErr;
1754
2247
  const url = new URL(request.url);
1755
2248
  const page = Number(url.searchParams.get('page')) || 1;
1756
2249
  const pageSize = clampPageSize(url.searchParams.get('pageSize'));
@@ -1792,7 +2285,7 @@ export function registerCMSRoutes(router) {
1792
2285
  return errorResponse('Form not found', 404);
1793
2286
  }
1794
2287
  const formData = (form.data ?? {});
1795
- const body = await request.json();
2288
+ const body = (await request.json());
1796
2289
  if (!body.fields || typeof body.fields !== 'object') {
1797
2290
  return errorResponse('Missing or invalid "fields" in request body', 400);
1798
2291
  }
@@ -1812,14 +2305,10 @@ export function registerCMSRoutes(router) {
1812
2305
  submittedAt: new Date(),
1813
2306
  },
1814
2307
  });
1815
- // Fire form hooks asynchronously (email notification, webhooks)
1816
2308
  (async () => {
1817
2309
  try {
1818
2310
  const config = globalThis.__actuateConfig;
1819
- const hooks = [
1820
- ...(config?.plugins?.forms?.hooks ?? []),
1821
- ...(config?._pluginHooks ?? []),
1822
- ];
2311
+ const hooks = [...(config?.plugins?.forms?.hooks ?? []), ...(config?._pluginHooks ?? [])];
1823
2312
  const formHooks = hooks.filter((h) => h.event === 'afterCreate:form-submissions');
1824
2313
  for (const hook of formHooks) {
1825
2314
  await hook.handler({ formId, data: body.fields });
@@ -1869,23 +2358,51 @@ export function registerCMSRoutes(router) {
1869
2358
  return auth.error;
1870
2359
  if (auth.session.role !== 'ADMIN')
1871
2360
  return errorResponse('Admin access required', 403);
1872
- const body = await request.json();
2361
+ const body = (await request.json());
1873
2362
  const source = String(body.source ?? body.from ?? '').trim();
1874
2363
  const destination = String(body.destination ?? body.to ?? '').trim();
1875
2364
  const requestedStatus = Number(body.statusCode ?? body.type);
1876
2365
  if (!source || !destination) {
1877
2366
  return errorResponse('source and destination are required', 400);
1878
2367
  }
1879
- if (destination.startsWith('http') && !destination.startsWith(process.env.NEXT_PUBLIC_SITE_URL ?? 'https://')) {
2368
+ // Open-redirect defence: relative destinations (`/foo`) are always
2369
+ // allowed; absolute destinations must point at an explicitly trusted
2370
+ // host. We compare on parsed origins (not string `startsWith`) so
2371
+ // `https://attacker.com.example.com` no longer passes a `startsWith`
2372
+ // check on `https://example.com`. Configure the allowlist via
2373
+ // `redirects.allowedExternalHosts` (string[] of hostnames).
2374
+ if (destination.startsWith('http://') || destination.startsWith('https://')) {
2375
+ let destUrl;
1880
2376
  try {
1881
- const destUrl = new URL(destination);
1882
- if (!['http:', 'https:'].includes(destUrl.protocol)) {
1883
- return errorResponse('Invalid destination URL', 400);
1884
- }
2377
+ destUrl = new URL(destination);
1885
2378
  }
1886
2379
  catch {
1887
2380
  return errorResponse('Invalid destination URL', 400);
1888
2381
  }
2382
+ if (!['http:', 'https:'].includes(destUrl.protocol)) {
2383
+ return errorResponse('Invalid destination URL', 400);
2384
+ }
2385
+ const cmsConfig = globalThis.__actuateConfig;
2386
+ const allowed = new Set([
2387
+ ...(Array.isArray(cmsConfig?.redirects?.allowedExternalHosts)
2388
+ ? cmsConfig.redirects.allowedExternalHosts.map((h) => h.toLowerCase())
2389
+ : []),
2390
+ ]);
2391
+ const siteUrl = process.env.NEXT_PUBLIC_SITE_URL;
2392
+ if (siteUrl) {
2393
+ try {
2394
+ allowed.add(new URL(siteUrl).hostname.toLowerCase());
2395
+ }
2396
+ catch {
2397
+ /* noop */
2398
+ }
2399
+ }
2400
+ if (!allowed.has(destUrl.hostname.toLowerCase())) {
2401
+ return errorResponse('External redirect destinations must be to an allowlisted host. Add the host to `redirects.allowedExternalHosts` in your CMS config.', 400);
2402
+ }
2403
+ }
2404
+ else if (!destination.startsWith('/')) {
2405
+ return errorResponse('Destination must be an absolute URL or a path beginning with /', 400);
1889
2406
  }
1890
2407
  const redirect = await db().redirect.create({
1891
2408
  data: {
@@ -1946,48 +2463,98 @@ export function registerCMSRoutes(router) {
1946
2463
  const auth = await requireAuth(request);
1947
2464
  if (auth.error)
1948
2465
  return auth.error;
2466
+ // EDITOR+ only — this endpoint hits arbitrary URLs and can be expensive.
2467
+ const roleErr = requireRole(auth.session.role, WRITE_ROLES);
2468
+ if (roleErr)
2469
+ return roleErr;
2470
+ // Tight rate limit because each call fans out to many outbound requests.
2471
+ const ip = clientIp(request);
2472
+ const rateKey = isResolvedIp(ip)
2473
+ ? `link-health:${ip}`
2474
+ : `link-health-user:${auth.session.userId}`;
2475
+ if (!(await checkRateLimitAsync(linkHealthLimiter, rateKey))) {
2476
+ return errorResponse('Too many link-health scans. Please try again later.', 429);
2477
+ }
2478
+ const MAX_LINKS_PER_PAGE = 50;
2479
+ const MAX_TOTAL_LINKS = 500;
2480
+ const PER_LINK_TIMEOUT_MS = 4000;
2481
+ const CONCURRENCY = 8;
1949
2482
  const docs = await db().document.findMany({
1950
2483
  where: { deletedAt: null, status: 'PUBLISHED' },
1951
2484
  select: { id: true, title: true, data: true, collection: true },
1952
2485
  });
1953
- const linkResults = [];
1954
2486
  const urlRegex = /https?:\/\/[^\s"'<>]+/g;
1955
2487
  const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? '';
1956
- for (const doc of docs) {
2488
+ const queue = [];
2489
+ const seenGlobal = new Set();
2490
+ outer: for (const doc of docs) {
1957
2491
  const pageTitle = doc.title ?? doc.data?.title ?? doc.id;
1958
2492
  const content = JSON.stringify(doc.data ?? {});
1959
2493
  const urls = content.match(urlRegex) ?? [];
1960
- const seen = new Set();
2494
+ const seenInPage = new Set();
2495
+ let countInPage = 0;
1961
2496
  for (const url of urls) {
1962
2497
  const clean = url.replace(/[",;)}\]]+$/, '');
1963
- if (seen.has(clean))
2498
+ if (seenInPage.has(clean))
2499
+ continue;
2500
+ seenInPage.add(clean);
2501
+ if (seenGlobal.has(clean))
1964
2502
  continue;
1965
- seen.add(clean);
1966
- const isInternal = siteUrl && clean.startsWith(siteUrl);
2503
+ seenGlobal.add(clean);
2504
+ if (countInPage >= MAX_LINKS_PER_PAGE)
2505
+ break;
2506
+ if (queue.length >= MAX_TOTAL_LINKS)
2507
+ break outer;
2508
+ const isInternal = !!siteUrl && clean.startsWith(siteUrl);
2509
+ queue.push({ docId: doc.id, pageTitle, clean, isInternal });
2510
+ countInPage++;
2511
+ }
2512
+ }
2513
+ const linkResults = [];
2514
+ let cursor = 0;
2515
+ const worker = async () => {
2516
+ while (cursor < queue.length) {
2517
+ const idx = cursor++;
2518
+ const job = queue[idx];
2519
+ let status = 0;
1967
2520
  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
- });
2521
+ // safeFetch rejects private/loopback IPs and disables redirect
2522
+ // following so 302->internal can't smuggle the scanner past SSRF.
2523
+ const resp = await safeFetch(job.clean, {
2524
+ method: 'HEAD',
2525
+ timeoutMs: PER_LINK_TIMEOUT_MS,
2526
+ });
2527
+ status = resp.status;
2528
+ // Drain the body so the connection can be reused.
2529
+ try {
2530
+ await resp.body?.cancel();
2531
+ }
2532
+ catch {
2533
+ /* noop */
1977
2534
  }
1978
2535
  }
1979
- catch {
2536
+ catch (err) {
2537
+ status = err instanceof SsrfBlockedError ? -1 : 0;
2538
+ }
2539
+ if (status === -1 || status === 0 || status >= 300) {
1980
2540
  linkResults.push({
1981
- id: `${doc.id}-${linkResults.length}`,
1982
- page: pageTitle,
1983
- url: clean,
1984
- status: 0,
1985
- type: isInternal ? 'internal' : 'external',
2541
+ id: `${job.docId}-${idx}`,
2542
+ page: job.pageTitle,
2543
+ url: job.clean,
2544
+ status: status === -1 ? 0 : status,
2545
+ type: job.isInternal ? 'internal' : 'external',
1986
2546
  });
1987
2547
  }
1988
2548
  }
1989
- }
1990
- return json({ data: linkResults });
2549
+ };
2550
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
2551
+ return json({
2552
+ data: {
2553
+ truncated: queue.length >= MAX_TOTAL_LINKS,
2554
+ checked: queue.length,
2555
+ issues: linkResults,
2556
+ },
2557
+ });
1991
2558
  }
1992
2559
  catch (err) {
1993
2560
  return internalError(err);
@@ -2003,7 +2570,14 @@ export function registerCMSRoutes(router) {
2003
2570
  return roleErr;
2004
2571
  const documents = await db().document.findMany({
2005
2572
  where: { status: 'PUBLISHED', deletedAt: null },
2006
- select: { id: true, title: true, slug: true, collection: true, data: true, plainText: true },
2573
+ select: {
2574
+ id: true,
2575
+ title: true,
2576
+ slug: true,
2577
+ collection: true,
2578
+ data: true,
2579
+ plainText: true,
2580
+ },
2007
2581
  });
2008
2582
  const issues = [];
2009
2583
  for (const doc of documents) {
@@ -2020,7 +2594,11 @@ export function registerCMSRoutes(router) {
2020
2594
  const plainText = (doc.plainText ?? '');
2021
2595
  if (plainText.length > 0 && plainText.length < 300)
2022
2596
  problems.push('Content is too short (< 300 characters)');
2023
- const content = typeof data.body === 'string' ? data.body : typeof data.content === 'string' ? data.content : '';
2597
+ const content = typeof data.body === 'string'
2598
+ ? data.body
2599
+ : typeof data.content === 'string'
2600
+ ? data.content
2601
+ : '';
2024
2602
  if (content) {
2025
2603
  const imgMatches = content.match(/<img\b[^>]*>/gi) ?? [];
2026
2604
  const missingAlt = imgMatches.filter((img) => !img.includes('alt=')).length;
@@ -2030,7 +2608,12 @@ export function registerCMSRoutes(router) {
2030
2608
  problems.push('No H1 heading found in content');
2031
2609
  }
2032
2610
  if (problems.length > 0) {
2033
- issues.push({ documentId: doc.id, title: doc.title ?? 'Untitled', slug: doc.slug ?? '', problems });
2611
+ issues.push({
2612
+ documentId: doc.id,
2613
+ title: doc.title ?? 'Untitled',
2614
+ slug: doc.slug ?? '',
2615
+ problems,
2616
+ });
2034
2617
  }
2035
2618
  }
2036
2619
  const total = documents.length;
@@ -2047,7 +2630,12 @@ export function registerCMSRoutes(router) {
2047
2630
  // ---------------------------------------------------------------------------
2048
2631
  router.get('/seo/analysis/:documentId', async (request, params) => {
2049
2632
  try {
2050
- const doc = await db().document.findUnique({ where: { id: params.documentId } });
2633
+ const auth = await requireAuth(request);
2634
+ if (auth.error)
2635
+ return auth.error;
2636
+ const doc = await db().document.findFirst({
2637
+ where: { id: params.documentId, deletedAt: null },
2638
+ });
2051
2639
  if (!doc)
2052
2640
  return errorResponse('Not found', 404);
2053
2641
  const { analyzeContent } = await import('../seo/analysis.js');
@@ -2076,7 +2664,12 @@ export function registerCMSRoutes(router) {
2076
2664
  });
2077
2665
  router.get('/seo/readability/:documentId', async (request, params) => {
2078
2666
  try {
2079
- const doc = await db().document.findUnique({ where: { id: params.documentId } });
2667
+ const auth = await requireAuth(request);
2668
+ if (auth.error)
2669
+ return auth.error;
2670
+ const doc = await db().document.findFirst({
2671
+ where: { id: params.documentId, deletedAt: null },
2672
+ });
2080
2673
  if (!doc)
2081
2674
  return errorResponse('Not found', 404);
2082
2675
  const { calculateReadability, stripHtmlTags } = await import('../seo/analysis.js');
@@ -2094,12 +2687,20 @@ export function registerCMSRoutes(router) {
2094
2687
  });
2095
2688
  router.get('/seo/internal-links/:documentId', async (request, params) => {
2096
2689
  try {
2097
- const doc = await db().document.findUnique({ where: { id: params.documentId } });
2690
+ const auth = await requireAuth(request);
2691
+ if (auth.error)
2692
+ return auth.error;
2693
+ const doc = await db().document.findFirst({
2694
+ where: { id: params.documentId, deletedAt: null },
2695
+ });
2098
2696
  if (!doc)
2099
2697
  return errorResponse('Not found', 404);
2100
2698
  const data = doc.data || {};
2101
2699
  const title = doc.title || data.title || '';
2102
- const keywords = title.split(/\s+/).filter((w) => w.length > 3).slice(0, 5);
2700
+ const keywords = title
2701
+ .split(/\s+/)
2702
+ .filter((w) => w.length > 3)
2703
+ .slice(0, 5);
2103
2704
  if (keywords.length === 0) {
2104
2705
  return new Response(JSON.stringify({ suggestions: [] }), {
2105
2706
  status: 200,
@@ -2110,6 +2711,7 @@ export function registerCMSRoutes(router) {
2110
2711
  where: {
2111
2712
  id: { not: params.documentId },
2112
2713
  status: 'PUBLISHED',
2714
+ deletedAt: null,
2113
2715
  OR: keywords.map((kw) => ({
2114
2716
  title: { contains: kw, mode: 'insensitive' },
2115
2717
  })),
@@ -2165,7 +2767,12 @@ export function registerCMSRoutes(router) {
2165
2767
  });
2166
2768
  router.get('/seo/schema/:documentId', async (request, params) => {
2167
2769
  try {
2168
- const doc = await db().document.findUnique({ where: { id: params.documentId } });
2770
+ const auth = await requireAuth(request);
2771
+ if (auth.error)
2772
+ return auth.error;
2773
+ const doc = await db().document.findFirst({
2774
+ where: { id: params.documentId, deletedAt: null },
2775
+ });
2169
2776
  if (!doc)
2170
2777
  return errorResponse('Not found', 404);
2171
2778
  const { buildSchemaGraph } = await import('../content/structured-data.js');
@@ -2194,7 +2801,12 @@ export function registerCMSRoutes(router) {
2194
2801
  });
2195
2802
  router.get('/seo/meta/:documentId', async (request, params) => {
2196
2803
  try {
2197
- const doc = await db().document.findUnique({ where: { id: params.documentId } });
2804
+ const auth = await requireAuth(request);
2805
+ if (auth.error)
2806
+ return auth.error;
2807
+ const doc = await db().document.findFirst({
2808
+ where: { id: params.documentId, deletedAt: null },
2809
+ });
2198
2810
  if (!doc)
2199
2811
  return errorResponse('Not found', 404);
2200
2812
  const { generateMetaTags } = await import('../seo/meta-tags.js');
@@ -2251,12 +2863,12 @@ export function registerCMSRoutes(router) {
2251
2863
  const auth = await requireAuth(request);
2252
2864
  if (auth.error)
2253
2865
  return auth.error;
2254
- const docs = await safeFindMany(db().document, {
2866
+ const docs = (await safeFindMany(db().document, {
2255
2867
  where: { deletedAt: null, status: 'PUBLISHED' },
2256
2868
  select: { id: true, title: true, collection: true, data: true, plainText: true },
2257
2869
  orderBy: { updatedAt: 'desc' },
2258
2870
  take: 50,
2259
- });
2871
+ }));
2260
2872
  let missingMetaDescriptions = 0;
2261
2873
  let missingAltText = 0;
2262
2874
  const topContent = [];
@@ -2264,7 +2876,11 @@ export function registerCMSRoutes(router) {
2264
2876
  const data = doc.data ?? {};
2265
2877
  if (!data.metaDescription && !data.seoDescription)
2266
2878
  missingMetaDescriptions++;
2267
- const content = typeof data.body === 'string' ? data.body : typeof data.content === 'string' ? data.content : '';
2879
+ const content = typeof data.body === 'string'
2880
+ ? data.body
2881
+ : typeof data.content === 'string'
2882
+ ? data.content
2883
+ : '';
2268
2884
  if (content) {
2269
2885
  const imgMatches = content.match(/<img\b[^>]*>/gi) ?? [];
2270
2886
  const noAlt = imgMatches.filter((img) => !img.includes('alt=')).length;
@@ -2294,7 +2910,11 @@ export function registerCMSRoutes(router) {
2294
2910
  continue;
2295
2911
  seen.add(clean);
2296
2912
  try {
2297
- const resp = await fetch(clean, { method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(3000) });
2913
+ const resp = await fetch(clean, {
2914
+ method: 'HEAD',
2915
+ redirect: 'manual',
2916
+ signal: AbortSignal.timeout(3000),
2917
+ });
2298
2918
  if (resp.status >= 400)
2299
2919
  brokenInternalLinks++;
2300
2920
  }
@@ -2304,7 +2924,9 @@ export function registerCMSRoutes(router) {
2304
2924
  }
2305
2925
  }
2306
2926
  }
2307
- catch { /* best effort */ }
2927
+ catch {
2928
+ /* best effort */
2929
+ }
2308
2930
  return json({
2309
2931
  data: {
2310
2932
  totalPages: docs.length,
@@ -2345,7 +2967,10 @@ export function registerCMSRoutes(router) {
2345
2967
  }
2346
2968
  }
2347
2969
  if (unresolved.size > 0 && layoutConfig.inherit !== false) {
2348
- const segments = path.replace(/^\/|\/$/g, '').split('/').filter(Boolean);
2970
+ const segments = path
2971
+ .replace(/^\/|\/$/g, '')
2972
+ .split('/')
2973
+ .filter(Boolean);
2349
2974
  const walkDepth = Math.min(segments.length - 1, MAX_RESOLVE_DEPTH);
2350
2975
  for (let i = walkDepth; i > 0 && unresolved.size > 0; i--) {
2351
2976
  const parentSlug = segments.slice(0, i).join('/');
@@ -2354,10 +2979,7 @@ export function registerCMSRoutes(router) {
2354
2979
  collection: matchedCollection,
2355
2980
  deletedAt: null,
2356
2981
  status: 'PUBLISHED',
2357
- OR: [
2358
- { data: { path: ['slug'], equals: parentSlug } },
2359
- { slug: parentSlug },
2360
- ],
2982
+ OR: [{ data: { path: ['slug'], equals: parentSlug } }, { slug: parentSlug }],
2361
2983
  },
2362
2984
  select: { data: true },
2363
2985
  });
@@ -2414,14 +3036,17 @@ export function registerCMSRoutes(router) {
2414
3036
  if (!pathParam) {
2415
3037
  return errorResponse('Missing required "path" query parameter', 400);
2416
3038
  }
2417
- const segments = pathParam.replace(/^\/|\/$/g, '').split('/').filter(Boolean);
3039
+ const segments = pathParam
3040
+ .replace(/^\/|\/$/g, '')
3041
+ .split('/')
3042
+ .filter(Boolean);
2418
3043
  const configCollections = globalThis.__actuateConfig?.collections ?? {};
2419
3044
  const collectionDefs = Object.values(configCollections);
2420
3045
  let matchedCollection = null;
2421
3046
  let docSlug = null;
2422
3047
  if (segments.length === 0) {
2423
3048
  // Root path — find a page-type collection and look for "home" or "index"
2424
- const pageCol = collectionDefs.find((c) => c.type === 'page' && !((c.urlPrefix ?? '').replace(/^\/|\/$/g, '')));
3049
+ const pageCol = collectionDefs.find((c) => c.type === 'page' && !(c.urlPrefix ?? '').replace(/^\/|\/$/g, ''));
2425
3050
  matchedCollection = pageCol?.slug ?? 'pages';
2426
3051
  docSlug = 'home';
2427
3052
  }
@@ -2467,16 +3092,13 @@ export function registerCMSRoutes(router) {
2467
3092
  { slug: 'home' },
2468
3093
  { slug: 'index' },
2469
3094
  ]
2470
- : [
2471
- { data: { path: ['slug'], equals: docSlug } },
2472
- { slug: docSlug },
2473
- ],
3095
+ : [{ data: { path: ['slug'], equals: docSlug } }, { slug: docSlug }],
2474
3096
  },
2475
3097
  });
2476
3098
  if (!doc) {
2477
3099
  return errorResponse('Document not found', 404);
2478
3100
  }
2479
- const docData = (doc.data && typeof doc.data === 'object') ? doc.data : {};
3101
+ const docData = doc.data && typeof doc.data === 'object' ? doc.data : {};
2480
3102
  const layout = await resolveLayout(pathParam, docData, matchedCollection);
2481
3103
  const { _layout: _omit, ...cleanData } = docData;
2482
3104
  return json({
@@ -2528,9 +3150,20 @@ export function registerCMSRoutes(router) {
2528
3150
  const roleErr = requireRole(auth.session.role, WRITE_ROLES);
2529
3151
  if (roleErr)
2530
3152
  return roleErr;
2531
- const body = await request.json();
3153
+ const body = (await request.json());
2532
3154
  if (!body.name || !body.scope)
2533
3155
  return errorResponse('name and scope are required', 400);
3156
+ // A child folder MUST live in the same scope as its parent — without
3157
+ // this check, a 'documents' folder could be reparented under a 'media'
3158
+ // folder, hiding it from both UIs.
3159
+ if (body.parentId) {
3160
+ const parent = await db().folder.findUnique({ where: { id: body.parentId } });
3161
+ if (!parent)
3162
+ return errorResponse('Parent folder not found', 404);
3163
+ if (parent.scope !== body.scope) {
3164
+ return errorResponse('Parent folder is in a different scope', 400);
3165
+ }
3166
+ }
2534
3167
  const folder = await db().folder.create({
2535
3168
  data: {
2536
3169
  name: body.name,
@@ -2552,7 +3185,23 @@ export function registerCMSRoutes(router) {
2552
3185
  const roleErr = requireRole(auth.session.role, WRITE_ROLES);
2553
3186
  if (roleErr)
2554
3187
  return roleErr;
2555
- const body = await request.json();
3188
+ const body = (await request.json());
3189
+ const existing = await db().folder.findUnique({ where: { id: params.id } });
3190
+ if (!existing)
3191
+ return errorResponse('Folder not found', 404);
3192
+ // Prevent reparenting into a different scope, and prevent making a folder
3193
+ // a descendant of itself (which would create a cycle).
3194
+ if (body.parentId !== undefined && body.parentId !== null) {
3195
+ if (body.parentId === params.id) {
3196
+ return errorResponse('Folder cannot be its own parent', 400);
3197
+ }
3198
+ const parent = await db().folder.findUnique({ where: { id: body.parentId } });
3199
+ if (!parent)
3200
+ return errorResponse('Parent folder not found', 404);
3201
+ if (parent.scope !== existing.scope) {
3202
+ return errorResponse('Parent folder is in a different scope', 400);
3203
+ }
3204
+ }
2556
3205
  const data = {};
2557
3206
  if (body.name !== undefined)
2558
3207
  data.name = body.name;
@@ -2578,7 +3227,24 @@ export function registerCMSRoutes(router) {
2578
3227
  const roleErr = requireRole(auth.session.role, WRITE_ROLES);
2579
3228
  if (roleErr)
2580
3229
  return roleErr;
2581
- await db().folder.delete({ where: { id: params.id } });
3230
+ // We don't cascade-delete the contents they would silently vanish.
3231
+ // Instead, refuse to delete a folder that still has documents, media,
3232
+ // or sub-folders inside it.
3233
+ const d = db();
3234
+ const folder = await d.folder.findUnique({ where: { id: params.id } });
3235
+ if (!folder)
3236
+ return errorResponse('Folder not found', 404);
3237
+ const [docCount, mediaCount, childCount] = await Promise.all([
3238
+ hasModel(d, 'document')
3239
+ ? d.document.count({ where: { folderId: params.id, deletedAt: null } })
3240
+ : 0,
3241
+ hasModel(d, 'media') ? d.media.count({ where: { folderId: params.id } }) : 0,
3242
+ d.folder.count({ where: { parentId: params.id } }),
3243
+ ]);
3244
+ if (docCount + mediaCount + childCount > 0) {
3245
+ return errorResponse(`Folder is not empty (${docCount} documents, ${mediaCount} media, ${childCount} sub-folders). Move or delete its contents first.`, 409);
3246
+ }
3247
+ await d.folder.delete({ where: { id: params.id } });
2582
3248
  return json({ data: { success: true } });
2583
3249
  }
2584
3250
  catch (err) {
@@ -2593,7 +3259,16 @@ export function registerCMSRoutes(router) {
2593
3259
  const roleErr = requireRole(auth.session.role, WRITE_ROLES);
2594
3260
  if (roleErr)
2595
3261
  return roleErr;
2596
- const body = await request.json();
3262
+ const body = (await request.json());
3263
+ // Confirm the target folder is in the documents scope before moving in.
3264
+ if (body.folderId) {
3265
+ const target = await db().folder.findUnique({ where: { id: body.folderId } });
3266
+ if (!target)
3267
+ return errorResponse('Folder not found', 404);
3268
+ if (target.scope !== 'documents' && target.scope !== 'collections') {
3269
+ return errorResponse('Target folder is not a documents folder', 400);
3270
+ }
3271
+ }
2597
3272
  await db().document.update({
2598
3273
  where: { id: params.id },
2599
3274
  data: { folderId: body.folderId ?? null },
@@ -2612,7 +3287,15 @@ export function registerCMSRoutes(router) {
2612
3287
  const roleErr = requireRole(auth.session.role, WRITE_ROLES);
2613
3288
  if (roleErr)
2614
3289
  return roleErr;
2615
- const body = await request.json();
3290
+ const body = (await request.json());
3291
+ if (body.folderId) {
3292
+ const target = await db().folder.findUnique({ where: { id: body.folderId } });
3293
+ if (!target)
3294
+ return errorResponse('Folder not found', 404);
3295
+ if (target.scope !== 'media') {
3296
+ return errorResponse('Target folder is not a media folder', 400);
3297
+ }
3298
+ }
2616
3299
  await db().media.update({
2617
3300
  where: { id: params.id },
2618
3301
  data: { folderId: body.folderId ?? null },
@@ -2631,7 +3314,7 @@ export function registerCMSRoutes(router) {
2631
3314
  const auth = await requireAuth(request);
2632
3315
  if (auth.error)
2633
3316
  return auth.error;
2634
- const body = await request.json();
3317
+ const body = (await request.json());
2635
3318
  if (!body.collection || !body.documentId) {
2636
3319
  return errorResponse('collection and documentId are required', 400);
2637
3320
  }
@@ -2673,7 +3356,7 @@ export function registerCMSRoutes(router) {
2673
3356
  const auth = await requireAuth(request);
2674
3357
  if (auth.error)
2675
3358
  return auth.error;
2676
- const body = await request.json();
3359
+ const body = (await request.json());
2677
3360
  if (!body.stage)
2678
3361
  return errorResponse('Stage is required', 400);
2679
3362
  const { transitionDocument } = await import('../workflow/index.js');
@@ -2700,7 +3383,9 @@ export function registerCMSRoutes(router) {
2700
3383
  return errorResponse('Document not found', 404);
2701
3384
  const stage = (doc.workflowStage ?? 'DRAFT');
2702
3385
  const transitions = getAvailableTransitions(stage, auth.session.role);
2703
- return json({ data: { stage, transitions, reviewerId: doc.reviewerId, reviewNote: doc.reviewNote } });
3386
+ return json({
3387
+ data: { stage, transitions, reviewerId: doc.reviewerId, reviewNote: doc.reviewNote },
3388
+ });
2704
3389
  }
2705
3390
  catch (err) {
2706
3391
  return internalError(err);
@@ -2821,17 +3506,17 @@ export function registerCMSRoutes(router) {
2821
3506
  if (!doc) {
2822
3507
  return errorResponse('Global not found', 404);
2823
3508
  }
3509
+ // Globals routed through `/public/globals/:slug` are by definition
3510
+ // public site data — when no `access.read` is set we default to
3511
+ // allowed. Integrators that want to gate a global must set
3512
+ // `access.read` explicitly (returning `false` for public).
2824
3513
  const readAccess = globalConfig.access?.read;
2825
- const allowed = readAccess
2826
- ? await readAccess({ user: null, doc })
2827
- : false;
3514
+ const allowed = readAccess ? await readAccess({ user: null, doc }) : true;
2828
3515
  if (!allowed) {
2829
3516
  return errorResponse('Forbidden', 403);
2830
3517
  }
2831
3518
  return json({
2832
- data: doc.data && typeof doc.data === 'object'
2833
- ? doc.data
2834
- : {},
3519
+ data: doc.data && typeof doc.data === 'object' ? doc.data : {},
2835
3520
  });
2836
3521
  }
2837
3522
  catch (err) {
@@ -2862,7 +3547,7 @@ export function registerCMSRoutes(router) {
2862
3547
  const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
2863
3548
  if (roleErr)
2864
3549
  return roleErr;
2865
- const body = await request.json();
3550
+ const body = (await request.json());
2866
3551
  const ctx = buildActionContext(auth.session, db());
2867
3552
  const global = await updateGlobal(params.slug, body, ctx);
2868
3553
  return json({ data: global });
@@ -2898,7 +3583,7 @@ export function registerCMSRoutes(router) {
2898
3583
  const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
2899
3584
  if (roleErr)
2900
3585
  return roleErr;
2901
- const body = await request.json();
3586
+ const body = (await request.json());
2902
3587
  if (!body.url || !body.events?.length) {
2903
3588
  return errorResponse('url and events are required', 400);
2904
3589
  }
@@ -2930,7 +3615,7 @@ export function registerCMSRoutes(router) {
2930
3615
  const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
2931
3616
  if (roleErr)
2932
3617
  return roleErr;
2933
- const body = await request.json();
3618
+ const body = (await request.json());
2934
3619
  const { updateEndpoint } = await import('../webhooks/index.js');
2935
3620
  const updated = await updateEndpoint(params.id, body);
2936
3621
  return json({ data: updated });
@@ -3040,7 +3725,15 @@ export function registerCMSRoutes(router) {
3040
3725
  bucket.push(tag.code);
3041
3726
  }
3042
3727
  }
3043
- return json(grouped);
3728
+ // Public endpoint that fans out to many page renders. Add a short
3729
+ // edge cache so it doesn't become a per-request DB hit.
3730
+ const response = new Response(JSON.stringify(grouped), {
3731
+ status: 200,
3732
+ headers: { ...SECURITY_HEADERS, 'Content-Type': 'application/json' },
3733
+ });
3734
+ response.headers.set('Cache-Control', 'public, max-age=60, s-maxage=60, stale-while-revalidate=300');
3735
+ response.headers.set('Vary', 'path');
3736
+ return response;
3044
3737
  }
3045
3738
  catch (err) {
3046
3739
  return internalError(err, 'script-tags/resolve');
@@ -3077,7 +3770,7 @@ export function registerCMSRoutes(router) {
3077
3770
  const d = db();
3078
3771
  if (!hasModel(d, 'scriptTag'))
3079
3772
  return modelNotAvailable('ScriptTag');
3080
- const body = await request.json();
3773
+ const body = (await request.json());
3081
3774
  if (!body.name || !body.code || !body.placement) {
3082
3775
  return errorResponse('name, code, and placement are required', 400);
3083
3776
  }
@@ -3126,7 +3819,7 @@ export function registerCMSRoutes(router) {
3126
3819
  const existing = await d.scriptTag.findUnique({ where: { id: params.id } });
3127
3820
  if (!existing)
3128
3821
  return errorResponse('Script tag not found', 404);
3129
- const body = await request.json();
3822
+ const body = (await request.json());
3130
3823
  const update = {};
3131
3824
  if (body.name !== undefined)
3132
3825
  update.name = body.name;
@@ -3308,7 +4001,7 @@ export function registerCMSRoutes(router) {
3308
4001
  const d = db();
3309
4002
  if (!hasModel(d, 'pageTemplate'))
3310
4003
  return modelNotAvailable('PageTemplate');
3311
- const body = await request.json();
4004
+ const body = (await request.json());
3312
4005
  if (!body.name)
3313
4006
  return errorResponse('name is required', 400);
3314
4007
  if (!body.tree)
@@ -3359,7 +4052,7 @@ export function registerCMSRoutes(router) {
3359
4052
  return errorResponse('Template not found', 404);
3360
4053
  if (existing.builtIn)
3361
4054
  return errorResponse('Cannot update built-in templates', 403);
3362
- const body = await request.json();
4055
+ const body = (await request.json());
3363
4056
  const update = {};
3364
4057
  if (body.name !== undefined)
3365
4058
  update.name = body.name;
@@ -3507,7 +4200,7 @@ export function registerCMSRoutes(router) {
3507
4200
  const d = db();
3508
4201
  if (!hasModel(d, 'savedSection'))
3509
4202
  return modelNotAvailable('SavedSection');
3510
- const body = await request.json();
4203
+ const body = (await request.json());
3511
4204
  if (!body.name)
3512
4205
  return errorResponse('name is required', 400);
3513
4206
  if (!body.tree)
@@ -3556,7 +4249,7 @@ export function registerCMSRoutes(router) {
3556
4249
  const existing = await d.savedSection.findUnique({ where: { id: params.id } });
3557
4250
  if (!existing)
3558
4251
  return errorResponse('Saved section not found', 404);
3559
- const body = await request.json();
4252
+ const body = (await request.json());
3560
4253
  const update = {};
3561
4254
  if (body.name !== undefined)
3562
4255
  update.name = body.name;
@@ -3621,6 +4314,11 @@ export function registerCMSRoutes(router) {
3621
4314
  }
3622
4315
  });
3623
4316
  // ─── Page Builder AI Generation ─────────────────────────────────────
4317
+ // Hard caps for AI input to keep token cost bounded and to limit the
4318
+ // surface area for prompt injection. Adjust via the `ai.limits` block in
4319
+ // the CMS config if you want stricter values; never raise past 8k.
4320
+ const AI_PROMPT_MAX_CHARS = 4000;
4321
+ const AI_CONTEXT_MAX_CHARS = 8000;
3624
4322
  router.post('/page-builder/generate', async (request) => {
3625
4323
  try {
3626
4324
  const auth = await requireAuth(request);
@@ -3629,11 +4327,23 @@ export function registerCMSRoutes(router) {
3629
4327
  const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
3630
4328
  if (roleErr)
3631
4329
  return roleErr;
4330
+ // Per-user rate limit. AI generation is the single most expensive
4331
+ // operation in the CMS — without this, a compromised admin account
4332
+ // can drain a provider key in minutes.
4333
+ if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-gen:${auth.session.userId}`))) {
4334
+ return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
4335
+ }
3632
4336
  const body = await request.json();
3633
4337
  const { prompt, template, context, steps, tone } = body;
3634
4338
  if (!prompt || typeof prompt !== 'string') {
3635
4339
  return errorResponse('prompt is required', 400);
3636
4340
  }
4341
+ if (prompt.length > AI_PROMPT_MAX_CHARS) {
4342
+ return errorResponse(`prompt exceeds ${AI_PROMPT_MAX_CHARS} character limit`, 400);
4343
+ }
4344
+ if (typeof context === 'string' && context.length > AI_CONTEXT_MAX_CHARS) {
4345
+ return errorResponse(`context exceeds ${AI_CONTEXT_MAX_CHARS} character limit`, 400);
4346
+ }
3637
4347
  if (!steps || !Array.isArray(steps) || steps.length === 0) {
3638
4348
  return errorResponse('steps array is required', 400);
3639
4349
  }
@@ -3646,7 +4356,15 @@ export function registerCMSRoutes(router) {
3646
4356
  await logEvent({
3647
4357
  event: 'settings_changed',
3648
4358
  userId: auth.session.userId,
3649
- details: { action: 'page_generation_started', prompt, steps, template },
4359
+ details: {
4360
+ action: 'page_generation_started',
4361
+ // Redact secrets from the prompt before persisting to the audit log.
4362
+ // Even an admin pasting a key into a prompt by mistake shouldn't
4363
+ // result in that key being mirrored into permanent storage.
4364
+ prompt: redactSecrets(prompt).slice(0, 500),
4365
+ steps,
4366
+ template,
4367
+ },
3650
4368
  });
3651
4369
  let generatePage = null;
3652
4370
  try {
@@ -3681,11 +4399,21 @@ export function registerCMSRoutes(router) {
3681
4399
  const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
3682
4400
  if (roleErr)
3683
4401
  return roleErr;
4402
+ if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-block:${auth.session.userId}`))) {
4403
+ return errorResponse('AI generation rate limit reached. Try again in an hour.', 429);
4404
+ }
3684
4405
  const body = await request.json();
3685
4406
  const { blockType, variant, pageContext, tone } = body;
3686
4407
  if (!blockType || typeof blockType !== 'string') {
3687
4408
  return errorResponse('blockType is required', 400);
3688
4409
  }
4410
+ // Limit caller-supplied context that flows directly into the prompt.
4411
+ if (pageContext) {
4412
+ const total = JSON.stringify(pageContext).length;
4413
+ if (total > AI_CONTEXT_MAX_CHARS) {
4414
+ return errorResponse(`pageContext exceeds ${AI_CONTEXT_MAX_CHARS} character limit`, 400);
4415
+ }
4416
+ }
3689
4417
  let generateBlockContent = null;
3690
4418
  try {
3691
4419
  const aiModule = await importAIPlugin();
@@ -3738,7 +4466,11 @@ export function registerCMSRoutes(router) {
3738
4466
  await logEvent({
3739
4467
  event: 'settings_changed',
3740
4468
  userId: auth.session.userId,
3741
- details: { action: 'a11y_auto_fix', fixedCount: result.report.fixedCount, remainingCount: result.report.remainingCount },
4469
+ details: {
4470
+ action: 'a11y_auto_fix',
4471
+ fixedCount: result.report.fixedCount,
4472
+ remainingCount: result.report.remainingCount,
4473
+ },
3742
4474
  });
3743
4475
  return json({ data: result });
3744
4476
  }