emdash 0.0.0-b → 0.0.2

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 (661) hide show
  1. package/README.md +87 -43
  2. package/dist/adapters-BLMa4JGD.d.mts +106 -0
  3. package/dist/adapters-BLMa4JGD.d.mts.map +1 -0
  4. package/dist/apply-Bjfq_b4-.mjs +1293 -0
  5. package/dist/apply-Bjfq_b4-.mjs.map +1 -0
  6. package/dist/astro/index.d.mts +51 -0
  7. package/dist/astro/index.d.mts.map +1 -0
  8. package/dist/astro/index.mjs +1336 -0
  9. package/dist/astro/index.mjs.map +1 -0
  10. package/dist/astro/middleware/auth.d.mts +31 -0
  11. package/dist/astro/middleware/auth.d.mts.map +1 -0
  12. package/dist/astro/middleware/auth.mjs +654 -0
  13. package/dist/astro/middleware/auth.mjs.map +1 -0
  14. package/dist/astro/middleware/redirect.d.mts +22 -0
  15. package/dist/astro/middleware/redirect.d.mts.map +1 -0
  16. package/dist/astro/middleware/redirect.mjs +63 -0
  17. package/dist/astro/middleware/redirect.mjs.map +1 -0
  18. package/dist/astro/middleware/request-context.d.mts +18 -0
  19. package/dist/astro/middleware/request-context.d.mts.map +1 -0
  20. package/dist/astro/middleware/request-context.mjs +1310 -0
  21. package/dist/astro/middleware/request-context.mjs.map +1 -0
  22. package/dist/astro/middleware/setup.d.mts +20 -0
  23. package/dist/astro/middleware/setup.d.mts.map +1 -0
  24. package/dist/astro/middleware/setup.mjs +47 -0
  25. package/dist/astro/middleware/setup.mjs.map +1 -0
  26. package/dist/astro/middleware.d.mts +13 -0
  27. package/dist/astro/middleware.d.mts.map +1 -0
  28. package/dist/astro/middleware.mjs +1613 -0
  29. package/dist/astro/middleware.mjs.map +1 -0
  30. package/dist/astro/types.d.mts +250 -0
  31. package/dist/astro/types.d.mts.map +1 -0
  32. package/dist/astro/types.mjs +1 -0
  33. package/dist/base64-MBPo9ozB.mjs +59 -0
  34. package/dist/base64-MBPo9ozB.mjs.map +1 -0
  35. package/dist/byline-CL847F26.mjs +213 -0
  36. package/dist/byline-CL847F26.mjs.map +1 -0
  37. package/dist/bylines-C2a-2TGt.mjs +136 -0
  38. package/dist/bylines-C2a-2TGt.mjs.map +1 -0
  39. package/dist/chunk-ClPoSABd.mjs +21 -0
  40. package/dist/cli/index.d.mts +1 -0
  41. package/dist/cli/index.mjs +3909 -0
  42. package/dist/cli/index.mjs.map +1 -0
  43. package/dist/client/cf-access.d.mts +60 -0
  44. package/dist/client/cf-access.d.mts.map +1 -0
  45. package/dist/client/cf-access.mjs +179 -0
  46. package/dist/client/cf-access.mjs.map +1 -0
  47. package/dist/client/index.d.mts +398 -0
  48. package/dist/client/index.d.mts.map +1 -0
  49. package/dist/client/index.mjs +346 -0
  50. package/dist/client/index.mjs.map +1 -0
  51. package/dist/config-CKE8p9xM.mjs +55 -0
  52. package/dist/config-CKE8p9xM.mjs.map +1 -0
  53. package/dist/connection-B4zVnQIa.mjs +40 -0
  54. package/dist/connection-B4zVnQIa.mjs.map +1 -0
  55. package/dist/content-D6C2WsZC.mjs +824 -0
  56. package/dist/content-D6C2WsZC.mjs.map +1 -0
  57. package/dist/db/index.d.mts +4 -0
  58. package/dist/db/index.mjs +62 -0
  59. package/dist/db/index.mjs.map +1 -0
  60. package/dist/db/libsql.d.mts +11 -0
  61. package/dist/db/libsql.d.mts.map +1 -0
  62. package/dist/db/libsql.mjs +17 -0
  63. package/dist/db/libsql.mjs.map +1 -0
  64. package/dist/db/postgres.d.mts +11 -0
  65. package/dist/db/postgres.d.mts.map +1 -0
  66. package/dist/db/postgres.mjs +30 -0
  67. package/dist/db/postgres.mjs.map +1 -0
  68. package/dist/db/sqlite.d.mts +11 -0
  69. package/dist/db/sqlite.d.mts.map +1 -0
  70. package/dist/db/sqlite.mjs +16 -0
  71. package/dist/db/sqlite.mjs.map +1 -0
  72. package/dist/default-Cyi4aAxu.mjs +81 -0
  73. package/dist/default-Cyi4aAxu.mjs.map +1 -0
  74. package/dist/dialect-helpers-B9uSp2GJ.mjs +90 -0
  75. package/dist/dialect-helpers-B9uSp2GJ.mjs.map +1 -0
  76. package/dist/error-Cxz0tQeO.mjs +27 -0
  77. package/dist/error-Cxz0tQeO.mjs.map +1 -0
  78. package/dist/index-C1xF3OGh.d.mts +4527 -0
  79. package/dist/index-C1xF3OGh.d.mts.map +1 -0
  80. package/dist/index.d.mts +16 -0
  81. package/dist/index.mjs +30 -0
  82. package/dist/load-yOOlckBj.mjs +28 -0
  83. package/dist/load-yOOlckBj.mjs.map +1 -0
  84. package/dist/loader-fz8Q_3EO.mjs +447 -0
  85. package/dist/loader-fz8Q_3EO.mjs.map +1 -0
  86. package/dist/manifest-schema-Dcl0R6nM.mjs +184 -0
  87. package/dist/manifest-schema-Dcl0R6nM.mjs.map +1 -0
  88. package/dist/media/index.d.mts +26 -0
  89. package/dist/media/index.d.mts.map +1 -0
  90. package/dist/media/index.mjs +55 -0
  91. package/dist/media/index.mjs.map +1 -0
  92. package/dist/media/local-runtime.d.mts +39 -0
  93. package/dist/media/local-runtime.d.mts.map +1 -0
  94. package/dist/media/local-runtime.mjs +133 -0
  95. package/dist/media/local-runtime.mjs.map +1 -0
  96. package/dist/media-DqHVh136.mjs +200 -0
  97. package/dist/media-DqHVh136.mjs.map +1 -0
  98. package/dist/mode-C2EzN1uE.mjs +23 -0
  99. package/dist/mode-C2EzN1uE.mjs.map +1 -0
  100. package/dist/page/index.d.mts +140 -0
  101. package/dist/page/index.d.mts.map +1 -0
  102. package/dist/page/index.mjs +416 -0
  103. package/dist/page/index.mjs.map +1 -0
  104. package/dist/placeholder-CmGAmqeO.d.mts +276 -0
  105. package/dist/placeholder-CmGAmqeO.d.mts.map +1 -0
  106. package/dist/placeholder-SmpOx-_v.mjs +243 -0
  107. package/dist/placeholder-SmpOx-_v.mjs.map +1 -0
  108. package/dist/plugin-utils.d.mts +58 -0
  109. package/dist/plugin-utils.d.mts.map +1 -0
  110. package/dist/plugin-utils.mjs +78 -0
  111. package/dist/plugin-utils.mjs.map +1 -0
  112. package/dist/plugins/adapt-sandbox-entry.d.mts +22 -0
  113. package/dist/plugins/adapt-sandbox-entry.d.mts.map +1 -0
  114. package/dist/plugins/adapt-sandbox-entry.mjs +113 -0
  115. package/dist/plugins/adapt-sandbox-entry.mjs.map +1 -0
  116. package/dist/query-CS_iSj34.mjs +460 -0
  117. package/dist/query-CS_iSj34.mjs.map +1 -0
  118. package/dist/redirect-DIfIni3r.mjs +329 -0
  119. package/dist/redirect-DIfIni3r.mjs.map +1 -0
  120. package/dist/registry-D_w5HW4G.mjs +863 -0
  121. package/dist/registry-D_w5HW4G.mjs.map +1 -0
  122. package/dist/request-context.d.mts +49 -0
  123. package/dist/request-context.d.mts.map +1 -0
  124. package/dist/request-context.mjs +43 -0
  125. package/dist/request-context.mjs.map +1 -0
  126. package/dist/runner-C0hCbYnD.mjs +1412 -0
  127. package/dist/runner-C0hCbYnD.mjs.map +1 -0
  128. package/dist/runner-EAtf0ZIe.d.mts +27 -0
  129. package/dist/runner-EAtf0ZIe.d.mts.map +1 -0
  130. package/dist/runtime.d.mts +26 -0
  131. package/dist/runtime.d.mts.map +1 -0
  132. package/dist/runtime.mjs +42 -0
  133. package/dist/runtime.mjs.map +1 -0
  134. package/dist/search-DG603UrT.mjs +9211 -0
  135. package/dist/search-DG603UrT.mjs.map +1 -0
  136. package/dist/seed/index.d.mts +3 -0
  137. package/dist/seed/index.mjs +15 -0
  138. package/dist/seo/index.d.mts +70 -0
  139. package/dist/seo/index.d.mts.map +1 -0
  140. package/dist/seo/index.mjs +70 -0
  141. package/dist/seo/index.mjs.map +1 -0
  142. package/dist/storage/local.d.mts +39 -0
  143. package/dist/storage/local.d.mts.map +1 -0
  144. package/dist/storage/local.mjs +166 -0
  145. package/dist/storage/local.mjs.map +1 -0
  146. package/dist/storage/s3.d.mts +32 -0
  147. package/dist/storage/s3.d.mts.map +1 -0
  148. package/dist/storage/s3.mjs +175 -0
  149. package/dist/storage/s3.mjs.map +1 -0
  150. package/dist/tokens-DpgrkrXK.mjs +171 -0
  151. package/dist/tokens-DpgrkrXK.mjs.map +1 -0
  152. package/dist/transport-BFGblqwG.d.mts +42 -0
  153. package/dist/transport-BFGblqwG.d.mts.map +1 -0
  154. package/dist/transport-yxiQsi8I.mjs +418 -0
  155. package/dist/transport-yxiQsi8I.mjs.map +1 -0
  156. package/dist/types-BRuPJGdV.d.mts +102 -0
  157. package/dist/types-BRuPJGdV.d.mts.map +1 -0
  158. package/dist/types-C4-fAxN3.d.mts +182 -0
  159. package/dist/types-C4-fAxN3.d.mts.map +1 -0
  160. package/dist/types-CMMN0pNg.mjs +31 -0
  161. package/dist/types-CMMN0pNg.mjs.map +1 -0
  162. package/dist/types-CUBbjgmP.mjs +16 -0
  163. package/dist/types-CUBbjgmP.mjs.map +1 -0
  164. package/dist/types-DRjfYOEv.d.mts +426 -0
  165. package/dist/types-DRjfYOEv.d.mts.map +1 -0
  166. package/dist/types-DY5zk5HN.mjs +73 -0
  167. package/dist/types-DY5zk5HN.mjs.map +1 -0
  168. package/dist/types-DaNLHo_T.d.mts +184 -0
  169. package/dist/types-DaNLHo_T.d.mts.map +1 -0
  170. package/dist/types-DvhsUmSJ.d.mts +1111 -0
  171. package/dist/types-DvhsUmSJ.d.mts.map +1 -0
  172. package/dist/validate-CpBtVMsD.d.mts +378 -0
  173. package/dist/validate-CpBtVMsD.d.mts.map +1 -0
  174. package/dist/validate-CqRJb_xU.mjs +97 -0
  175. package/dist/validate-CqRJb_xU.mjs.map +1 -0
  176. package/dist/validate-O7PWmlnq.mjs +328 -0
  177. package/dist/validate-O7PWmlnq.mjs.map +1 -0
  178. package/locals.d.ts +46 -0
  179. package/package.json +233 -19
  180. package/src/api/authorize.ts +63 -0
  181. package/src/api/csrf.ts +48 -0
  182. package/src/api/error.ts +99 -0
  183. package/src/api/errors.ts +445 -0
  184. package/src/api/escape.ts +9 -0
  185. package/src/api/handlers/api-tokens.ts +240 -0
  186. package/src/api/handlers/comments.ts +314 -0
  187. package/src/api/handlers/content.ts +1315 -0
  188. package/src/api/handlers/dashboard.ts +205 -0
  189. package/src/api/handlers/device-flow.ts +684 -0
  190. package/src/api/handlers/index.ts +163 -0
  191. package/src/api/handlers/manifest.ts +158 -0
  192. package/src/api/handlers/marketplace.ts +930 -0
  193. package/src/api/handlers/media.ts +207 -0
  194. package/src/api/handlers/menus.ts +493 -0
  195. package/src/api/handlers/oauth-authorization.ts +429 -0
  196. package/src/api/handlers/oauth-clients.ts +349 -0
  197. package/src/api/handlers/oauth-user-lookup.ts +39 -0
  198. package/src/api/handlers/plugins.ts +254 -0
  199. package/src/api/handlers/redirects.ts +360 -0
  200. package/src/api/handlers/revision.ts +145 -0
  201. package/src/api/handlers/schema.ts +534 -0
  202. package/src/api/handlers/sections.ts +289 -0
  203. package/src/api/handlers/seo.ts +115 -0
  204. package/src/api/handlers/settings.ts +49 -0
  205. package/src/api/handlers/snapshot.ts +350 -0
  206. package/src/api/handlers/taxonomies.ts +523 -0
  207. package/src/api/index.ts +6 -0
  208. package/src/api/openapi/document.ts +2368 -0
  209. package/src/api/openapi/index.ts +1 -0
  210. package/src/api/parse.ts +139 -0
  211. package/src/api/redirect.ts +14 -0
  212. package/src/api/rev.ts +67 -0
  213. package/src/api/schemas/auth.ts +112 -0
  214. package/src/api/schemas/bylines.ts +85 -0
  215. package/src/api/schemas/comments.ts +117 -0
  216. package/src/api/schemas/common.ts +89 -0
  217. package/src/api/schemas/content.ts +191 -0
  218. package/src/api/schemas/import.ts +52 -0
  219. package/src/api/schemas/index.ts +17 -0
  220. package/src/api/schemas/media.ts +116 -0
  221. package/src/api/schemas/menus.ts +111 -0
  222. package/src/api/schemas/redirects.ts +155 -0
  223. package/src/api/schemas/schema.ts +203 -0
  224. package/src/api/schemas/search.ts +63 -0
  225. package/src/api/schemas/sections.ts +67 -0
  226. package/src/api/schemas/settings.ts +63 -0
  227. package/src/api/schemas/setup.ts +37 -0
  228. package/src/api/schemas/taxonomies.ts +113 -0
  229. package/src/api/schemas/users.ts +96 -0
  230. package/src/api/schemas/widgets.ts +80 -0
  231. package/src/api/site-url.ts +25 -0
  232. package/src/api/types.ts +82 -0
  233. package/src/astro/index.ts +27 -0
  234. package/src/astro/integration/index.ts +303 -0
  235. package/src/astro/integration/routes.ts +834 -0
  236. package/src/astro/integration/runtime.ts +338 -0
  237. package/src/astro/integration/virtual-modules.ts +469 -0
  238. package/src/astro/integration/vite-config.ts +335 -0
  239. package/src/astro/middleware/auth.ts +743 -0
  240. package/src/astro/middleware/redirect.ts +89 -0
  241. package/src/astro/middleware/request-context.ts +129 -0
  242. package/src/astro/middleware/setup.ts +89 -0
  243. package/src/astro/middleware.ts +398 -0
  244. package/src/astro/routes/PluginRegistry.tsx +15 -0
  245. package/src/astro/routes/admin.astro +81 -0
  246. package/src/astro/routes/api/admin/allowed-domains/[domain].ts +112 -0
  247. package/src/astro/routes/api/admin/allowed-domains/index.ts +108 -0
  248. package/src/astro/routes/api/admin/api-tokens/[id].ts +40 -0
  249. package/src/astro/routes/api/admin/api-tokens/index.ts +68 -0
  250. package/src/astro/routes/api/admin/bylines/[id]/index.ts +87 -0
  251. package/src/astro/routes/api/admin/bylines/index.ts +72 -0
  252. package/src/astro/routes/api/admin/comments/[id]/status.ts +116 -0
  253. package/src/astro/routes/api/admin/comments/[id].ts +64 -0
  254. package/src/astro/routes/api/admin/comments/bulk.ts +42 -0
  255. package/src/astro/routes/api/admin/comments/counts.ts +30 -0
  256. package/src/astro/routes/api/admin/comments/index.ts +46 -0
  257. package/src/astro/routes/api/admin/hooks/exclusive/[hookName].ts +91 -0
  258. package/src/astro/routes/api/admin/hooks/exclusive/index.ts +51 -0
  259. package/src/astro/routes/api/admin/oauth-clients/[id].ts +110 -0
  260. package/src/astro/routes/api/admin/oauth-clients/index.ts +71 -0
  261. package/src/astro/routes/api/admin/plugins/[id]/disable.ts +39 -0
  262. package/src/astro/routes/api/admin/plugins/[id]/enable.ts +39 -0
  263. package/src/astro/routes/api/admin/plugins/[id]/index.ts +38 -0
  264. package/src/astro/routes/api/admin/plugins/[id]/uninstall.ts +48 -0
  265. package/src/astro/routes/api/admin/plugins/[id]/update.ts +59 -0
  266. package/src/astro/routes/api/admin/plugins/index.ts +32 -0
  267. package/src/astro/routes/api/admin/plugins/marketplace/[id]/icon.ts +61 -0
  268. package/src/astro/routes/api/admin/plugins/marketplace/[id]/index.ts +33 -0
  269. package/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts +62 -0
  270. package/src/astro/routes/api/admin/plugins/marketplace/index.ts +38 -0
  271. package/src/astro/routes/api/admin/plugins/updates.ts +28 -0
  272. package/src/astro/routes/api/admin/themes/marketplace/[id]/index.ts +33 -0
  273. package/src/astro/routes/api/admin/themes/marketplace/[id]/thumbnail.ts +61 -0
  274. package/src/astro/routes/api/admin/themes/marketplace/index.ts +45 -0
  275. package/src/astro/routes/api/admin/users/[id]/disable.ts +69 -0
  276. package/src/astro/routes/api/admin/users/[id]/enable.ts +48 -0
  277. package/src/astro/routes/api/admin/users/[id]/index.ts +146 -0
  278. package/src/astro/routes/api/admin/users/[id]/send-recovery.ts +72 -0
  279. package/src/astro/routes/api/admin/users/index.ts +66 -0
  280. package/src/astro/routes/api/auth/dev-bypass.ts +139 -0
  281. package/src/astro/routes/api/auth/invite/accept.ts +52 -0
  282. package/src/astro/routes/api/auth/invite/complete.ts +84 -0
  283. package/src/astro/routes/api/auth/invite/index.ts +99 -0
  284. package/src/astro/routes/api/auth/logout.ts +40 -0
  285. package/src/astro/routes/api/auth/magic-link/send.ts +89 -0
  286. package/src/astro/routes/api/auth/magic-link/verify.ts +71 -0
  287. package/src/astro/routes/api/auth/me.ts +60 -0
  288. package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +219 -0
  289. package/src/astro/routes/api/auth/oauth/[provider].ts +119 -0
  290. package/src/astro/routes/api/auth/passkey/[id].ts +124 -0
  291. package/src/astro/routes/api/auth/passkey/index.ts +54 -0
  292. package/src/astro/routes/api/auth/passkey/options.ts +82 -0
  293. package/src/astro/routes/api/auth/passkey/register/options.ts +86 -0
  294. package/src/astro/routes/api/auth/passkey/register/verify.ts +115 -0
  295. package/src/astro/routes/api/auth/passkey/verify.ts +66 -0
  296. package/src/astro/routes/api/auth/signup/complete.ts +85 -0
  297. package/src/astro/routes/api/auth/signup/request.ts +77 -0
  298. package/src/astro/routes/api/auth/signup/verify.ts +53 -0
  299. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +312 -0
  300. package/src/astro/routes/api/content/[collection]/[id]/compare.ts +28 -0
  301. package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +54 -0
  302. package/src/astro/routes/api/content/[collection]/[id]/duplicate.ts +61 -0
  303. package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +33 -0
  304. package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +107 -0
  305. package/src/astro/routes/api/content/[collection]/[id]/publish.ts +56 -0
  306. package/src/astro/routes/api/content/[collection]/[id]/restore.ts +54 -0
  307. package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +31 -0
  308. package/src/astro/routes/api/content/[collection]/[id]/schedule.ts +101 -0
  309. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +140 -0
  310. package/src/astro/routes/api/content/[collection]/[id]/translations.ts +30 -0
  311. package/src/astro/routes/api/content/[collection]/[id]/unpublish.ts +56 -0
  312. package/src/astro/routes/api/content/[collection]/[id].ts +137 -0
  313. package/src/astro/routes/api/content/[collection]/index.ts +59 -0
  314. package/src/astro/routes/api/content/[collection]/trash.ts +33 -0
  315. package/src/astro/routes/api/dashboard.ts +32 -0
  316. package/src/astro/routes/api/dev/emails.ts +36 -0
  317. package/src/astro/routes/api/import/probe.ts +47 -0
  318. package/src/astro/routes/api/import/wordpress/analyze.ts +510 -0
  319. package/src/astro/routes/api/import/wordpress/execute.ts +283 -0
  320. package/src/astro/routes/api/import/wordpress/media.ts +338 -0
  321. package/src/astro/routes/api/import/wordpress/prepare.ts +181 -0
  322. package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +393 -0
  323. package/src/astro/routes/api/import/wordpress-plugin/analyze.ts +111 -0
  324. package/src/astro/routes/api/import/wordpress-plugin/callback.ts +58 -0
  325. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +347 -0
  326. package/src/astro/routes/api/manifest.ts +62 -0
  327. package/src/astro/routes/api/mcp.ts +124 -0
  328. package/src/astro/routes/api/media/[id]/confirm.ts +93 -0
  329. package/src/astro/routes/api/media/[id].ts +145 -0
  330. package/src/astro/routes/api/media/file/[key].ts +79 -0
  331. package/src/astro/routes/api/media/providers/[providerId]/[itemId].ts +86 -0
  332. package/src/astro/routes/api/media/providers/[providerId]/index.ts +111 -0
  333. package/src/astro/routes/api/media/providers/index.ts +30 -0
  334. package/src/astro/routes/api/media/upload-url.ts +137 -0
  335. package/src/astro/routes/api/media.ts +190 -0
  336. package/src/astro/routes/api/menus/[name]/items.ts +87 -0
  337. package/src/astro/routes/api/menus/[name]/reorder.ts +33 -0
  338. package/src/astro/routes/api/menus/[name].ts +65 -0
  339. package/src/astro/routes/api/menus/index.ts +47 -0
  340. package/src/astro/routes/api/oauth/authorize.ts +412 -0
  341. package/src/astro/routes/api/oauth/device/authorize.ts +45 -0
  342. package/src/astro/routes/api/oauth/device/code.ts +51 -0
  343. package/src/astro/routes/api/oauth/device/token.ts +69 -0
  344. package/src/astro/routes/api/oauth/token/refresh.ts +38 -0
  345. package/src/astro/routes/api/oauth/token/revoke.ts +38 -0
  346. package/src/astro/routes/api/oauth/token.ts +184 -0
  347. package/src/astro/routes/api/openapi.json.ts +32 -0
  348. package/src/astro/routes/api/plugins/[pluginId]/[...path].ts +92 -0
  349. package/src/astro/routes/api/redirects/404s/index.ts +72 -0
  350. package/src/astro/routes/api/redirects/404s/summary.ts +33 -0
  351. package/src/astro/routes/api/redirects/[id].ts +84 -0
  352. package/src/astro/routes/api/redirects/index.ts +52 -0
  353. package/src/astro/routes/api/revisions/[revisionId]/index.ts +29 -0
  354. package/src/astro/routes/api/revisions/[revisionId]/restore.ts +58 -0
  355. package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +76 -0
  356. package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +52 -0
  357. package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +32 -0
  358. package/src/astro/routes/api/schema/collections/[slug]/index.ts +80 -0
  359. package/src/astro/routes/api/schema/collections/index.ts +47 -0
  360. package/src/astro/routes/api/schema/index.ts +109 -0
  361. package/src/astro/routes/api/schema/orphans/[slug].ts +36 -0
  362. package/src/astro/routes/api/schema/orphans/index.ts +26 -0
  363. package/src/astro/routes/api/search/enable.ts +64 -0
  364. package/src/astro/routes/api/search/index.ts +55 -0
  365. package/src/astro/routes/api/search/rebuild.ts +72 -0
  366. package/src/astro/routes/api/search/stats.ts +35 -0
  367. package/src/astro/routes/api/search/suggest.ts +53 -0
  368. package/src/astro/routes/api/sections/[slug].ts +84 -0
  369. package/src/astro/routes/api/sections/index.ts +52 -0
  370. package/src/astro/routes/api/settings/email.ts +150 -0
  371. package/src/astro/routes/api/settings.ts +67 -0
  372. package/src/astro/routes/api/setup/admin-verify.ts +100 -0
  373. package/src/astro/routes/api/setup/admin.ts +94 -0
  374. package/src/astro/routes/api/setup/dev-bypass.ts +199 -0
  375. package/src/astro/routes/api/setup/dev-reset.ts +40 -0
  376. package/src/astro/routes/api/setup/index.ts +126 -0
  377. package/src/astro/routes/api/setup/status.ts +122 -0
  378. package/src/astro/routes/api/snapshot.ts +75 -0
  379. package/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts +95 -0
  380. package/src/astro/routes/api/taxonomies/[name]/terms/index.ts +69 -0
  381. package/src/astro/routes/api/taxonomies/index.ts +59 -0
  382. package/src/astro/routes/api/themes/preview.ts +77 -0
  383. package/src/astro/routes/api/typegen.ts +114 -0
  384. package/src/astro/routes/api/well-known/auth.ts +68 -0
  385. package/src/astro/routes/api/well-known/oauth-authorization-server.ts +44 -0
  386. package/src/astro/routes/api/well-known/oauth-protected-resource.ts +37 -0
  387. package/src/astro/routes/api/widget-areas/[name]/reorder.ts +68 -0
  388. package/src/astro/routes/api/widget-areas/[name]/widgets/[id].ts +127 -0
  389. package/src/astro/routes/api/widget-areas/[name]/widgets.ts +80 -0
  390. package/src/astro/routes/api/widget-areas/[name].ts +87 -0
  391. package/src/astro/routes/api/widget-areas/index.ts +99 -0
  392. package/src/astro/routes/api/widget-components.ts +22 -0
  393. package/src/astro/routes/robots.txt.ts +77 -0
  394. package/src/astro/routes/sitemap.xml.ts +97 -0
  395. package/src/astro/storage/adapters.ts +74 -0
  396. package/src/astro/storage/index.ts +19 -0
  397. package/src/astro/storage/types.ts +60 -0
  398. package/src/astro/types.ts +346 -0
  399. package/src/auth/api-tokens.ts +25 -0
  400. package/src/auth/challenge-store.ts +80 -0
  401. package/src/auth/mode.ts +96 -0
  402. package/src/auth/oauth-state-store.ts +96 -0
  403. package/src/auth/passkey-config.ts +27 -0
  404. package/src/auth/rate-limit.ts +158 -0
  405. package/src/auth/scopes.ts +33 -0
  406. package/src/auth/types.ts +104 -0
  407. package/src/aws-sdk.d.ts +100 -0
  408. package/src/bylines/index.ts +237 -0
  409. package/src/cleanup.ts +153 -0
  410. package/src/cli/client-factory.ts +100 -0
  411. package/src/cli/commands/auth.ts +46 -0
  412. package/src/cli/commands/bundle-utils.ts +247 -0
  413. package/src/cli/commands/bundle.ts +609 -0
  414. package/src/cli/commands/content.ts +442 -0
  415. package/src/cli/commands/dev.ts +191 -0
  416. package/src/cli/commands/doctor.ts +211 -0
  417. package/src/cli/commands/export-seed.ts +630 -0
  418. package/src/cli/commands/import/wordpress.ts +1056 -0
  419. package/src/cli/commands/init.ts +192 -0
  420. package/src/cli/commands/login.ts +547 -0
  421. package/src/cli/commands/media.ts +165 -0
  422. package/src/cli/commands/menu.ts +67 -0
  423. package/src/cli/commands/plugin-init.ts +291 -0
  424. package/src/cli/commands/plugin-validate.ts +31 -0
  425. package/src/cli/commands/plugin.ts +33 -0
  426. package/src/cli/commands/publish.ts +697 -0
  427. package/src/cli/commands/schema.ts +233 -0
  428. package/src/cli/commands/search-cmd.ts +54 -0
  429. package/src/cli/commands/seed.ts +286 -0
  430. package/src/cli/commands/taxonomy.ts +128 -0
  431. package/src/cli/commands/types.ts +68 -0
  432. package/src/cli/credentials.ts +236 -0
  433. package/src/cli/index.ts +70 -0
  434. package/src/cli/output.ts +75 -0
  435. package/src/cli/wxr/parser.ts +969 -0
  436. package/src/client/cf-access.ts +193 -0
  437. package/src/client/index.ts +854 -0
  438. package/src/client/portable-text.ts +413 -0
  439. package/src/client/transport.ts +200 -0
  440. package/src/comments/moderator.ts +46 -0
  441. package/src/comments/notifications.ts +144 -0
  442. package/src/comments/query.ts +105 -0
  443. package/src/comments/service.ts +213 -0
  444. package/src/components/Break.astro +45 -0
  445. package/src/components/Button.astro +71 -0
  446. package/src/components/Buttons.astro +49 -0
  447. package/src/components/Code.astro +59 -0
  448. package/src/components/Columns.astro +59 -0
  449. package/src/components/CommentForm.astro +315 -0
  450. package/src/components/Comments.astro +232 -0
  451. package/src/components/Cover.astro +128 -0
  452. package/src/components/EmDashBodyEnd.astro +32 -0
  453. package/src/components/EmDashBodyStart.astro +32 -0
  454. package/src/components/EmDashHead.astro +53 -0
  455. package/src/components/EmDashImage.astro +178 -0
  456. package/src/components/EmDashMedia.astro +167 -0
  457. package/src/components/Embed.astro +128 -0
  458. package/src/components/File.astro +122 -0
  459. package/src/components/Gallery.astro +93 -0
  460. package/src/components/HtmlBlock.astro +33 -0
  461. package/src/components/Image.astro +178 -0
  462. package/src/components/InlineEditor.astro +27 -0
  463. package/src/components/InlinePortableTextEditor.tsx +1905 -0
  464. package/src/components/LiveSearch.astro +614 -0
  465. package/src/components/PortableText.astro +51 -0
  466. package/src/components/Pullquote.astro +51 -0
  467. package/src/components/Table.astro +108 -0
  468. package/src/components/WidgetArea.astro +22 -0
  469. package/src/components/WidgetRenderer.astro +72 -0
  470. package/src/components/index.ts +116 -0
  471. package/src/components/marks/Link.astro +31 -0
  472. package/src/components/marks/StrikeThrough.astro +7 -0
  473. package/src/components/marks/Subscript.astro +7 -0
  474. package/src/components/marks/Superscript.astro +7 -0
  475. package/src/components/marks/Underline.astro +7 -0
  476. package/src/components/widgets/Archives.astro +65 -0
  477. package/src/components/widgets/Categories.astro +35 -0
  478. package/src/components/widgets/RecentPosts.astro +51 -0
  479. package/src/components/widgets/Search.astro +18 -0
  480. package/src/components/widgets/Tags.astro +38 -0
  481. package/src/content/converters/index.ts +9 -0
  482. package/src/content/converters/portable-text-to-prosemirror.ts +385 -0
  483. package/src/content/converters/prosemirror-to-portable-text.ts +413 -0
  484. package/src/content/converters/types.ts +120 -0
  485. package/src/content/index.ts +5 -0
  486. package/src/database/connection.ts +67 -0
  487. package/src/database/dialect-helpers.ts +138 -0
  488. package/src/database/index.ts +5 -0
  489. package/src/database/migrations/001_initial.ts +170 -0
  490. package/src/database/migrations/002_media_status.ts +26 -0
  491. package/src/database/migrations/003_schema_registry.ts +79 -0
  492. package/src/database/migrations/004_plugins.ts +62 -0
  493. package/src/database/migrations/005_menus.ts +67 -0
  494. package/src/database/migrations/006_taxonomy_defs.ts +51 -0
  495. package/src/database/migrations/007_widgets.ts +42 -0
  496. package/src/database/migrations/008_auth.ts +194 -0
  497. package/src/database/migrations/009_user_disabled.ts +27 -0
  498. package/src/database/migrations/011_sections.ts +65 -0
  499. package/src/database/migrations/012_search.ts +25 -0
  500. package/src/database/migrations/013_scheduled_publishing.ts +51 -0
  501. package/src/database/migrations/014_draft_revisions.ts +72 -0
  502. package/src/database/migrations/015_indexes.ts +82 -0
  503. package/src/database/migrations/016_api_tokens.ts +89 -0
  504. package/src/database/migrations/017_authorization_codes.ts +45 -0
  505. package/src/database/migrations/018_seo.ts +56 -0
  506. package/src/database/migrations/019_i18n.ts +618 -0
  507. package/src/database/migrations/020_collection_url_pattern.ts +23 -0
  508. package/src/database/migrations/021_remove_section_categories.ts +43 -0
  509. package/src/database/migrations/022_marketplace_plugin_state.ts +46 -0
  510. package/src/database/migrations/023_plugin_metadata.ts +33 -0
  511. package/src/database/migrations/024_media_placeholders.ts +32 -0
  512. package/src/database/migrations/025_oauth_clients.ts +28 -0
  513. package/src/database/migrations/026_cron_tasks.ts +49 -0
  514. package/src/database/migrations/027_comments.ts +87 -0
  515. package/src/database/migrations/028_drop_author_url.ts +9 -0
  516. package/src/database/migrations/029_redirects.ts +67 -0
  517. package/src/database/migrations/030_widen_scheduled_index.ts +48 -0
  518. package/src/database/migrations/031_bylines.ts +90 -0
  519. package/src/database/migrations/032_rate_limits.ts +39 -0
  520. package/src/database/migrations/runner.ts +170 -0
  521. package/src/database/repositories/audit.ts +294 -0
  522. package/src/database/repositories/byline.ts +387 -0
  523. package/src/database/repositories/comment.ts +458 -0
  524. package/src/database/repositories/content.ts +1144 -0
  525. package/src/database/repositories/index.ts +30 -0
  526. package/src/database/repositories/media.ts +347 -0
  527. package/src/database/repositories/options.ts +150 -0
  528. package/src/database/repositories/plugin-storage.ts +373 -0
  529. package/src/database/repositories/redirect.ts +480 -0
  530. package/src/database/repositories/revision.ts +200 -0
  531. package/src/database/repositories/seo.ts +176 -0
  532. package/src/database/repositories/taxonomy.ts +294 -0
  533. package/src/database/repositories/types.ts +132 -0
  534. package/src/database/repositories/user.ts +258 -0
  535. package/src/database/transaction.ts +54 -0
  536. package/src/database/types.ts +501 -0
  537. package/src/database/validate.ts +138 -0
  538. package/src/db/adapters.ts +125 -0
  539. package/src/db/index.ts +37 -0
  540. package/src/db/libsql.ts +23 -0
  541. package/src/db/postgres.ts +30 -0
  542. package/src/db/sqlite.ts +27 -0
  543. package/src/emdash-runtime.ts +2096 -0
  544. package/src/fields/boolean.ts +34 -0
  545. package/src/fields/datetime.ts +44 -0
  546. package/src/fields/file.ts +41 -0
  547. package/src/fields/image.ts +34 -0
  548. package/src/fields/index.ts +42 -0
  549. package/src/fields/integer.ts +50 -0
  550. package/src/fields/json.ts +37 -0
  551. package/src/fields/multiselect.ts +48 -0
  552. package/src/fields/number.ts +52 -0
  553. package/src/fields/portable-text.ts +33 -0
  554. package/src/fields/reference.ts +29 -0
  555. package/src/fields/richtext.ts +31 -0
  556. package/src/fields/select.ts +46 -0
  557. package/src/fields/slug.ts +38 -0
  558. package/src/fields/text.ts +55 -0
  559. package/src/fields/textarea.ts +52 -0
  560. package/src/fields/types.ts +64 -0
  561. package/src/i18n/config.ts +68 -0
  562. package/src/import/index.ts +90 -0
  563. package/src/import/menus.ts +436 -0
  564. package/src/import/registry.ts +111 -0
  565. package/src/import/sections.ts +103 -0
  566. package/src/import/settings.ts +281 -0
  567. package/src/import/sources/wordpress-plugin.ts +641 -0
  568. package/src/import/sources/wordpress-rest.ts +191 -0
  569. package/src/import/sources/wxr.ts +330 -0
  570. package/src/import/ssrf.ts +260 -0
  571. package/src/import/types.ts +418 -0
  572. package/src/import/utils.ts +412 -0
  573. package/src/index.ts +481 -0
  574. package/src/loader.ts +770 -0
  575. package/src/mcp/server.ts +1463 -0
  576. package/src/media/index.ts +32 -0
  577. package/src/media/local-runtime.ts +213 -0
  578. package/src/media/local.ts +46 -0
  579. package/src/media/normalize.ts +190 -0
  580. package/src/media/placeholder.ts +150 -0
  581. package/src/media/provider-loader.ts +78 -0
  582. package/src/media/types.ts +279 -0
  583. package/src/menus/index.ts +324 -0
  584. package/src/menus/types.ts +112 -0
  585. package/src/page/context.ts +93 -0
  586. package/src/page/fragments.ts +89 -0
  587. package/src/page/index.ts +58 -0
  588. package/src/page/jsonld.ts +94 -0
  589. package/src/page/metadata.ts +185 -0
  590. package/src/page/seo-contributions.ts +136 -0
  591. package/src/plugin-utils.ts +80 -0
  592. package/src/plugins/adapt-sandbox-entry.ts +207 -0
  593. package/src/plugins/context.ts +833 -0
  594. package/src/plugins/cron.ts +361 -0
  595. package/src/plugins/define-plugin.ts +259 -0
  596. package/src/plugins/email-console.ts +73 -0
  597. package/src/plugins/email.ts +209 -0
  598. package/src/plugins/hooks.ts +1273 -0
  599. package/src/plugins/index.ts +193 -0
  600. package/src/plugins/manager.ts +595 -0
  601. package/src/plugins/manifest-schema.ts +230 -0
  602. package/src/plugins/marketplace.ts +460 -0
  603. package/src/plugins/request-meta.ts +139 -0
  604. package/src/plugins/routes.ts +302 -0
  605. package/src/plugins/sandbox/index.ts +18 -0
  606. package/src/plugins/sandbox/noop.ts +76 -0
  607. package/src/plugins/sandbox/types.ts +173 -0
  608. package/src/plugins/scheduler/node.ts +122 -0
  609. package/src/plugins/scheduler/piggyback.ts +71 -0
  610. package/src/plugins/scheduler/types.ts +27 -0
  611. package/src/plugins/state.ts +208 -0
  612. package/src/plugins/storage-indexes.ts +326 -0
  613. package/src/plugins/storage-query.ts +240 -0
  614. package/src/plugins/types.ts +1284 -0
  615. package/src/preview/helpers.ts +27 -0
  616. package/src/preview/index.ts +40 -0
  617. package/src/preview/tokens.ts +279 -0
  618. package/src/preview/urls.ts +118 -0
  619. package/src/query.ts +674 -0
  620. package/src/redirects/patterns.ts +224 -0
  621. package/src/request-context.ts +67 -0
  622. package/src/runtime.ts +21 -0
  623. package/src/schema/index.ts +29 -0
  624. package/src/schema/query.ts +44 -0
  625. package/src/schema/registry.ts +965 -0
  626. package/src/schema/types.ts +276 -0
  627. package/src/schema/zod-generator.ts +413 -0
  628. package/src/search/fts-manager.ts +452 -0
  629. package/src/search/index.ts +26 -0
  630. package/src/search/query.ts +396 -0
  631. package/src/search/text-extraction.ts +162 -0
  632. package/src/search/types.ts +114 -0
  633. package/src/sections/index.ts +226 -0
  634. package/src/sections/types.ts +86 -0
  635. package/src/seed/apply.ts +1141 -0
  636. package/src/seed/default.ts +86 -0
  637. package/src/seed/index.ts +28 -0
  638. package/src/seed/load.ts +35 -0
  639. package/src/seed/types.ts +341 -0
  640. package/src/seed/validate.ts +642 -0
  641. package/src/seo/index.ts +179 -0
  642. package/src/settings/index.ts +203 -0
  643. package/src/settings/types.ts +58 -0
  644. package/src/storage/index.ts +28 -0
  645. package/src/storage/local.ts +249 -0
  646. package/src/storage/s3.ts +263 -0
  647. package/src/storage/types.ts +204 -0
  648. package/src/taxonomies/index.ts +309 -0
  649. package/src/taxonomies/types.ts +61 -0
  650. package/src/ui.ts +75 -0
  651. package/src/utils/base64.ts +73 -0
  652. package/src/utils/hash.ts +36 -0
  653. package/src/utils/sanitize.ts +20 -0
  654. package/src/utils/slugify.ts +29 -0
  655. package/src/utils/url.ts +48 -0
  656. package/src/virtual-modules.d.ts +111 -0
  657. package/src/visual-editing/editable.ts +108 -0
  658. package/src/visual-editing/toolbar.ts +1229 -0
  659. package/src/widgets/components.ts +105 -0
  660. package/src/widgets/index.ts +131 -0
  661. package/src/widgets/types.ts +81 -0
