@growth-labs/cms 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (1093) hide show
  1. package/README.md +165 -0
  2. package/dist/engine/activity-log.d.ts +17 -0
  3. package/dist/engine/activity-log.d.ts.map +1 -0
  4. package/dist/engine/activity-log.js +17 -0
  5. package/dist/engine/activity-log.js.map +1 -0
  6. package/dist/engine/ai-prompts.d.ts +57 -0
  7. package/dist/engine/ai-prompts.d.ts.map +1 -0
  8. package/dist/engine/ai-prompts.js +90 -0
  9. package/dist/engine/ai-prompts.js.map +1 -0
  10. package/dist/engine/ai-writeback.d.ts +36 -0
  11. package/dist/engine/ai-writeback.d.ts.map +1 -0
  12. package/dist/engine/ai-writeback.js +45 -0
  13. package/dist/engine/ai-writeback.js.map +1 -0
  14. package/dist/engine/api-keys.d.ts +76 -0
  15. package/dist/engine/api-keys.d.ts.map +1 -0
  16. package/dist/engine/api-keys.js +165 -0
  17. package/dist/engine/api-keys.js.map +1 -0
  18. package/dist/engine/content-insights.d.ts +36 -0
  19. package/dist/engine/content-insights.d.ts.map +1 -0
  20. package/dist/engine/content-insights.js +114 -0
  21. package/dist/engine/content-insights.js.map +1 -0
  22. package/dist/engine/contributors.d.ts +25 -0
  23. package/dist/engine/contributors.d.ts.map +1 -0
  24. package/dist/engine/contributors.js +59 -0
  25. package/dist/engine/contributors.js.map +1 -0
  26. package/dist/engine/cron.d.ts +15 -0
  27. package/dist/engine/cron.d.ts.map +1 -0
  28. package/dist/engine/cron.js +33 -0
  29. package/dist/engine/cron.js.map +1 -0
  30. package/dist/engine/d1.d.ts +16 -0
  31. package/dist/engine/d1.d.ts.map +1 -0
  32. package/dist/engine/d1.js +13 -0
  33. package/dist/engine/d1.js.map +1 -0
  34. package/dist/engine/foundry-dispatch.d.ts +52 -0
  35. package/dist/engine/foundry-dispatch.d.ts.map +1 -0
  36. package/dist/engine/foundry-dispatch.js +290 -0
  37. package/dist/engine/foundry-dispatch.js.map +1 -0
  38. package/dist/engine/import-parsers.d.ts +11 -0
  39. package/dist/engine/import-parsers.d.ts.map +1 -0
  40. package/dist/engine/import-parsers.js +373 -0
  41. package/dist/engine/import-parsers.js.map +1 -0
  42. package/dist/engine/index.d.ts +28 -0
  43. package/dist/engine/index.d.ts.map +1 -0
  44. package/dist/engine/index.js +57 -0
  45. package/dist/engine/index.js.map +1 -0
  46. package/dist/engine/invites.d.ts +78 -0
  47. package/dist/engine/invites.d.ts.map +1 -0
  48. package/dist/engine/invites.js +158 -0
  49. package/dist/engine/invites.js.map +1 -0
  50. package/dist/engine/members.d.ts +59 -0
  51. package/dist/engine/members.d.ts.map +1 -0
  52. package/dist/engine/members.js +124 -0
  53. package/dist/engine/members.js.map +1 -0
  54. package/dist/engine/membership-rules.d.ts +25 -0
  55. package/dist/engine/membership-rules.d.ts.map +1 -0
  56. package/dist/engine/membership-rules.js +44 -0
  57. package/dist/engine/membership-rules.js.map +1 -0
  58. package/dist/engine/og-render.d.ts +40 -0
  59. package/dist/engine/og-render.d.ts.map +1 -0
  60. package/dist/engine/og-render.js +26 -0
  61. package/dist/engine/og-render.js.map +1 -0
  62. package/dist/engine/publish-guard.d.ts +58 -0
  63. package/dist/engine/publish-guard.d.ts.map +1 -0
  64. package/dist/engine/publish-guard.js +80 -0
  65. package/dist/engine/publish-guard.js.map +1 -0
  66. package/dist/engine/publisher.d.ts +171 -0
  67. package/dist/engine/publisher.d.ts.map +1 -0
  68. package/dist/engine/publisher.js +597 -0
  69. package/dist/engine/publisher.js.map +1 -0
  70. package/dist/engine/revisions.d.ts +39 -0
  71. package/dist/engine/revisions.d.ts.map +1 -0
  72. package/dist/engine/revisions.js +203 -0
  73. package/dist/engine/revisions.js.map +1 -0
  74. package/dist/engine/sanitize.d.ts +52 -0
  75. package/dist/engine/sanitize.d.ts.map +1 -0
  76. package/dist/engine/sanitize.js +155 -0
  77. package/dist/engine/sanitize.js.map +1 -0
  78. package/dist/engine/seed-membership.d.ts +29 -0
  79. package/dist/engine/seed-membership.d.ts.map +1 -0
  80. package/dist/engine/seed-membership.js +65 -0
  81. package/dist/engine/seed-membership.js.map +1 -0
  82. package/dist/engine/seo.d.ts +20 -0
  83. package/dist/engine/seo.d.ts.map +1 -0
  84. package/dist/engine/seo.js +50 -0
  85. package/dist/engine/seo.js.map +1 -0
  86. package/dist/engine/slug-redirects.d.ts +8 -0
  87. package/dist/engine/slug-redirects.d.ts.map +1 -0
  88. package/dist/engine/slug-redirects.js +26 -0
  89. package/dist/engine/slug-redirects.js.map +1 -0
  90. package/dist/engine/slug.d.ts +6 -0
  91. package/dist/engine/slug.d.ts.map +1 -0
  92. package/dist/engine/slug.js +28 -0
  93. package/dist/engine/slug.js.map +1 -0
  94. package/dist/engine/soft-delete.d.ts +8 -0
  95. package/dist/engine/soft-delete.d.ts.map +1 -0
  96. package/dist/engine/soft-delete.js +28 -0
  97. package/dist/engine/soft-delete.js.map +1 -0
  98. package/dist/engine/tags.d.ts +14 -0
  99. package/dist/engine/tags.d.ts.map +1 -0
  100. package/dist/engine/tags.js +79 -0
  101. package/dist/engine/tags.js.map +1 -0
  102. package/dist/engine/topics.d.ts +10 -0
  103. package/dist/engine/topics.d.ts.map +1 -0
  104. package/dist/engine/topics.js +140 -0
  105. package/dist/engine/topics.js.map +1 -0
  106. package/dist/engine/url-guard.d.ts +12 -0
  107. package/dist/engine/url-guard.d.ts.map +1 -0
  108. package/dist/engine/url-guard.js +129 -0
  109. package/dist/engine/url-guard.js.map +1 -0
  110. package/dist/engine/validator/checks/bare-url-not-autolinked.d.ts +20 -0
  111. package/dist/engine/validator/checks/bare-url-not-autolinked.d.ts.map +1 -0
  112. package/dist/engine/validator/checks/bare-url-not-autolinked.js +54 -0
  113. package/dist/engine/validator/checks/bare-url-not-autolinked.js.map +1 -0
  114. package/dist/engine/validator/checks/broken-footnote-label.d.ts +16 -0
  115. package/dist/engine/validator/checks/broken-footnote-label.d.ts.map +1 -0
  116. package/dist/engine/validator/checks/broken-footnote-label.js +17 -0
  117. package/dist/engine/validator/checks/broken-footnote-label.js.map +1 -0
  118. package/dist/engine/validator/checks/double-encoded-entities.d.ts +18 -0
  119. package/dist/engine/validator/checks/double-encoded-entities.d.ts.map +1 -0
  120. package/dist/engine/validator/checks/double-encoded-entities.js +23 -0
  121. package/dist/engine/validator/checks/double-encoded-entities.js.map +1 -0
  122. package/dist/engine/validator/checks/empty-alt-text.d.ts +14 -0
  123. package/dist/engine/validator/checks/empty-alt-text.d.ts.map +1 -0
  124. package/dist/engine/validator/checks/empty-alt-text.js +23 -0
  125. package/dist/engine/validator/checks/empty-alt-text.js.map +1 -0
  126. package/dist/engine/validator/checks/heading-hierarchy-skip.d.ts +11 -0
  127. package/dist/engine/validator/checks/heading-hierarchy-skip.d.ts.map +1 -0
  128. package/dist/engine/validator/checks/heading-hierarchy-skip.js +20 -0
  129. package/dist/engine/validator/checks/heading-hierarchy-skip.js.map +1 -0
  130. package/dist/engine/validator/checks/html-comment-leak.d.ts +20 -0
  131. package/dist/engine/validator/checks/html-comment-leak.d.ts.map +1 -0
  132. package/dist/engine/validator/checks/html-comment-leak.js +30 -0
  133. package/dist/engine/validator/checks/html-comment-leak.js.map +1 -0
  134. package/dist/engine/validator/checks/iframe-missing-dims-and-wrapper.d.ts +12 -0
  135. package/dist/engine/validator/checks/iframe-missing-dims-and-wrapper.d.ts.map +1 -0
  136. package/dist/engine/validator/checks/iframe-missing-dims-and-wrapper.js +17 -0
  137. package/dist/engine/validator/checks/iframe-missing-dims-and-wrapper.js.map +1 -0
  138. package/dist/engine/validator/checks/invisible-control-chars.d.ts +24 -0
  139. package/dist/engine/validator/checks/invisible-control-chars.d.ts.map +1 -0
  140. package/dist/engine/validator/checks/invisible-control-chars.js +30 -0
  141. package/dist/engine/validator/checks/invisible-control-chars.js.map +1 -0
  142. package/dist/engine/validator/checks/paywall-marker-leak.d.ts +17 -0
  143. package/dist/engine/validator/checks/paywall-marker-leak.d.ts.map +1 -0
  144. package/dist/engine/validator/checks/paywall-marker-leak.js +22 -0
  145. package/dist/engine/validator/checks/paywall-marker-leak.js.map +1 -0
  146. package/dist/engine/validator/checks/raw-block-html.d.ts +28 -0
  147. package/dist/engine/validator/checks/raw-block-html.d.ts.map +1 -0
  148. package/dist/engine/validator/checks/raw-block-html.js +38 -0
  149. package/dist/engine/validator/checks/raw-block-html.js.map +1 -0
  150. package/dist/engine/validator/checks/stale-body-html.d.ts +28 -0
  151. package/dist/engine/validator/checks/stale-body-html.d.ts.map +1 -0
  152. package/dist/engine/validator/checks/stale-body-html.js +15 -0
  153. package/dist/engine/validator/checks/stale-body-html.js.map +1 -0
  154. package/dist/engine/validator/checks/unresolved-footnote-anchor.d.ts +11 -0
  155. package/dist/engine/validator/checks/unresolved-footnote-anchor.d.ts.map +1 -0
  156. package/dist/engine/validator/checks/unresolved-footnote-anchor.js +48 -0
  157. package/dist/engine/validator/checks/unresolved-footnote-anchor.js.map +1 -0
  158. package/dist/engine/validator/checks/word-gdocs-paste-artifacts.d.ts +23 -0
  159. package/dist/engine/validator/checks/word-gdocs-paste-artifacts.d.ts.map +1 -0
  160. package/dist/engine/validator/checks/word-gdocs-paste-artifacts.js +47 -0
  161. package/dist/engine/validator/checks/word-gdocs-paste-artifacts.js.map +1 -0
  162. package/dist/engine/validator/index.d.ts +75 -0
  163. package/dist/engine/validator/index.d.ts.map +1 -0
  164. package/dist/engine/validator/index.js +313 -0
  165. package/dist/engine/validator/index.js.map +1 -0
  166. package/dist/engine/validator/scan.d.ts +28 -0
  167. package/dist/engine/validator/scan.d.ts.map +1 -0
  168. package/dist/engine/validator/scan.js +97 -0
  169. package/dist/engine/validator/scan.js.map +1 -0
  170. package/dist/engine/validator/types.d.ts +50 -0
  171. package/dist/engine/validator/types.d.ts.map +1 -0
  172. package/dist/engine/validator/types.js +51 -0
  173. package/dist/engine/validator/types.js.map +1 -0
  174. package/dist/engine/webhook-signer.d.ts +39 -0
  175. package/dist/engine/webhook-signer.d.ts.map +1 -0
  176. package/dist/engine/webhook-signer.js +117 -0
  177. package/dist/engine/webhook-signer.js.map +1 -0
  178. package/dist/engine/webhooks.d.ts +75 -0
  179. package/dist/engine/webhooks.d.ts.map +1 -0
  180. package/dist/engine/webhooks.js +139 -0
  181. package/dist/engine/webhooks.js.map +1 -0
  182. package/dist/index.d.ts +5 -0
  183. package/dist/index.d.ts.map +1 -0
  184. package/dist/index.js +40 -0
  185. package/dist/index.js.map +1 -0
  186. package/dist/integration/index.d.ts +6 -0
  187. package/dist/integration/index.d.ts.map +1 -0
  188. package/dist/integration/index.js +294 -0
  189. package/dist/integration/index.js.map +1 -0
  190. package/dist/integration/options.d.ts +105 -0
  191. package/dist/integration/options.d.ts.map +1 -0
  192. package/dist/integration/options.js +25 -0
  193. package/dist/integration/options.js.map +1 -0
  194. package/dist/integration/vite-plugin.d.ts +4 -0
  195. package/dist/integration/vite-plugin.d.ts.map +1 -0
  196. package/dist/integration/vite-plugin.js +37 -0
  197. package/dist/integration/vite-plugin.js.map +1 -0
  198. package/dist/providers/index.d.ts +3 -0
  199. package/dist/providers/index.d.ts.map +1 -0
  200. package/dist/providers/index.js +3 -0
  201. package/dist/providers/index.js.map +1 -0
  202. package/dist/providers/null.d.ts +9 -0
  203. package/dist/providers/null.d.ts.map +1 -0
  204. package/dist/providers/null.js +144 -0
  205. package/dist/providers/null.js.map +1 -0
  206. package/dist/providers/types.d.ts +277 -0
  207. package/dist/providers/types.d.ts.map +1 -0
  208. package/dist/providers/types.js +2 -0
  209. package/dist/providers/types.js.map +1 -0
  210. package/dist/routes/ai.d.ts +25 -0
  211. package/dist/routes/ai.d.ts.map +1 -0
  212. package/dist/routes/ai.js +381 -0
  213. package/dist/routes/ai.js.map +1 -0
  214. package/dist/routes/analytics.d.ts +15 -0
  215. package/dist/routes/analytics.d.ts.map +1 -0
  216. package/dist/routes/analytics.js +61 -0
  217. package/dist/routes/analytics.js.map +1 -0
  218. package/dist/routes/api-keys.d.ts +13 -0
  219. package/dist/routes/api-keys.d.ts.map +1 -0
  220. package/dist/routes/api-keys.js +109 -0
  221. package/dist/routes/api-keys.js.map +1 -0
  222. package/dist/routes/authors.d.ts +19 -0
  223. package/dist/routes/authors.d.ts.map +1 -0
  224. package/dist/routes/authors.js +202 -0
  225. package/dist/routes/authors.js.map +1 -0
  226. package/dist/routes/authz-matrix.d.ts +78 -0
  227. package/dist/routes/authz-matrix.d.ts.map +1 -0
  228. package/dist/routes/authz-matrix.js +170 -0
  229. package/dist/routes/authz-matrix.js.map +1 -0
  230. package/dist/routes/calendar.d.ts +19 -0
  231. package/dist/routes/calendar.d.ts.map +1 -0
  232. package/dist/routes/calendar.js +89 -0
  233. package/dist/routes/calendar.js.map +1 -0
  234. package/dist/routes/config.d.ts +70 -0
  235. package/dist/routes/config.d.ts.map +1 -0
  236. package/dist/routes/config.js +23 -0
  237. package/dist/routes/config.js.map +1 -0
  238. package/dist/routes/content-insights.d.ts +18 -0
  239. package/dist/routes/content-insights.d.ts.map +1 -0
  240. package/dist/routes/content-insights.js +137 -0
  241. package/dist/routes/content-insights.js.map +1 -0
  242. package/dist/routes/content.d.ts +145 -0
  243. package/dist/routes/content.d.ts.map +1 -0
  244. package/dist/routes/content.js +1374 -0
  245. package/dist/routes/content.js.map +1 -0
  246. package/dist/routes/context.d.ts +104 -0
  247. package/dist/routes/context.d.ts.map +1 -0
  248. package/dist/routes/context.js +26 -0
  249. package/dist/routes/context.js.map +1 -0
  250. package/dist/routes/cron.d.ts +8 -0
  251. package/dist/routes/cron.d.ts.map +1 -0
  252. package/dist/routes/cron.js +20 -0
  253. package/dist/routes/cron.js.map +1 -0
  254. package/dist/routes/dashboard.d.ts +12 -0
  255. package/dist/routes/dashboard.d.ts.map +1 -0
  256. package/dist/routes/dashboard.js +113 -0
  257. package/dist/routes/dashboard.js.map +1 -0
  258. package/dist/routes/imports.d.ts +10 -0
  259. package/dist/routes/imports.d.ts.map +1 -0
  260. package/dist/routes/imports.js +149 -0
  261. package/dist/routes/imports.js.map +1 -0
  262. package/dist/routes/index.d.ts +75 -0
  263. package/dist/routes/index.d.ts.map +1 -0
  264. package/dist/routes/index.js +141 -0
  265. package/dist/routes/index.js.map +1 -0
  266. package/dist/routes/media-lib.d.ts +75 -0
  267. package/dist/routes/media-lib.d.ts.map +1 -0
  268. package/dist/routes/media-lib.js +305 -0
  269. package/dist/routes/media-lib.js.map +1 -0
  270. package/dist/routes/media.d.ts +32 -0
  271. package/dist/routes/media.d.ts.map +1 -0
  272. package/dist/routes/media.js +756 -0
  273. package/dist/routes/media.js.map +1 -0
  274. package/dist/routes/preview.d.ts +19 -0
  275. package/dist/routes/preview.d.ts.map +1 -0
  276. package/dist/routes/preview.js +150 -0
  277. package/dist/routes/preview.js.map +1 -0
  278. package/dist/routes/rbac-invites.d.ts +31 -0
  279. package/dist/routes/rbac-invites.d.ts.map +1 -0
  280. package/dist/routes/rbac-invites.js +174 -0
  281. package/dist/routes/rbac-invites.js.map +1 -0
  282. package/dist/routes/rbac.d.ts +12 -0
  283. package/dist/routes/rbac.d.ts.map +1 -0
  284. package/dist/routes/rbac.js +126 -0
  285. package/dist/routes/rbac.js.map +1 -0
  286. package/dist/routes/shell.d.ts +22 -0
  287. package/dist/routes/shell.d.ts.map +1 -0
  288. package/dist/routes/shell.js +123 -0
  289. package/dist/routes/shell.js.map +1 -0
  290. package/dist/routes/subscriptions.d.ts +21 -0
  291. package/dist/routes/subscriptions.d.ts.map +1 -0
  292. package/dist/routes/subscriptions.js +127 -0
  293. package/dist/routes/subscriptions.js.map +1 -0
  294. package/dist/routes/tags.d.ts +23 -0
  295. package/dist/routes/tags.d.ts.map +1 -0
  296. package/dist/routes/tags.js +68 -0
  297. package/dist/routes/tags.js.map +1 -0
  298. package/dist/routes/topics.d.ts +12 -0
  299. package/dist/routes/topics.d.ts.map +1 -0
  300. package/dist/routes/topics.js +49 -0
  301. package/dist/routes/topics.js.map +1 -0
  302. package/dist/routes/webhooks.d.ts +31 -0
  303. package/dist/routes/webhooks.d.ts.map +1 -0
  304. package/dist/routes/webhooks.js +173 -0
  305. package/dist/routes/webhooks.js.map +1 -0
  306. package/dist/schema/index.d.ts +4 -0
  307. package/dist/schema/index.d.ts.map +1 -0
  308. package/dist/schema/index.js +6 -0
  309. package/dist/schema/index.js.map +1 -0
  310. package/dist/schema/insights-ingest.d.ts +959 -0
  311. package/dist/schema/insights-ingest.d.ts.map +1 -0
  312. package/dist/schema/insights-ingest.js +112 -0
  313. package/dist/schema/insights-ingest.js.map +1 -0
  314. package/dist/schema/migrations.d.ts +63 -0
  315. package/dist/schema/migrations.d.ts.map +1 -0
  316. package/dist/schema/migrations.js +589 -0
  317. package/dist/schema/migrations.js.map +1 -0
  318. package/dist/schema/tables.d.ts +11 -0
  319. package/dist/schema/tables.d.ts.map +1 -0
  320. package/dist/schema/tables.js +56 -0
  321. package/dist/schema/tables.js.map +1 -0
  322. package/dist/schema/types.d.ts +476 -0
  323. package/dist/schema/types.d.ts.map +1 -0
  324. package/dist/schema/types.js +37 -0
  325. package/dist/schema/types.js.map +1 -0
  326. package/dist/ui/api/_authz.d.ts +6 -0
  327. package/dist/ui/api/_authz.d.ts.map +1 -0
  328. package/dist/ui/api/_authz.js +74 -0
  329. package/dist/ui/api/_authz.js.map +1 -0
  330. package/dist/ui/api/_content-config.d.ts +22 -0
  331. package/dist/ui/api/_content-config.d.ts.map +1 -0
  332. package/dist/ui/api/_content-config.js +50 -0
  333. package/dist/ui/api/_content-config.js.map +1 -0
  334. package/dist/ui/api/activity.d.ts +3 -0
  335. package/dist/ui/api/activity.d.ts.map +1 -0
  336. package/dist/ui/api/activity.js +28 -0
  337. package/dist/ui/api/activity.js.map +1 -0
  338. package/dist/ui/api/analytics.d.ts +3 -0
  339. package/dist/ui/api/analytics.d.ts.map +1 -0
  340. package/dist/ui/api/analytics.js +36 -0
  341. package/dist/ui/api/analytics.js.map +1 -0
  342. package/dist/ui/api/authors/[id].d.ts +4 -0
  343. package/dist/ui/api/authors/[id].d.ts.map +1 -0
  344. package/dist/ui/api/authors/[id].js +17 -0
  345. package/dist/ui/api/authors/[id].js.map +1 -0
  346. package/dist/ui/api/authors.d.ts +4 -0
  347. package/dist/ui/api/authors.d.ts.map +1 -0
  348. package/dist/ui/api/authors.js +12 -0
  349. package/dist/ui/api/authors.js.map +1 -0
  350. package/dist/ui/api/calendar.d.ts +3 -0
  351. package/dist/ui/api/calendar.d.ts.map +1 -0
  352. package/dist/ui/api/calendar.js +16 -0
  353. package/dist/ui/api/calendar.js.map +1 -0
  354. package/dist/ui/api/content/[id]/ai/headlines.d.ts +3 -0
  355. package/dist/ui/api/content/[id]/ai/headlines.d.ts.map +1 -0
  356. package/dist/ui/api/content/[id]/ai/headlines.js +7 -0
  357. package/dist/ui/api/content/[id]/ai/headlines.js.map +1 -0
  358. package/dist/ui/api/content/[id]/ai/meta-description.d.ts +3 -0
  359. package/dist/ui/api/content/[id]/ai/meta-description.d.ts.map +1 -0
  360. package/dist/ui/api/content/[id]/ai/meta-description.js +6 -0
  361. package/dist/ui/api/content/[id]/ai/meta-description.js.map +1 -0
  362. package/dist/ui/api/content/[id]/ai/og-image.d.ts +3 -0
  363. package/dist/ui/api/content/[id]/ai/og-image.d.ts.map +1 -0
  364. package/dist/ui/api/content/[id]/ai/og-image.js +7 -0
  365. package/dist/ui/api/content/[id]/ai/og-image.js.map +1 -0
  366. package/dist/ui/api/content/[id]/ai/proofread.d.ts +3 -0
  367. package/dist/ui/api/content/[id]/ai/proofread.d.ts.map +1 -0
  368. package/dist/ui/api/content/[id]/ai/proofread.js +7 -0
  369. package/dist/ui/api/content/[id]/ai/proofread.js.map +1 -0
  370. package/dist/ui/api/content/[id]/ai/takeaways.d.ts +3 -0
  371. package/dist/ui/api/content/[id]/ai/takeaways.d.ts.map +1 -0
  372. package/dist/ui/api/content/[id]/ai/takeaways.js +6 -0
  373. package/dist/ui/api/content/[id]/ai/takeaways.js.map +1 -0
  374. package/dist/ui/api/content/[id]/contributors.d.ts +4 -0
  375. package/dist/ui/api/content/[id]/contributors.d.ts.map +1 -0
  376. package/dist/ui/api/content/[id]/contributors.js +8 -0
  377. package/dist/ui/api/content/[id]/contributors.js.map +1 -0
  378. package/dist/ui/api/content/[id]/preview-token.d.ts +3 -0
  379. package/dist/ui/api/content/[id]/preview-token.d.ts.map +1 -0
  380. package/dist/ui/api/content/[id]/preview-token.js +16 -0
  381. package/dist/ui/api/content/[id]/preview-token.js.map +1 -0
  382. package/dist/ui/api/content/[id]/revisions/[rev]/restore.d.ts +3 -0
  383. package/dist/ui/api/content/[id]/revisions/[rev]/restore.d.ts.map +1 -0
  384. package/dist/ui/api/content/[id]/revisions/[rev]/restore.js +7 -0
  385. package/dist/ui/api/content/[id]/revisions/[rev]/restore.js.map +1 -0
  386. package/dist/ui/api/content/[id]/revisions/[rev].d.ts +3 -0
  387. package/dist/ui/api/content/[id]/revisions/[rev].d.ts.map +1 -0
  388. package/dist/ui/api/content/[id]/revisions/[rev].js +6 -0
  389. package/dist/ui/api/content/[id]/revisions/[rev].js.map +1 -0
  390. package/dist/ui/api/content/[id]/revisions.d.ts +3 -0
  391. package/dist/ui/api/content/[id]/revisions.d.ts.map +1 -0
  392. package/dist/ui/api/content/[id]/revisions.js +6 -0
  393. package/dist/ui/api/content/[id]/revisions.js.map +1 -0
  394. package/dist/ui/api/content/[id]/seo-score.d.ts +3 -0
  395. package/dist/ui/api/content/[id]/seo-score.d.ts.map +1 -0
  396. package/dist/ui/api/content/[id]/seo-score.js +7 -0
  397. package/dist/ui/api/content/[id]/seo-score.js.map +1 -0
  398. package/dist/ui/api/content/[id].d.ts +5 -0
  399. package/dist/ui/api/content/[id].d.ts.map +1 -0
  400. package/dist/ui/api/content/[id].js +17 -0
  401. package/dist/ui/api/content/[id].js.map +1 -0
  402. package/dist/ui/api/content/bulk.d.ts +3 -0
  403. package/dist/ui/api/content/bulk.d.ts.map +1 -0
  404. package/dist/ui/api/content/bulk.js +7 -0
  405. package/dist/ui/api/content/bulk.js.map +1 -0
  406. package/dist/ui/api/content/counts.d.ts +3 -0
  407. package/dist/ui/api/content/counts.d.ts.map +1 -0
  408. package/dist/ui/api/content/counts.js +8 -0
  409. package/dist/ui/api/content/counts.js.map +1 -0
  410. package/dist/ui/api/content/foundry-callback.d.ts +3 -0
  411. package/dist/ui/api/content/foundry-callback.d.ts.map +1 -0
  412. package/dist/ui/api/content/foundry-callback.js +8 -0
  413. package/dist/ui/api/content/foundry-callback.js.map +1 -0
  414. package/dist/ui/api/content/import/confirm.d.ts +3 -0
  415. package/dist/ui/api/content/import/confirm.d.ts.map +1 -0
  416. package/dist/ui/api/content/import/confirm.js +11 -0
  417. package/dist/ui/api/content/import/confirm.js.map +1 -0
  418. package/dist/ui/api/content/import/parse.d.ts +3 -0
  419. package/dist/ui/api/content/import/parse.d.ts.map +1 -0
  420. package/dist/ui/api/content/import/parse.js +11 -0
  421. package/dist/ui/api/content/import/parse.js.map +1 -0
  422. package/dist/ui/api/content/insights-ingest.d.ts +3 -0
  423. package/dist/ui/api/content/insights-ingest.d.ts.map +1 -0
  424. package/dist/ui/api/content/insights-ingest.js +15 -0
  425. package/dist/ui/api/content/insights-ingest.js.map +1 -0
  426. package/dist/ui/api/content-insights/dismiss.d.ts +3 -0
  427. package/dist/ui/api/content-insights/dismiss.d.ts.map +1 -0
  428. package/dist/ui/api/content-insights/dismiss.js +16 -0
  429. package/dist/ui/api/content-insights/dismiss.js.map +1 -0
  430. package/dist/ui/api/content-insights/index.d.ts +3 -0
  431. package/dist/ui/api/content-insights/index.d.ts.map +1 -0
  432. package/dist/ui/api/content-insights/index.js +12 -0
  433. package/dist/ui/api/content-insights/index.js.map +1 -0
  434. package/dist/ui/api/content-insights/undismiss.d.ts +3 -0
  435. package/dist/ui/api/content-insights/undismiss.d.ts.map +1 -0
  436. package/dist/ui/api/content-insights/undismiss.js +16 -0
  437. package/dist/ui/api/content-insights/undismiss.js.map +1 -0
  438. package/dist/ui/api/content.d.ts +4 -0
  439. package/dist/ui/api/content.d.ts.map +1 -0
  440. package/dist/ui/api/content.js +16 -0
  441. package/dist/ui/api/content.js.map +1 -0
  442. package/dist/ui/api/dashboard.d.ts +3 -0
  443. package/dist/ui/api/dashboard.d.ts.map +1 -0
  444. package/dist/ui/api/dashboard.js +22 -0
  445. package/dist/ui/api/dashboard.js.map +1 -0
  446. package/dist/ui/api/me.d.ts +3 -0
  447. package/dist/ui/api/me.d.ts.map +1 -0
  448. package/dist/ui/api/me.js +18 -0
  449. package/dist/ui/api/me.js.map +1 -0
  450. package/dist/ui/api/media/[id].d.ts +3 -0
  451. package/dist/ui/api/media/[id].d.ts.map +1 -0
  452. package/dist/ui/api/media/[id].js +17 -0
  453. package/dist/ui/api/media/[id].js.map +1 -0
  454. package/dist/ui/api/media/images.d.ts +3 -0
  455. package/dist/ui/api/media/images.d.ts.map +1 -0
  456. package/dist/ui/api/media/images.js +6 -0
  457. package/dist/ui/api/media/images.js.map +1 -0
  458. package/dist/ui/api/media/library/[id].d.ts +3 -0
  459. package/dist/ui/api/media/library/[id].d.ts.map +1 -0
  460. package/dist/ui/api/media/library/[id].js +6 -0
  461. package/dist/ui/api/media/library/[id].js.map +1 -0
  462. package/dist/ui/api/media/library.d.ts +3 -0
  463. package/dist/ui/api/media/library.d.ts.map +1 -0
  464. package/dist/ui/api/media/library.js +17 -0
  465. package/dist/ui/api/media/library.js.map +1 -0
  466. package/dist/ui/api/media/podcast/abort.d.ts +3 -0
  467. package/dist/ui/api/media/podcast/abort.d.ts.map +1 -0
  468. package/dist/ui/api/media/podcast/abort.js +4 -0
  469. package/dist/ui/api/media/podcast/abort.js.map +1 -0
  470. package/dist/ui/api/media/podcast/complete.d.ts +3 -0
  471. package/dist/ui/api/media/podcast/complete.d.ts.map +1 -0
  472. package/dist/ui/api/media/podcast/complete.js +4 -0
  473. package/dist/ui/api/media/podcast/complete.js.map +1 -0
  474. package/dist/ui/api/media/podcast/init.d.ts +3 -0
  475. package/dist/ui/api/media/podcast/init.d.ts.map +1 -0
  476. package/dist/ui/api/media/podcast/init.js +4 -0
  477. package/dist/ui/api/media/podcast/init.js.map +1 -0
  478. package/dist/ui/api/media/podcast/part.d.ts +3 -0
  479. package/dist/ui/api/media/podcast/part.d.ts.map +1 -0
  480. package/dist/ui/api/media/podcast/part.js +4 -0
  481. package/dist/ui/api/media/podcast/part.js.map +1 -0
  482. package/dist/ui/api/media/podcast.d.ts +3 -0
  483. package/dist/ui/api/media/podcast.d.ts.map +1 -0
  484. package/dist/ui/api/media/podcast.js +6 -0
  485. package/dist/ui/api/media/podcast.js.map +1 -0
  486. package/dist/ui/api/media/videos/abort.d.ts +3 -0
  487. package/dist/ui/api/media/videos/abort.d.ts.map +1 -0
  488. package/dist/ui/api/media/videos/abort.js +6 -0
  489. package/dist/ui/api/media/videos/abort.js.map +1 -0
  490. package/dist/ui/api/media/videos/complete.d.ts +3 -0
  491. package/dist/ui/api/media/videos/complete.d.ts.map +1 -0
  492. package/dist/ui/api/media/videos/complete.js +6 -0
  493. package/dist/ui/api/media/videos/complete.js.map +1 -0
  494. package/dist/ui/api/media/videos/init.d.ts +3 -0
  495. package/dist/ui/api/media/videos/init.d.ts.map +1 -0
  496. package/dist/ui/api/media/videos/init.js +6 -0
  497. package/dist/ui/api/media/videos/init.js.map +1 -0
  498. package/dist/ui/api/media/videos/part.d.ts +3 -0
  499. package/dist/ui/api/media/videos/part.d.ts.map +1 -0
  500. package/dist/ui/api/media/videos/part.js +6 -0
  501. package/dist/ui/api/media/videos/part.js.map +1 -0
  502. package/dist/ui/api/notifications.d.ts +4 -0
  503. package/dist/ui/api/notifications.d.ts.map +1 -0
  504. package/dist/ui/api/notifications.js +20 -0
  505. package/dist/ui/api/notifications.js.map +1 -0
  506. package/dist/ui/api/search.d.ts +3 -0
  507. package/dist/ui/api/search.d.ts.map +1 -0
  508. package/dist/ui/api/search.js +18 -0
  509. package/dist/ui/api/search.js.map +1 -0
  510. package/dist/ui/api/settings/api-keys/[id].d.ts +3 -0
  511. package/dist/ui/api/settings/api-keys/[id].d.ts.map +1 -0
  512. package/dist/ui/api/settings/api-keys/[id].js +22 -0
  513. package/dist/ui/api/settings/api-keys/[id].js.map +1 -0
  514. package/dist/ui/api/settings/api-keys.d.ts +4 -0
  515. package/dist/ui/api/settings/api-keys.d.ts.map +1 -0
  516. package/dist/ui/api/settings/api-keys.js +19 -0
  517. package/dist/ui/api/settings/api-keys.js.map +1 -0
  518. package/dist/ui/api/settings/domains.d.ts +3 -0
  519. package/dist/ui/api/settings/domains.d.ts.map +1 -0
  520. package/dist/ui/api/settings/domains.js +32 -0
  521. package/dist/ui/api/settings/domains.js.map +1 -0
  522. package/dist/ui/api/settings/integrations.d.ts +3 -0
  523. package/dist/ui/api/settings/integrations.d.ts.map +1 -0
  524. package/dist/ui/api/settings/integrations.js +32 -0
  525. package/dist/ui/api/settings/integrations.js.map +1 -0
  526. package/dist/ui/api/settings/members/[userId].d.ts +4 -0
  527. package/dist/ui/api/settings/members/[userId].d.ts.map +1 -0
  528. package/dist/ui/api/settings/members/[userId].js +26 -0
  529. package/dist/ui/api/settings/members/[userId].js.map +1 -0
  530. package/dist/ui/api/settings/members/invite.d.ts +3 -0
  531. package/dist/ui/api/settings/members/invite.d.ts.map +1 -0
  532. package/dist/ui/api/settings/members/invite.js +21 -0
  533. package/dist/ui/api/settings/members/invite.js.map +1 -0
  534. package/dist/ui/api/settings/members.d.ts +3 -0
  535. package/dist/ui/api/settings/members.d.ts.map +1 -0
  536. package/dist/ui/api/settings/members.js +17 -0
  537. package/dist/ui/api/settings/members.js.map +1 -0
  538. package/dist/ui/api/settings/webhooks/[id]/test.d.ts +3 -0
  539. package/dist/ui/api/settings/webhooks/[id]/test.d.ts.map +1 -0
  540. package/dist/ui/api/settings/webhooks/[id]/test.js +24 -0
  541. package/dist/ui/api/settings/webhooks/[id]/test.js.map +1 -0
  542. package/dist/ui/api/settings/webhooks/[id].d.ts +3 -0
  543. package/dist/ui/api/settings/webhooks/[id].d.ts.map +1 -0
  544. package/dist/ui/api/settings/webhooks/[id].js +23 -0
  545. package/dist/ui/api/settings/webhooks/[id].js.map +1 -0
  546. package/dist/ui/api/settings/webhooks.d.ts +4 -0
  547. package/dist/ui/api/settings/webhooks.d.ts.map +1 -0
  548. package/dist/ui/api/settings/webhooks.js +21 -0
  549. package/dist/ui/api/settings/webhooks.js.map +1 -0
  550. package/dist/ui/api/subscriptions.d.ts +4 -0
  551. package/dist/ui/api/subscriptions.d.ts.map +1 -0
  552. package/dist/ui/api/subscriptions.js +47 -0
  553. package/dist/ui/api/subscriptions.js.map +1 -0
  554. package/dist/ui/api/tags/[id].d.ts +4 -0
  555. package/dist/ui/api/tags/[id].d.ts.map +1 -0
  556. package/dist/ui/api/tags/[id].js +18 -0
  557. package/dist/ui/api/tags/[id].js.map +1 -0
  558. package/dist/ui/api/tags/index.d.ts +4 -0
  559. package/dist/ui/api/tags/index.d.ts.map +1 -0
  560. package/dist/ui/api/tags/index.js +13 -0
  561. package/dist/ui/api/tags/index.js.map +1 -0
  562. package/dist/ui/api/topics/[id].d.ts +3 -0
  563. package/dist/ui/api/topics/[id].d.ts.map +1 -0
  564. package/dist/ui/api/topics/[id].js +15 -0
  565. package/dist/ui/api/topics/[id].js.map +1 -0
  566. package/dist/ui/api/topics/index.d.ts +4 -0
  567. package/dist/ui/api/topics/index.d.ts.map +1 -0
  568. package/dist/ui/api/topics/index.js +12 -0
  569. package/dist/ui/api/topics/index.js.map +1 -0
  570. package/dist/ui/api/workspace-settings.d.ts +4 -0
  571. package/dist/ui/api/workspace-settings.d.ts.map +1 -0
  572. package/dist/ui/api/workspace-settings.js +20 -0
  573. package/dist/ui/api/workspace-settings.js.map +1 -0
  574. package/dist/ui/client/boot-state.d.ts +15 -0
  575. package/dist/ui/client/boot-state.d.ts.map +1 -0
  576. package/dist/ui/client/boot-state.js +36 -0
  577. package/dist/ui/client/boot-state.js.map +1 -0
  578. package/dist/ui/client/mount.d.ts +3 -0
  579. package/dist/ui/client/mount.d.ts.map +1 -0
  580. package/dist/ui/client/mount.js +37 -0
  581. package/dist/ui/client/mount.js.map +1 -0
  582. package/dist/ui/commands.d.ts +23 -0
  583. package/dist/ui/commands.d.ts.map +1 -0
  584. package/dist/ui/commands.js +48 -0
  585. package/dist/ui/commands.js.map +1 -0
  586. package/dist/ui/components/CmsApp.d.ts +16 -0
  587. package/dist/ui/components/CmsApp.d.ts.map +1 -0
  588. package/dist/ui/components/CmsApp.js +74 -0
  589. package/dist/ui/components/CmsApp.js.map +1 -0
  590. package/dist/ui/components/CommandPalette.d.ts +7 -0
  591. package/dist/ui/components/CommandPalette.d.ts.map +1 -0
  592. package/dist/ui/components/CommandPalette.js +61 -0
  593. package/dist/ui/components/CommandPalette.js.map +1 -0
  594. package/dist/ui/components/NoAccessScreen.d.ts +2 -0
  595. package/dist/ui/components/NoAccessScreen.d.ts.map +1 -0
  596. package/dist/ui/components/NoAccessScreen.js +42 -0
  597. package/dist/ui/components/NoAccessScreen.js.map +1 -0
  598. package/dist/ui/components/ShareModal.d.ts +27 -0
  599. package/dist/ui/components/ShareModal.d.ts.map +1 -0
  600. package/dist/ui/components/ShareModal.js +208 -0
  601. package/dist/ui/components/ShareModal.js.map +1 -0
  602. package/dist/ui/components/SharePickers.d.ts +39 -0
  603. package/dist/ui/components/SharePickers.d.ts.map +1 -0
  604. package/dist/ui/components/SharePickers.js +352 -0
  605. package/dist/ui/components/SharePickers.js.map +1 -0
  606. package/dist/ui/components/ShareStatsPanel.d.ts +22 -0
  607. package/dist/ui/components/ShareStatsPanel.d.ts.map +1 -0
  608. package/dist/ui/components/ShareStatsPanel.js +317 -0
  609. package/dist/ui/components/ShareStatsPanel.js.map +1 -0
  610. package/dist/ui/components/Sidebar.d.ts +7 -0
  611. package/dist/ui/components/Sidebar.d.ts.map +1 -0
  612. package/dist/ui/components/Sidebar.js +20 -0
  613. package/dist/ui/components/Sidebar.js.map +1 -0
  614. package/dist/ui/components/SiteSwitcher.d.ts +4 -0
  615. package/dist/ui/components/SiteSwitcher.d.ts.map +1 -0
  616. package/dist/ui/components/SiteSwitcher.js +35 -0
  617. package/dist/ui/components/SiteSwitcher.js.map +1 -0
  618. package/dist/ui/components/Topbar.d.ts +9 -0
  619. package/dist/ui/components/Topbar.d.ts.map +1 -0
  620. package/dist/ui/components/Topbar.js +20 -0
  621. package/dist/ui/components/Topbar.js.map +1 -0
  622. package/dist/ui/editor/AiAssistPanel.d.ts +8 -0
  623. package/dist/ui/editor/AiAssistPanel.d.ts.map +1 -0
  624. package/dist/ui/editor/AiAssistPanel.js +221 -0
  625. package/dist/ui/editor/AiAssistPanel.js.map +1 -0
  626. package/dist/ui/editor/ContentForm.d.ts +46 -0
  627. package/dist/ui/editor/ContentForm.d.ts.map +1 -0
  628. package/dist/ui/editor/ContentForm.js +821 -0
  629. package/dist/ui/editor/ContentForm.js.map +1 -0
  630. package/dist/ui/editor/Rte.d.ts +16 -0
  631. package/dist/ui/editor/Rte.d.ts.map +1 -0
  632. package/dist/ui/editor/Rte.js +272 -0
  633. package/dist/ui/editor/Rte.js.map +1 -0
  634. package/dist/ui/editor/ai-assist.d.ts +43 -0
  635. package/dist/ui/editor/ai-assist.d.ts.map +1 -0
  636. package/dist/ui/editor/ai-assist.js +114 -0
  637. package/dist/ui/editor/ai-assist.js.map +1 -0
  638. package/dist/ui/editor/autosave.d.ts +18 -0
  639. package/dist/ui/editor/autosave.d.ts.map +1 -0
  640. package/dist/ui/editor/autosave.js +23 -0
  641. package/dist/ui/editor/autosave.js.map +1 -0
  642. package/dist/ui/editor/content-payload.d.ts +19 -0
  643. package/dist/ui/editor/content-payload.d.ts.map +1 -0
  644. package/dist/ui/editor/content-payload.js +97 -0
  645. package/dist/ui/editor/content-payload.js.map +1 -0
  646. package/dist/ui/editor/editor-media-upload.d.ts +6 -0
  647. package/dist/ui/editor/editor-media-upload.d.ts.map +1 -0
  648. package/dist/ui/editor/editor-media-upload.js +20 -0
  649. package/dist/ui/editor/editor-media-upload.js.map +1 -0
  650. package/dist/ui/editor/serialize.d.ts +6 -0
  651. package/dist/ui/editor/serialize.d.ts.map +1 -0
  652. package/dist/ui/editor/serialize.js +479 -0
  653. package/dist/ui/editor/serialize.js.map +1 -0
  654. package/dist/ui/editor/tweet-embed.d.ts +4 -0
  655. package/dist/ui/editor/tweet-embed.d.ts.map +1 -0
  656. package/dist/ui/editor/tweet-embed.js +49 -0
  657. package/dist/ui/editor/tweet-embed.js.map +1 -0
  658. package/dist/ui/hash-router.d.ts +5 -0
  659. package/dist/ui/hash-router.d.ts.map +1 -0
  660. package/dist/ui/hash-router.js +25 -0
  661. package/dist/ui/hash-router.js.map +1 -0
  662. package/dist/ui/icons.d.ts +32 -0
  663. package/dist/ui/icons.d.ts.map +1 -0
  664. package/dist/ui/icons.js +86 -0
  665. package/dist/ui/icons.js.map +1 -0
  666. package/dist/ui/inspector/Field.d.ts +12 -0
  667. package/dist/ui/inspector/Field.d.ts.map +1 -0
  668. package/dist/ui/inspector/Field.js +8 -0
  669. package/dist/ui/inspector/Field.js.map +1 -0
  670. package/dist/ui/inspector/FoundryTab.d.ts +9 -0
  671. package/dist/ui/inspector/FoundryTab.d.ts.map +1 -0
  672. package/dist/ui/inspector/FoundryTab.js +362 -0
  673. package/dist/ui/inspector/FoundryTab.js.map +1 -0
  674. package/dist/ui/inspector/HistoryTab.d.ts +7 -0
  675. package/dist/ui/inspector/HistoryTab.d.ts.map +1 -0
  676. package/dist/ui/inspector/HistoryTab.js +289 -0
  677. package/dist/ui/inspector/HistoryTab.js.map +1 -0
  678. package/dist/ui/inspector/Inspector.d.ts +13 -0
  679. package/dist/ui/inspector/Inspector.d.ts.map +1 -0
  680. package/dist/ui/inspector/Inspector.js +163 -0
  681. package/dist/ui/inspector/Inspector.js.map +1 -0
  682. package/dist/ui/inspector/OrganizeTab.d.ts +15 -0
  683. package/dist/ui/inspector/OrganizeTab.d.ts.map +1 -0
  684. package/dist/ui/inspector/OrganizeTab.js +319 -0
  685. package/dist/ui/inspector/OrganizeTab.js.map +1 -0
  686. package/dist/ui/inspector/PublishTab.d.ts +18 -0
  687. package/dist/ui/inspector/PublishTab.d.ts.map +1 -0
  688. package/dist/ui/inspector/PublishTab.js +339 -0
  689. package/dist/ui/inspector/PublishTab.js.map +1 -0
  690. package/dist/ui/inspector/Section.d.ts +10 -0
  691. package/dist/ui/inspector/Section.d.ts.map +1 -0
  692. package/dist/ui/inspector/Section.js +40 -0
  693. package/dist/ui/inspector/Section.js.map +1 -0
  694. package/dist/ui/inspector/SeoTab.d.ts +19 -0
  695. package/dist/ui/inspector/SeoTab.d.ts.map +1 -0
  696. package/dist/ui/inspector/SeoTab.js +328 -0
  697. package/dist/ui/inspector/SeoTab.js.map +1 -0
  698. package/dist/ui/inspector/foundry-stages.d.ts +36 -0
  699. package/dist/ui/inspector/foundry-stages.d.ts.map +1 -0
  700. package/dist/ui/inspector/foundry-stages.js +101 -0
  701. package/dist/ui/inspector/foundry-stages.js.map +1 -0
  702. package/dist/ui/inspector/inspector-data.d.ts +80 -0
  703. package/dist/ui/inspector/inspector-data.d.ts.map +1 -0
  704. package/dist/ui/inspector/inspector-data.js +172 -0
  705. package/dist/ui/inspector/inspector-data.js.map +1 -0
  706. package/dist/ui/inspector/organize-data.d.ts +23 -0
  707. package/dist/ui/inspector/organize-data.d.ts.map +1 -0
  708. package/dist/ui/inspector/organize-data.js +28 -0
  709. package/dist/ui/inspector/organize-data.js.map +1 -0
  710. package/dist/ui/inspector/revision-diff.d.ts +49 -0
  711. package/dist/ui/inspector/revision-diff.d.ts.map +1 -0
  712. package/dist/ui/inspector/revision-diff.js +166 -0
  713. package/dist/ui/inspector/revision-diff.js.map +1 -0
  714. package/dist/ui/inspector/seo-helpers.d.ts +37 -0
  715. package/dist/ui/inspector/seo-helpers.d.ts.map +1 -0
  716. package/dist/ui/inspector/seo-helpers.js +37 -0
  717. package/dist/ui/inspector/seo-helpers.js.map +1 -0
  718. package/dist/ui/inspector/tab-visibility.d.ts +14 -0
  719. package/dist/ui/inspector/tab-visibility.d.ts.map +1 -0
  720. package/dist/ui/inspector/tab-visibility.js +28 -0
  721. package/dist/ui/inspector/tab-visibility.js.map +1 -0
  722. package/dist/ui/nav.d.ts +16 -0
  723. package/dist/ui/nav.d.ts.map +1 -0
  724. package/dist/ui/nav.js +33 -0
  725. package/dist/ui/nav.js.map +1 -0
  726. package/dist/ui/pages/admin.astro +32 -0
  727. package/dist/ui/preview/draft-page.d.ts +37 -0
  728. package/dist/ui/preview/draft-page.d.ts.map +1 -0
  729. package/dist/ui/preview/draft-page.js +212 -0
  730. package/dist/ui/preview/draft-page.js.map +1 -0
  731. package/dist/ui/preview/preview-layout.d.ts +23 -0
  732. package/dist/ui/preview/preview-layout.d.ts.map +1 -0
  733. package/dist/ui/preview/preview-layout.js +30 -0
  734. package/dist/ui/preview/preview-layout.js.map +1 -0
  735. package/dist/ui/screens/AnalyticsScreen.d.ts +2 -0
  736. package/dist/ui/screens/AnalyticsScreen.d.ts.map +1 -0
  737. package/dist/ui/screens/AnalyticsScreen.js +408 -0
  738. package/dist/ui/screens/AnalyticsScreen.js.map +1 -0
  739. package/dist/ui/screens/AuthorsScreen.d.ts +2 -0
  740. package/dist/ui/screens/AuthorsScreen.d.ts.map +1 -0
  741. package/dist/ui/screens/AuthorsScreen.js +225 -0
  742. package/dist/ui/screens/AuthorsScreen.js.map +1 -0
  743. package/dist/ui/screens/CalendarScreen.d.ts +6 -0
  744. package/dist/ui/screens/CalendarScreen.d.ts.map +1 -0
  745. package/dist/ui/screens/CalendarScreen.js +327 -0
  746. package/dist/ui/screens/CalendarScreen.js.map +1 -0
  747. package/dist/ui/screens/ContentInsightsScreen.d.ts +2 -0
  748. package/dist/ui/screens/ContentInsightsScreen.d.ts.map +1 -0
  749. package/dist/ui/screens/ContentInsightsScreen.js +129 -0
  750. package/dist/ui/screens/ContentInsightsScreen.js.map +1 -0
  751. package/dist/ui/screens/ContentRoute.d.ts +2 -0
  752. package/dist/ui/screens/ContentRoute.d.ts.map +1 -0
  753. package/dist/ui/screens/ContentRoute.js +32 -0
  754. package/dist/ui/screens/ContentRoute.js.map +1 -0
  755. package/dist/ui/screens/DashboardScreen.d.ts +6 -0
  756. package/dist/ui/screens/DashboardScreen.d.ts.map +1 -0
  757. package/dist/ui/screens/DashboardScreen.js +273 -0
  758. package/dist/ui/screens/DashboardScreen.js.map +1 -0
  759. package/dist/ui/screens/EditorScreen.d.ts +7 -0
  760. package/dist/ui/screens/EditorScreen.d.ts.map +1 -0
  761. package/dist/ui/screens/EditorScreen.js +426 -0
  762. package/dist/ui/screens/EditorScreen.js.map +1 -0
  763. package/dist/ui/screens/LibraryScreen.d.ts +6 -0
  764. package/dist/ui/screens/LibraryScreen.d.ts.map +1 -0
  765. package/dist/ui/screens/LibraryScreen.js +580 -0
  766. package/dist/ui/screens/LibraryScreen.js.map +1 -0
  767. package/dist/ui/screens/MediaScreen.d.ts +2 -0
  768. package/dist/ui/screens/MediaScreen.d.ts.map +1 -0
  769. package/dist/ui/screens/MediaScreen.js +173 -0
  770. package/dist/ui/screens/MediaScreen.js.map +1 -0
  771. package/dist/ui/screens/SettingsScreen.d.ts +4 -0
  772. package/dist/ui/screens/SettingsScreen.d.ts.map +1 -0
  773. package/dist/ui/screens/SettingsScreen.js +751 -0
  774. package/dist/ui/screens/SettingsScreen.js.map +1 -0
  775. package/dist/ui/screens/SocialShareScreen.d.ts +2 -0
  776. package/dist/ui/screens/SocialShareScreen.d.ts.map +1 -0
  777. package/dist/ui/screens/SocialShareScreen.js +224 -0
  778. package/dist/ui/screens/SocialShareScreen.js.map +1 -0
  779. package/dist/ui/screens/SubscriptionsScreen.d.ts +2 -0
  780. package/dist/ui/screens/SubscriptionsScreen.d.ts.map +1 -0
  781. package/dist/ui/screens/SubscriptionsScreen.js +441 -0
  782. package/dist/ui/screens/SubscriptionsScreen.js.map +1 -0
  783. package/dist/ui/screens/TopicsScreen.d.ts +2 -0
  784. package/dist/ui/screens/TopicsScreen.d.ts.map +1 -0
  785. package/dist/ui/screens/TopicsScreen.js +360 -0
  786. package/dist/ui/screens/TopicsScreen.js.map +1 -0
  787. package/dist/ui/screens/analytics-data.d.ts +19 -0
  788. package/dist/ui/screens/analytics-data.d.ts.map +1 -0
  789. package/dist/ui/screens/analytics-data.js +42 -0
  790. package/dist/ui/screens/analytics-data.js.map +1 -0
  791. package/dist/ui/screens/calendar-data.d.ts +45 -0
  792. package/dist/ui/screens/calendar-data.d.ts.map +1 -0
  793. package/dist/ui/screens/calendar-data.js +70 -0
  794. package/dist/ui/screens/calendar-data.js.map +1 -0
  795. package/dist/ui/screens/content-insights-data.d.ts +54 -0
  796. package/dist/ui/screens/content-insights-data.d.ts.map +1 -0
  797. package/dist/ui/screens/content-insights-data.js +82 -0
  798. package/dist/ui/screens/content-insights-data.js.map +1 -0
  799. package/dist/ui/screens/content-view.d.ts +21 -0
  800. package/dist/ui/screens/content-view.d.ts.map +1 -0
  801. package/dist/ui/screens/content-view.js +17 -0
  802. package/dist/ui/screens/content-view.js.map +1 -0
  803. package/dist/ui/screens/library-data.d.ts +76 -0
  804. package/dist/ui/screens/library-data.d.ts.map +1 -0
  805. package/dist/ui/screens/library-data.js +116 -0
  806. package/dist/ui/screens/library-data.js.map +1 -0
  807. package/dist/ui/screens/media-upload.d.ts +19 -0
  808. package/dist/ui/screens/media-upload.d.ts.map +1 -0
  809. package/dist/ui/screens/media-upload.js +187 -0
  810. package/dist/ui/screens/media-upload.js.map +1 -0
  811. package/dist/ui/screens/public-url.d.ts +24 -0
  812. package/dist/ui/screens/public-url.d.ts.map +1 -0
  813. package/dist/ui/screens/public-url.js +74 -0
  814. package/dist/ui/screens/public-url.js.map +1 -0
  815. package/dist/ui/screens/registry.d.ts +3 -0
  816. package/dist/ui/screens/registry.d.ts.map +1 -0
  817. package/dist/ui/screens/registry.js +38 -0
  818. package/dist/ui/screens/registry.js.map +1 -0
  819. package/dist/ui/screens/render-state.d.ts +105 -0
  820. package/dist/ui/screens/render-state.d.ts.map +1 -0
  821. package/dist/ui/screens/render-state.js +127 -0
  822. package/dist/ui/screens/render-state.js.map +1 -0
  823. package/dist/ui/screens/settings-data.d.ts +55 -0
  824. package/dist/ui/screens/settings-data.d.ts.map +1 -0
  825. package/dist/ui/screens/settings-data.js +89 -0
  826. package/dist/ui/screens/settings-data.js.map +1 -0
  827. package/dist/ui/screens/settings-panels-data.d.ts +58 -0
  828. package/dist/ui/screens/settings-panels-data.d.ts.map +1 -0
  829. package/dist/ui/screens/settings-panels-data.js +88 -0
  830. package/dist/ui/screens/settings-panels-data.js.map +1 -0
  831. package/dist/ui/screens/social-share-data.d.ts +307 -0
  832. package/dist/ui/screens/social-share-data.d.ts.map +1 -0
  833. package/dist/ui/screens/social-share-data.js +447 -0
  834. package/dist/ui/screens/social-share-data.js.map +1 -0
  835. package/dist/ui/screens/topics-data.d.ts +38 -0
  836. package/dist/ui/screens/topics-data.d.ts.map +1 -0
  837. package/dist/ui/screens/topics-data.js +50 -0
  838. package/dist/ui/screens/topics-data.js.map +1 -0
  839. package/dist/ui/styles/broadsheet.css +394 -0
  840. package/dist/ui/theme.d.ts +23 -0
  841. package/dist/ui/theme.d.ts.map +1 -0
  842. package/dist/ui/theme.js +87 -0
  843. package/dist/ui/theme.js.map +1 -0
  844. package/dist/ui/tweaks.d.ts +7 -0
  845. package/dist/ui/tweaks.d.ts.map +1 -0
  846. package/dist/ui/tweaks.js +31 -0
  847. package/dist/ui/tweaks.js.map +1 -0
  848. package/dist/ui/use-hash-router.d.ts +6 -0
  849. package/dist/ui/use-hash-router.d.ts.map +1 -0
  850. package/dist/ui/use-hash-router.js +15 -0
  851. package/dist/ui/use-hash-router.js.map +1 -0
  852. package/dist/ui/use-tweaks.d.ts +6 -0
  853. package/dist/ui/use-tweaks.d.ts.map +1 -0
  854. package/dist/ui/use-tweaks.js +24 -0
  855. package/dist/ui/use-tweaks.js.map +1 -0
  856. package/dist/ui/workspace-context.d.ts +34 -0
  857. package/dist/ui/workspace-context.d.ts.map +1 -0
  858. package/dist/ui/workspace-context.js +32 -0
  859. package/dist/ui/workspace-context.js.map +1 -0
  860. package/migrations/0001_create_cms_tables.sql +200 -0
  861. package/migrations/0002_review_softdelete_board.sql +112 -0
  862. package/migrations/0003_content_contributors.sql +16 -0
  863. package/migrations/0004_seo_faq_columns.sql +6 -0
  864. package/migrations/0005_revision_delta_columns.sql +5 -0
  865. package/migrations/0006_processing_trigger_token.sql +4 -0
  866. package/migrations/0007_content_slug_redirects.sql +9 -0
  867. package/migrations/0008_workspace_settings.sql +17 -0
  868. package/migrations/0009_workspace_memberships.sql +36 -0
  869. package/migrations/0010_api_keys_webhooks.sql +21 -0
  870. package/migrations/0011_notifications_activity.sql +22 -0
  871. package/migrations/0012_content_imports.sql +10 -0
  872. package/migrations/0013_media_normalization.sql +4 -0
  873. package/migrations/0014_api_key_prefix.sql +3 -0
  874. package/migrations/0015_cms_topics.sql +7 -0
  875. package/migrations/0016_content_insights.sql +53 -0
  876. package/package.json +82 -0
  877. package/src/engine/activity-log.ts +39 -0
  878. package/src/engine/ai-prompts.ts +124 -0
  879. package/src/engine/ai-writeback.ts +62 -0
  880. package/src/engine/api-keys.ts +239 -0
  881. package/src/engine/content-insights.ts +198 -0
  882. package/src/engine/contributors.ts +95 -0
  883. package/src/engine/cron.ts +62 -0
  884. package/src/engine/d1.ts +29 -0
  885. package/src/engine/foundry-dispatch.ts +417 -0
  886. package/src/engine/import-parsers.ts +478 -0
  887. package/src/engine/index.ts +230 -0
  888. package/src/engine/invites.ts +271 -0
  889. package/src/engine/members.ts +216 -0
  890. package/src/engine/membership-rules.ts +63 -0
  891. package/src/engine/og-render.ts +59 -0
  892. package/src/engine/publish-guard.ts +123 -0
  893. package/src/engine/publisher.ts +1032 -0
  894. package/src/engine/revisions.ts +292 -0
  895. package/src/engine/sanitize.ts +183 -0
  896. package/src/engine/seed-membership.ts +92 -0
  897. package/src/engine/seo.ts +72 -0
  898. package/src/engine/slug-redirects.ts +34 -0
  899. package/src/engine/slug.ts +33 -0
  900. package/src/engine/soft-delete.ts +42 -0
  901. package/src/engine/tags.ts +95 -0
  902. package/src/engine/topics.ts +158 -0
  903. package/src/engine/url-guard.ts +136 -0
  904. package/src/engine/validator/checks/bare-url-not-autolinked.ts +78 -0
  905. package/src/engine/validator/checks/broken-footnote-label.ts +33 -0
  906. package/src/engine/validator/checks/double-encoded-entities.ts +46 -0
  907. package/src/engine/validator/checks/empty-alt-text.ts +35 -0
  908. package/src/engine/validator/checks/heading-hierarchy-skip.ts +33 -0
  909. package/src/engine/validator/checks/html-comment-leak.ts +58 -0
  910. package/src/engine/validator/checks/iframe-missing-dims-and-wrapper.ts +34 -0
  911. package/src/engine/validator/checks/invisible-control-chars.ts +58 -0
  912. package/src/engine/validator/checks/paywall-marker-leak.ts +43 -0
  913. package/src/engine/validator/checks/raw-block-html.ts +65 -0
  914. package/src/engine/validator/checks/stale-body-html.ts +39 -0
  915. package/src/engine/validator/checks/unresolved-footnote-anchor.ts +61 -0
  916. package/src/engine/validator/checks/word-gdocs-paste-artifacts.ts +72 -0
  917. package/src/engine/validator/index.ts +385 -0
  918. package/src/engine/validator/scan.ts +103 -0
  919. package/src/engine/validator/types.ts +114 -0
  920. package/src/engine/webhook-signer.ts +139 -0
  921. package/src/engine/webhooks.ts +224 -0
  922. package/src/index.ts +79 -0
  923. package/src/integration/index.ts +298 -0
  924. package/src/integration/options.ts +30 -0
  925. package/src/integration/vite-plugin.ts +37 -0
  926. package/src/providers/index.ts +2 -0
  927. package/src/providers/null.ts +160 -0
  928. package/src/providers/types.ts +284 -0
  929. package/src/routes/ai.ts +461 -0
  930. package/src/routes/analytics.ts +78 -0
  931. package/src/routes/api-keys.ts +133 -0
  932. package/src/routes/authors.ts +282 -0
  933. package/src/routes/authz-matrix.ts +239 -0
  934. package/src/routes/calendar.ts +127 -0
  935. package/src/routes/config.ts +99 -0
  936. package/src/routes/content-insights.ts +159 -0
  937. package/src/routes/content.ts +1753 -0
  938. package/src/routes/context.ts +146 -0
  939. package/src/routes/cron.ts +27 -0
  940. package/src/routes/dashboard.ts +174 -0
  941. package/src/routes/imports.ts +190 -0
  942. package/src/routes/index.ts +295 -0
  943. package/src/routes/media-lib.ts +405 -0
  944. package/src/routes/media.ts +944 -0
  945. package/src/routes/preview.ts +182 -0
  946. package/src/routes/rbac-invites.ts +220 -0
  947. package/src/routes/rbac.ts +155 -0
  948. package/src/routes/shell.ts +163 -0
  949. package/src/routes/subscriptions.ts +167 -0
  950. package/src/routes/tags.ts +93 -0
  951. package/src/routes/topics.ts +58 -0
  952. package/src/routes/webhooks.ts +233 -0
  953. package/src/schema/index.ts +45 -0
  954. package/src/schema/insights-ingest.ts +126 -0
  955. package/src/schema/migrations.ts +599 -0
  956. package/src/schema/tables.ts +59 -0
  957. package/src/schema/types.ts +576 -0
  958. package/src/ui/api/_authz.ts +100 -0
  959. package/src/ui/api/_content-config.ts +75 -0
  960. package/src/ui/api/activity.ts +33 -0
  961. package/src/ui/api/analytics.ts +42 -0
  962. package/src/ui/api/authors/[id].ts +23 -0
  963. package/src/ui/api/authors.ts +19 -0
  964. package/src/ui/api/calendar.ts +21 -0
  965. package/src/ui/api/content/[id]/ai/headlines.ts +10 -0
  966. package/src/ui/api/content/[id]/ai/meta-description.ts +11 -0
  967. package/src/ui/api/content/[id]/ai/og-image.ts +10 -0
  968. package/src/ui/api/content/[id]/ai/proofread.ts +10 -0
  969. package/src/ui/api/content/[id]/ai/takeaways.ts +11 -0
  970. package/src/ui/api/content/[id]/contributors.ts +13 -0
  971. package/src/ui/api/content/[id]/preview-token.ts +21 -0
  972. package/src/ui/api/content/[id]/revisions/[rev]/restore.ts +12 -0
  973. package/src/ui/api/content/[id]/revisions/[rev].ts +11 -0
  974. package/src/ui/api/content/[id]/revisions.ts +9 -0
  975. package/src/ui/api/content/[id]/seo-score.ts +10 -0
  976. package/src/ui/api/content/[id].ts +23 -0
  977. package/src/ui/api/content/bulk.ts +10 -0
  978. package/src/ui/api/content/counts.ts +11 -0
  979. package/src/ui/api/content/foundry-callback.ts +11 -0
  980. package/src/ui/api/content/import/confirm.ts +16 -0
  981. package/src/ui/api/content/import/parse.ts +16 -0
  982. package/src/ui/api/content/insights-ingest.ts +24 -0
  983. package/src/ui/api/content-insights/dismiss.ts +23 -0
  984. package/src/ui/api/content-insights/index.ts +21 -0
  985. package/src/ui/api/content-insights/undismiss.ts +23 -0
  986. package/src/ui/api/content.ts +21 -0
  987. package/src/ui/api/dashboard.ts +28 -0
  988. package/src/ui/api/me.ts +23 -0
  989. package/src/ui/api/media/[id].ts +22 -0
  990. package/src/ui/api/media/images.ts +9 -0
  991. package/src/ui/api/media/library/[id].ts +9 -0
  992. package/src/ui/api/media/library.ts +22 -0
  993. package/src/ui/api/media/podcast/abort.ts +6 -0
  994. package/src/ui/api/media/podcast/complete.ts +6 -0
  995. package/src/ui/api/media/podcast/init.ts +6 -0
  996. package/src/ui/api/media/podcast/part.ts +6 -0
  997. package/src/ui/api/media/podcast.ts +9 -0
  998. package/src/ui/api/media/videos/abort.ts +9 -0
  999. package/src/ui/api/media/videos/complete.ts +9 -0
  1000. package/src/ui/api/media/videos/init.ts +9 -0
  1001. package/src/ui/api/media/videos/part.ts +9 -0
  1002. package/src/ui/api/notifications.ts +26 -0
  1003. package/src/ui/api/search.ts +23 -0
  1004. package/src/ui/api/settings/api-keys/[id].ts +28 -0
  1005. package/src/ui/api/settings/api-keys.ts +25 -0
  1006. package/src/ui/api/settings/domains.ts +37 -0
  1007. package/src/ui/api/settings/integrations.ts +40 -0
  1008. package/src/ui/api/settings/members/[userId].ts +33 -0
  1009. package/src/ui/api/settings/members/invite.ts +27 -0
  1010. package/src/ui/api/settings/members.ts +23 -0
  1011. package/src/ui/api/settings/webhooks/[id]/test.ts +30 -0
  1012. package/src/ui/api/settings/webhooks/[id].ts +29 -0
  1013. package/src/ui/api/settings/webhooks.ts +27 -0
  1014. package/src/ui/api/subscriptions.ts +56 -0
  1015. package/src/ui/api/tags/[id].ts +24 -0
  1016. package/src/ui/api/tags/index.ts +18 -0
  1017. package/src/ui/api/topics/[id].ts +20 -0
  1018. package/src/ui/api/topics/index.ts +17 -0
  1019. package/src/ui/api/workspace-settings.ts +26 -0
  1020. package/src/ui/client/boot-state.ts +42 -0
  1021. package/src/ui/client/mount.tsx +41 -0
  1022. package/src/ui/commands.ts +62 -0
  1023. package/src/ui/components/CmsApp.tsx +149 -0
  1024. package/src/ui/components/CommandPalette.tsx +118 -0
  1025. package/src/ui/components/NoAccessScreen.tsx +79 -0
  1026. package/src/ui/components/ShareModal.tsx +650 -0
  1027. package/src/ui/components/SharePickers.tsx +790 -0
  1028. package/src/ui/components/ShareStatsPanel.tsx +721 -0
  1029. package/src/ui/components/Sidebar.tsx +86 -0
  1030. package/src/ui/components/SiteSwitcher.tsx +100 -0
  1031. package/src/ui/components/Topbar.tsx +93 -0
  1032. package/src/ui/editor/AiAssistPanel.tsx +407 -0
  1033. package/src/ui/editor/ContentForm.tsx +1462 -0
  1034. package/src/ui/editor/Rte.tsx +382 -0
  1035. package/src/ui/editor/ai-assist.ts +139 -0
  1036. package/src/ui/editor/autosave.ts +36 -0
  1037. package/src/ui/editor/content-payload.ts +125 -0
  1038. package/src/ui/editor/editor-media-upload.ts +26 -0
  1039. package/src/ui/editor/serialize.ts +522 -0
  1040. package/src/ui/editor/tweet-embed.ts +60 -0
  1041. package/src/ui/hash-router.ts +30 -0
  1042. package/src/ui/icons.tsx +208 -0
  1043. package/src/ui/inspector/Field.tsx +30 -0
  1044. package/src/ui/inspector/FoundryTab.tsx +613 -0
  1045. package/src/ui/inspector/HistoryTab.tsx +482 -0
  1046. package/src/ui/inspector/Inspector.tsx +328 -0
  1047. package/src/ui/inspector/OrganizeTab.tsx +534 -0
  1048. package/src/ui/inspector/PublishTab.tsx +626 -0
  1049. package/src/ui/inspector/Section.tsx +81 -0
  1050. package/src/ui/inspector/SeoTab.tsx +573 -0
  1051. package/src/ui/inspector/foundry-stages.ts +140 -0
  1052. package/src/ui/inspector/inspector-data.ts +232 -0
  1053. package/src/ui/inspector/organize-data.ts +51 -0
  1054. package/src/ui/inspector/revision-diff.ts +213 -0
  1055. package/src/ui/inspector/seo-helpers.ts +71 -0
  1056. package/src/ui/inspector/tab-visibility.ts +37 -0
  1057. package/src/ui/nav.ts +48 -0
  1058. package/src/ui/pages/admin.astro +32 -0
  1059. package/src/ui/preview/draft-page.tsx +395 -0
  1060. package/src/ui/preview/preview-layout.ts +49 -0
  1061. package/src/ui/screens/AnalyticsScreen.tsx +938 -0
  1062. package/src/ui/screens/AuthorsScreen.tsx +524 -0
  1063. package/src/ui/screens/CalendarScreen.tsx +694 -0
  1064. package/src/ui/screens/ContentInsightsScreen.tsx +417 -0
  1065. package/src/ui/screens/ContentRoute.tsx +35 -0
  1066. package/src/ui/screens/DashboardScreen.tsx +654 -0
  1067. package/src/ui/screens/EditorScreen.tsx +673 -0
  1068. package/src/ui/screens/LibraryScreen.tsx +1350 -0
  1069. package/src/ui/screens/MediaScreen.tsx +357 -0
  1070. package/src/ui/screens/SettingsScreen.tsx +1841 -0
  1071. package/src/ui/screens/SocialShareScreen.tsx +670 -0
  1072. package/src/ui/screens/SubscriptionsScreen.tsx +1240 -0
  1073. package/src/ui/screens/TopicsScreen.tsx +912 -0
  1074. package/src/ui/screens/analytics-data.ts +68 -0
  1075. package/src/ui/screens/calendar-data.ts +126 -0
  1076. package/src/ui/screens/content-insights-data.ts +127 -0
  1077. package/src/ui/screens/content-view.ts +30 -0
  1078. package/src/ui/screens/library-data.ts +177 -0
  1079. package/src/ui/screens/media-upload.ts +283 -0
  1080. package/src/ui/screens/public-url.ts +81 -0
  1081. package/src/ui/screens/registry.tsx +53 -0
  1082. package/src/ui/screens/render-state.ts +228 -0
  1083. package/src/ui/screens/settings-data.ts +140 -0
  1084. package/src/ui/screens/settings-panels-data.ts +142 -0
  1085. package/src/ui/screens/social-share-data.ts +753 -0
  1086. package/src/ui/screens/topics-data.ts +75 -0
  1087. package/src/ui/styles/broadsheet.css +394 -0
  1088. package/src/ui/theme.ts +104 -0
  1089. package/src/ui/tweaks.ts +37 -0
  1090. package/src/ui/use-hash-router.ts +17 -0
  1091. package/src/ui/use-tweaks.ts +31 -0
  1092. package/src/ui/workspace-context.tsx +62 -0
  1093. package/src/virtual.d.ts +4 -0
@@ -0,0 +1,1753 @@
1
+ // Content route handlers (WS7-07). Ported from fulcrum-labs/fronts
2
+ // src/pages/api/v1/publisher/content/index.ts + [id].ts (inventory §3:
3
+ // extract). Calls the already-extracted engine functions (createContent,
4
+ // updateContentItem, scheduleContent, publishContent, unpublishContent,
5
+ // duplicateContentItem, createRevision, evaluateContentBodyForPublish).
6
+ //
7
+ // Two Fronts couplings are removed:
8
+ // - authz: injected via config.authz.requirePublisher (NOT context.locals).
9
+ // - the Foundry article-takeaways dispatch (lib/foundry/article-takeaways.ts)
10
+ // is bead-08 glue, NOT yet extracted into the engine. So instead of
11
+ // hard-importing it, the publish + regenerate-takeaways actions call an
12
+ // OPTIONAL injected `dispatchTakeaways` hook (config). A site that wires the
13
+ // Foundry dispatcher gets the exact Fronts behavior; a site that omits it
14
+ // simply publishes without queuing takeaways. This keeps the package free
15
+ // of the Foundry/Hermes media glue (which lands with bead 08 + OD-CMS7).
16
+
17
+ import { z } from 'zod'
18
+ import { logActivity } from '../engine/activity-log.js'
19
+ import { listContributors, setContributors } from '../engine/contributors.js'
20
+ import type { D1Database } from '../engine/d1.js'
21
+ import {
22
+ applyFoundryCallback,
23
+ type DispatchResult,
24
+ dispatchToFoundry,
25
+ type ProcessingState,
26
+ verifyHmac,
27
+ } from '../engine/foundry-dispatch.js'
28
+ import { evaluateArticleBody } from '../engine/publish-guard.js'
29
+ import {
30
+ createContent,
31
+ createRevision,
32
+ duplicateContentItem,
33
+ evaluateContentBodyForPublish,
34
+ getContentItem,
35
+ getContentRelations,
36
+ publishContent,
37
+ scheduleContent,
38
+ unpublishContent,
39
+ updateContentItem,
40
+ } from '../engine/publisher.js'
41
+ import {
42
+ createRevisionWithDelta,
43
+ getRevision,
44
+ listRevisions,
45
+ restoreRevision,
46
+ } from '../engine/revisions.js'
47
+ import { recordSlugRedirect } from '../engine/slug-redirects.js'
48
+ import { softDeleteContent } from '../engine/soft-delete.js'
49
+ import type { ContentStatus } from '../schema/types.js'
50
+ import { type ContentAction, canPerformContentAction } from './authz-matrix.js'
51
+ import type { CmsRouteConfig } from './config.js'
52
+ import { resolveConfig } from './config.js'
53
+ import { json, type RouteContext, requireId } from './context.js'
54
+ import type { DispatchWebhookHook } from './webhooks.js'
55
+
56
+ export type { DispatchWebhookHook }
57
+
58
+ /**
59
+ * Optional injected hook to dispatch article-takeaways generation to Foundry
60
+ * (Hermes). The package never imports the Foundry glue — the site supplies this
61
+ * (bead 08). `force` mirrors the Fronts dispatcher: skip when takeaways already
62
+ * exist unless forced.
63
+ */
64
+ export type DispatchTakeawaysHook = (input: {
65
+ ctx: RouteContext
66
+ contentId: string
67
+ force: boolean
68
+ }) => Promise<{ queued: boolean; correlationId?: string | null; reason?: string }>
69
+
70
+ /**
71
+ * Optional injected hook to dispatch article-FAQ generation (P4 Task 7).
72
+ * Same shape as `DispatchTakeawaysHook` — the site supplies the impl; the
73
+ * package defines the hook contract + calls it in the publish action.
74
+ */
75
+ export type DispatchFaqHook = (input: {
76
+ ctx: RouteContext
77
+ contentId: string
78
+ force: boolean
79
+ }) => Promise<{ queued: boolean; correlationId?: string | null; reason?: string }>
80
+
81
+ export interface PublishReadinessOutcome {
82
+ ready: boolean
83
+ state?: string | null
84
+ blockers?: string[]
85
+ message?: string
86
+ etaLabel?: string
87
+ checkedAt?: number
88
+ status?: number
89
+ }
90
+
91
+ /**
92
+ * Optional injected publish-readiness hook. Hosts use this to enforce
93
+ * site-specific prerequisites (for example video HLS/transcript/captions
94
+ * readiness) before the generic CMS lifecycle publishes an item.
95
+ */
96
+ export type PublishReadinessHook = (input: {
97
+ ctx: RouteContext
98
+ contentId: string
99
+ type: ContentType
100
+ status: string
101
+ }) => Promise<PublishReadinessOutcome>
102
+
103
+ export interface ContentRouteConfig extends CmsRouteConfig {
104
+ /** Optional Foundry takeaways dispatcher (bead 08). Omit to disable. */
105
+ dispatchTakeaways?: DispatchTakeawaysHook
106
+ /** Optional FAQ enrichment dispatcher (P4 Task 7). Omit to disable. */
107
+ dispatchFaq?: DispatchFaqHook
108
+ /** Optional host-specific readiness guard. A not-ready result blocks publish. */
109
+ publishReadiness?: PublishReadinessHook
110
+ /**
111
+ * Optional webhook dispatcher hook (P7 Task 9). Fired at each content
112
+ * lifecycle transition (publish, unpublish, schedule, archive, create, etc.).
113
+ * A missing hook is silently skipped — the lifecycle action still succeeds
114
+ * (mirrors the dispatchTakeaways pattern). The hook reads the HMAC secret
115
+ * from ctx.env (Secrets Store binding) — never from D1.
116
+ */
117
+ dispatchWebhook?: DispatchWebhookHook
118
+ /**
119
+ * Active workspace id — used to scope the activity log write on publish.
120
+ * Matches the ShellRouteConfig.activeWorkspaceId seam. Defaults to 'default'
121
+ * when absent so callers without a workspace concept still produce a scoped row.
122
+ */
123
+ activeWorkspaceId?: string
124
+ }
125
+
126
+ const PUBLISH_TIMEZONE_FALLBACK = 'Europe/Paris'
127
+ const LOCAL_DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/
128
+
129
+ type ContentType = 'article' | 'video' | 'podcast' | 'newsletter'
130
+
131
+ /**
132
+ * Map a content `action` verb (the ?action= query value) to the capability the
133
+ * 5-role matrix gates it on. Lifecycle/destructive verbs require publish-level
134
+ * authority; status/duplicate/snapshot edits are writes; media-pipeline dispatch
135
+ * verbs require media_pipeline. publish on a video/podcast resolves to
136
+ * publish_video so the producer role can ship video.
137
+ */
138
+ function mapActionToCapability(action: string | null, type: ContentType): ContentAction {
139
+ switch (action) {
140
+ case 'publish':
141
+ return type === 'video' || type === 'podcast' ? 'publish_video' : 'publish'
142
+ case 'unpublish':
143
+ case 'archive':
144
+ case 'unschedule':
145
+ return 'publish'
146
+ case 'schedule':
147
+ return 'schedule'
148
+ case 'trash':
149
+ return 'trash'
150
+ case 'dispatch-to-foundry':
151
+ case 'regenerate-takeaways':
152
+ case 'regenerate-faq':
153
+ return 'media'
154
+ default:
155
+ // duplicate / set-status / snapshot and any unknown verb → write-level.
156
+ return 'write'
157
+ }
158
+ }
159
+
160
+ const parseTypes = (value: string | null): ContentType[] | undefined => {
161
+ if (!value) return undefined
162
+ const allowed: ContentType[] = ['article', 'video', 'podcast', 'newsletter']
163
+ const parts = value
164
+ .split(',')
165
+ .map((item) => item.trim())
166
+ .filter(Boolean) as ContentType[]
167
+ const filtered = parts.filter((part) => allowed.includes(part))
168
+ return filtered.length > 0 ? filtered : undefined
169
+ }
170
+
171
+ const parseStatus = (value: string | null): ContentStatus | undefined => {
172
+ if (!value) return undefined
173
+ const allowed: ContentStatus[] = ['draft', 'scheduled', 'review', 'published', 'archived']
174
+ return allowed.includes(value as ContentStatus) ? (value as ContentStatus) : undefined
175
+ }
176
+
177
+ const parseCursor = (
178
+ cursor: string | null | undefined,
179
+ ): { updatedAt: number; id: string } | null => {
180
+ if (!cursor) return null
181
+ const [timestamp, id] = cursor.split(':')
182
+ const updatedAt = Number(timestamp)
183
+ if (!Number.isFinite(updatedAt) || !id) return null
184
+ return { updatedAt, id }
185
+ }
186
+
187
+ function normalizeUnixSeconds(value: unknown): number | null {
188
+ if (value === null || value === undefined || value === '') return null
189
+ if (typeof value === 'number') {
190
+ if (!Number.isFinite(value)) return null
191
+ return value > 1e11 ? Math.floor(value / 1000) : value
192
+ }
193
+ if (typeof value !== 'string') return null
194
+ const trimmed = value.trim()
195
+ if (!trimmed) return null
196
+ const numeric = Number(trimmed)
197
+ if (Number.isFinite(numeric)) return numeric > 1e11 ? Math.floor(numeric / 1000) : numeric
198
+ const parsedMs = Date.parse(trimmed)
199
+ if (!Number.isFinite(parsedMs)) return null
200
+ return Math.floor(parsedMs / 1000)
201
+ }
202
+
203
+ function unixSecondsToIso(value: unknown): string | null {
204
+ const seconds = normalizeUnixSeconds(value)
205
+ if (seconds === null) return null
206
+ const date = new Date(seconds * 1000)
207
+ return Number.isFinite(date.getTime()) ? date.toISOString() : null
208
+ }
209
+
210
+ // A category/topic enum becomes a site-supplied list; default to any string.
211
+ function slugEnum(values: readonly string[] | null) {
212
+ return values && values.length > 0
213
+ ? z.enum([values[0], ...values.slice(1)] as [string, ...string[]])
214
+ : z.string().min(1)
215
+ }
216
+
217
+ function buildSchemas(resolved: ReturnType<typeof resolveConfig>) {
218
+ const category = slugEnum(resolved.primaryCategorySlugs)
219
+ const topic = slugEnum(resolved.primaryTopicSlugs)
220
+
221
+ const BaseSchema = z.object({
222
+ slug: z.string().min(3),
223
+ title: z.string().min(3),
224
+ seoTitle: z.string().optional().nullable(),
225
+ description: z.string().optional().nullable(),
226
+ excerpt: z.string().optional().nullable(),
227
+ byline: z.string().optional().nullable(),
228
+ primaryCategory: category.default(resolved.defaultPrimaryCategory),
229
+ primaryTopic: topic.default(resolved.defaultPrimaryTopic),
230
+ featured: z.boolean().optional(),
231
+ visibility: z.enum(['free', 'premium']).optional(),
232
+ authorId: z.string().optional().nullable(),
233
+ heroImageId: z.string().optional().nullable(),
234
+ heroImageAlt: z.string().optional().nullable(),
235
+ heroImageCaption: z.string().optional().nullable(),
236
+ socialImageId: z.string().optional().nullable(),
237
+ canonicalUrl: z.string().url().optional().nullable(),
238
+ tags: z.array(z.string()).optional(),
239
+ })
240
+
241
+ const CreateSchema = z.discriminatedUnion('type', [
242
+ BaseSchema.extend({
243
+ type: z.literal('article'),
244
+ content: z.object({
245
+ bodyMarkdown: z.string().min(1),
246
+ bodyHtml: z.string().optional().nullable(),
247
+ subtitle: z.string().optional().nullable(),
248
+ wordCount: z.number().int().optional().nullable(),
249
+ readTimeMinutes: z.number().int().optional().nullable(),
250
+ editorTakeaways: z.array(z.string().min(1)).max(4).optional().nullable(),
251
+ }),
252
+ }),
253
+ BaseSchema.extend({
254
+ type: z.literal('video'),
255
+ content: z.object({
256
+ script: z.string().default(''),
257
+ videoId: z.string().min(1),
258
+ durationSeconds: z.number().int().optional().nullable(),
259
+ thumbnailImageId: z.string().optional().nullable(),
260
+ processingSourceUrl: z.string().url().optional().nullable(),
261
+ processingSourceKind: z.string().min(1).optional().nullable(),
262
+ }),
263
+ }),
264
+ BaseSchema.extend({
265
+ type: z.literal('podcast'),
266
+ content: z.object({
267
+ // Empty allowed: a podcast is created as a draft with audio uploaded
268
+ // but no transcript yet — the transcription pipeline fills it async
269
+ // (Foundry callback hydrates podcast_content.transcript later). Mirrors
270
+ // the video `script` default-empty pattern above.
271
+ transcript: z.string().default(''),
272
+ audioR2Key: z.string().min(1),
273
+ durationSeconds: z.number().int().optional().nullable(),
274
+ }),
275
+ }),
276
+ BaseSchema.extend({
277
+ type: z.literal('newsletter'),
278
+ content: z.object({
279
+ bodyMarkdown: z.string().min(1),
280
+ bodyHtml: z.string().optional().nullable(),
281
+ subtitle: z.string().optional().nullable(),
282
+ wordCount: z.number().int().optional().nullable(),
283
+ readTimeMinutes: z.number().int().optional().nullable(),
284
+ editorTakeaways: z.array(z.string().min(1)).max(4).optional().nullable(),
285
+ }),
286
+ }),
287
+ ])
288
+
289
+ const RelationSchema = z.object({
290
+ relatedId: z.string().min(1),
291
+ rank: z.number().int().min(0),
292
+ reason: z.string().optional().nullable(),
293
+ })
294
+
295
+ const UpdateSchema = z.object({
296
+ slug: z.string().min(3).optional(),
297
+ title: z.string().min(3).optional(),
298
+ seoTitle: z.string().optional().nullable(),
299
+ description: z.string().optional().nullable(),
300
+ excerpt: z.string().optional().nullable(),
301
+ byline: z.string().optional().nullable(),
302
+ primaryCategory: category.optional().nullable(),
303
+ primaryTopic: topic.optional().nullable(),
304
+ featured: z.boolean().optional(),
305
+ visibility: z.enum(['free', 'premium']).optional(),
306
+ authorId: z.string().optional().nullable(),
307
+ heroImageId: z.string().optional().nullable(),
308
+ heroImageAlt: z.string().optional().nullable(),
309
+ heroImageCaption: z.string().optional().nullable(),
310
+ socialImageId: z.string().optional().nullable(),
311
+ canonicalUrl: z.string().url().optional().nullable(),
312
+ tags: z.array(z.string()).optional(),
313
+ content: z.record(z.string(), z.any()).optional(),
314
+ relations: z.array(RelationSchema).optional(),
315
+ aiLockedFields: z.array(z.string()).optional(),
316
+ /** SEO focus keyword (P3 SEO tab — migration 0004 column). */
317
+ seoFocusKeyword: z.string().optional().nullable(),
318
+ })
319
+
320
+ return { CreateSchema, UpdateSchema }
321
+ }
322
+
323
+ const ArticleContentUpdateSchema = z.object({
324
+ bodyMarkdown: z.string().optional(),
325
+ bodyHtml: z.string().optional().nullable(),
326
+ subtitle: z.string().optional().nullable(),
327
+ wordCount: z.number().int().optional().nullable(),
328
+ readTimeMinutes: z.number().int().optional().nullable(),
329
+ editorTakeaways: z.array(z.string().min(1)).max(4).optional().nullable(),
330
+ })
331
+
332
+ const VideoContentUpdateSchema = z.object({
333
+ script: z.string().optional(),
334
+ videoId: z.string().optional(),
335
+ durationSeconds: z.number().int().optional().nullable(),
336
+ thumbnailImageId: z.string().optional().nullable(),
337
+ processingSourceUrl: z.string().url().optional().nullable(),
338
+ processingSourceKind: z.string().min(1).optional().nullable(),
339
+ })
340
+
341
+ const PodcastContentUpdateSchema = z.object({
342
+ transcript: z.string().optional(),
343
+ audioR2Key: z.string().optional(),
344
+ durationSeconds: z.number().int().optional().nullable(),
345
+ })
346
+
347
+ function getContentUpdateSchema(type: string) {
348
+ switch (type) {
349
+ case 'article':
350
+ case 'newsletter':
351
+ return ArticleContentUpdateSchema
352
+ case 'video':
353
+ return VideoContentUpdateSchema
354
+ case 'podcast':
355
+ return PodcastContentUpdateSchema
356
+ default:
357
+ return null
358
+ }
359
+ }
360
+
361
+ const ScheduleSchema = z
362
+ .object({
363
+ publishAt: z.string().min(1).optional().nullable(),
364
+ publishAtLocal: z.string().min(1).optional().nullable(),
365
+ timezone: z.string().optional().nullable(),
366
+ })
367
+ .refine((value) => Boolean(value.publishAt || value.publishAtLocal), {
368
+ message: 'publishAt or publishAtLocal is required',
369
+ path: ['publishAt'],
370
+ })
371
+
372
+ // Bulk operations schema — the Library bulk-action bar sends one of these.
373
+ // publish/schedule/unpublish use their own guarded action routes; bulk schedule
374
+ // is a convenience wrapper only (no body-validation gate — use the per-item
375
+ // action for pre-publish lint).
376
+ const BulkSchema = z.discriminatedUnion('op', [
377
+ z.object({ op: z.literal('trash'), ids: z.array(z.string().min(1)).min(1) }),
378
+ z.object({
379
+ op: z.literal('topic'),
380
+ ids: z.array(z.string().min(1)).min(1),
381
+ topic: z.string().min(1),
382
+ }),
383
+ z.object({
384
+ op: z.literal('schedule'),
385
+ ids: z.array(z.string().min(1)).min(1),
386
+ publishAt: z.string().min(1),
387
+ }),
388
+ ])
389
+
390
+ // set-status only allows the safe non-publish transitions.
391
+ // publish/schedule keep their dedicated guarded action routes.
392
+ const SetStatusSchema = z.object({
393
+ status: z.enum(['draft', 'review']),
394
+ })
395
+
396
+ // Contributors PUT body schema (P3 Task 3).
397
+ const ContributorItemSchema = z.object({
398
+ authorId: z.string().min(1),
399
+ role: z.string().optional().nullable(),
400
+ position: z.number().int().min(0).default(0),
401
+ })
402
+ const ContributorsReplaceSchema = z.object({
403
+ contributors: z.array(ContributorItemSchema),
404
+ })
405
+
406
+ // --- timezone-aware local-datetime parsing (ported verbatim) --------------
407
+
408
+ type LocalDateTimeParts = { year: number; month: number; day: number; hour: number; minute: number }
409
+
410
+ function parseLocalDateTime(value: string): LocalDateTimeParts | null {
411
+ const match = LOCAL_DATE_TIME_RE.exec(value)
412
+ if (!match) return null
413
+ const [, y, m, d, h, min] = match
414
+ const parts: LocalDateTimeParts = {
415
+ year: Number.parseInt(y, 10),
416
+ month: Number.parseInt(m, 10),
417
+ day: Number.parseInt(d, 10),
418
+ hour: Number.parseInt(h, 10),
419
+ minute: Number.parseInt(min, 10),
420
+ }
421
+ if (
422
+ !Number.isInteger(parts.year) ||
423
+ parts.month < 1 ||
424
+ parts.month > 12 ||
425
+ parts.day < 1 ||
426
+ parts.day > 31 ||
427
+ parts.hour < 0 ||
428
+ parts.hour > 23 ||
429
+ parts.minute < 0 ||
430
+ parts.minute > 59
431
+ ) {
432
+ return null
433
+ }
434
+ const dateCheck = new Date(Date.UTC(parts.year, parts.month - 1, parts.day))
435
+ if (
436
+ dateCheck.getUTCFullYear() !== parts.year ||
437
+ dateCheck.getUTCMonth() + 1 !== parts.month ||
438
+ dateCheck.getUTCDate() !== parts.day
439
+ ) {
440
+ return null
441
+ }
442
+ return parts
443
+ }
444
+
445
+ function getZonedParts(instantMs: number, timeZone: string): LocalDateTimeParts | null {
446
+ const formatter = new Intl.DateTimeFormat('en-US', {
447
+ timeZone,
448
+ hour12: false,
449
+ year: 'numeric',
450
+ month: '2-digit',
451
+ day: '2-digit',
452
+ hour: '2-digit',
453
+ minute: '2-digit',
454
+ second: '2-digit',
455
+ })
456
+ const values = { year: '', month: '', day: '', hour: '', minute: '', second: '' }
457
+ for (const part of formatter.formatToParts(new Date(instantMs))) {
458
+ if (part.type in values) values[part.type as keyof typeof values] = part.value
459
+ }
460
+ const year = Number.parseInt(values.year, 10)
461
+ const month = Number.parseInt(values.month, 10)
462
+ const day = Number.parseInt(values.day, 10)
463
+ const hour = Number.parseInt(values.hour, 10)
464
+ const minute = Number.parseInt(values.minute, 10)
465
+ if (![year, month, day, hour, minute].every(Number.isFinite)) return null
466
+ return { year, month, day, hour, minute }
467
+ }
468
+
469
+ function isValidTimeZone(timeZone: string): boolean {
470
+ try {
471
+ new Intl.DateTimeFormat('en-US', { timeZone }).format(new Date())
472
+ return true
473
+ } catch {
474
+ return false
475
+ }
476
+ }
477
+
478
+ function getTimeZoneOffsetMs(instantMs: number, timeZone: string): number | null {
479
+ const zoned = getZonedParts(instantMs, timeZone)
480
+ if (!zoned) return null
481
+ const asUtc = Date.UTC(zoned.year, zoned.month - 1, zoned.day, zoned.hour, zoned.minute, 0)
482
+ return asUtc - instantMs
483
+ }
484
+
485
+ function parseLocalDateTimeInZoneToUnixSeconds(value: string, timeZone: string): number | null {
486
+ const target = parseLocalDateTime(value)
487
+ if (!target) return null
488
+ const targetUtcMs = Date.UTC(
489
+ target.year,
490
+ target.month - 1,
491
+ target.day,
492
+ target.hour,
493
+ target.minute,
494
+ 0,
495
+ )
496
+ let candidateMs = targetUtcMs
497
+ for (let i = 0; i < 5; i += 1) {
498
+ const offsetMs = getTimeZoneOffsetMs(candidateMs, timeZone)
499
+ if (offsetMs === null) return null
500
+ const next = targetUtcMs - offsetMs
501
+ if (Math.abs(next - candidateMs) < 1000) {
502
+ candidateMs = next
503
+ break
504
+ }
505
+ candidateMs = next
506
+ }
507
+ const resolved = getZonedParts(candidateMs, timeZone)
508
+ if (
509
+ !resolved ||
510
+ resolved.year !== target.year ||
511
+ resolved.month !== target.month ||
512
+ resolved.day !== target.day ||
513
+ resolved.hour !== target.hour ||
514
+ resolved.minute !== target.minute
515
+ ) {
516
+ return null
517
+ }
518
+ return Math.floor(candidateMs / 1000)
519
+ }
520
+
521
+ // --- read-side payload helpers (ported verbatim) --------------------------
522
+
523
+ async function getContentPayload(ctx: RouteContext, type: string, id: string) {
524
+ if (type === 'article' || type === 'newsletter') {
525
+ return ctx.db
526
+ .prepare(
527
+ `SELECT body_markdown, body_html, word_count, read_time_minutes, subtitle,
528
+ ai_takeaways, ai_takeaways_at, ai_takeaways_model, editor_takeaways,
529
+ ai_takeaways_correlation_id
530
+ FROM article_content WHERE content_id = ? LIMIT 1`,
531
+ )
532
+ .bind(id)
533
+ .first()
534
+ }
535
+ if (type === 'video') {
536
+ return ctx.db
537
+ .prepare(
538
+ `SELECT script, video_id, duration_seconds, thumbnail_image_id, processing_state,
539
+ processing_correlation_id, processing_started_at, processing_completed_at,
540
+ processing_last_event_at, processing_error, processing_source_url,
541
+ processing_source_kind, source_fetched, transcript_ready, hls_ready,
542
+ transcript_url, hls_manifest_url, hls_poster_url
543
+ FROM video_content WHERE content_id = ? LIMIT 1`,
544
+ )
545
+ .bind(id)
546
+ .first()
547
+ }
548
+ if (type === 'podcast') {
549
+ return ctx.db
550
+ .prepare(
551
+ 'SELECT transcript, audio_r2_key, duration_seconds FROM podcast_content WHERE content_id = ? LIMIT 1',
552
+ )
553
+ .bind(id)
554
+ .first()
555
+ }
556
+ return null
557
+ }
558
+
559
+ async function getContentTags(ctx: RouteContext, id: string): Promise<string[]> {
560
+ const result = await ctx.db
561
+ .prepare(
562
+ 'SELECT t.slug FROM content_tag_links l JOIN content_tags t ON t.id = l.tag_id WHERE l.content_id = ? ORDER BY t.label ASC',
563
+ )
564
+ .bind(id)
565
+ .all<{ slug: string }>()
566
+ return (result.results || []).map((row) => row.slug)
567
+ }
568
+
569
+ async function getMediaAssetUrls(db: D1Database, ids: string[]): Promise<Record<string, string>> {
570
+ const uniqueIds = Array.from(new Set(ids.filter(Boolean)))
571
+ if (uniqueIds.length === 0) return {}
572
+ const placeholders = uniqueIds.map(() => '?').join(',')
573
+ const result = await db
574
+ .prepare(`SELECT id, public_url, url FROM media_assets WHERE id IN (${placeholders})`)
575
+ .bind(...uniqueIds)
576
+ .all<{ id: string; public_url: string | null; url: string | null }>()
577
+
578
+ const urls: Record<string, string> = {}
579
+ for (const row of result.results ?? []) {
580
+ const url = row.public_url || row.url
581
+ if (url) urls[row.id] = url
582
+ }
583
+ return urls
584
+ }
585
+
586
+ async function readJson(ctx: RouteContext): Promise<unknown> {
587
+ return ctx.request.json().catch(() => null)
588
+ }
589
+
590
+ export interface ContentRouteHandlers {
591
+ /** GET /content — cursor-paginated list (q, author, status, types filters; excludes deleted). */
592
+ list(ctx: RouteContext): Promise<Response>
593
+ /** GET /content/counts — status-tab counts for the Library (excludes deleted; includes derived processing). */
594
+ counts(ctx: RouteContext): Promise<Response>
595
+ /** POST /content/bulk — bulk ops: trash | topic | schedule. */
596
+ bulk(ctx: RouteContext): Promise<Response>
597
+ /** POST /content — create. */
598
+ create(ctx: RouteContext): Promise<Response>
599
+ /** GET /content/:id — read (item + content + tags + relations). */
600
+ get(ctx: RouteContext): Promise<Response>
601
+ /** PATCH /content/:id — update (with pre-publish body lint on article bodies). */
602
+ update(ctx: RouteContext): Promise<Response>
603
+ /**
604
+ * POST /content/:id?action=... — lifecycle actions: schedule | publish |
605
+ * regenerate-takeaways | unpublish | archive | unschedule | duplicate |
606
+ * set-status (draft|review only) | snapshot (autosave revision).
607
+ */
608
+ action(ctx: RouteContext): Promise<Response>
609
+ /**
610
+ * POST /content/:id/seo-score — real-time draft SEO score (P3 SEO tab).
611
+ * Reads the live row + body, merges optional JSON body overrides
612
+ * ({ title?, seoTitle?, description?, focusKeyword?, bodyMarkdown? }),
613
+ * runs the injected SeoProvider and returns { score, checks }.
614
+ * Persists nothing (read-only scoring).
615
+ */
616
+ seoScore(ctx: RouteContext): Promise<Response>
617
+ /**
618
+ * GET /content/:id/contributors — list co-authors ordered by position, joined
619
+ * to author name + avatar. Returns { contributors: [...] }.
620
+ */
621
+ contributorsList(ctx: RouteContext): Promise<Response>
622
+ /**
623
+ * PUT /content/:id/contributors — fully replace the contributor list.
624
+ * Body: { contributors: [{ authorId, role?, position }] }.
625
+ * Returns { contributors: [...] } (the new list after replace).
626
+ */
627
+ contributorsReplace(ctx: RouteContext): Promise<Response>
628
+ /**
629
+ * GET /content/:id/revisions — list all revisions newest-first.
630
+ * Returns { revisions: [{ id, createdAt, createdBy, wordsAdded, wordsRemoved, tag }] }.
631
+ * 404 when the content item does not exist.
632
+ */
633
+ revisionsList(ctx: RouteContext): Promise<Response>
634
+ /**
635
+ * GET /content/:id/revisions/:rev — get a single revision's summary + full payload.
636
+ * Returns { summary, payload }. 404 when not found.
637
+ */
638
+ revisionGet(ctx: RouteContext): Promise<Response>
639
+ /**
640
+ * POST /content/foundry-callback — machine-to-machine Foundry pipeline callback.
641
+ * Verifies the HMAC-SHA256 signature in `X-Foundry-Signature` against
642
+ * `ctx.env.FOUNDRY_HMAC_SECRET`, then advances the video_content processing
643
+ * state machine via `applyFoundryCallback`. Guarded by HMAC only (not
644
+ * `requirePublisher` — this is a machine callback, not a user action).
645
+ * Returns { ok: true } on success, { ok: false } on dedup/token mismatch,
646
+ * 401 on bad signature.
647
+ */
648
+ foundryCallback(ctx: RouteContext): Promise<Response>
649
+ /**
650
+ * POST /content/:id/revisions/:rev/restore — restore the content to a revision snapshot.
651
+ * Returns { restored: true }. 404 when the revision is not found.
652
+ * IMPORTANT: restore rolls back content fields but NEVER changes publication state
653
+ * (status / published_at / published_revision_id).
654
+ */
655
+ revisionRestore(ctx: RouteContext): Promise<Response>
656
+ }
657
+
658
+ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHandlers {
659
+ const resolved = resolveConfig(config)
660
+ const { CreateSchema, UpdateSchema } = buildSchemas(resolved)
661
+ const dispatchTakeaways = config.dispatchTakeaways
662
+ const dispatchFaq = config.dispatchFaq
663
+ const dispatchWebhook = config.dispatchWebhook
664
+ const publishReadiness = config.publishReadiness
665
+ const activeWorkspaceId = config.activeWorkspaceId ?? 'default'
666
+
667
+ async function maybeDispatchPodcastToFoundry(
668
+ ctx: RouteContext,
669
+ contentId: string,
670
+ ): Promise<DispatchResult | null> {
671
+ if (!resolved.hooks.foundryVideo) return null
672
+ const row = await ctx.db
673
+ .prepare(
674
+ `SELECT transcript, audio_r2_key, processing_trigger_token
675
+ FROM podcast_content
676
+ WHERE content_id = ?
677
+ LIMIT 1`,
678
+ )
679
+ .bind(contentId)
680
+ .first<{
681
+ transcript: string | null
682
+ audio_r2_key: string | null
683
+ processing_trigger_token: string | null
684
+ }>()
685
+ const audioR2Key = row?.audio_r2_key?.trim() ?? ''
686
+ if (!audioR2Key) return null
687
+ if (row?.processing_trigger_token?.trim()) return null
688
+ if (row?.transcript?.trim()) return null
689
+ return dispatchToFoundry(ctx.db, contentId, resolved.hooks.foundryVideo)
690
+ }
691
+
692
+ /**
693
+ * Fire the optional dispatchWebhook hook for a lifecycle transition.
694
+ *
695
+ * Wrapped in try/catch so a webhook dispatch failure NEVER fails the action
696
+ * — identical guard pattern to dispatchTakeaways / logActivity.
697
+ * When no hook is injected this is a no-op (no 501, no error).
698
+ */
699
+ async function fireWebhook(
700
+ ctx: RouteContext,
701
+ eventType: string,
702
+ data: Record<string, unknown>,
703
+ ): Promise<void> {
704
+ if (!dispatchWebhook) return
705
+ try {
706
+ await dispatchWebhook(ctx, {
707
+ eventType,
708
+ workspaceId: activeWorkspaceId,
709
+ data,
710
+ })
711
+ } catch (err) {
712
+ console.error(`[cms] dispatchWebhook(${eventType}) failed`, err)
713
+ }
714
+ }
715
+
716
+ /**
717
+ * Capability gate for a content mutation. Enforces the 5-role matrix WITH the
718
+ * contributor/producer ownership predicate (canPerformContentAction).
719
+ *
720
+ * When the host wires the role seam (authz.getUser), this resolves the actor's
721
+ * role + subject and checks the action against the content's author. The launch
722
+ * roster (owner/senior_editor/editor) is NEVER ownership-restricted, so editors
723
+ * keep full editorial. contributor/producer are gated to their own/assigned
724
+ * content and blocked from publish/destructive actions.
725
+ *
726
+ * `contentAuthorId` is the content_items.author_id (the host maps an authored
727
+ * row to the actor's subject; the package compares the two for ownership).
728
+ *
729
+ * Backwards compatible: when authz.getUser is absent (host has not wired the
730
+ * role layer) this falls back to the flat requirePublisher gate.
731
+ */
732
+ async function requireContentAction(
733
+ ctx: RouteContext,
734
+ action: ContentAction,
735
+ contentAuthorId: string | null,
736
+ ): Promise<Response | null> {
737
+ // Role seam not wired → flat publisher gate (legacy hosts).
738
+ if (!resolved.authz.getUser) {
739
+ return resolved.authz.requirePublisher(ctx)
740
+ }
741
+ const user = await resolved.authz.getUser(ctx)
742
+ if (!user) return json({ error: 'Unauthorized' }, 401)
743
+ const actorSubject = user.subject ?? ctx.userId ?? null
744
+ const allowed = canPerformContentAction(
745
+ user.role,
746
+ { actorSubject: actorSubject ?? '', contentAuthorId },
747
+ action,
748
+ )
749
+ if (!allowed) {
750
+ return json({ error: 'Forbidden', action, role: user.role }, 403)
751
+ }
752
+ return null
753
+ }
754
+
755
+ return {
756
+ async list(ctx) {
757
+ const denied = await resolved.authz.requirePublisher(ctx)
758
+ if (denied) return denied
759
+
760
+ const url = new URL(ctx.request.url)
761
+ const limit = Math.min(Number(url.searchParams.get('limit') || '20'), 50)
762
+ const status = parseStatus(url.searchParams.get('status'))
763
+ const types = parseTypes(url.searchParams.get('types'))
764
+ const cursor = parseCursor(url.searchParams.get('cursor'))
765
+ const q = url.searchParams.get('q') || null
766
+ const author = url.searchParams.get('author') || null
767
+
768
+ const bindings: unknown[] = []
769
+ // Always exclude soft-deleted rows (migration 0002 added deleted_at).
770
+ let where = 'c.deleted_at IS NULL'
771
+ if (status) {
772
+ where += ' AND c.status = ?'
773
+ bindings.push(status)
774
+ }
775
+ if (types && types.length > 0) {
776
+ where += ` AND c.type IN (${types.map(() => '?').join(',')})`
777
+ bindings.push(...types)
778
+ }
779
+ if (q) {
780
+ where += ' AND (c.title LIKE ? OR c.slug LIKE ?)'
781
+ const pattern = `%${q}%`
782
+ bindings.push(pattern, pattern)
783
+ }
784
+ if (author) {
785
+ where += ' AND c.author_id = ?'
786
+ bindings.push(author)
787
+ }
788
+ if (cursor) {
789
+ where += ' AND (c.updated_at < ? OR (c.updated_at = ? AND c.id < ?))'
790
+ bindings.push(cursor.updatedAt, cursor.updatedAt, cursor.id)
791
+ }
792
+ bindings.push(limit + 1)
793
+
794
+ // LEFT JOIN video_content so each row carries its processing_state
795
+ // (null for non-video rows). The UI's StatusPill derives the
796
+ // "processing" pseudo-status from this; without it the field is
797
+ // undefined client-side and `processingState !== null` is true for
798
+ // EVERY row, mis-stamping all rows as PROCESSING. Mirrors the JOIN the
799
+ // counts handler below already uses.
800
+ const query = `
801
+ SELECT c.id, c.type, c.status, c.slug, c.title, c.visibility, c.featured,
802
+ c.publish_at, c.published_at, c.updated_at, c.author_id,
803
+ a.slug AS author_slug, a.name AS author_name,
804
+ vc.processing_state AS processing_state
805
+ FROM content_items c
806
+ LEFT JOIN authors a ON a.id = c.author_id
807
+ LEFT JOIN video_content vc ON vc.content_id = c.id
808
+ WHERE ${where}
809
+ ORDER BY c.updated_at DESC, c.id DESC
810
+ LIMIT ?`
811
+
812
+ const result = await ctx.db
813
+ .prepare(query)
814
+ .bind(...bindings)
815
+ .all<{
816
+ id: string
817
+ type: ContentType
818
+ status: ContentStatus
819
+ slug: string
820
+ title: string
821
+ visibility: 'free' | 'premium'
822
+ featured: number | null
823
+ publish_at: number | null
824
+ published_at: number | null
825
+ updated_at: number | null
826
+ author_id: string | null
827
+ author_slug: string | null
828
+ author_name: string | null
829
+ processing_state: string | null
830
+ }>()
831
+
832
+ const rows = result.results || []
833
+ const hasMore = rows.length > limit
834
+ const sliced = hasMore ? rows.slice(0, limit) : rows
835
+ const last = sliced[sliced.length - 1]
836
+ const lastUpdatedAt = last ? normalizeUnixSeconds(last.updated_at) : null
837
+ const nextCursor = last && lastUpdatedAt !== null ? `${lastUpdatedAt}:${last.id}` : null
838
+
839
+ const items = sliced.map((row) => ({
840
+ ...row,
841
+ featured: Boolean(row.featured),
842
+ authorId: row.author_id ?? null,
843
+ authorSlug: row.author_slug ?? null,
844
+ authorName: row.author_name ?? null,
845
+ processingState: row.processing_state ?? null,
846
+ publishAt: unixSecondsToIso(row.publish_at),
847
+ publishedAt: unixSecondsToIso(row.published_at),
848
+ updatedAt: unixSecondsToIso(row.updated_at),
849
+ }))
850
+
851
+ return json({ items, nextCursor })
852
+ },
853
+
854
+ async counts(ctx) {
855
+ const denied = await resolved.authz.requirePublisher(ctx)
856
+ if (denied) return denied
857
+
858
+ // Count non-deleted items grouped by status.
859
+ // "processing" is a derived pseudo-status: video rows whose
860
+ // processing_state is NOT in ('ready','failed') — a mid-flight
861
+ // Foundry pipeline job. It is NOT a value in the content_items.status
862
+ // CHECK enum (draft|scheduled|review|published|archived).
863
+ const rows = await ctx.db
864
+ .prepare(
865
+ `SELECT
866
+ COUNT(*) AS total,
867
+ SUM(CASE WHEN c.status = 'draft' THEN 1 ELSE 0 END) AS draft,
868
+ SUM(CASE WHEN c.status = 'scheduled' THEN 1 ELSE 0 END) AS scheduled,
869
+ SUM(CASE WHEN c.status = 'review' THEN 1 ELSE 0 END) AS review,
870
+ SUM(CASE WHEN c.status = 'published' THEN 1 ELSE 0 END) AS published,
871
+ SUM(CASE WHEN vc.processing_state IS NOT NULL
872
+ AND vc.processing_state NOT IN ('ready','failed')
873
+ THEN 1 ELSE 0 END) AS processing
874
+ FROM content_items c
875
+ LEFT JOIN video_content vc ON vc.content_id = c.id
876
+ WHERE c.deleted_at IS NULL`,
877
+ )
878
+ .first<{
879
+ total: number
880
+ draft: number
881
+ scheduled: number
882
+ review: number
883
+ published: number
884
+ processing: number
885
+ }>()
886
+
887
+ return json({
888
+ counts: {
889
+ all: rows?.total ?? 0,
890
+ draft: rows?.draft ?? 0,
891
+ scheduled: rows?.scheduled ?? 0,
892
+ review: rows?.review ?? 0,
893
+ published: rows?.published ?? 0,
894
+ processing: rows?.processing ?? 0,
895
+ },
896
+ })
897
+ },
898
+
899
+ async bulk(ctx) {
900
+ const parsed = BulkSchema.safeParse(await readJson(ctx))
901
+ if (!parsed.success) {
902
+ return json({ error: 'Invalid request', details: parsed.error.flatten() }, 400)
903
+ }
904
+
905
+ const { op, ids } = parsed.data
906
+
907
+ // Bulk ops act across many (cross-author) items → require publish-level
908
+ // authority (publish_others_draft). contentAuthorId null forces the
909
+ // stricter bar, blocking contributor/producer; editors+ pass.
910
+ const bulkAction: ContentAction =
911
+ op === 'trash' ? 'trash' : op === 'schedule' ? 'schedule' : 'publish'
912
+ const denied = await requireContentAction(ctx, bulkAction, null)
913
+ if (denied) return denied
914
+
915
+ if (op === 'trash') {
916
+ for (const id of ids) {
917
+ await softDeleteContent(ctx.db, id)
918
+ }
919
+ return json({ trashed: ids })
920
+ }
921
+
922
+ if (op === 'topic') {
923
+ const { topic } = parsed.data
924
+ for (const id of ids) {
925
+ await ctx.db
926
+ .prepare(
927
+ 'UPDATE content_items SET primary_topic = ?, updated_at = unixepoch() WHERE id = ? AND deleted_at IS NULL',
928
+ )
929
+ .bind(topic, id)
930
+ .run()
931
+ }
932
+ return json({ updated: ids })
933
+ }
934
+
935
+ if (op === 'schedule') {
936
+ const { publishAt } = parsed.data
937
+ const publishAtMs = Date.parse(publishAt)
938
+ if (!Number.isFinite(publishAtMs)) {
939
+ return json({ error: 'Invalid publishAt' }, 400)
940
+ }
941
+ const publishAtSeconds = Math.floor(publishAtMs / 1000)
942
+ for (const id of ids) {
943
+ await scheduleContent(ctx.db, id, publishAtSeconds)
944
+ }
945
+ return json({ scheduled: ids })
946
+ }
947
+
948
+ // Exhaustive – the discriminated union above covers all ops.
949
+ return json({ error: 'Unhandled op' }, 400)
950
+ },
951
+
952
+ async create(ctx) {
953
+ // Create = write a new draft the actor authors. Pass the actor's own
954
+ // subject as the content author so the ownership predicate treats it as
955
+ // own content (contributor/producer may create their own drafts).
956
+ const actor = resolved.authz.getUser ? await resolved.authz.getUser(ctx) : null
957
+ const ownAuthor = actor?.subject ?? ctx.userId ?? null
958
+ const denied = await requireContentAction(ctx, 'write', ownAuthor)
959
+ if (denied) return denied
960
+
961
+ const parsed = CreateSchema.safeParse(await readJson(ctx))
962
+ if (!parsed.success) {
963
+ return json({ error: 'Invalid request', details: parsed.error.flatten() }, 400)
964
+ }
965
+
966
+ const created = await createContent(ctx.db, parsed.data)
967
+ const podcastDispatch =
968
+ parsed.data.type === 'podcast' ? await maybeDispatchPodcastToFoundry(ctx, created.id) : null
969
+
970
+ // Fire content.created webhook (lifecycle transition).
971
+ await fireWebhook(ctx, 'content.created', {
972
+ id: created.id,
973
+ slug: created.slug,
974
+ type: parsed.data.type,
975
+ })
976
+
977
+ return json(
978
+ {
979
+ id: created.id,
980
+ slug: created.slug,
981
+ podcastFoundryQueued: Boolean(podcastDispatch),
982
+ podcastFoundryCorrelationId: podcastDispatch?.correlationId ?? null,
983
+ },
984
+ 201,
985
+ )
986
+ },
987
+
988
+ async get(ctx) {
989
+ const denied = await resolved.authz.requirePublisher(ctx)
990
+ if (denied) return denied
991
+
992
+ const idResult = requireId(ctx)
993
+ if ('error' in idResult) return idResult.error
994
+ const id = idResult.id
995
+
996
+ const item = await getContentItem(ctx.db, id)
997
+ if (!item) return json({ error: 'Not found' }, 404)
998
+
999
+ const [content, tags, relations, author] = await Promise.all([
1000
+ getContentPayload(ctx, item.type, id),
1001
+ getContentTags(ctx, id),
1002
+ getContentRelations(ctx.db, id),
1003
+ item.author_id
1004
+ ? ctx.db
1005
+ .prepare('SELECT name, slug FROM authors WHERE id = ? LIMIT 1')
1006
+ .bind(item.author_id)
1007
+ .first<{ name: string | null; slug: string | null }>()
1008
+ : Promise.resolve(null),
1009
+ ])
1010
+ const contentRecord = content as Record<string, unknown> | null
1011
+ const thumbnailImageId =
1012
+ typeof contentRecord?.thumbnail_image_id === 'string'
1013
+ ? contentRecord.thumbnail_image_id
1014
+ : null
1015
+ const mediaUrls = await getMediaAssetUrls(
1016
+ ctx.db,
1017
+ [item.hero_image_id, thumbnailImageId].filter((value): value is string => Boolean(value)),
1018
+ )
1019
+ const heroImageUrl = item.hero_image_id ? (mediaUrls[item.hero_image_id] ?? null) : null
1020
+ const thumbnailImageUrl = thumbnailImageId ? (mediaUrls[thumbnailImageId] ?? null) : null
1021
+ const itemWithMedia = {
1022
+ ...item,
1023
+ hero_image_url: heroImageUrl,
1024
+ author_name: author?.name ?? null,
1025
+ author_slug: author?.slug ?? null,
1026
+ }
1027
+ const contentWithMedia = contentRecord
1028
+ ? {
1029
+ ...contentRecord,
1030
+ ...(thumbnailImageId ? { thumbnail_image_url: thumbnailImageUrl } : {}),
1031
+ }
1032
+ : content
1033
+
1034
+ const aiLockedFields: string[] = (() => {
1035
+ try {
1036
+ return JSON.parse(item.ai_locked_fields || '[]')
1037
+ } catch {
1038
+ return []
1039
+ }
1040
+ })()
1041
+
1042
+ return json({
1043
+ item: itemWithMedia,
1044
+ content: contentWithMedia,
1045
+ tags,
1046
+ relations,
1047
+ aiLockedFields,
1048
+ })
1049
+ },
1050
+
1051
+ async update(ctx) {
1052
+ const idResult = requireId(ctx)
1053
+ if ('error' in idResult) return idResult.error
1054
+ const id = idResult.id
1055
+
1056
+ const existing = await getContentItem(ctx.db, id)
1057
+ if (!existing) return json({ error: 'Not found' }, 404)
1058
+
1059
+ // Edit = write; contributor/producer gated to their own content.
1060
+ const denied = await requireContentAction(ctx, 'write', existing.author_id)
1061
+ if (denied) return denied
1062
+
1063
+ const parsed = UpdateSchema.safeParse(await readJson(ctx))
1064
+ if (!parsed.success) {
1065
+ return json({ error: 'Invalid request', details: parsed.error.flatten() }, 400)
1066
+ }
1067
+
1068
+ if (parsed.data.content !== undefined) {
1069
+ const contentSchema = getContentUpdateSchema(existing.type)
1070
+ if (!contentSchema) return json({ error: 'Invalid content type' }, 400)
1071
+
1072
+ const contentParsed = contentSchema.safeParse(parsed.data.content)
1073
+ if (!contentParsed.success) {
1074
+ return json({ error: 'Invalid request', details: contentParsed.error.flatten() }, 400)
1075
+ }
1076
+ parsed.data.content = contentParsed.data
1077
+
1078
+ // Pre-publish content lint: refuse to overwrite an article body with
1079
+ // content that contains known formatting artifacts. Draft saves run
1080
+ // the same check but allow empty bodies (the auto-save path needs to
1081
+ // persist work in progress); publish-time validation rejects empty
1082
+ // bodies separately.
1083
+ if (existing.type === 'article' || existing.type === 'newsletter') {
1084
+ const articleContent = contentParsed.data as {
1085
+ bodyMarkdown?: string
1086
+ bodyHtml?: string | null
1087
+ }
1088
+ const bodyMarkdown = articleContent.bodyMarkdown
1089
+ const bodyHtml = articleContent.bodyHtml
1090
+ const isDraft = existing.status === 'draft'
1091
+ if (bodyMarkdown !== undefined || bodyHtml !== undefined) {
1092
+ const guard = evaluateArticleBody(
1093
+ { body_markdown: bodyMarkdown ?? null, body_html: bodyHtml ?? null },
1094
+ { source: 'cms.patch', contentId: id, requireBody: !isDraft },
1095
+ )
1096
+ if (!guard.ok) {
1097
+ return json(
1098
+ { error: 'Article body failed pre-publish validation', errors: guard.errors },
1099
+ 422,
1100
+ )
1101
+ }
1102
+ }
1103
+ }
1104
+ }
1105
+
1106
+ const priorSlug = existing.slug
1107
+ const updated = await updateContentItem(ctx.db, id, parsed.data)
1108
+ if (!updated) return json({ error: 'Not found' }, 404)
1109
+ const podcastDispatch =
1110
+ existing.type === 'podcast' ? await maybeDispatchPodcastToFoundry(ctx, id) : null
1111
+
1112
+ // If the slug changed, record the old slug as a redirect (spec §8).
1113
+ if (updated.slug !== priorSlug) {
1114
+ await recordSlugRedirect(ctx.db, priorSlug, id)
1115
+ }
1116
+
1117
+ return json({
1118
+ id,
1119
+ slug: updated.slug,
1120
+ podcastFoundryQueued: Boolean(podcastDispatch),
1121
+ podcastFoundryCorrelationId: podcastDispatch?.correlationId ?? null,
1122
+ })
1123
+ },
1124
+
1125
+ async action(ctx) {
1126
+ const idResult = requireId(ctx)
1127
+ if ('error' in idResult) return idResult.error
1128
+ const id = idResult.id
1129
+
1130
+ const url = new URL(ctx.request.url)
1131
+ const action = url.searchParams.get('action')
1132
+ const existing = await getContentItem(ctx.db, id)
1133
+ if (!existing) return json({ error: 'Not found' }, 404)
1134
+
1135
+ // Map the action verb to a capability, then gate with the ownership
1136
+ // predicate (contributor/producer scoped; owner/senior_editor/editor full).
1137
+ const contentAction = mapActionToCapability(action, existing.type)
1138
+ const denied = await requireContentAction(ctx, contentAction, existing.author_id)
1139
+ if (denied) return denied
1140
+
1141
+ if (action === 'schedule') {
1142
+ const parsed = ScheduleSchema.safeParse(await readJson(ctx))
1143
+ if (!parsed.success) {
1144
+ return json({ error: 'Invalid request', details: parsed.error.flatten() }, 400)
1145
+ }
1146
+
1147
+ let publishAtSeconds: number | null = null
1148
+ const publishTimeZone =
1149
+ parsed.data.timezone || resolved.publishTimezone || PUBLISH_TIMEZONE_FALLBACK
1150
+ if (!isValidTimeZone(publishTimeZone)) {
1151
+ return json({ error: 'Invalid timezone' }, 400)
1152
+ }
1153
+
1154
+ if (parsed.data.publishAtLocal) {
1155
+ publishAtSeconds = parseLocalDateTimeInZoneToUnixSeconds(
1156
+ parsed.data.publishAtLocal,
1157
+ publishTimeZone,
1158
+ )
1159
+ if (!Number.isFinite(publishAtSeconds)) {
1160
+ return json({ error: `Invalid publishAtLocal for ${publishTimeZone} timezone` }, 400)
1161
+ }
1162
+ } else if (parsed.data.publishAt) {
1163
+ const publishAt = Date.parse(parsed.data.publishAt)
1164
+ if (!Number.isFinite(publishAt)) return json({ error: 'Invalid publishAt' }, 400)
1165
+ publishAtSeconds = Math.floor(publishAt / 1000)
1166
+ }
1167
+
1168
+ if (!Number.isFinite(publishAtSeconds)) {
1169
+ return json({ error: 'Missing publish time' }, 400)
1170
+ }
1171
+
1172
+ await scheduleContent(ctx.db, id, publishAtSeconds as number, publishTimeZone)
1173
+
1174
+ // Fire content.scheduled webhook.
1175
+ await fireWebhook(ctx, 'content.scheduled', {
1176
+ id,
1177
+ type: existing.type,
1178
+ title: existing.title,
1179
+ publishAt: publishAtSeconds,
1180
+ })
1181
+
1182
+ return json({ status: 'scheduled' })
1183
+ }
1184
+
1185
+ if (action === 'publish') {
1186
+ const now = Math.floor(Date.now() / 1000)
1187
+ const wasDraft = existing.status === 'draft'
1188
+ let podcastDispatch: DispatchResult | null = null
1189
+ if (existing.type === 'podcast') {
1190
+ try {
1191
+ podcastDispatch = await maybeDispatchPodcastToFoundry(ctx, id)
1192
+ } catch (error) {
1193
+ console.error('Podcast Foundry dispatch failed before publish', error)
1194
+ return json({ error: 'Foundry dispatch failed' }, 502)
1195
+ }
1196
+ }
1197
+ if (publishReadiness) {
1198
+ let readiness: PublishReadinessOutcome
1199
+ try {
1200
+ readiness = await publishReadiness({
1201
+ ctx,
1202
+ contentId: id,
1203
+ type: existing.type,
1204
+ status: existing.status,
1205
+ })
1206
+ } catch (err) {
1207
+ console.error('[cms] publishReadiness failed', err)
1208
+ return json({ error: 'Publish readiness check failed' }, 503)
1209
+ }
1210
+ if (!readiness.ready) {
1211
+ return json(
1212
+ {
1213
+ error: 'Content is not ready to publish',
1214
+ state: readiness.state,
1215
+ blockers: readiness.blockers ?? [],
1216
+ message: readiness.message,
1217
+ etaLabel: readiness.etaLabel,
1218
+ checkedAt: readiness.checkedAt,
1219
+ },
1220
+ readiness.status ?? 409,
1221
+ )
1222
+ }
1223
+ }
1224
+ const guard = await evaluateContentBodyForPublish(ctx.db, id, 'cms.publish')
1225
+ if (guard && !guard.ok) {
1226
+ return json(
1227
+ {
1228
+ error: 'Article body failed pre-publish validation',
1229
+ source: 'cms.publish',
1230
+ errors: guard.errors,
1231
+ },
1232
+ 422,
1233
+ )
1234
+ }
1235
+ await publishContent(ctx.db, id, now)
1236
+ await createRevision(ctx.db, id, ctx.userId || null)
1237
+
1238
+ // Write first-party activity log row (cms_activity_log, P5 Task 3).
1239
+ // Wrapped so a logging failure never breaks the publish response.
1240
+ try {
1241
+ await logActivity(ctx.db, {
1242
+ workspaceId: activeWorkspaceId,
1243
+ userId: ctx.userId ?? null,
1244
+ action: 'published',
1245
+ targetId: id,
1246
+ targetTitle: existing.title,
1247
+ type: existing.type,
1248
+ })
1249
+ } catch (err) {
1250
+ console.error('logActivity failed after publish', err)
1251
+ }
1252
+
1253
+ let takeawaysQueued = false
1254
+ if (wasDraft && existing.type === 'article' && dispatchTakeaways) {
1255
+ try {
1256
+ const result = await dispatchTakeaways({ ctx, contentId: id, force: false })
1257
+ takeawaysQueued = result.queued
1258
+ } catch (error) {
1259
+ console.error('Article takeaway dispatch failed', error)
1260
+ }
1261
+ }
1262
+
1263
+ let faqQueued = false
1264
+ if (wasDraft && existing.type === 'article' && dispatchFaq) {
1265
+ try {
1266
+ const result = await dispatchFaq({ ctx, contentId: id, force: false })
1267
+ faqQueued = result.queued
1268
+ } catch (error) {
1269
+ console.error('Article FAQ dispatch failed', error)
1270
+ }
1271
+ }
1272
+
1273
+ // Fire content.published webhook.
1274
+ await fireWebhook(ctx, 'content.published', {
1275
+ id,
1276
+ type: existing.type,
1277
+ title: existing.title,
1278
+ })
1279
+
1280
+ return json({
1281
+ status: 'published',
1282
+ takeawaysQueued,
1283
+ faqQueued,
1284
+ podcastFoundryQueued: Boolean(podcastDispatch),
1285
+ podcastFoundryCorrelationId: podcastDispatch?.correlationId ?? null,
1286
+ })
1287
+ }
1288
+
1289
+ if (action === 'regenerate-takeaways') {
1290
+ if (existing.type !== 'article') return json({ error: 'Invalid content type' }, 400)
1291
+ if (!dispatchTakeaways) {
1292
+ return json({ error: 'Takeaways generation is not configured' }, 501)
1293
+ }
1294
+ try {
1295
+ const result = await dispatchTakeaways({ ctx, contentId: id, force: true })
1296
+ if (!result.queued) {
1297
+ return json({ status: 'skipped', reason: result.reason || 'not queued' }, 409)
1298
+ }
1299
+ return json({ status: 'queued', correlationId: result.correlationId ?? null }, 202)
1300
+ } catch (error) {
1301
+ console.error('Article takeaway regeneration failed', error)
1302
+ return json({ error: 'Foundry dispatch failed' }, 502)
1303
+ }
1304
+ }
1305
+
1306
+ if (action === 'regenerate-faq') {
1307
+ if (existing.type !== 'article') return json({ error: 'Invalid content type' }, 400)
1308
+ if (!dispatchFaq) {
1309
+ return json({ error: 'FAQ generation is not configured' }, 501)
1310
+ }
1311
+ try {
1312
+ const result = await dispatchFaq({ ctx, contentId: id, force: true })
1313
+ if (!result.queued) {
1314
+ return json({ status: 'skipped', reason: result.reason || 'not queued' }, 409)
1315
+ }
1316
+ return json({ status: 'queued', correlationId: result.correlationId ?? null }, 202)
1317
+ } catch (error) {
1318
+ console.error('Article FAQ regeneration failed', error)
1319
+ return json({ error: 'FAQ dispatch failed' }, 502)
1320
+ }
1321
+ }
1322
+
1323
+ if (action === 'unpublish') {
1324
+ await unpublishContent(ctx.db, id)
1325
+
1326
+ // Fire content.unpublished webhook.
1327
+ await fireWebhook(ctx, 'content.unpublished', {
1328
+ id,
1329
+ type: existing.type,
1330
+ title: existing.title,
1331
+ })
1332
+
1333
+ return json({ status: 'draft' })
1334
+ }
1335
+
1336
+ if (action === 'archive') {
1337
+ await ctx.db
1338
+ .prepare(
1339
+ "UPDATE content_items SET status = 'archived', updated_at = unixepoch() WHERE id = ?",
1340
+ )
1341
+ .bind(id)
1342
+ .run()
1343
+
1344
+ // Fire content.deleted (archive = soft delete lifecycle) webhook.
1345
+ await fireWebhook(ctx, 'content.deleted', {
1346
+ id,
1347
+ type: existing.type,
1348
+ title: existing.title,
1349
+ reason: 'archived',
1350
+ })
1351
+
1352
+ return json({ status: 'archived' })
1353
+ }
1354
+
1355
+ if (action === 'unschedule') {
1356
+ // publish_tz is NOT NULL (DEFAULT 'Europe/Paris'); reset it to the
1357
+ // default rather than nulling it (a NULL violates the constraint).
1358
+ await ctx.db
1359
+ .prepare(
1360
+ "UPDATE content_items SET status = 'draft', publish_at = NULL, publish_tz = 'Europe/Paris', updated_at = unixepoch() WHERE id = ?",
1361
+ )
1362
+ .bind(id)
1363
+ .run()
1364
+ return json({ status: 'draft' })
1365
+ }
1366
+
1367
+ if (action === 'duplicate') {
1368
+ const result = await duplicateContentItem(ctx.db, id)
1369
+ if (!result) return json({ error: 'Not found' }, 404)
1370
+
1371
+ // Fire content.duplicated webhook.
1372
+ await fireWebhook(ctx, 'content.duplicated', {
1373
+ id: result.id,
1374
+ slug: result.slug,
1375
+ sourceId: id,
1376
+ type: existing.type,
1377
+ })
1378
+
1379
+ return json({ id: result.id, slug: result.slug }, 201)
1380
+ }
1381
+
1382
+ if (action === 'set-status') {
1383
+ const parsed = SetStatusSchema.safeParse(await readJson(ctx))
1384
+ if (!parsed.success) {
1385
+ return json(
1386
+ {
1387
+ error: 'Invalid request — status must be "draft" or "review"',
1388
+ details: parsed.error.flatten(),
1389
+ },
1390
+ 400,
1391
+ )
1392
+ }
1393
+ const { status } = parsed.data
1394
+ // set-status only ever moves to draft/review (never scheduled), so it is
1395
+ // always a transition OUT of any scheduled state. Clear the stale
1396
+ // publish_at so a de-scheduled item doesn't carry a phantom publish time.
1397
+ // publish_tz is NOT NULL (DEFAULT 'Europe/Paris'), so it is reset to the
1398
+ // default rather than nulled.
1399
+ await ctx.db
1400
+ .prepare(
1401
+ "UPDATE content_items SET status = ?, publish_at = NULL, publish_tz = 'Europe/Paris', updated_at = unixepoch() WHERE id = ?",
1402
+ )
1403
+ .bind(status, id)
1404
+ .run()
1405
+
1406
+ // Fire content.review when submitted for review.
1407
+ if (status === 'review') {
1408
+ await fireWebhook(ctx, 'content.review', {
1409
+ id,
1410
+ type: existing.type,
1411
+ title: existing.title,
1412
+ })
1413
+ }
1414
+
1415
+ return json({ status })
1416
+ }
1417
+
1418
+ if (action === 'snapshot') {
1419
+ const revision = await createRevisionWithDelta(ctx.db, id, {
1420
+ createdBy: ctx.userId ?? null,
1421
+ tag: 'autosave',
1422
+ })
1423
+ if (!revision) return json({ error: 'Not found' }, 404)
1424
+ return json({ status: 'snapshot', revisionId: revision.id })
1425
+ }
1426
+
1427
+ if (action === 'dispatch-to-foundry') {
1428
+ // Media-type guard: only video and podcast can be dispatched.
1429
+ if (existing.type !== 'video' && existing.type !== 'podcast') {
1430
+ return json(
1431
+ {
1432
+ error: 'dispatch-to-foundry is only valid for video and podcast content types',
1433
+ },
1434
+ 400,
1435
+ )
1436
+ }
1437
+ // Hook-presence guard (mirrors the dispatchTakeaways 501 pattern).
1438
+ if (!resolved.hooks.foundryVideo) {
1439
+ return json({ error: 'Foundry video dispatch is not configured' }, 501)
1440
+ }
1441
+ if (existing.type === 'podcast') {
1442
+ const row = await ctx.db
1443
+ .prepare(
1444
+ `SELECT processing_trigger_token
1445
+ FROM podcast_content
1446
+ WHERE content_id = ?
1447
+ LIMIT 1`,
1448
+ )
1449
+ .bind(id)
1450
+ .first<{ processing_trigger_token: string | null }>()
1451
+ const existingToken = row?.processing_trigger_token?.trim() ?? ''
1452
+ if (existingToken) {
1453
+ return json(
1454
+ {
1455
+ status: 'queued',
1456
+ correlationId: null,
1457
+ triggerToken: existingToken,
1458
+ skipped: true,
1459
+ },
1460
+ 202,
1461
+ )
1462
+ }
1463
+ }
1464
+ try {
1465
+ const result = await dispatchToFoundry(ctx.db, id, resolved.hooks.foundryVideo)
1466
+ return json(
1467
+ {
1468
+ status: 'queued',
1469
+ correlationId: result.correlationId,
1470
+ triggerToken: result.triggerToken,
1471
+ },
1472
+ 202,
1473
+ )
1474
+ } catch (error) {
1475
+ console.error('Foundry dispatch failed', error)
1476
+ return json({ error: 'Foundry dispatch failed' }, 502)
1477
+ }
1478
+ }
1479
+
1480
+ if (action === 'trash') {
1481
+ await softDeleteContent(ctx.db, id)
1482
+
1483
+ // Fire content.deleted (trash) webhook.
1484
+ await fireWebhook(ctx, 'content.deleted', {
1485
+ id,
1486
+ type: existing.type,
1487
+ title: existing.title,
1488
+ reason: 'trashed',
1489
+ })
1490
+
1491
+ return json({ status: 'trashed' })
1492
+ }
1493
+
1494
+ return json({ error: 'Unknown action' }, 400)
1495
+ },
1496
+
1497
+ async foundryCallback(ctx) {
1498
+ // HMAC-guarded machine callback — NOT behind requirePublisher.
1499
+ // The HMAC secret is read from ctx.env (Secrets Store binding on the
1500
+ // Workers side; `env.FOUNDRY_HMAC_SECRET`). Never a value in code/D1.
1501
+ const hmacSecret =
1502
+ typeof ctx.env?.FOUNDRY_HMAC_SECRET === 'string' ? ctx.env.FOUNDRY_HMAC_SECRET : null
1503
+
1504
+ if (!hmacSecret) {
1505
+ // No secret configured — refuse with 500 (misconfiguration).
1506
+ return json({ error: 'FOUNDRY_HMAC_SECRET is not configured' }, 500)
1507
+ }
1508
+
1509
+ // Read the raw body text (needed for HMAC verification over the exact bytes).
1510
+ let rawBody: string
1511
+ try {
1512
+ rawBody = await ctx.request.text()
1513
+ } catch {
1514
+ return json({ error: 'Failed to read request body' }, 400)
1515
+ }
1516
+
1517
+ const signature = ctx.request.headers.get('X-Foundry-Signature') ?? ''
1518
+ const valid = await verifyHmac(hmacSecret, rawBody, signature)
1519
+ if (!valid) {
1520
+ return json({ error: 'Invalid signature' }, 401)
1521
+ }
1522
+
1523
+ let payload: Record<string, unknown>
1524
+ try {
1525
+ payload = JSON.parse(rawBody) as Record<string, unknown>
1526
+ } catch {
1527
+ return json({ error: 'Invalid JSON body' }, 400)
1528
+ }
1529
+
1530
+ const eventId = typeof payload.eventId === 'string' ? payload.eventId : null
1531
+ const eventKind = typeof payload.eventKind === 'string' ? payload.eventKind : null
1532
+ const correlationId = typeof payload.correlationId === 'string' ? payload.correlationId : null
1533
+ const triggerToken = typeof payload.triggerToken === 'string' ? payload.triggerToken : null
1534
+ const nextState = typeof payload.nextState === 'string' ? payload.nextState : null
1535
+ const errorMsg = typeof payload.error === 'string' ? payload.error : null
1536
+ const transcript = typeof payload.transcript === 'string' ? payload.transcript : null
1537
+ const durationSeconds =
1538
+ typeof payload.durationSeconds === 'number'
1539
+ ? payload.durationSeconds
1540
+ : typeof payload.duration_seconds === 'number'
1541
+ ? payload.duration_seconds
1542
+ : null
1543
+ const description =
1544
+ typeof payload.description === 'string'
1545
+ ? payload.description
1546
+ : typeof payload.summary === 'string'
1547
+ ? payload.summary
1548
+ : null
1549
+ const excerpt =
1550
+ typeof payload.excerpt === 'string'
1551
+ ? payload.excerpt
1552
+ : typeof payload.summary === 'string'
1553
+ ? payload.summary
1554
+ : null
1555
+
1556
+ if (!eventId || !eventKind || !correlationId || !triggerToken || !nextState) {
1557
+ return json({ error: 'Missing required callback fields' }, 400)
1558
+ }
1559
+
1560
+ const ok = await applyFoundryCallback(ctx.db, {
1561
+ eventId,
1562
+ eventKind,
1563
+ correlationId,
1564
+ triggerToken,
1565
+ nextState: nextState as ProcessingState,
1566
+ error: errorMsg,
1567
+ transcript,
1568
+ durationSeconds,
1569
+ description,
1570
+ excerpt,
1571
+ })
1572
+
1573
+ // Fire video.processed / video.failed webhook on terminal Foundry states.
1574
+ if (nextState === 'ready') {
1575
+ await fireWebhook(ctx, 'video.processed', { correlationId, eventId, eventKind })
1576
+ } else if (nextState === 'failed') {
1577
+ await fireWebhook(ctx, 'video.failed', {
1578
+ correlationId,
1579
+ eventId,
1580
+ eventKind,
1581
+ error: errorMsg,
1582
+ })
1583
+ }
1584
+
1585
+ return json({ ok })
1586
+ },
1587
+
1588
+ async seoScore(ctx) {
1589
+ const denied = await resolved.authz.requirePublisher(ctx)
1590
+ if (denied) return denied
1591
+
1592
+ const idResult = requireId(ctx)
1593
+ if ('error' in idResult) return idResult.error
1594
+ const id = idResult.id
1595
+
1596
+ const item = await getContentItem(ctx.db, id)
1597
+ if (!item) return json({ error: 'Not found' }, 404)
1598
+
1599
+ // Read the persisted body markdown for article / newsletter types.
1600
+ let persistedBodyMarkdown: string | null = null
1601
+ if (item.type === 'article' || item.type === 'newsletter') {
1602
+ const row = await ctx.db
1603
+ .prepare('SELECT body_markdown FROM article_content WHERE content_id = ? LIMIT 1')
1604
+ .bind(id)
1605
+ .first<{ body_markdown: string | null }>()
1606
+ persistedBodyMarkdown = row?.body_markdown ?? null
1607
+ }
1608
+
1609
+ // Read the persisted focus keyword (migration 0004 column).
1610
+ const focusRow = await ctx.db
1611
+ .prepare('SELECT seo_focus_keyword FROM content_items WHERE id = ? LIMIT 1')
1612
+ .bind(id)
1613
+ .first<{ seo_focus_keyword: string | null }>()
1614
+ const persistedFocusKeyword = focusRow?.seo_focus_keyword ?? null
1615
+
1616
+ // Parse optional override fields from the request body.
1617
+ const rawBody = (await readJson(ctx)) as Record<string, unknown> | null
1618
+ const overrides = rawBody && typeof rawBody === 'object' ? rawBody : {}
1619
+
1620
+ const titleOverride = typeof overrides.title === 'string' ? overrides.title : undefined
1621
+ const seoTitleOverride =
1622
+ typeof overrides.seoTitle === 'string' ? overrides.seoTitle : undefined
1623
+ const descriptionOverride =
1624
+ typeof overrides.description === 'string' ? overrides.description : undefined
1625
+ const focusKeywordOverride =
1626
+ typeof overrides.focusKeyword === 'string' ? overrides.focusKeyword : undefined
1627
+ const bodyMarkdownOverride =
1628
+ typeof overrides.bodyMarkdown === 'string' ? overrides.bodyMarkdown : undefined
1629
+
1630
+ const input = {
1631
+ title: titleOverride ?? item.title,
1632
+ seoTitle: seoTitleOverride !== undefined ? seoTitleOverride : (item.seo_title ?? undefined),
1633
+ description:
1634
+ descriptionOverride !== undefined ? descriptionOverride : (item.description ?? undefined),
1635
+ focusKeyword:
1636
+ focusKeywordOverride !== undefined
1637
+ ? focusKeywordOverride
1638
+ : (persistedFocusKeyword ?? undefined),
1639
+ bodyMarkdown:
1640
+ bodyMarkdownOverride !== undefined
1641
+ ? bodyMarkdownOverride
1642
+ : (persistedBodyMarkdown ?? undefined),
1643
+ }
1644
+
1645
+ const result = await resolved.providers.seo.scoreDraft(input)
1646
+ return json({ score: result.score, checks: result.checks })
1647
+ },
1648
+
1649
+ async contributorsList(ctx) {
1650
+ const denied = await resolved.authz.requirePublisher(ctx)
1651
+ if (denied) return denied
1652
+
1653
+ const idResult = requireId(ctx)
1654
+ if ('error' in idResult) return idResult.error
1655
+ const id = idResult.id
1656
+
1657
+ // 404 if the content item doesn't exist.
1658
+ const item = await getContentItem(ctx.db, id)
1659
+ if (!item) return json({ error: 'Not found' }, 404)
1660
+
1661
+ const contributors = await listContributors(ctx.db, id)
1662
+ return json({ contributors })
1663
+ },
1664
+
1665
+ async contributorsReplace(ctx) {
1666
+ const idResult = requireId(ctx)
1667
+ if ('error' in idResult) return idResult.error
1668
+ const id = idResult.id
1669
+
1670
+ // 404 if the content item doesn't exist.
1671
+ const item = await getContentItem(ctx.db, id)
1672
+ if (!item) return json({ error: 'Not found' }, 404)
1673
+
1674
+ // Editing the byline list is a write on the content item.
1675
+ const denied = await requireContentAction(ctx, 'write', item.author_id)
1676
+ if (denied) return denied
1677
+
1678
+ const parsed = ContributorsReplaceSchema.safeParse(await readJson(ctx))
1679
+ if (!parsed.success) {
1680
+ return json({ error: 'Invalid request', details: parsed.error.flatten() }, 400)
1681
+ }
1682
+
1683
+ await setContributors(
1684
+ ctx.db,
1685
+ id,
1686
+ parsed.data.contributors.map((c) => ({
1687
+ // Prod's content_contributors.role is NOT NULL DEFAULT 'cohost'; an
1688
+ // omitted role must resolve to that default, never NULL. The engine
1689
+ // applies the same fallback as the load-bearing guard.
1690
+ authorId: c.authorId,
1691
+ role: c.role ?? 'cohost',
1692
+ position: c.position,
1693
+ })),
1694
+ )
1695
+
1696
+ const contributors = await listContributors(ctx.db, id)
1697
+ return json({ contributors })
1698
+ },
1699
+
1700
+ async revisionsList(ctx) {
1701
+ const denied = await resolved.authz.requirePublisher(ctx)
1702
+ if (denied) return denied
1703
+
1704
+ const idResult = requireId(ctx)
1705
+ if ('error' in idResult) return idResult.error
1706
+ const id = idResult.id
1707
+
1708
+ // 404 if the content item doesn't exist.
1709
+ const item = await getContentItem(ctx.db, id)
1710
+ if (!item) return json({ error: 'Not found' }, 404)
1711
+
1712
+ const revisions = await listRevisions(ctx.db, id)
1713
+ return json({ revisions })
1714
+ },
1715
+
1716
+ async revisionGet(ctx) {
1717
+ const denied = await resolved.authz.requirePublisher(ctx)
1718
+ if (denied) return denied
1719
+
1720
+ const idResult = requireId(ctx)
1721
+ if ('error' in idResult) return idResult.error
1722
+ const id = idResult.id
1723
+
1724
+ const rev = ctx.params.rev
1725
+ if (!rev) return json({ error: 'Missing rev' }, 400)
1726
+
1727
+ const result = await getRevision(ctx.db, id, rev)
1728
+ if (!result) return json({ error: 'Not found' }, 404)
1729
+
1730
+ return json({ summary: result.summary, payload: result.payload })
1731
+ },
1732
+
1733
+ async revisionRestore(ctx) {
1734
+ const idResult = requireId(ctx)
1735
+ if ('error' in idResult) return idResult.error
1736
+ const id = idResult.id
1737
+
1738
+ // Restoring a revision rewrites content fields → a write on the item.
1739
+ const existing = await getContentItem(ctx.db, id)
1740
+ if (!existing) return json({ error: 'Not found' }, 404)
1741
+ const denied = await requireContentAction(ctx, 'write', existing.author_id)
1742
+ if (denied) return denied
1743
+
1744
+ const rev = ctx.params.rev
1745
+ if (!rev) return json({ error: 'Missing rev' }, 400)
1746
+
1747
+ const ok = await restoreRevision(ctx.db, id, rev, { createdBy: ctx.userId ?? null })
1748
+ if (!ok) return json({ error: 'Not found' }, 404)
1749
+
1750
+ return json({ restored: true })
1751
+ },
1752
+ }
1753
+ }