@@ -0,0 +1,2096 @@
1
+ /**
2
+ * EmDashRuntime - Core runtime for EmDash CMS
3
+ *
4
+ * Manages database, storage, plugins (trusted + sandboxed), hooks, and
5
+ * provides handlers for content/media operations.
6
+ *
7
+ * Created once per worker lifetime, cached and reused across requests.
8
+ */
9
+
10
+ import type { Element } from "@emdash-cms/blocks";
11
+ import { Kysely, sql, type Dialect } from "kysely";
12
+
13
+ import { validateRev } from "./api/rev.js";
14
+ import type {
15
+ EmDashConfig,
16
+ PluginAdminPage,
17
+ PluginDashboardWidget,
18
+ } from "./astro/integration/runtime.js";
19
+ import type { EmDashManifest, ManifestCollection } from "./astro/types.js";
20
+ import { getAuthMode } from "./auth/mode.js";
21
+ import { isSqlite } from "./database/dialect-helpers.js";
22
+ import { runMigrations } from "./database/migrations/runner.js";
23
+ import { RevisionRepository } from "./database/repositories/revision.js";
24
+ import type { ContentItem as ContentItemInternal } from "./database/repositories/types.js";
25
+ import { normalizeMediaValue } from "./media/normalize.js";
26
+ import type { MediaProvider, MediaProviderCapabilities } from "./media/types.js";
27
+ import type { SandboxedPlugin, SandboxRunner } from "./plugins/sandbox/types.js";
28
+ import type {
29
+ ResolvedPlugin,
30
+ MediaItem,
31
+ PluginManifest,
32
+ PluginCapability,
33
+ PluginStorageConfig,
34
+ PublicPageContext,
35
+ PageMetadataContribution,
36
+ PageFragmentContribution,
37
+ } from "./plugins/types.js";
38
+ import type { FieldType } from "./schema/types.js";
39
+ import { hashString } from "./utils/hash.js";
40
+
41
+ const LEADING_SLASH_PATTERN = /^\//;
42
+
43
+ /** Combined result from a single-pass page contribution collection */
44
+ interface PageContributions {
45
+ metadata: PageMetadataContribution[];
46
+ fragments: PageFragmentContribution[];
47
+ }
48
+
49
+ const VALID_METADATA_KINDS = new Set(["meta", "property", "link", "jsonld"]);
50
+
51
+ /** Security-critical allowlist for link rel values from sandboxed plugins */
52
+ const VALID_LINK_REL = new Set([
53
+ "canonical",
54
+ "alternate",
55
+ "author",
56
+ "license",
57
+ "site.standard.document",
58
+ ]);
59
+
60
+ /**
61
+ * Runtime validation for sandboxed plugin metadata contributions.
62
+ * Sandboxed plugins return `unknown` across the RPC boundary — we must
63
+ * verify the shape before passing to the metadata collector.
64
+ */
65
+ function isValidMetadataContribution(c: unknown): c is PageMetadataContribution {
66
+ if (!c || typeof c !== "object" || !("kind" in c)) return false;
67
+ const obj = c as Record<string, unknown>;
68
+ if (typeof obj.kind !== "string" || !VALID_METADATA_KINDS.has(obj.kind)) return false;
69
+
70
+ switch (obj.kind) {
71
+ case "meta":
72
+ return typeof obj.name === "string" && typeof obj.content === "string";
73
+ case "property":
74
+ return typeof obj.property === "string" && typeof obj.content === "string";
75
+ case "link":
76
+ return (
77
+ typeof obj.href === "string" && typeof obj.rel === "string" && VALID_LINK_REL.has(obj.rel)
78
+ );
79
+ case "jsonld":
80
+ return obj.graph != null && typeof obj.graph === "object";
81
+ default:
82
+ return false;
83
+ }
84
+ }
85
+
86
+ import { loadBundleFromR2 } from "./api/handlers/marketplace.js";
87
+ import { runSystemCleanup } from "./cleanup.js";
88
+ import {
89
+ DEFAULT_COMMENT_MODERATOR_PLUGIN_ID,
90
+ defaultCommentModerate,
91
+ } from "./comments/moderator.js";
92
+ import { OptionsRepository } from "./database/repositories/options.js";
93
+ import {
94
+ handleContentList,
95
+ handleContentGet,
96
+ handleContentGetIncludingTrashed,
97
+ handleContentCreate,
98
+ handleContentUpdate,
99
+ handleContentDelete,
100
+ handleContentDuplicate,
101
+ handleContentRestore,
102
+ handleContentPermanentDelete,
103
+ handleContentListTrashed,
104
+ handleContentCountTrashed,
105
+ handleContentPublish,
106
+ handleContentUnpublish,
107
+ handleContentSchedule,
108
+ handleContentUnschedule,
109
+ handleContentCountScheduled,
110
+ handleContentDiscardDraft,
111
+ handleContentCompare,
112
+ handleContentTranslations,
113
+ handleMediaList,
114
+ handleMediaGet,
115
+ handleMediaCreate,
116
+ handleMediaUpdate,
117
+ handleMediaDelete,
118
+ handleRevisionList,
119
+ handleRevisionGet,
120
+ handleRevisionRestore,
121
+ SchemaRegistry,
122
+ type Database,
123
+ type Storage,
124
+ } from "./index.js";
125
+ import { getDb } from "./loader.js";
126
+ import { CronExecutor, type InvokeCronHookFn } from "./plugins/cron.js";
127
+ import { definePlugin } from "./plugins/define-plugin.js";
128
+ import { DEV_CONSOLE_EMAIL_PLUGIN_ID, devConsoleEmailDeliver } from "./plugins/email-console.js";
129
+ import { EmailPipeline } from "./plugins/email.js";
130
+ import {
131
+ createHookPipeline,
132
+ resolveExclusiveHooks as resolveExclusiveHooksShared,
133
+ type HookPipeline,
134
+ } from "./plugins/hooks.js";
135
+ import { normalizeManifestRoute } from "./plugins/manifest-schema.js";
136
+ import { extractRequestMeta, sanitizeHeadersForSandbox } from "./plugins/request-meta.js";
137
+ import { PluginRouteRegistry, type RouteMeta } from "./plugins/routes.js";
138
+ import { NodeCronScheduler } from "./plugins/scheduler/node.js";
139
+ import { PiggybackScheduler } from "./plugins/scheduler/piggyback.js";
140
+ import type { CronScheduler } from "./plugins/scheduler/types.js";
141
+ import { PluginStateRepository } from "./plugins/state.js";
142
+ import { getRequestContext } from "./request-context.js";
143
+ import { FTSManager } from "./search/fts-manager.js";
144
+
145
+ /**
146
+ * Map schema field types to editor field kinds
147
+ */
148
+ const FIELD_TYPE_TO_KIND: Record<FieldType, string> = {
149
+ string: "string",
150
+ slug: "string",
151
+ text: "richText",
152
+ number: "number",
153
+ integer: "number",
154
+ boolean: "boolean",
155
+ datetime: "datetime",
156
+ select: "select",
157
+ multiSelect: "multiSelect",
158
+ portableText: "portableText",
159
+ image: "image",
160
+ file: "file",
161
+ reference: "reference",
162
+ json: "json",
163
+ };
164
+
165
+ /**
166
+ * Sandboxed plugin entry from virtual module
167
+ */
168
+ export interface SandboxedPluginEntry {
169
+ id: string;
170
+ version: string;
171
+ options: Record<string, unknown>;
172
+ code: string;
173
+ /** Capabilities the plugin requests */
174
+ capabilities: PluginCapability[];
175
+ /** Allowed hosts for network:fetch */
176
+ allowedHosts: string[];
177
+ /** Declared storage collections */
178
+ storage: PluginStorageConfig;
179
+ /** Admin pages */
180
+ adminPages?: Array<{ path: string; label?: string; icon?: string }>;
181
+ /** Dashboard widgets */
182
+ adminWidgets?: Array<{ id: string; title?: string; size?: string }>;
183
+ /** Admin entry module */
184
+ adminEntry?: string;
185
+ /**
186
+ * Exclusive hooks this plugin should be auto-selected for.
187
+ * Weaker than an existing admin DB selection — config order wins when no selection exists.
188
+ */
189
+ preferred?: string[];
190
+ }
191
+
192
+ /**
193
+ * Media provider entry from virtual module
194
+ */
195
+ export interface MediaProviderEntry {
196
+ id: string;
197
+ name: string;
198
+ icon?: string;
199
+ capabilities: MediaProviderCapabilities;
200
+ /** Factory function to create the provider instance */
201
+ createProvider: (ctx: MediaProviderContext) => MediaProvider;
202
+ }
203
+
204
+ /**
205
+ * Context passed to media provider factory functions
206
+ */
207
+ export interface MediaProviderContext {
208
+ db: Kysely<Database>;
209
+ storage: Storage | null;
210
+ }
211
+
212
+ /**
213
+ * Dependencies injected from virtual modules (middleware reads these)
214
+ */
215
+ export interface RuntimeDependencies {
216
+ config: EmDashConfig;
217
+ plugins: ResolvedPlugin[];
218
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
219
+ createDialect: (config: any) => Dialect;
220
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
221
+ createStorage: ((config: any) => Storage) | null;
222
+ sandboxEnabled: boolean;
223
+ /** Media provider entries from virtual module */
224
+ mediaProviderEntries?: MediaProviderEntry[];
225
+ sandboxedPluginEntries: SandboxedPluginEntry[];
226
+ /** Factory function matching SandboxRunnerFactory signature */
227
+ createSandboxRunner: ((opts: { db: Kysely<Database> }) => SandboxRunner) | null;
228
+ }
229
+
230
+ /**
231
+ * Convert a ContentItem to Record<string, unknown> for hook consumption.
232
+ * Hooks receive the full item as a flat record.
233
+ */
234
+ function contentItemToRecord(item: ContentItemInternal): Record<string, unknown> {
235
+ return { ...item };
236
+ }
237
+
238
+ // Module-level caches (persist across requests within worker)
239
+ const dbCache = new Map<string, Kysely<Database>>();
240
+ let dbInitPromise: Promise<Kysely<Database>> | null = null;
241
+ const storageCache = new Map<string, Storage>();
242
+ const sandboxedPluginCache = new Map<string, SandboxedPlugin>();
243
+ const marketplacePluginKeys = new Set<string>();
244
+ /** Manifest metadata for marketplace plugins: pluginId -> manifest admin config */
245
+ const marketplaceManifestCache = new Map<
246
+ string,
247
+ {
248
+ id: string;
249
+ version: string;
250
+ admin?: { pages?: PluginAdminPage[]; widgets?: PluginDashboardWidget[] };
251
+ }
252
+ >();
253
+ /** Route metadata for sandboxed plugins: pluginId -> routeName -> RouteMeta */
254
+ const sandboxedRouteMetaCache = new Map<string, Map<string, RouteMeta>>();
255
+ let sandboxRunner: SandboxRunner | null = null;
256
+
257
+ /**
258
+ * EmDashRuntime - singleton per worker
259
+ */
260
+ export class EmDashRuntime {
261
+ /**
262
+ * The singleton database instance (worker-lifetime cached).
263
+ * Use the `db` getter instead — it checks the request context first
264
+ * for per-request overrides (D1 read replica sessions, DO multi-site).
265
+ */
266
+ private readonly _db: Kysely<Database>;
267
+ readonly storage: Storage | null;
268
+ readonly configuredPlugins: ResolvedPlugin[];
269
+ readonly sandboxedPlugins: Map<string, SandboxedPlugin>;
270
+ readonly sandboxedPluginEntries: SandboxedPluginEntry[];
271
+ readonly schemaRegistry: SchemaRegistry;
272
+ private _hooks!: HookPipeline;
273
+ readonly config: EmDashConfig;
274
+ readonly mediaProviders: Map<string, MediaProvider>;
275
+ readonly mediaProviderEntries: MediaProviderEntry[];
276
+ readonly cronExecutor: CronExecutor | null;
277
+ readonly email: EmailPipeline | null;
278
+
279
+ private cronScheduler: CronScheduler | null;
280
+ private enabledPlugins: Set<string>;
281
+ private pluginStates: Map<string, string>;
282
+
283
+ /** Current hook pipeline. Use the `hooks` getter for external access. */
284
+ get hooks(): HookPipeline {
285
+ return this._hooks;
286
+ }
287
+
288
+ /** All plugins eligible for the hook pipeline (includes built-in plugins).
289
+ * Stored so we can rebuild the pipeline when plugins are enabled/disabled. */
290
+ private allPipelinePlugins: ResolvedPlugin[];
291
+ /** Factory options for the hook pipeline context factory */
292
+ private pipelineFactoryOptions: {
293
+ db: Kysely<Database>;
294
+ storage?: Storage;
295
+ siteInfo?: { siteName?: string; siteUrl?: string; locale?: string };
296
+ };
297
+ /** Dependencies needed for exclusive hook resolution */
298
+ private runtimeDeps: RuntimeDependencies;
299
+ /** Mutable ref for the cron invokeCronHook closure to read the current pipeline */
300
+ private pipelineRef!: { current: HookPipeline };
301
+
302
+ /**
303
+ * Get the database instance for the current request.
304
+ *
305
+ * Checks the ALS-based request context first — middleware sets a
306
+ * per-request Kysely instance there for D1 read replica sessions
307
+ * or DO preview databases. Falls back to the singleton instance.
308
+ */
309
+ get db(): Kysely<Database> {
310
+ const ctx = getRequestContext();
311
+ if (ctx?.db) {
312
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- db in context is set by middleware with correct type
313
+ return ctx.db as Kysely<Database>;
314
+ }
315
+ return this._db;
316
+ }
317
+
318
+ private constructor(
319
+ db: Kysely<Database>,
320
+ storage: Storage | null,
321
+ configuredPlugins: ResolvedPlugin[],
322
+ sandboxedPlugins: Map<string, SandboxedPlugin>,
323
+ sandboxedPluginEntries: SandboxedPluginEntry[],
324
+ hooks: HookPipeline,
325
+ enabledPlugins: Set<string>,
326
+ pluginStates: Map<string, string>,
327
+ config: EmDashConfig,
328
+ mediaProviders: Map<string, MediaProvider>,
329
+ mediaProviderEntries: MediaProviderEntry[],
330
+ cronExecutor: CronExecutor | null,
331
+ cronScheduler: CronScheduler | null,
332
+ emailPipeline: EmailPipeline | null,
333
+ allPipelinePlugins: ResolvedPlugin[],
334
+ pipelineFactoryOptions: {
335
+ db: Kysely<Database>;
336
+ storage?: Storage;
337
+ siteInfo?: { siteName?: string; siteUrl?: string; locale?: string };
338
+ },
339
+ runtimeDeps: RuntimeDependencies,
340
+ pipelineRef: { current: HookPipeline },
341
+ ) {
342
+ this._db = db;
343
+ this.storage = storage;
344
+ this.configuredPlugins = configuredPlugins;
345
+ this.sandboxedPlugins = sandboxedPlugins;
346
+ this.sandboxedPluginEntries = sandboxedPluginEntries;
347
+ this.schemaRegistry = new SchemaRegistry(db);
348
+ this._hooks = hooks;
349
+ this.enabledPlugins = enabledPlugins;
350
+ this.pluginStates = pluginStates;
351
+ this.config = config;
352
+ this.mediaProviders = mediaProviders;
353
+ this.mediaProviderEntries = mediaProviderEntries;
354
+ this.cronExecutor = cronExecutor;
355
+ this.cronScheduler = cronScheduler;
356
+ this.email = emailPipeline;
357
+ this.allPipelinePlugins = allPipelinePlugins;
358
+ this.pipelineFactoryOptions = pipelineFactoryOptions;
359
+ this.runtimeDeps = runtimeDeps;
360
+ this.pipelineRef = pipelineRef;
361
+ }
362
+
363
+ /**
364
+ * Get the sandbox runner instance (for marketplace install/update)
365
+ */
366
+ getSandboxRunner(): SandboxRunner | null {
367
+ return sandboxRunner;
368
+ }
369
+
370
+ /**
371
+ * Tick the cron system from request context (piggyback mode).
372
+ * Call this from middleware on each request to ensure cron tasks
373
+ * execute even when no dedicated scheduler is available.
374
+ */
375
+ tickCron(): void {
376
+ if (this.cronScheduler instanceof PiggybackScheduler) {
377
+ this.cronScheduler.onRequest();
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Stop the cron scheduler gracefully.
383
+ * Call during worker shutdown or hot-reload.
384
+ */
385
+ async stopCron(): Promise<void> {
386
+ if (this.cronScheduler) {
387
+ await this.cronScheduler.stop();
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Update in-memory plugin status and rebuild the hook pipeline.
393
+ *
394
+ * Rebuilding the pipeline ensures disabled plugins' hooks stop firing
395
+ * and re-enabled plugins' hooks start firing again without a restart.
396
+ * Exclusive hook selections are re-resolved after each rebuild.
397
+ */
398
+ async setPluginStatus(pluginId: string, status: "active" | "inactive"): Promise<void> {
399
+ this.pluginStates.set(pluginId, status);
400
+ if (status === "active") {
401
+ this.enabledPlugins.add(pluginId);
402
+ } else {
403
+ this.enabledPlugins.delete(pluginId);
404
+ }
405
+
406
+ await this.rebuildHookPipeline();
407
+ }
408
+
409
+ /**
410
+ * Rebuild the hook pipeline from the current set of enabled plugins.
411
+ *
412
+ * Filters `allPipelinePlugins` to only those in `enabledPlugins`,
413
+ * creates a fresh HookPipeline, re-resolves exclusive hook selections,
414
+ * and re-wires the context factory so existing references (cron
415
+ * callbacks, email pipeline) use the new pipeline.
416
+ */
417
+ private async rebuildHookPipeline(): Promise<void> {
418
+ const enabledList = this.allPipelinePlugins.filter((p) => this.enabledPlugins.has(p.id));
419
+ const newPipeline = createHookPipeline(enabledList, this.pipelineFactoryOptions);
420
+
421
+ // Re-resolve exclusive hooks against the new pipeline
422
+ await EmDashRuntime.resolveExclusiveHooks(newPipeline, this.db, this.runtimeDeps);
423
+
424
+ // Carry over context factory options from the old pipeline so that
425
+ // email, cron reschedule, and other wired-in options are preserved.
426
+ // The old pipeline's contextFactoryOptions were built up incrementally
427
+ // via setContextFactory calls during create(). We replay them here.
428
+ if (this.email) {
429
+ newPipeline.setContextFactory({ db: this.db, emailPipeline: this.email });
430
+ }
431
+ if (this.cronScheduler) {
432
+ const scheduler = this.cronScheduler;
433
+ newPipeline.setContextFactory({
434
+ cronReschedule: () => scheduler.reschedule(),
435
+ });
436
+ }
437
+
438
+ // Update the email pipeline to use the new hook pipeline
439
+ if (this.email) {
440
+ this.email.setPipeline(newPipeline);
441
+ }
442
+
443
+ // Update the mutable ref so the cron closure dispatches through
444
+ // the new pipeline without needing to reconstruct the CronExecutor.
445
+ this.pipelineRef.current = newPipeline;
446
+
447
+ this._hooks = newPipeline;
448
+ }
449
+
450
+ /**
451
+ * Synchronize marketplace plugin runtime state with DB + storage.
452
+ *
453
+ * Ensures install/update/uninstall changes take effect immediately in the
454
+ * current worker: loads newly active plugins and removes uninstalled ones.
455
+ */
456
+ async syncMarketplacePlugins(): Promise<void> {
457
+ if (!this.config.marketplace || !this.storage) return;
458
+ if (!sandboxRunner || !sandboxRunner.isAvailable()) return;
459
+
460
+ try {
461
+ const stateRepo = new PluginStateRepository(this.db);
462
+ const marketplaceStates = await stateRepo.getMarketplacePlugins();
463
+
464
+ const desired = new Map<string, string>();
465
+ for (const state of marketplaceStates) {
466
+ this.pluginStates.set(state.pluginId, state.status);
467
+ if (state.status === "active") {
468
+ this.enabledPlugins.add(state.pluginId);
469
+ } else {
470
+ this.enabledPlugins.delete(state.pluginId);
471
+ }
472
+ if (state.status !== "active") continue;
473
+ desired.set(state.pluginId, state.marketplaceVersion ?? state.version);
474
+ }
475
+
476
+ // Remove uninstalled or no-longer-active marketplace plugins from memory.
477
+ const keysToRemove: string[] = [];
478
+ for (const key of marketplacePluginKeys) {
479
+ const [pluginId] = key.split(":");
480
+ if (!pluginId) continue;
481
+ const desiredVersion = desired.get(pluginId);
482
+ if (desiredVersion && key === `${pluginId}:${desiredVersion}`) continue;
483
+ keysToRemove.push(key);
484
+ }
485
+
486
+ for (const key of keysToRemove) {
487
+ const [pluginId] = key.split(":");
488
+ if (!pluginId) continue;
489
+ const desiredVersion = desired.get(pluginId);
490
+ if (!desiredVersion) {
491
+ this.pluginStates.delete(pluginId);
492
+ this.enabledPlugins.delete(pluginId);
493
+ }
494
+
495
+ const existing = sandboxedPluginCache.get(key);
496
+ if (existing) {
497
+ try {
498
+ await existing.terminate();
499
+ } catch (error) {
500
+ console.warn(`EmDash: Failed to terminate sandboxed plugin ${key}:`, error);
501
+ }
502
+ }
503
+
504
+ sandboxedPluginCache.delete(key);
505
+ this.sandboxedPlugins.delete(key);
506
+ marketplacePluginKeys.delete(key);
507
+ if (pluginId) {
508
+ sandboxedRouteMetaCache.delete(pluginId);
509
+ marketplaceManifestCache.delete(pluginId);
510
+ }
511
+ }
512
+
513
+ // Load newly active marketplace plugins.
514
+ for (const [pluginId, version] of desired) {
515
+ const key = `${pluginId}:${version}`;
516
+ if (sandboxedPluginCache.has(key)) {
517
+ marketplacePluginKeys.add(key);
518
+ continue;
519
+ }
520
+
521
+ const bundle = await loadBundleFromR2(this.storage, pluginId, version);
522
+ if (!bundle) {
523
+ console.warn(`EmDash: Marketplace plugin ${pluginId}@${version} not found in R2`);
524
+ continue;
525
+ }
526
+
527
+ const loaded = await sandboxRunner.load(bundle.manifest, bundle.backendCode);
528
+ sandboxedPluginCache.set(key, loaded);
529
+ this.sandboxedPlugins.set(key, loaded);
530
+ marketplacePluginKeys.add(key);
531
+
532
+ // Cache manifest admin config for getManifest()
533
+ marketplaceManifestCache.set(pluginId, {
534
+ id: bundle.manifest.id,
535
+ version: bundle.manifest.version,
536
+ admin: bundle.manifest.admin,
537
+ });
538
+
539
+ // Cache route metadata from manifest for auth decisions
540
+ if (bundle.manifest.routes.length > 0) {
541
+ const routeMetaMap = new Map<string, RouteMeta>();
542
+ for (const entry of bundle.manifest.routes) {
543
+ const normalized = normalizeManifestRoute(entry);
544
+ routeMetaMap.set(normalized.name, { public: normalized.public === true });
545
+ }
546
+ sandboxedRouteMetaCache.set(pluginId, routeMetaMap);
547
+ } else {
548
+ sandboxedRouteMetaCache.delete(pluginId);
549
+ }
550
+ }
551
+ } catch (error) {
552
+ console.error("EmDash: Failed to sync marketplace plugins:", error);
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Create and initialize the runtime
558
+ */
559
+ static async create(deps: RuntimeDependencies): Promise<EmDashRuntime> {
560
+ // Initialize database
561
+ const db = await EmDashRuntime.getDatabase(deps);
562
+
563
+ // Verify and repair FTS indexes (auto-heal crash corruption)
564
+ // FTS5 is SQLite-only; on other dialects, search is a no-op until
565
+ // the pluggable SearchProvider work lands.
566
+ if (isSqlite(db)) {
567
+ try {
568
+ const ftsManager = new FTSManager(db);
569
+ const repaired = await ftsManager.verifyAndRepairAll();
570
+ if (repaired > 0) {
571
+ console.log(`Repaired ${repaired} corrupted FTS index(es) at startup`);
572
+ }
573
+ } catch {
574
+ // FTS tables may not exist yet (pre-setup). Non-fatal.
575
+ }
576
+ }
577
+
578
+ // Initialize storage
579
+ const storage = EmDashRuntime.getStorage(deps);
580
+
581
+ // Fetch plugin states from database
582
+ let pluginStates: Map<string, string> = new Map();
583
+ try {
584
+ const states = await db.selectFrom("_plugin_state").select(["plugin_id", "status"]).execute();
585
+ pluginStates = new Map(states.map((s) => [s.plugin_id, s.status]));
586
+ } catch {
587
+ // Plugin state table may not exist yet
588
+ }
589
+
590
+ // Build set of enabled plugins
591
+ const enabledPlugins = new Set<string>();
592
+ for (const plugin of deps.plugins) {
593
+ const status = pluginStates.get(plugin.id);
594
+ if (status === undefined || status === "active") {
595
+ enabledPlugins.add(plugin.id);
596
+ }
597
+ }
598
+
599
+ // Load site info for plugin context extensions
600
+ let siteInfo: { siteName?: string; siteUrl?: string; locale?: string } | undefined;
601
+ try {
602
+ const optionsRepo = new OptionsRepository(db);
603
+ const siteName = await optionsRepo.get<string>("emdash:site_title");
604
+ const siteUrl = await optionsRepo.get<string>("emdash:site_url");
605
+ const locale = await optionsRepo.get<string>("emdash:locale");
606
+ siteInfo = {
607
+ siteName: siteName ?? undefined,
608
+ siteUrl: siteUrl ?? undefined,
609
+ locale: locale ?? undefined,
610
+ };
611
+ } catch {
612
+ // Options table may not exist yet (pre-setup)
613
+ }
614
+
615
+ // Build the full list of pipeline-eligible plugins: all configured
616
+ // plugins (regardless of current enabled status) plus built-in plugins.
617
+ // rebuildHookPipeline() filters this to only enabled plugins.
618
+ const allPipelinePlugins: ResolvedPlugin[] = [...deps.plugins];
619
+
620
+ // In dev mode, register a built-in console email provider.
621
+ // It participates in exclusive hook resolution like any other plugin —
622
+ // auto-selected when it's the sole provider, overridden when a real one is configured.
623
+ // Gated by import.meta.env.DEV to prevent silent email loss in production.
624
+ if (import.meta.env.DEV) {
625
+ try {
626
+ const devConsolePlugin = definePlugin({
627
+ id: DEV_CONSOLE_EMAIL_PLUGIN_ID,
628
+ version: "0.0.0",
629
+ capabilities: ["email:provide"],
630
+ hooks: {
631
+ "email:deliver": {
632
+ exclusive: true,
633
+ handler: devConsoleEmailDeliver,
634
+ },
635
+ },
636
+ });
637
+ allPipelinePlugins.push(devConsolePlugin);
638
+ // Built-in plugins are always enabled
639
+ enabledPlugins.add(devConsolePlugin.id);
640
+ } catch (error) {
641
+ console.warn("[email] Failed to register dev console email provider:", error);
642
+ }
643
+ }
644
+
645
+ // Register built-in default comment moderator.
646
+ // Always present — auto-selected as the sole comment:moderate provider
647
+ // unless a plugin (e.g. AI moderation) provides its own.
648
+ try {
649
+ const defaultModeratorPlugin = definePlugin({
650
+ id: DEFAULT_COMMENT_MODERATOR_PLUGIN_ID,
651
+ version: "0.0.0",
652
+ capabilities: ["read:users"],
653
+ hooks: {
654
+ "comment:moderate": {
655
+ exclusive: true,
656
+ handler: defaultCommentModerate,
657
+ },
658
+ },
659
+ });
660
+ allPipelinePlugins.push(defaultModeratorPlugin);
661
+ // Built-in plugins are always enabled
662
+ enabledPlugins.add(defaultModeratorPlugin.id);
663
+ } catch (error) {
664
+ console.warn("[comments] Failed to register default moderator:", error);
665
+ }
666
+
667
+ // Filter to currently enabled plugins for the initial pipeline
668
+ const enabledPluginList = allPipelinePlugins.filter((p) => enabledPlugins.has(p.id));
669
+
670
+ // Create hook pipeline
671
+ const pipelineFactoryOptions = {
672
+ db,
673
+ storage: storage ?? undefined,
674
+ siteInfo,
675
+ };
676
+ const pipeline = createHookPipeline(enabledPluginList, pipelineFactoryOptions);
677
+
678
+ // Load sandboxed plugins (build-time)
679
+ const sandboxedPlugins = await EmDashRuntime.loadSandboxedPlugins(deps, db);
680
+
681
+ // Cold-start: load marketplace-installed plugins from site R2
682
+ if (deps.config.marketplace && storage) {
683
+ await EmDashRuntime.loadMarketplacePlugins(db, storage, deps, sandboxedPlugins);
684
+ }
685
+
686
+ // Initialize media providers
687
+ const mediaProviders = new Map<string, MediaProvider>();
688
+ const mediaProviderEntries = deps.mediaProviderEntries ?? [];
689
+ const providerContext: MediaProviderContext = { db, storage };
690
+
691
+ for (const entry of mediaProviderEntries) {
692
+ try {
693
+ const provider = entry.createProvider(providerContext);
694
+ mediaProviders.set(entry.id, provider);
695
+ } catch (error) {
696
+ console.warn(`Failed to initialize media provider "${entry.id}":`, error);
697
+ }
698
+ }
699
+
700
+ // Resolve exclusive hooks — auto-select providers and sync with DB
701
+ await EmDashRuntime.resolveExclusiveHooks(pipeline, db, deps);
702
+
703
+ // ── Email pipeline ───────────────────────────────────────────────
704
+ // The email pipeline orchestrates beforeSend → deliver → afterSend.
705
+ // The dev console provider was registered above and will be auto-selected
706
+ // by resolveExclusiveHooks if it's the sole email:deliver provider.
707
+ const emailPipeline = new EmailPipeline(pipeline);
708
+
709
+ // Wire email send into sandbox runner (created earlier but without
710
+ // email pipeline since it didn't exist yet)
711
+ if (sandboxRunner) {
712
+ sandboxRunner.setEmailSend((message, pluginId) => emailPipeline.send(message, pluginId));
713
+ }
714
+
715
+ // ── Cron system ──────────────────────────────────────────────────
716
+ // Create executor with a hook dispatch function that uses the pipeline.
717
+ // The callback reads from a mutable ref so that rebuildHookPipeline()
718
+ // can swap the pipeline without reconstructing the CronExecutor.
719
+ const pipelineRef = { current: pipeline };
720
+ const invokeCronHook: InvokeCronHookFn = async (pluginId, event) => {
721
+ const result = await pipelineRef.current.invokeCronHook(pluginId, event);
722
+ if (!result.success && result.error) {
723
+ throw result.error;
724
+ }
725
+ };
726
+
727
+ // Wire email pipeline into context factory (independent of cron —
728
+ // must not be inside the cron try/catch or ctx.email breaks when cron fails)
729
+ pipeline.setContextFactory({ db, emailPipeline });
730
+
731
+ let cronExecutor: CronExecutor | null = null;
732
+ let cronScheduler: CronScheduler | null = null;
733
+
734
+ try {
735
+ cronExecutor = new CronExecutor(db, invokeCronHook);
736
+
737
+ // Recover stale locks from previous crashes
738
+ const recovered = await cronExecutor.recoverStaleLocks();
739
+ if (recovered > 0) {
740
+ console.log(`[cron] Recovered ${recovered} stale task lock(s)`);
741
+ }
742
+
743
+ // Detect platform and create appropriate scheduler.
744
+ // On Cloudflare Workers, setTimeout is available but unreliable for
745
+ // long durations — use PiggybackScheduler as default.
746
+ // In Node/Bun, use NodeCronScheduler with real timers.
747
+ const isWorkersRuntime =
748
+ typeof globalThis.navigator !== "undefined" &&
749
+ globalThis.navigator.userAgent === "Cloudflare-Workers";
750
+
751
+ if (isWorkersRuntime) {
752
+ cronScheduler = new PiggybackScheduler(cronExecutor);
753
+ } else {
754
+ cronScheduler = new NodeCronScheduler(cronExecutor);
755
+ }
756
+
757
+ // Register system cleanup to run alongside each scheduler tick.
758
+ // Pass storage so cleanupPendingUploads can delete orphaned files.
759
+ cronScheduler.setSystemCleanup(async () => {
760
+ try {
761
+ await runSystemCleanup(db, storage ?? undefined);
762
+ } catch (error) {
763
+ // Non-fatal -- individual cleanup failures are already logged
764
+ // by runSystemCleanup. This catches unexpected errors.
765
+ console.error("[cleanup] System cleanup failed:", error);
766
+ }
767
+ });
768
+
769
+ // Add cron reschedule callback (merges with existing factory options)
770
+ pipeline.setContextFactory({
771
+ cronReschedule: () => cronScheduler?.reschedule(),
772
+ });
773
+
774
+ // Start the scheduler
775
+ await cronScheduler.start();
776
+ } catch (error) {
777
+ console.warn("[cron] Failed to initialize cron system:", error);
778
+ // Non-fatal — CMS works without cron
779
+ }
780
+
781
+ return new EmDashRuntime(
782
+ db,
783
+ storage,
784
+ deps.plugins,
785
+ sandboxedPlugins,
786
+ deps.sandboxedPluginEntries,
787
+ pipeline,
788
+ enabledPlugins,
789
+ pluginStates,
790
+ deps.config,
791
+ mediaProviders,
792
+ mediaProviderEntries,
793
+ cronExecutor,
794
+ cronScheduler,
795
+ emailPipeline,
796
+ allPipelinePlugins,
797
+ pipelineFactoryOptions,
798
+ deps,
799
+ pipelineRef,
800
+ );
801
+ }
802
+
803
+ /**
804
+ * Get a media provider by ID
805
+ */
806
+ getMediaProvider(providerId: string): MediaProvider | undefined {
807
+ return this.mediaProviders.get(providerId);
808
+ }
809
+
810
+ /**
811
+ * Get all media provider entries (for admin UI)
812
+ */
813
+ getMediaProviderList(): Array<{
814
+ id: string;
815
+ name: string;
816
+ icon?: string;
817
+ capabilities: MediaProviderCapabilities;
818
+ }> {
819
+ return this.mediaProviderEntries.map((e) => ({
820
+ id: e.id,
821
+ name: e.name,
822
+ icon: e.icon,
823
+ capabilities: e.capabilities,
824
+ }));
825
+ }
826
+
827
+ /**
828
+ * Get or create database instance
829
+ */
830
+ private static async getDatabase(deps: RuntimeDependencies): Promise<Kysely<Database>> {
831
+ // If a per-request DB override is set (e.g. by the playground middleware
832
+ // which runs before the runtime init), use that directly. This allows
833
+ // the runtime to initialize against the real DO database instead of
834
+ // the dummy singleton dialect.
835
+ const ctx = getRequestContext();
836
+ if (ctx?.db) {
837
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- db in context is typed as unknown to avoid circular deps
838
+ return ctx.db as Kysely<Database>;
839
+ }
840
+
841
+ const dbConfig = deps.config.database;
842
+
843
+ // If no database configured in integration, try to get from loader
844
+ if (!dbConfig) {
845
+ try {
846
+ return await getDb();
847
+ } catch {
848
+ throw new Error(
849
+ "EmDash database not configured. Either configure database in astro.config.mjs or use emdashLoader in live.config.ts",
850
+ );
851
+ }
852
+ }
853
+
854
+ const cacheKey = dbConfig.entrypoint;
855
+
856
+ // Return cached instance if available
857
+ const cached = dbCache.get(cacheKey);
858
+ if (cached) {
859
+ return cached;
860
+ }
861
+
862
+ // Use initialization lock to prevent race conditions.
863
+ // Sharing this promise across requests is safe because the Kysely instance
864
+ // doesn't hold a request-scoped resource — the DO dialect uses a getStub()
865
+ // factory that creates a fresh stub per query execution.
866
+ if (dbInitPromise) {
867
+ return dbInitPromise;
868
+ }
869
+
870
+ dbInitPromise = (async () => {
871
+ const dialect = deps.createDialect(dbConfig.config);
872
+ const db = new Kysely<Database>({ dialect });
873
+
874
+ await runMigrations(db);
875
+
876
+ // Auto-seed schema if no collections exist and setup hasn't run.
877
+ // This covers first-load on sites that skip the setup wizard.
878
+ // Dev-bypass and the wizard apply seeds explicitly.
879
+ try {
880
+ const [collectionCount, setupOption] = await Promise.all([
881
+ db
882
+ .selectFrom("_emdash_collections")
883
+ .select((eb) => eb.fn.countAll<number>().as("count"))
884
+ .executeTakeFirstOrThrow(),
885
+ db
886
+ .selectFrom("options")
887
+ .select("value")
888
+ .where("name", "=", "emdash:setup_complete")
889
+ .executeTakeFirst(),
890
+ ]);
891
+
892
+ const setupDone = (() => {
893
+ try {
894
+ return setupOption && JSON.parse(setupOption.value) === true;
895
+ } catch {
896
+ return false;
897
+ }
898
+ })();
899
+
900
+ if (collectionCount.count === 0 && !setupDone) {
901
+ const { applySeed } = await import("./seed/apply.js");
902
+ const { loadSeed } = await import("./seed/load.js");
903
+ const { validateSeed } = await import("./seed/validate.js");
904
+
905
+ const seed = await loadSeed();
906
+ const validation = validateSeed(seed);
907
+ if (validation.valid) {
908
+ await applySeed(db, seed, { onConflict: "skip" });
909
+ console.log("Auto-seeded default collections");
910
+ }
911
+ }
912
+ } catch {
913
+ // Tables may not exist yet. Non-fatal.
914
+ }
915
+
916
+ dbCache.set(cacheKey, db);
917
+ return db;
918
+ })();
919
+
920
+ try {
921
+ return await dbInitPromise;
922
+ } finally {
923
+ dbInitPromise = null;
924
+ }
925
+ }
926
+
927
+ /**
928
+ * Get or create storage instance
929
+ */
930
+ private static getStorage(deps: RuntimeDependencies): Storage | null {
931
+ const storageConfig = deps.config.storage;
932
+ if (!storageConfig || !deps.createStorage) {
933
+ return null;
934
+ }
935
+
936
+ const cacheKey = storageConfig.entrypoint;
937
+ const cached = storageCache.get(cacheKey);
938
+ if (cached) {
939
+ return cached;
940
+ }
941
+
942
+ const storage = deps.createStorage(storageConfig.config);
943
+ storageCache.set(cacheKey, storage);
944
+ return storage;
945
+ }
946
+
947
+ /**
948
+ * Load sandboxed plugins using SandboxRunner
949
+ */
950
+ private static async loadSandboxedPlugins(
951
+ deps: RuntimeDependencies,
952
+ db: Kysely<Database>,
953
+ ): Promise<Map<string, SandboxedPlugin>> {
954
+ // Return cached plugins if already loaded
955
+ if (sandboxedPluginCache.size > 0) {
956
+ return sandboxedPluginCache;
957
+ }
958
+
959
+ // Check if sandboxing is enabled
960
+ if (!deps.sandboxEnabled || deps.sandboxedPluginEntries.length === 0) {
961
+ return sandboxedPluginCache;
962
+ }
963
+
964
+ // Create sandbox runner if not exists
965
+ if (!sandboxRunner && deps.createSandboxRunner) {
966
+ sandboxRunner = deps.createSandboxRunner({ db });
967
+ }
968
+
969
+ if (!sandboxRunner) {
970
+ return sandboxedPluginCache;
971
+ }
972
+
973
+ // Check if the runner is actually available (has required bindings)
974
+ if (!sandboxRunner.isAvailable()) {
975
+ console.debug("EmDash: Sandbox runner not available (missing bindings), skipping sandbox");
976
+ return sandboxedPluginCache;
977
+ }
978
+
979
+ // Load each sandboxed plugin
980
+ for (const entry of deps.sandboxedPluginEntries) {
981
+ const pluginKey = `${entry.id}:${entry.version}`;
982
+ if (sandboxedPluginCache.has(pluginKey)) {
983
+ continue;
984
+ }
985
+
986
+ try {
987
+ // Build manifest from entry's declared config
988
+ const manifest: PluginManifest = {
989
+ id: entry.id,
990
+ version: entry.version,
991
+ capabilities: entry.capabilities ?? [],
992
+ allowedHosts: entry.allowedHosts ?? [],
993
+ storage: entry.storage ?? {},
994
+ hooks: [],
995
+ routes: [],
996
+ admin: {},
997
+ };
998
+
999
+ const plugin = await sandboxRunner.load(manifest, entry.code);
1000
+ sandboxedPluginCache.set(pluginKey, plugin);
1001
+ console.log(
1002
+ `EmDash: Loaded sandboxed plugin ${pluginKey} with capabilities: [${manifest.capabilities.join(", ")}]`,
1003
+ );
1004
+ } catch (error) {
1005
+ console.error(`EmDash: Failed to load sandboxed plugin ${entry.id}:`, error);
1006
+ }
1007
+ }
1008
+
1009
+ return sandboxedPluginCache;
1010
+ }
1011
+
1012
+ /**
1013
+ * Cold-start: load marketplace-installed plugins from site-local R2 storage
1014
+ *
1015
+ * Queries _plugin_state for source='marketplace' rows, fetches each bundle
1016
+ * from R2, and loads via SandboxRunner.
1017
+ */
1018
+ private static async loadMarketplacePlugins(
1019
+ db: Kysely<Database>,
1020
+ storage: Storage,
1021
+ deps: RuntimeDependencies,
1022
+ cache: Map<string, SandboxedPlugin>,
1023
+ ): Promise<void> {
1024
+ // Ensure sandbox runner exists
1025
+ if (!sandboxRunner && deps.createSandboxRunner) {
1026
+ sandboxRunner = deps.createSandboxRunner({ db });
1027
+ }
1028
+ if (!sandboxRunner || !sandboxRunner.isAvailable()) {
1029
+ return;
1030
+ }
1031
+
1032
+ try {
1033
+ const stateRepo = new PluginStateRepository(db);
1034
+ const marketplacePlugins = await stateRepo.getMarketplacePlugins();
1035
+
1036
+ for (const plugin of marketplacePlugins) {
1037
+ if (plugin.status !== "active") continue;
1038
+
1039
+ const version = plugin.marketplaceVersion ?? plugin.version;
1040
+ const pluginKey = `${plugin.pluginId}:${version}`;
1041
+
1042
+ // Skip if already loaded (shouldn't happen, but guard)
1043
+ if (cache.has(pluginKey)) continue;
1044
+
1045
+ try {
1046
+ const bundle = await loadBundleFromR2(storage, plugin.pluginId, version);
1047
+ if (!bundle) {
1048
+ console.warn(
1049
+ `EmDash: Marketplace plugin ${plugin.pluginId}@${version} not found in R2`,
1050
+ );
1051
+ continue;
1052
+ }
1053
+
1054
+ const loaded = await sandboxRunner.load(bundle.manifest, bundle.backendCode);
1055
+ cache.set(pluginKey, loaded);
1056
+ marketplacePluginKeys.add(pluginKey);
1057
+
1058
+ // Cache manifest admin config for getManifest()
1059
+ marketplaceManifestCache.set(plugin.pluginId, {
1060
+ id: bundle.manifest.id,
1061
+ version: bundle.manifest.version,
1062
+ admin: bundle.manifest.admin,
1063
+ });
1064
+
1065
+ // Cache route metadata from manifest for auth decisions
1066
+ if (bundle.manifest.routes.length > 0) {
1067
+ const routeMeta = new Map<string, RouteMeta>();
1068
+ for (const entry of bundle.manifest.routes) {
1069
+ const normalized = normalizeManifestRoute(entry);
1070
+ routeMeta.set(normalized.name, { public: normalized.public === true });
1071
+ }
1072
+ sandboxedRouteMetaCache.set(plugin.pluginId, routeMeta);
1073
+ }
1074
+
1075
+ console.log(
1076
+ `EmDash: Loaded marketplace plugin ${pluginKey} with capabilities: [${bundle.manifest.capabilities.join(", ")}]`,
1077
+ );
1078
+ } catch (error) {
1079
+ console.error(`EmDash: Failed to load marketplace plugin ${plugin.pluginId}:`, error);
1080
+ }
1081
+ }
1082
+ } catch {
1083
+ // _plugin_state table may not exist yet (pre-migration)
1084
+ }
1085
+ }
1086
+
1087
+ /**
1088
+ * Resolve exclusive hook selections on startup.
1089
+ *
1090
+ * Delegates to the shared resolveExclusiveHooks() in hooks.ts.
1091
+ * The runtime version considers all pipeline providers as "active" since
1092
+ * the pipeline was already built from only active/enabled plugins.
1093
+ */
1094
+ private static async resolveExclusiveHooks(
1095
+ pipeline: HookPipeline,
1096
+ db: Kysely<Database>,
1097
+ deps: RuntimeDependencies,
1098
+ ): Promise<void> {
1099
+ const exclusiveHookNames = pipeline.getRegisteredExclusiveHooks();
1100
+ if (exclusiveHookNames.length === 0) return;
1101
+
1102
+ let optionsRepo: OptionsRepository;
1103
+ try {
1104
+ optionsRepo = new OptionsRepository(db);
1105
+ } catch {
1106
+ return; // Options table may not exist yet
1107
+ }
1108
+
1109
+ // Build preferred hints from sandboxed plugin entries
1110
+ const preferredHints = new Map<string, string[]>();
1111
+ for (const entry of deps.sandboxedPluginEntries) {
1112
+ if (entry.preferred && entry.preferred.length > 0) {
1113
+ preferredHints.set(entry.id, entry.preferred);
1114
+ }
1115
+ }
1116
+
1117
+ // The pipeline was created from only enabled plugins, so all providers
1118
+ // in it are active. The isActive check always returns true.
1119
+ await resolveExclusiveHooksShared({
1120
+ pipeline,
1121
+ isActive: () => true,
1122
+ getOption: (key) => optionsRepo.get<string>(key),
1123
+ setOption: (key, value) => optionsRepo.set(key, value),
1124
+ deleteOption: async (key) => {
1125
+ await optionsRepo.delete(key);
1126
+ },
1127
+ preferredHints,
1128
+ });
1129
+ }
1130
+
1131
+ // =========================================================================
1132
+ // Manifest
1133
+ // =========================================================================
1134
+
1135
+ /**
1136
+ * Build the manifest (rebuilt on each request for freshness)
1137
+ */
1138
+ async getManifest(): Promise<EmDashManifest> {
1139
+ // Build collections from database.
1140
+ // Use this.db (ALS-aware getter) so playground mode picks up the
1141
+ // per-session DO database instead of the hardcoded singleton.
1142
+ const manifestCollections: Record<string, ManifestCollection> = {};
1143
+ try {
1144
+ const registry = new SchemaRegistry(this.db);
1145
+ const dbCollections = await registry.listCollections();
1146
+ for (const collection of dbCollections) {
1147
+ const collectionWithFields = await registry.getCollectionWithFields(collection.slug);
1148
+ const fields: Record<
1149
+ string,
1150
+ {
1151
+ kind: string;
1152
+ label?: string;
1153
+ required?: boolean;
1154
+ widget?: string;
1155
+ options?: Array<{ value: string; label: string }>;
1156
+ }
1157
+ > = {};
1158
+
1159
+ if (collectionWithFields?.fields) {
1160
+ for (const field of collectionWithFields.fields) {
1161
+ const entry: (typeof fields)[string] = {
1162
+ kind: FIELD_TYPE_TO_KIND[field.type] ?? "string",
1163
+ label: field.label,
1164
+ required: field.required,
1165
+ };
1166
+ if (field.widget) entry.widget = field.widget;
1167
+ // Include select/multiSelect options from validation
1168
+ if (field.validation?.options) {
1169
+ entry.options = field.validation.options.map((v) => ({
1170
+ value: v,
1171
+ label: v.charAt(0).toUpperCase() + v.slice(1),
1172
+ }));
1173
+ }
1174
+ fields[field.slug] = entry;
1175
+ }
1176
+ }
1177
+
1178
+ manifestCollections[collection.slug] = {
1179
+ label: collection.label,
1180
+ labelSingular: collection.labelSingular || collection.label,
1181
+ supports: collection.supports || [],
1182
+ hasSeo: collection.hasSeo,
1183
+ fields,
1184
+ };
1185
+ }
1186
+ } catch (error) {
1187
+ console.debug("EmDash: Could not load database collections:", error);
1188
+ }
1189
+
1190
+ // Build plugins manifest
1191
+ const manifestPlugins: Record<
1192
+ string,
1193
+ {
1194
+ version?: string;
1195
+ enabled?: boolean;
1196
+ sandboxed?: boolean;
1197
+ adminMode?: "react" | "blocks" | "none";
1198
+ adminPages?: Array<{ path: string; label?: string; icon?: string }>;
1199
+ dashboardWidgets?: Array<{
1200
+ id: string;
1201
+ title?: string;
1202
+ size?: string;
1203
+ }>;
1204
+ portableTextBlocks?: Array<{
1205
+ type: string;
1206
+ label: string;
1207
+ icon?: string;
1208
+ description?: string;
1209
+ placeholder?: string;
1210
+ fields?: Element[];
1211
+ }>;
1212
+ fieldWidgets?: Array<{
1213
+ name: string;
1214
+ label: string;
1215
+ fieldTypes: string[];
1216
+ elements?: Element[];
1217
+ }>;
1218
+ }
1219
+ > = {};
1220
+
1221
+ for (const plugin of this.configuredPlugins) {
1222
+ const status = this.pluginStates.get(plugin.id);
1223
+ const enabled = status === undefined || status === "active";
1224
+
1225
+ // Determine admin mode: has admin entry → react, has pages/widgets → blocks, else none
1226
+ const hasAdminEntry = !!plugin.admin?.entry;
1227
+ const hasAdminPages = (plugin.admin?.pages?.length ?? 0) > 0;
1228
+ const hasWidgets = (plugin.admin?.widgets?.length ?? 0) > 0;
1229
+ let adminMode: "react" | "blocks" | "none" = "none";
1230
+ if (hasAdminEntry) {
1231
+ adminMode = "react";
1232
+ } else if (hasAdminPages || hasWidgets) {
1233
+ adminMode = "blocks";
1234
+ }
1235
+
1236
+ manifestPlugins[plugin.id] = {
1237
+ version: plugin.version,
1238
+ enabled,
1239
+ adminMode,
1240
+ adminPages: plugin.admin?.pages,
1241
+ dashboardWidgets: plugin.admin?.widgets,
1242
+ portableTextBlocks: plugin.admin?.portableTextBlocks,
1243
+ fieldWidgets: plugin.admin?.fieldWidgets,
1244
+ };
1245
+ }
1246
+
1247
+ // Add sandboxed plugins (use entries for admin config)
1248
+ // TODO: sandboxed plugins need fieldWidgets extracted from their manifest
1249
+ // to support Block Kit field widgets. Currently only trusted plugins carry
1250
+ // fieldWidgets through the admin.fieldWidgets path.
1251
+ for (const entry of this.sandboxedPluginEntries) {
1252
+ const status = this.pluginStates.get(entry.id);
1253
+ const enabled = status === undefined || status === "active";
1254
+
1255
+ const hasAdminPages = (entry.adminPages?.length ?? 0) > 0;
1256
+ const hasWidgets = (entry.adminWidgets?.length ?? 0) > 0;
1257
+
1258
+ manifestPlugins[entry.id] = {
1259
+ version: entry.version,
1260
+ enabled,
1261
+ sandboxed: true,
1262
+ adminMode: hasAdminPages || hasWidgets ? "blocks" : "none",
1263
+ adminPages: entry.adminPages,
1264
+ dashboardWidgets: entry.adminWidgets,
1265
+ };
1266
+ }
1267
+
1268
+ // Add marketplace-installed plugins (dynamically loaded from R2)
1269
+ for (const [pluginId, meta] of marketplaceManifestCache) {
1270
+ // Skip if already included from build-time config
1271
+ if (manifestPlugins[pluginId]) continue;
1272
+
1273
+ const status = this.pluginStates.get(pluginId);
1274
+ const enabled = status === "active";
1275
+
1276
+ const pages = meta.admin?.pages;
1277
+ const widgets = meta.admin?.widgets;
1278
+ const hasAdminPages = (pages?.length ?? 0) > 0;
1279
+ const hasWidgets = (widgets?.length ?? 0) > 0;
1280
+
1281
+ manifestPlugins[pluginId] = {
1282
+ version: meta.version,
1283
+ enabled,
1284
+ sandboxed: true,
1285
+ adminMode: hasAdminPages || hasWidgets ? "blocks" : "none",
1286
+ adminPages: pages,
1287
+ dashboardWidgets: widgets,
1288
+ };
1289
+ }
1290
+
1291
+ // Generate hash from both collections and plugins so cache invalidates
1292
+ // when plugins are enabled/disabled or their config changes
1293
+ const manifestHash = await hashString(
1294
+ JSON.stringify(manifestCollections) + JSON.stringify(manifestPlugins),
1295
+ );
1296
+
1297
+ // Determine auth mode
1298
+ const authMode = getAuthMode(this.config);
1299
+ const authModeValue = authMode.type === "external" ? authMode.providerType : "passkey";
1300
+
1301
+ // Include i18n config if enabled
1302
+ const { getI18nConfig, isI18nEnabled } = await import("./i18n/config.js");
1303
+ const i18nConfig = getI18nConfig();
1304
+ const i18n =
1305
+ isI18nEnabled() && i18nConfig
1306
+ ? { defaultLocale: i18nConfig.defaultLocale, locales: i18nConfig.locales }
1307
+ : undefined;
1308
+
1309
+ return {
1310
+ version: "0.1.0",
1311
+ hash: manifestHash,
1312
+ collections: manifestCollections,
1313
+ plugins: manifestPlugins,
1314
+ authMode: authModeValue,
1315
+ i18n,
1316
+ marketplace: !!this.config.marketplace,
1317
+ };
1318
+ }
1319
+
1320
+ /**
1321
+ * Invalidate the cached manifest (no-op now that we don't cache).
1322
+ * Kept for API compatibility.
1323
+ */
1324
+ invalidateManifest(): void {
1325
+ // No-op - manifest is rebuilt on each request
1326
+ }
1327
+
1328
+ // =========================================================================
1329
+ // Content Handlers
1330
+ // =========================================================================
1331
+
1332
+ async handleContentList(
1333
+ collection: string,
1334
+ params: {
1335
+ cursor?: string;
1336
+ limit?: number;
1337
+ status?: string;
1338
+ orderBy?: string;
1339
+ order?: "asc" | "desc";
1340
+ locale?: string;
1341
+ },
1342
+ ) {
1343
+ return handleContentList(this.db, collection, params);
1344
+ }
1345
+
1346
+ async handleContentGet(collection: string, id: string, locale?: string) {
1347
+ return handleContentGet(this.db, collection, id, locale);
1348
+ }
1349
+
1350
+ async handleContentGetIncludingTrashed(collection: string, id: string, locale?: string) {
1351
+ return handleContentGetIncludingTrashed(this.db, collection, id, locale);
1352
+ }
1353
+
1354
+ async handleContentCreate(
1355
+ collection: string,
1356
+ body: {
1357
+ data: Record<string, unknown>;
1358
+ slug?: string;
1359
+ status?: string;
1360
+ authorId?: string;
1361
+ bylines?: Array<{ bylineId: string; roleLabel?: string | null }>;
1362
+ locale?: string;
1363
+ translationOf?: string;
1364
+ },
1365
+ ) {
1366
+ // Run beforeSave hooks (trusted plugins)
1367
+ let processedData = body.data;
1368
+ if (this.hooks.hasHooks("content:beforeSave")) {
1369
+ const hookResult = await this.hooks.runContentBeforeSave(body.data, collection, true);
1370
+ processedData = hookResult.content;
1371
+ }
1372
+
1373
+ // Run beforeSave hooks (sandboxed plugins)
1374
+ processedData = await this.runSandboxedBeforeSave(processedData, collection, true);
1375
+
1376
+ // Normalize media fields (fill dimensions, storageKey, etc.)
1377
+ processedData = await this.normalizeMediaFields(collection, processedData);
1378
+
1379
+ // Create the content
1380
+ const result = await handleContentCreate(this.db, collection, {
1381
+ ...body,
1382
+ data: processedData,
1383
+ authorId: body.authorId,
1384
+ bylines: body.bylines,
1385
+ });
1386
+
1387
+ // Run afterSave hooks (fire-and-forget)
1388
+ if (result.success && result.data) {
1389
+ this.runAfterSaveHooks(contentItemToRecord(result.data.item), collection, true);
1390
+ }
1391
+
1392
+ return result;
1393
+ }
1394
+
1395
+ async handleContentUpdate(
1396
+ collection: string,
1397
+ id: string,
1398
+ body: {
1399
+ data?: Record<string, unknown>;
1400
+ slug?: string;
1401
+ status?: string;
1402
+ authorId?: string | null;
1403
+ bylines?: Array<{ bylineId: string; roleLabel?: string | null }>;
1404
+ /** Skip revision creation (used by autosave) */
1405
+ skipRevision?: boolean;
1406
+ _rev?: string;
1407
+ },
1408
+ ) {
1409
+ // Resolve slug → ID if needed (before any lookups)
1410
+ const { ContentRepository } = await import("./database/repositories/content.js");
1411
+ const repo = new ContentRepository(this.db);
1412
+ const resolvedItem = await repo.findByIdOrSlug(collection, id);
1413
+ const resolvedId = resolvedItem?.id ?? id;
1414
+
1415
+ // Validate _rev early — before draft revision writes which modify updated_at.
1416
+ // After validation, strip _rev so the handler doesn't double-check against
1417
+ // the now-modified timestamp.
1418
+ if (body._rev) {
1419
+ if (!resolvedItem) {
1420
+ return {
1421
+ success: false as const,
1422
+ error: { code: "NOT_FOUND", message: `Content item not found: ${id}` },
1423
+ };
1424
+ }
1425
+ const revCheck = validateRev(body._rev, resolvedItem);
1426
+ if (!revCheck.valid) {
1427
+ return {
1428
+ success: false as const,
1429
+ error: { code: "CONFLICT", message: revCheck.message },
1430
+ };
1431
+ }
1432
+ }
1433
+ const { _rev: _discardedRev, ...bodyWithoutRev } = body;
1434
+
1435
+ // Run beforeSave hooks if data is provided
1436
+ let processedData = bodyWithoutRev.data;
1437
+ if (bodyWithoutRev.data) {
1438
+ if (this.hooks.hasHooks("content:beforeSave")) {
1439
+ const hookResult = await this.hooks.runContentBeforeSave(
1440
+ bodyWithoutRev.data,
1441
+ collection,
1442
+ false,
1443
+ );
1444
+ processedData = hookResult.content;
1445
+ }
1446
+
1447
+ // Run sandboxed beforeSave hooks
1448
+ processedData = await this.runSandboxedBeforeSave(processedData!, collection, false);
1449
+
1450
+ // Normalize media fields (fill dimensions, storageKey, etc.)
1451
+ processedData = await this.normalizeMediaFields(collection, processedData);
1452
+ }
1453
+
1454
+ // Draft-aware revision handling (if collection supports revisions)
1455
+ // Content table columns = published data (never written by saves).
1456
+ // Draft data lives only in the revisions table.
1457
+ let usesDraftRevisions = false;
1458
+ if (processedData) {
1459
+ try {
1460
+ const collectionInfo = await this.schemaRegistry.getCollectionWithFields(collection);
1461
+ if (collectionInfo?.supports?.includes("revisions")) {
1462
+ usesDraftRevisions = true;
1463
+ const revisionRepo = new RevisionRepository(this.db);
1464
+ // Re-fetch to get latest state (resolvedItem may be stale after _rev check)
1465
+ const existing = await repo.findById(collection, resolvedId);
1466
+
1467
+ if (existing) {
1468
+ // Build the draft data: merge with existing draft revision if one exists,
1469
+ // otherwise merge with the published data from the content table
1470
+ let baseData: Record<string, unknown>;
1471
+ if (existing.draftRevisionId) {
1472
+ const draftRevision = await revisionRepo.findById(existing.draftRevisionId);
1473
+ baseData = draftRevision?.data ?? existing.data;
1474
+ } else {
1475
+ baseData = existing.data;
1476
+ }
1477
+
1478
+ // Include slug in the revision data if it changed
1479
+ const mergedData = { ...baseData, ...processedData };
1480
+ if (bodyWithoutRev.slug !== undefined) {
1481
+ mergedData._slug = bodyWithoutRev.slug;
1482
+ }
1483
+
1484
+ if (bodyWithoutRev.skipRevision && existing.draftRevisionId) {
1485
+ // Autosave: update existing draft revision in place
1486
+ await revisionRepo.updateData(existing.draftRevisionId, mergedData);
1487
+ } else {
1488
+ // Create new draft revision
1489
+ const revision = await revisionRepo.create({
1490
+ collection,
1491
+ entryId: resolvedId,
1492
+ data: mergedData,
1493
+ authorId: bodyWithoutRev.authorId ?? undefined,
1494
+ });
1495
+
1496
+ // Update entry to point to new draft (metadata only, not data columns)
1497
+ const tableName = `ec_${collection}`;
1498
+ await sql`
1499
+ UPDATE ${sql.ref(tableName)}
1500
+ SET draft_revision_id = ${revision.id},
1501
+ updated_at = ${new Date().toISOString()}
1502
+ WHERE id = ${resolvedId}
1503
+ `.execute(this.db);
1504
+
1505
+ // Fire-and-forget: prune old revisions to prevent unbounded growth
1506
+ void revisionRepo.pruneOldRevisions(collection, resolvedId, 50).catch(() => {});
1507
+ }
1508
+ }
1509
+ }
1510
+ } catch {
1511
+ // Don't fail the update if revision creation fails
1512
+ }
1513
+ }
1514
+
1515
+ // Update the content table:
1516
+ // - If collection uses draft revisions: only update metadata (no data fields, no slug)
1517
+ // - Otherwise: update everything as before
1518
+ const result = await handleContentUpdate(this.db, collection, resolvedId, {
1519
+ ...bodyWithoutRev,
1520
+ data: usesDraftRevisions ? undefined : processedData,
1521
+ slug: usesDraftRevisions ? undefined : bodyWithoutRev.slug,
1522
+ authorId: bodyWithoutRev.authorId,
1523
+ bylines: bodyWithoutRev.bylines,
1524
+ });
1525
+
1526
+ // Run afterSave hooks (fire-and-forget)
1527
+ if (result.success && result.data) {
1528
+ this.runAfterSaveHooks(contentItemToRecord(result.data.item), collection, false);
1529
+ }
1530
+
1531
+ return result;
1532
+ }
1533
+
1534
+ async handleContentDelete(collection: string, id: string) {
1535
+ // Run beforeDelete hooks (trusted plugins)
1536
+ if (this.hooks.hasHooks("content:beforeDelete")) {
1537
+ const { allowed } = await this.hooks.runContentBeforeDelete(id, collection);
1538
+ if (!allowed) {
1539
+ return {
1540
+ success: false,
1541
+ error: {
1542
+ code: "DELETE_BLOCKED",
1543
+ message: "Delete blocked by plugin hook",
1544
+ },
1545
+ };
1546
+ }
1547
+ }
1548
+
1549
+ // Run sandboxed beforeDelete hooks
1550
+ const sandboxAllowed = await this.runSandboxedBeforeDelete(id, collection);
1551
+ if (!sandboxAllowed) {
1552
+ return {
1553
+ success: false,
1554
+ error: {
1555
+ code: "DELETE_BLOCKED",
1556
+ message: "Delete blocked by sandboxed plugin hook",
1557
+ },
1558
+ };
1559
+ }
1560
+
1561
+ // Delete the content
1562
+ const result = await handleContentDelete(this.db, collection, id);
1563
+
1564
+ // Run afterDelete hooks (fire-and-forget)
1565
+ if (result.success) {
1566
+ this.runAfterDeleteHooks(id, collection);
1567
+ }
1568
+
1569
+ return result;
1570
+ }
1571
+
1572
+ // =========================================================================
1573
+ // Trash Handlers
1574
+ // =========================================================================
1575
+
1576
+ async handleContentListTrashed(
1577
+ collection: string,
1578
+ params: { cursor?: string; limit?: number } = {},
1579
+ ) {
1580
+ return handleContentListTrashed(this.db, collection, params);
1581
+ }
1582
+
1583
+ async handleContentRestore(collection: string, id: string) {
1584
+ return handleContentRestore(this.db, collection, id);
1585
+ }
1586
+
1587
+ async handleContentPermanentDelete(collection: string, id: string) {
1588
+ return handleContentPermanentDelete(this.db, collection, id);
1589
+ }
1590
+
1591
+ async handleContentCountTrashed(collection: string) {
1592
+ return handleContentCountTrashed(this.db, collection);
1593
+ }
1594
+
1595
+ async handleContentDuplicate(collection: string, id: string, authorId?: string) {
1596
+ return handleContentDuplicate(this.db, collection, id, authorId);
1597
+ }
1598
+
1599
+ // =========================================================================
1600
+ // Publishing & Scheduling Handlers
1601
+ // =========================================================================
1602
+
1603
+ async handleContentPublish(collection: string, id: string) {
1604
+ return handleContentPublish(this.db, collection, id);
1605
+ }
1606
+
1607
+ async handleContentUnpublish(collection: string, id: string) {
1608
+ return handleContentUnpublish(this.db, collection, id);
1609
+ }
1610
+
1611
+ async handleContentSchedule(collection: string, id: string, scheduledAt: string) {
1612
+ return handleContentSchedule(this.db, collection, id, scheduledAt);
1613
+ }
1614
+
1615
+ async handleContentUnschedule(collection: string, id: string) {
1616
+ return handleContentUnschedule(this.db, collection, id);
1617
+ }
1618
+
1619
+ async handleContentCountScheduled(collection: string) {
1620
+ return handleContentCountScheduled(this.db, collection);
1621
+ }
1622
+
1623
+ async handleContentDiscardDraft(collection: string, id: string) {
1624
+ return handleContentDiscardDraft(this.db, collection, id);
1625
+ }
1626
+
1627
+ async handleContentCompare(collection: string, id: string) {
1628
+ return handleContentCompare(this.db, collection, id);
1629
+ }
1630
+
1631
+ async handleContentTranslations(collection: string, id: string) {
1632
+ return handleContentTranslations(this.db, collection, id);
1633
+ }
1634
+
1635
+ // =========================================================================
1636
+ // Media Handlers
1637
+ // =========================================================================
1638
+
1639
+ async handleMediaList(params: { cursor?: string; limit?: number; mimeType?: string }) {
1640
+ return handleMediaList(this.db, params);
1641
+ }
1642
+
1643
+ async handleMediaGet(id: string) {
1644
+ return handleMediaGet(this.db, id);
1645
+ }
1646
+
1647
+ async handleMediaCreate(input: {
1648
+ filename: string;
1649
+ mimeType: string;
1650
+ size?: number;
1651
+ width?: number;
1652
+ height?: number;
1653
+ storageKey: string;
1654
+ contentHash?: string;
1655
+ blurhash?: string;
1656
+ dominantColor?: string;
1657
+ }) {
1658
+ // Run beforeUpload hooks
1659
+ let processedInput = input;
1660
+ if (this.hooks.hasHooks("media:beforeUpload")) {
1661
+ const hookResult = await this.hooks.runMediaBeforeUpload({
1662
+ name: input.filename,
1663
+ type: input.mimeType,
1664
+ size: input.size || 0,
1665
+ });
1666
+ processedInput = {
1667
+ ...input,
1668
+ filename: hookResult.file.name,
1669
+ mimeType: hookResult.file.type,
1670
+ size: hookResult.file.size,
1671
+ };
1672
+ }
1673
+
1674
+ // Create the media record
1675
+ const result = await handleMediaCreate(this.db, processedInput);
1676
+
1677
+ // Run afterUpload hooks (fire-and-forget)
1678
+ if (result.success && this.hooks.hasHooks("media:afterUpload")) {
1679
+ const item = result.data.item;
1680
+ const mediaItem: MediaItem = {
1681
+ id: item.id,
1682
+ filename: item.filename,
1683
+ mimeType: item.mimeType,
1684
+ size: item.size,
1685
+ url: `/media/${item.id}/${item.filename}`,
1686
+ createdAt: item.createdAt,
1687
+ };
1688
+ this.hooks
1689
+ .runMediaAfterUpload(mediaItem)
1690
+ .catch((err) => console.error("EmDash afterUpload hook error:", err));
1691
+ }
1692
+
1693
+ return result;
1694
+ }
1695
+
1696
+ async handleMediaUpdate(
1697
+ id: string,
1698
+ input: { alt?: string; caption?: string; width?: number; height?: number },
1699
+ ) {
1700
+ return handleMediaUpdate(this.db, id, input);
1701
+ }
1702
+
1703
+ async handleMediaDelete(id: string) {
1704
+ return handleMediaDelete(this.db, id);
1705
+ }
1706
+
1707
+ // =========================================================================
1708
+ // Revision Handlers
1709
+ // =========================================================================
1710
+
1711
+ async handleRevisionList(collection: string, entryId: string, params: { limit?: number } = {}) {
1712
+ return handleRevisionList(this.db, collection, entryId, params);
1713
+ }
1714
+
1715
+ async handleRevisionGet(revisionId: string) {
1716
+ return handleRevisionGet(this.db, revisionId);
1717
+ }
1718
+
1719
+ async handleRevisionRestore(revisionId: string, callerUserId: string) {
1720
+ return handleRevisionRestore(this.db, revisionId, callerUserId);
1721
+ }
1722
+
1723
+ // =========================================================================
1724
+ // Plugin Routes
1725
+ // =========================================================================
1726
+
1727
+ /**
1728
+ * Get route metadata for a plugin route without invoking the handler.
1729
+ * Used by the catch-all route to decide auth before dispatch.
1730
+ * Returns null if the plugin or route doesn't exist.
1731
+ */
1732
+ getPluginRouteMeta(pluginId: string, path: string): RouteMeta | null {
1733
+ if (!this.isPluginEnabled(pluginId)) return null;
1734
+
1735
+ const routeKey = path.replace(LEADING_SLASH_PATTERN, "");
1736
+
1737
+ // Check trusted plugins first
1738
+ const trustedPlugin = this.configuredPlugins.find((p) => p.id === pluginId);
1739
+ if (trustedPlugin) {
1740
+ const route = trustedPlugin.routes[routeKey];
1741
+ if (!route) return null;
1742
+ return { public: route.public === true };
1743
+ }
1744
+
1745
+ // Check sandboxed plugin route metadata cache
1746
+ const meta = sandboxedRouteMetaCache.get(pluginId);
1747
+ if (meta) {
1748
+ const routeMeta = meta.get(routeKey);
1749
+ if (routeMeta) return routeMeta;
1750
+ }
1751
+
1752
+ // The "admin" route is implicitly available for any sandboxed plugin
1753
+ // that declares admin pages or widgets. This handles plugins installed
1754
+ // from bundles that predate the explicit admin route requirement.
1755
+ if (routeKey === "admin") {
1756
+ const manifestMeta = marketplaceManifestCache.get(pluginId);
1757
+ if (manifestMeta?.admin?.pages?.length || manifestMeta?.admin?.widgets?.length) {
1758
+ return { public: false };
1759
+ }
1760
+ // Also check build-time sandboxed entries
1761
+ const entry = this.sandboxedPluginEntries.find((e) => e.id === pluginId);
1762
+ if (entry?.adminPages?.length || entry?.adminWidgets?.length) {
1763
+ return { public: false };
1764
+ }
1765
+ }
1766
+
1767
+ // Fallback: if the plugin exists in the sandbox cache, allow the route.
1768
+ // The sandbox runner will return an error if the route doesn't actually exist.
1769
+ if (this.findSandboxedPlugin(pluginId)) {
1770
+ return { public: false };
1771
+ }
1772
+
1773
+ return null;
1774
+ }
1775
+
1776
+ async handlePluginApiRoute(pluginId: string, _method: string, path: string, request: Request) {
1777
+ if (!this.isPluginEnabled(pluginId)) {
1778
+ return {
1779
+ success: false,
1780
+ error: { code: "NOT_FOUND", message: `Plugin not enabled: ${pluginId}` },
1781
+ };
1782
+ }
1783
+
1784
+ // Check trusted (configured) plugins first — this must match the
1785
+ // resolution order in getPluginRouteMeta to avoid auth/execution mismatches.
1786
+ const trustedPlugin = this.configuredPlugins.find((p) => p.id === pluginId);
1787
+ if (trustedPlugin && this.enabledPlugins.has(trustedPlugin.id)) {
1788
+ const routeRegistry = new PluginRouteRegistry({ db: this.db });
1789
+ routeRegistry.register(trustedPlugin);
1790
+
1791
+ const routeKey = path.replace(LEADING_SLASH_PATTERN, "");
1792
+
1793
+ let body: unknown = undefined;
1794
+ try {
1795
+ body = await request.json();
1796
+ } catch {
1797
+ // No body or not JSON
1798
+ }
1799
+
1800
+ return routeRegistry.invoke(pluginId, routeKey, { request, body });
1801
+ }
1802
+
1803
+ // Check sandboxed (marketplace) plugins second
1804
+ const sandboxedPlugin = this.findSandboxedPlugin(pluginId);
1805
+ if (sandboxedPlugin) {
1806
+ return this.handleSandboxedRoute(sandboxedPlugin, path, request);
1807
+ }
1808
+
1809
+ return {
1810
+ success: false,
1811
+ error: { code: "NOT_FOUND", message: `Plugin not found: ${pluginId}` },
1812
+ };
1813
+ }
1814
+
1815
+ // =========================================================================
1816
+ // Sandboxed Plugin Helpers
1817
+ // =========================================================================
1818
+
1819
+ private findSandboxedPlugin(pluginId: string): SandboxedPlugin | undefined {
1820
+ for (const [key, plugin] of this.sandboxedPlugins) {
1821
+ if (key.startsWith(pluginId + ":")) {
1822
+ return plugin;
1823
+ }
1824
+ }
1825
+ return undefined;
1826
+ }
1827
+
1828
+ /**
1829
+ * Normalize image/file fields in content data.
1830
+ * Fills missing dimensions, storageKey, mimeType, and filename from providers.
1831
+ */
1832
+ private async normalizeMediaFields(
1833
+ collection: string,
1834
+ data: Record<string, unknown>,
1835
+ ): Promise<Record<string, unknown>> {
1836
+ let collectionInfo;
1837
+ try {
1838
+ collectionInfo = await this.schemaRegistry.getCollectionWithFields(collection);
1839
+ } catch {
1840
+ return data;
1841
+ }
1842
+ if (!collectionInfo?.fields) return data;
1843
+
1844
+ const imageFields = collectionInfo.fields.filter(
1845
+ (f) => f.type === "image" || f.type === "file",
1846
+ );
1847
+ if (imageFields.length === 0) return data;
1848
+
1849
+ const getProvider = (id: string) => this.getMediaProvider(id);
1850
+ const result = { ...data };
1851
+
1852
+ for (const field of imageFields) {
1853
+ const value = result[field.slug];
1854
+ if (value == null) continue;
1855
+
1856
+ try {
1857
+ const normalized = await normalizeMediaValue(value, getProvider);
1858
+ if (normalized) {
1859
+ result[field.slug] = normalized;
1860
+ }
1861
+ } catch {
1862
+ // Don't fail the save if normalization fails for a single field
1863
+ }
1864
+ }
1865
+
1866
+ return result;
1867
+ }
1868
+
1869
+ private async runSandboxedBeforeSave(
1870
+ content: Record<string, unknown>,
1871
+ collection: string,
1872
+ isNew: boolean,
1873
+ ): Promise<Record<string, unknown>> {
1874
+ let result = content;
1875
+
1876
+ for (const [pluginKey, plugin] of this.sandboxedPlugins) {
1877
+ const [id] = pluginKey.split(":");
1878
+ if (!id || !this.isPluginEnabled(id)) continue;
1879
+
1880
+ try {
1881
+ const hookResult = await plugin.invokeHook("content:beforeSave", {
1882
+ content: result,
1883
+ collection,
1884
+ isNew,
1885
+ });
1886
+ if (hookResult && typeof hookResult === "object" && !Array.isArray(hookResult)) {
1887
+ // Sandbox returns unknown; convert to record by iterating own properties
1888
+ const record: Record<string, unknown> = {};
1889
+ for (const [k, v] of Object.entries(hookResult)) {
1890
+ record[k] = v;
1891
+ }
1892
+ result = record;
1893
+ }
1894
+ } catch (error) {
1895
+ console.error(`EmDash: Sandboxed plugin ${id} beforeSave hook error:`, error);
1896
+ }
1897
+ }
1898
+
1899
+ return result;
1900
+ }
1901
+
1902
+ private async runSandboxedBeforeDelete(id: string, collection: string): Promise<boolean> {
1903
+ for (const [pluginKey, plugin] of this.sandboxedPlugins) {
1904
+ const [pluginId] = pluginKey.split(":");
1905
+ if (!pluginId || !this.isPluginEnabled(pluginId)) continue;
1906
+
1907
+ try {
1908
+ const result = await plugin.invokeHook("content:beforeDelete", {
1909
+ id,
1910
+ collection,
1911
+ });
1912
+ if (result === false) {
1913
+ return false;
1914
+ }
1915
+ } catch (error) {
1916
+ console.error(`EmDash: Sandboxed plugin ${pluginId} beforeDelete hook error:`, error);
1917
+ }
1918
+ }
1919
+
1920
+ return true;
1921
+ }
1922
+
1923
+ private runAfterSaveHooks(
1924
+ content: Record<string, unknown>,
1925
+ collection: string,
1926
+ isNew: boolean,
1927
+ ): void {
1928
+ // Trusted plugins
1929
+ if (this.hooks.hasHooks("content:afterSave")) {
1930
+ this.hooks
1931
+ .runContentAfterSave(content, collection, isNew)
1932
+ .catch((err) => console.error("EmDash afterSave hook error:", err));
1933
+ }
1934
+
1935
+ // Sandboxed plugins
1936
+ for (const [pluginKey, plugin] of this.sandboxedPlugins) {
1937
+ const [id] = pluginKey.split(":");
1938
+ if (!id || !this.isPluginEnabled(id)) continue;
1939
+
1940
+ plugin
1941
+ .invokeHook("content:afterSave", { content, collection, isNew })
1942
+ .catch((err) => console.error(`EmDash: Sandboxed plugin ${id} afterSave error:`, err));
1943
+ }
1944
+ }
1945
+
1946
+ private runAfterDeleteHooks(id: string, collection: string): void {
1947
+ // Trusted plugins
1948
+ if (this.hooks.hasHooks("content:afterDelete")) {
1949
+ this.hooks
1950
+ .runContentAfterDelete(id, collection)
1951
+ .catch((err) => console.error("EmDash afterDelete hook error:", err));
1952
+ }
1953
+
1954
+ // Sandboxed plugins
1955
+ for (const [pluginKey, plugin] of this.sandboxedPlugins) {
1956
+ const [pluginId] = pluginKey.split(":");
1957
+ if (!pluginId || !this.isPluginEnabled(pluginId)) continue;
1958
+
1959
+ plugin
1960
+ .invokeHook("content:afterDelete", { id, collection })
1961
+ .catch((err) =>
1962
+ console.error(`EmDash: Sandboxed plugin ${pluginId} afterDelete error:`, err),
1963
+ );
1964
+ }
1965
+ }
1966
+
1967
+ private async handleSandboxedRoute(
1968
+ plugin: SandboxedPlugin,
1969
+ path: string,
1970
+ request: Request,
1971
+ ): Promise<{
1972
+ success: boolean;
1973
+ data?: unknown;
1974
+ error?: { code: string; message: string };
1975
+ }> {
1976
+ const routeName = path.replace(LEADING_SLASH_PATTERN, "");
1977
+
1978
+ let body: unknown = undefined;
1979
+ try {
1980
+ body = await request.json();
1981
+ } catch {
1982
+ // No body or not JSON
1983
+ }
1984
+
1985
+ try {
1986
+ const headers = sanitizeHeadersForSandbox(request.headers);
1987
+ const meta = extractRequestMeta(request);
1988
+ const result = await plugin.invokeRoute(routeName, body, {
1989
+ url: request.url,
1990
+ method: request.method,
1991
+ headers,
1992
+ meta,
1993
+ });
1994
+ return { success: true, data: result };
1995
+ } catch (error) {
1996
+ console.error(`EmDash: Sandboxed plugin route error:`, error);
1997
+ return {
1998
+ success: false,
1999
+ error: {
2000
+ code: "ROUTE_ERROR",
2001
+ message: error instanceof Error ? error.message : String(error),
2002
+ },
2003
+ };
2004
+ }
2005
+ }
2006
+
2007
+ // =========================================================================
2008
+ // Public Page Contributions
2009
+ // =========================================================================
2010
+
2011
+ /**
2012
+ * Cache for page contributions. Uses a WeakMap keyed on the PublicPageContext
2013
+ * object so results are collected once per page context per request, even when
2014
+ * multiple render components (EmDashHead, EmDashBodyStart, EmDashBodyEnd)
2015
+ * request contributions from the same page.
2016
+ */
2017
+ private pageContributionCache = new WeakMap<PublicPageContext, Promise<PageContributions>>();
2018
+
2019
+ /**
2020
+ * Collect all page contributions (metadata + fragments) in a single pass.
2021
+ * Results are cached by page context object identity.
2022
+ */
2023
+ async collectPageContributions(page: PublicPageContext): Promise<PageContributions> {
2024
+ const cached = this.pageContributionCache.get(page);
2025
+ if (cached) return cached;
2026
+
2027
+ const promise = this.doCollectPageContributions(page);
2028
+ this.pageContributionCache.set(page, promise);
2029
+ return promise;
2030
+ }
2031
+
2032
+ private async doCollectPageContributions(page: PublicPageContext): Promise<PageContributions> {
2033
+ const metadata: PageMetadataContribution[] = [];
2034
+ const fragments: PageFragmentContribution[] = [];
2035
+
2036
+ // Trusted plugins via HookPipeline — both metadata and fragments
2037
+ if (this.hooks.hasHooks("page:metadata")) {
2038
+ const results = await this.hooks.runPageMetadata({ page });
2039
+ for (const r of results) {
2040
+ metadata.push(...r.contributions);
2041
+ }
2042
+ }
2043
+
2044
+ if (this.hooks.hasHooks("page:fragments")) {
2045
+ const results = await this.hooks.runPageFragments({ page });
2046
+ for (const r of results) {
2047
+ fragments.push(...r.contributions);
2048
+ }
2049
+ }
2050
+
2051
+ // Sandboxed plugins — metadata only, never fragments
2052
+ for (const [pluginKey, plugin] of this.sandboxedPlugins) {
2053
+ const [id] = pluginKey.split(":");
2054
+ if (!id || !this.isPluginEnabled(id)) continue;
2055
+
2056
+ try {
2057
+ const result = await plugin.invokeHook("page:metadata", { page });
2058
+ if (result != null) {
2059
+ const items = Array.isArray(result) ? result : [result];
2060
+ for (const item of items) {
2061
+ if (isValidMetadataContribution(item)) {
2062
+ metadata.push(item);
2063
+ }
2064
+ }
2065
+ }
2066
+ } catch (error) {
2067
+ console.error(`EmDash: Sandboxed plugin ${id} page:metadata error:`, error);
2068
+ }
2069
+ }
2070
+
2071
+ return { metadata, fragments };
2072
+ }
2073
+
2074
+ /**
2075
+ * Collect page metadata contributions from trusted and sandboxed plugins.
2076
+ * Delegates to the single-pass collector and returns the metadata portion.
2077
+ */
2078
+ async collectPageMetadata(page: PublicPageContext): Promise<PageMetadataContribution[]> {
2079
+ const { metadata } = await this.collectPageContributions(page);
2080
+ return metadata;
2081
+ }
2082
+
2083
+ /**
2084
+ * Collect page fragment contributions from trusted plugins only.
2085
+ * Delegates to the single-pass collector and returns the fragments portion.
2086
+ */
2087
+ async collectPageFragments(page: PublicPageContext): Promise<PageFragmentContribution[]> {
2088
+ const { fragments } = await this.collectPageContributions(page);
2089
+ return fragments;
2090
+ }
2091
+
2092
+ private isPluginEnabled(pluginId: string): boolean {
2093
+ const status = this.pluginStates.get(pluginId);
2094
+ return status === undefined || status === "active";
2095
+ }
2096
+ }