emdash 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (263) hide show
  1. package/dist/{adapters-Di31kZ28.d.mts → adapters-BKSf3T9R.d.mts} +1 -1
  2. package/dist/{adapters-Di31kZ28.d.mts.map → adapters-BKSf3T9R.d.mts.map} +1 -1
  3. package/dist/{apply-B4MsLM-w.mjs → apply-x0eMK1lX.mjs} +186 -28
  4. package/dist/apply-x0eMK1lX.mjs.map +1 -0
  5. package/dist/astro/index.d.mts +6 -6
  6. package/dist/astro/index.d.mts.map +1 -1
  7. package/dist/astro/index.mjs +92 -17
  8. package/dist/astro/index.mjs.map +1 -1
  9. package/dist/astro/middleware/auth.d.mts +5 -5
  10. package/dist/astro/middleware/auth.d.mts.map +1 -1
  11. package/dist/astro/middleware/auth.mjs +22 -2
  12. package/dist/astro/middleware/auth.mjs.map +1 -1
  13. package/dist/astro/middleware/redirect.mjs +2 -2
  14. package/dist/astro/middleware/request-context.mjs +7 -2
  15. package/dist/astro/middleware/request-context.mjs.map +1 -1
  16. package/dist/astro/middleware/setup.mjs +1 -1
  17. package/dist/astro/middleware.d.mts.map +1 -1
  18. package/dist/astro/middleware.mjs +263 -74
  19. package/dist/astro/middleware.mjs.map +1 -1
  20. package/dist/astro/types.d.mts +25 -8
  21. package/dist/astro/types.d.mts.map +1 -1
  22. package/dist/{byline-C4OVd8b3.mjs → byline-Chbr2GoP.mjs} +3 -3
  23. package/dist/byline-Chbr2GoP.mjs.map +1 -0
  24. package/dist/{bylines-hPTW79hw.mjs → bylines-CRNsVG88.mjs} +4 -4
  25. package/dist/{bylines-hPTW79hw.mjs.map → bylines-CRNsVG88.mjs.map} +1 -1
  26. package/dist/cli/index.mjs +17 -13
  27. package/dist/cli/index.mjs.map +1 -1
  28. package/dist/client/cf-access.d.mts +1 -1
  29. package/dist/client/index.d.mts +1 -1
  30. package/dist/client/index.mjs +1 -1
  31. package/dist/{content-BsBoyj8G.mjs → content-BcQPYxdV.mjs} +39 -15
  32. package/dist/content-BcQPYxdV.mjs.map +1 -0
  33. package/dist/db/index.d.mts +3 -3
  34. package/dist/db/index.mjs +1 -1
  35. package/dist/db/libsql.d.mts +1 -1
  36. package/dist/db/postgres.d.mts +1 -1
  37. package/dist/db/sqlite.d.mts +1 -1
  38. package/dist/{db-errors-D0UT85nC.mjs → db-errors-l1Qh2RPR.mjs} +1 -1
  39. package/dist/{db-errors-D0UT85nC.mjs.map → db-errors-l1Qh2RPR.mjs.map} +1 -1
  40. package/dist/{default-CME5YdZ3.mjs → default-DCVqE5ib.mjs} +1 -1
  41. package/dist/{default-CME5YdZ3.mjs.map → default-DCVqE5ib.mjs.map} +1 -1
  42. package/dist/{error-CiYn9yDu.mjs → error-zG5T1UGA.mjs} +1 -1
  43. package/dist/error-zG5T1UGA.mjs.map +1 -0
  44. package/dist/{index-BYv0mB9g.d.mts → index-DIb-CzNx.d.mts} +232 -15
  45. package/dist/index-DIb-CzNx.d.mts.map +1 -0
  46. package/dist/index.d.mts +11 -11
  47. package/dist/index.mjs +23 -21
  48. package/dist/{load-CBcmDIot.mjs → load-CyEoextb.mjs} +1 -1
  49. package/dist/{load-CBcmDIot.mjs.map → load-CyEoextb.mjs.map} +1 -1
  50. package/dist/{loader-DeiBJEMe.mjs → loader-CndGj8kM.mjs} +8 -6
  51. package/dist/loader-CndGj8kM.mjs.map +1 -0
  52. package/dist/{manifest-schema-V30qsMft.mjs → manifest-schema-DH9xhc6t.mjs} +13 -1
  53. package/dist/manifest-schema-DH9xhc6t.mjs.map +1 -0
  54. package/dist/media/index.d.mts +1 -1
  55. package/dist/media/local-runtime.d.mts +7 -7
  56. package/dist/media/local-runtime.mjs +2 -2
  57. package/dist/{media-DqHVh136.mjs → media-D8FbNsl0.mjs} +4 -7
  58. package/dist/media-D8FbNsl0.mjs.map +1 -0
  59. package/dist/{mode-CpNnGkPz.mjs → mode-BnAOqItE.mjs} +1 -1
  60. package/dist/mode-BnAOqItE.mjs.map +1 -0
  61. package/dist/page/index.d.mts +2 -2
  62. package/dist/placeholder-C-fk5hYI.mjs.map +1 -1
  63. package/dist/{placeholder-tzpqGWII.d.mts → placeholder-D29tWZ7o.d.mts} +1 -1
  64. package/dist/{placeholder-tzpqGWII.d.mts.map → placeholder-D29tWZ7o.d.mts.map} +1 -1
  65. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  66. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  67. package/dist/{query-Bk_3vKvU.mjs → query-fqEdLFms.mjs} +9 -9
  68. package/dist/{query-Bk_3vKvU.mjs.map → query-fqEdLFms.mjs.map} +1 -1
  69. package/dist/{redirect-7lGhLBNZ.mjs → redirect-D_pshWdf.mjs} +69 -13
  70. package/dist/redirect-D_pshWdf.mjs.map +1 -0
  71. package/dist/{registry-Ci3WxVAr.mjs → registry-C3Mr0ODu.mjs} +33 -9
  72. package/dist/registry-C3Mr0ODu.mjs.map +1 -0
  73. package/dist/{request-cache-DiR961CV.mjs → request-cache-Ci7f5pBb.mjs} +1 -1
  74. package/dist/request-cache-Ci7f5pBb.mjs.map +1 -0
  75. package/dist/{runner-Fl2NcUUz.d.mts → runner-OURCaApa.d.mts} +2 -2
  76. package/dist/{runner-Fl2NcUUz.d.mts.map → runner-OURCaApa.d.mts.map} +1 -1
  77. package/dist/{runner-Cd-_WyDo.mjs → runner-tQ7BJ4T7.mjs} +211 -134
  78. package/dist/runner-tQ7BJ4T7.mjs.map +1 -0
  79. package/dist/runtime.d.mts +6 -6
  80. package/dist/runtime.mjs +2 -2
  81. package/dist/{search-DI4bM2w9.mjs → search-BoZYFuUk.mjs} +339 -102
  82. package/dist/search-BoZYFuUk.mjs.map +1 -0
  83. package/dist/seed/index.d.mts +2 -2
  84. package/dist/seed/index.mjs +12 -12
  85. package/dist/seo/index.d.mts +1 -1
  86. package/dist/storage/local.d.mts +1 -1
  87. package/dist/storage/local.mjs +1 -1
  88. package/dist/storage/s3.d.mts +1 -1
  89. package/dist/storage/s3.d.mts.map +1 -1
  90. package/dist/storage/s3.mjs +4 -4
  91. package/dist/storage/s3.mjs.map +1 -1
  92. package/dist/{taxonomies-DbrKzDju.mjs → taxonomies-B4IAshV8.mjs} +5 -5
  93. package/dist/{taxonomies-DbrKzDju.mjs.map → taxonomies-B4IAshV8.mjs.map} +1 -1
  94. package/dist/{tokens-BFPFx3CA.mjs → tokens-D9vnZqYS.mjs} +1 -1
  95. package/dist/{tokens-BFPFx3CA.mjs.map → tokens-D9vnZqYS.mjs.map} +1 -1
  96. package/dist/{transport-BykRfpyy.mjs → transport-C9ugt2Nr.mjs} +1 -1
  97. package/dist/{transport-BykRfpyy.mjs.map → transport-C9ugt2Nr.mjs.map} +1 -1
  98. package/dist/{transport-H4Iwx7tC.d.mts → transport-CUnEL3Vs.d.mts} +1 -1
  99. package/dist/{transport-H4Iwx7tC.d.mts.map → transport-CUnEL3Vs.d.mts.map} +1 -1
  100. package/dist/types-BIgulNsW.mjs +68 -0
  101. package/dist/types-BIgulNsW.mjs.map +1 -0
  102. package/dist/{types-DDS4MxsT.mjs → types-Bm1dn-q3.mjs} +1 -1
  103. package/dist/{types-DDS4MxsT.mjs.map → types-Bm1dn-q3.mjs.map} +1 -1
  104. package/dist/{types-CnZYHyLW.d.mts → types-BmPPSUEx.d.mts} +1 -1
  105. package/dist/{types-CnZYHyLW.d.mts.map → types-BmPPSUEx.d.mts.map} +1 -1
  106. package/dist/{types-6CUZRrZP.d.mts → types-BrA0xf5I.d.mts} +24 -2
  107. package/dist/{types-6CUZRrZP.d.mts.map → types-BrA0xf5I.d.mts.map} +1 -1
  108. package/dist/{types-8xrvl_68.d.mts → types-CS8FIX7L.d.mts} +10 -1
  109. package/dist/{types-8xrvl_68.d.mts.map → types-CS8FIX7L.d.mts.map} +1 -1
  110. package/dist/{types-BH2L167P.mjs → types-CgqmmMJB.mjs} +1 -1
  111. package/dist/{types-BH2L167P.mjs.map → types-CgqmmMJB.mjs.map} +1 -1
  112. package/dist/{types-CFWjXmus.d.mts → types-DIMwPFub.d.mts} +1 -1
  113. package/dist/{types-CFWjXmus.d.mts.map → types-DIMwPFub.d.mts.map} +1 -1
  114. package/dist/{types-DgrIP0tF.d.mts → types-i36XcA_X.d.mts} +49 -6
  115. package/dist/types-i36XcA_X.d.mts.map +1 -0
  116. package/dist/{validate-CqsNItbt.mjs → validate-CxVsLehf.mjs} +2 -2
  117. package/dist/{validate-CqsNItbt.mjs.map → validate-CxVsLehf.mjs.map} +1 -1
  118. package/dist/{validate-CaLH1Ia2.d.mts → validate-DHxmpFJt.d.mts} +4 -4
  119. package/dist/{validate-CaLH1Ia2.d.mts.map → validate-DHxmpFJt.d.mts.map} +1 -1
  120. package/dist/validation-C-ZpN2GI.mjs +144 -0
  121. package/dist/validation-C-ZpN2GI.mjs.map +1 -0
  122. package/dist/version-Bbq8TCrz.mjs +7 -0
  123. package/dist/{version-Uaf2ynPX.mjs.map → version-Bbq8TCrz.mjs.map} +1 -1
  124. package/dist/zod-generator-CpwccCIv.mjs +132 -0
  125. package/dist/zod-generator-CpwccCIv.mjs.map +1 -0
  126. package/package.json +18 -5
  127. package/src/api/auth-storage.ts +37 -0
  128. package/src/api/error.ts +6 -0
  129. package/src/api/errors.ts +8 -0
  130. package/src/api/handlers/comments.ts +13 -0
  131. package/src/api/handlers/content.ts +124 -3
  132. package/src/api/handlers/index.ts +2 -0
  133. package/src/api/handlers/media.ts +8 -1
  134. package/src/api/handlers/menus.ts +160 -21
  135. package/src/api/handlers/redirects.ts +16 -3
  136. package/src/api/handlers/sections.ts +8 -1
  137. package/src/api/handlers/taxonomies.ts +128 -16
  138. package/src/api/handlers/validation.ts +212 -0
  139. package/src/api/openapi/document.ts +4 -1
  140. package/src/api/public-url.ts +6 -3
  141. package/src/api/route-utils.ts +14 -0
  142. package/src/api/schemas/common.ts +1 -1
  143. package/src/api/schemas/content.ts +8 -0
  144. package/src/api/schemas/setup.ts +8 -0
  145. package/src/api/schemas/widgets.ts +12 -10
  146. package/src/api/setup-complete.ts +40 -0
  147. package/src/astro/integration/font-provider.ts +3 -1
  148. package/src/astro/integration/index.ts +15 -2
  149. package/src/astro/integration/routes.ts +28 -0
  150. package/src/astro/integration/runtime.ts +74 -2
  151. package/src/astro/integration/virtual-modules.ts +41 -0
  152. package/src/astro/integration/vite-config.ts +43 -12
  153. package/src/astro/middleware/auth.ts +21 -0
  154. package/src/astro/middleware.ts +18 -1
  155. package/src/astro/routes/PluginRegistry.tsx +10 -1
  156. package/src/astro/routes/admin.astro +14 -7
  157. package/src/astro/routes/api/auth/magic-link/send.ts +2 -1
  158. package/src/astro/routes/api/auth/mode.ts +57 -0
  159. package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +23 -3
  160. package/src/astro/routes/api/auth/oauth/[provider].ts +10 -4
  161. package/src/astro/routes/api/auth/passkey/options.ts +2 -1
  162. package/src/astro/routes/api/auth/signup/request.ts +26 -8
  163. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +10 -6
  164. package/src/astro/routes/api/content/[collection]/[id]/compare.ts +1 -1
  165. package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +1 -1
  166. package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +1 -1
  167. package/src/astro/routes/api/content/[collection]/[id]/translations.ts +26 -0
  168. package/src/astro/routes/api/content/[collection]/[id].ts +30 -2
  169. package/src/astro/routes/api/content/[collection]/index.ts +20 -10
  170. package/src/astro/routes/api/content/[collection]/trash.ts +1 -1
  171. package/src/astro/routes/api/import/wordpress/media.ts +2 -7
  172. package/src/astro/routes/api/import/wordpress/prepare.ts +10 -0
  173. package/src/astro/routes/api/import/wordpress-plugin/analyze.ts +4 -3
  174. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +4 -3
  175. package/src/astro/routes/api/manifest.ts +7 -0
  176. package/src/astro/routes/api/oauth/device/code.ts +2 -1
  177. package/src/astro/routes/api/oauth/device/token.ts +2 -1
  178. package/src/astro/routes/api/settings/email.ts +4 -9
  179. package/src/astro/routes/api/setup/admin-verify.ts +30 -5
  180. package/src/astro/routes/api/setup/admin.ts +38 -8
  181. package/src/astro/routes/api/setup/index.ts +7 -4
  182. package/src/astro/routes/api/setup/status.ts +3 -1
  183. package/src/astro/routes/api/widget-areas/[name]/widgets/[id].ts +4 -1
  184. package/src/astro/routes/api/widget-areas/[name]/widgets.ts +4 -1
  185. package/src/astro/routes/api/widget-areas/[name].ts +4 -1
  186. package/src/astro/routes/api/widget-areas/index.ts +4 -1
  187. package/src/astro/types.ts +18 -0
  188. package/src/auth/mode.ts +15 -3
  189. package/src/auth/providers/github-admin.tsx +29 -0
  190. package/src/auth/providers/github.ts +31 -0
  191. package/src/auth/providers/google-admin.tsx +44 -0
  192. package/src/auth/providers/google.ts +31 -0
  193. package/src/auth/rate-limit.ts +50 -22
  194. package/src/auth/setup-nonce.ts +22 -0
  195. package/src/auth/trusted-proxy.ts +92 -0
  196. package/src/auth/types.ts +114 -4
  197. package/src/cli/commands/bundle.ts +3 -1
  198. package/src/components/EmDashImage.astro +7 -6
  199. package/src/components/Gallery.astro +5 -3
  200. package/src/components/Image.astro +8 -3
  201. package/src/components/InlinePortableTextEditor.tsx +2 -1
  202. package/src/components/LiveSearch.astro +5 -14
  203. package/src/database/migrations/035_bounded_404_log.ts +112 -0
  204. package/src/database/migrations/runner.ts +2 -0
  205. package/src/database/repositories/audit.ts +6 -8
  206. package/src/database/repositories/byline.ts +6 -8
  207. package/src/database/repositories/comment.ts +12 -16
  208. package/src/database/repositories/content.ts +79 -40
  209. package/src/database/repositories/index.ts +1 -1
  210. package/src/database/repositories/media.ts +10 -13
  211. package/src/database/repositories/options.ts +25 -0
  212. package/src/database/repositories/plugin-storage.ts +4 -6
  213. package/src/database/repositories/redirect.ts +123 -24
  214. package/src/database/repositories/taxonomy.ts +14 -3
  215. package/src/database/repositories/types.ts +57 -8
  216. package/src/database/repositories/user.ts +6 -8
  217. package/src/database/types.ts +9 -0
  218. package/src/emdash-runtime.ts +309 -91
  219. package/src/import/registry.ts +4 -3
  220. package/src/import/ssrf.ts +253 -12
  221. package/src/index.ts +5 -1
  222. package/src/loader.ts +6 -5
  223. package/src/mcp/server.ts +753 -107
  224. package/src/media/normalize.ts +1 -1
  225. package/src/media/url.ts +78 -0
  226. package/src/plugins/context.ts +15 -3
  227. package/src/plugins/email-console.ts +10 -3
  228. package/src/plugins/hooks.ts +11 -0
  229. package/src/plugins/manager.ts +6 -0
  230. package/src/plugins/manifest-schema.ts +12 -0
  231. package/src/plugins/request-meta.ts +66 -15
  232. package/src/plugins/routes.ts +3 -1
  233. package/src/plugins/types.ts +23 -2
  234. package/src/query.ts +1 -1
  235. package/src/request-cache.ts +3 -0
  236. package/src/schema/registry.ts +41 -5
  237. package/src/search/fts-manager.ts +0 -2
  238. package/src/search/query.ts +111 -26
  239. package/src/search/types.ts +8 -1
  240. package/src/sections/index.ts +7 -9
  241. package/src/seed/apply.ts +26 -0
  242. package/src/storage/s3.ts +12 -6
  243. package/src/virtual-modules.d.ts +21 -1
  244. package/src/visual-editing/toolbar.ts +6 -1
  245. package/src/widgets/index.ts +1 -1
  246. package/dist/apply-B4MsLM-w.mjs.map +0 -1
  247. package/dist/byline-C4OVd8b3.mjs.map +0 -1
  248. package/dist/content-BsBoyj8G.mjs.map +0 -1
  249. package/dist/error-CiYn9yDu.mjs.map +0 -1
  250. package/dist/index-BYv0mB9g.d.mts.map +0 -1
  251. package/dist/loader-DeiBJEMe.mjs.map +0 -1
  252. package/dist/manifest-schema-V30qsMft.mjs.map +0 -1
  253. package/dist/media-DqHVh136.mjs.map +0 -1
  254. package/dist/mode-CpNnGkPz.mjs.map +0 -1
  255. package/dist/redirect-7lGhLBNZ.mjs.map +0 -1
  256. package/dist/registry-Ci3WxVAr.mjs.map +0 -1
  257. package/dist/request-cache-DiR961CV.mjs.map +0 -1
  258. package/dist/runner-Cd-_WyDo.mjs.map +0 -1
  259. package/dist/search-DI4bM2w9.mjs.map +0 -1
  260. package/dist/types-CMMN0pNg.mjs +0 -31
  261. package/dist/types-CMMN0pNg.mjs.map +0 -1
  262. package/dist/types-DgrIP0tF.d.mts.map +0 -1
  263. package/dist/version-Uaf2ynPX.mjs +0 -7
@@ -19,6 +19,7 @@ import type {
19
19
  } from "./astro/integration/runtime.js";
20
20
  import type { EmDashManifest, ManifestCollection } from "./astro/types.js";
21
21
  import { getAuthMode } from "./auth/mode.js";
22
+ import { getTrustedProxyHeaders } from "./auth/trusted-proxy.js";
22
23
  import { isSqlite } from "./database/dialect-helpers.js";
23
24
  import { kyselyLogOption } from "./database/instrumentation.js";
24
25
  import { runMigrations } from "./database/migrations/runner.js";
@@ -45,6 +46,20 @@ import { COMMIT, VERSION } from "./version.js";
45
46
 
46
47
  const LEADING_SLASH_PATTERN = /^\//;
47
48
 
49
+ /**
50
+ * Parse a JSON column expected to contain an array of strings.
51
+ *
52
+ * Throws on malformed JSON rather than returning []; callers are responsible
53
+ * for deciding how to handle/log the error. Empty string / null inputs return
54
+ * [] (they represent "no value"). Non-string array entries are filtered out.
55
+ */
56
+ function parseStringArray(raw: string | null | undefined): string[] {
57
+ if (!raw) return [];
58
+ const parsed: unknown = JSON.parse(raw);
59
+ if (!Array.isArray(parsed)) return [];
60
+ return parsed.filter((v): v is string => typeof v === "string");
61
+ }
62
+
48
63
  /** Combined result from a single-pass page contribution collection */
49
64
  interface PageContributions {
50
65
  metadata: PageMetadataContribution[];
@@ -236,6 +251,45 @@ export interface RuntimeDependencies {
236
251
  createSandboxRunner: ((opts: { db: Kysely<Database> }) => SandboxRunner) | null;
237
252
  }
238
253
 
254
+ /**
255
+ * Constructor parameters for `EmDashRuntime`.
256
+ *
257
+ * Production code should use `EmDashRuntime.create()` which discovers and
258
+ * loads all parts (database, plugins, hooks, cron, etc.) and then calls the
259
+ * constructor. Direct construction is supported for callers that already
260
+ * have all the dependencies in hand — for example, integration tests that
261
+ * supply a pre-migrated database and an empty plugin set.
262
+ *
263
+ * Every field corresponds 1:1 to internal state set on the runtime — none of
264
+ * these are derived. If you don't have a value for one, see what `create()`
265
+ * passes for that field as the canonical default.
266
+ */
267
+ export interface EmDashRuntimeParts {
268
+ db: Kysely<Database>;
269
+ storage: Storage | null;
270
+ configuredPlugins: ResolvedPlugin[];
271
+ sandboxedPlugins: Map<string, SandboxedPlugin>;
272
+ sandboxedPluginEntries: SandboxedPluginEntry[];
273
+ hooks: HookPipeline;
274
+ enabledPlugins: Set<string>;
275
+ pluginStates: Map<string, string>;
276
+ config: EmDashConfig;
277
+ mediaProviders: Map<string, MediaProvider>;
278
+ mediaProviderEntries: MediaProviderEntry[];
279
+ cronExecutor: CronExecutor | null;
280
+ cronScheduler: CronScheduler | null;
281
+ emailPipeline: EmailPipeline | null;
282
+ allPipelinePlugins: ResolvedPlugin[];
283
+ pipelineFactoryOptions: {
284
+ db: Kysely<Database>;
285
+ storage?: Storage;
286
+ siteInfo?: { siteName?: string; siteUrl?: string; locale?: string };
287
+ };
288
+ runtimeDeps: RuntimeDependencies;
289
+ pipelineRef: { current: HookPipeline };
290
+ manifestCacheKey: string;
291
+ }
292
+
239
293
  /**
240
294
  * Convert a ContentItem to Record<string, unknown> for hook consumption.
241
295
  * Hooks receive the full item as a flat record.
@@ -336,51 +390,27 @@ export class EmDashRuntime {
336
390
  return this._db;
337
391
  }
338
392
 
339
- private constructor(
340
- db: Kysely<Database>,
341
- storage: Storage | null,
342
- configuredPlugins: ResolvedPlugin[],
343
- sandboxedPlugins: Map<string, SandboxedPlugin>,
344
- sandboxedPluginEntries: SandboxedPluginEntry[],
345
- hooks: HookPipeline,
346
- enabledPlugins: Set<string>,
347
- pluginStates: Map<string, string>,
348
- config: EmDashConfig,
349
- mediaProviders: Map<string, MediaProvider>,
350
- mediaProviderEntries: MediaProviderEntry[],
351
- cronExecutor: CronExecutor | null,
352
- cronScheduler: CronScheduler | null,
353
- emailPipeline: EmailPipeline | null,
354
- allPipelinePlugins: ResolvedPlugin[],
355
- pipelineFactoryOptions: {
356
- db: Kysely<Database>;
357
- storage?: Storage;
358
- siteInfo?: { siteName?: string; siteUrl?: string; locale?: string };
359
- },
360
- runtimeDeps: RuntimeDependencies,
361
- pipelineRef: { current: HookPipeline },
362
- manifestCacheKey: string,
363
- ) {
364
- this._db = db;
365
- this.storage = storage;
366
- this.configuredPlugins = configuredPlugins;
367
- this.sandboxedPlugins = sandboxedPlugins;
368
- this.sandboxedPluginEntries = sandboxedPluginEntries;
369
- this.schemaRegistry = new SchemaRegistry(db);
370
- this._hooks = hooks;
371
- this.enabledPlugins = enabledPlugins;
372
- this.pluginStates = pluginStates;
373
- this.config = config;
374
- this.mediaProviders = mediaProviders;
375
- this.mediaProviderEntries = mediaProviderEntries;
376
- this.cronExecutor = cronExecutor;
377
- this.cronScheduler = cronScheduler;
378
- this.email = emailPipeline;
379
- this.allPipelinePlugins = allPipelinePlugins;
380
- this.pipelineFactoryOptions = pipelineFactoryOptions;
381
- this.runtimeDeps = runtimeDeps;
382
- this.pipelineRef = pipelineRef;
383
- this._manifestCacheKey = manifestCacheKey;
393
+ constructor(parts: EmDashRuntimeParts) {
394
+ this._db = parts.db;
395
+ this.storage = parts.storage;
396
+ this.configuredPlugins = parts.configuredPlugins;
397
+ this.sandboxedPlugins = parts.sandboxedPlugins;
398
+ this.sandboxedPluginEntries = parts.sandboxedPluginEntries;
399
+ this.schemaRegistry = new SchemaRegistry(parts.db);
400
+ this._hooks = parts.hooks;
401
+ this.enabledPlugins = parts.enabledPlugins;
402
+ this.pluginStates = parts.pluginStates;
403
+ this.config = parts.config;
404
+ this.mediaProviders = parts.mediaProviders;
405
+ this.mediaProviderEntries = parts.mediaProviderEntries;
406
+ this.cronExecutor = parts.cronExecutor;
407
+ this.cronScheduler = parts.cronScheduler;
408
+ this.email = parts.emailPipeline;
409
+ this.allPipelinePlugins = parts.allPipelinePlugins;
410
+ this.pipelineFactoryOptions = parts.pipelineFactoryOptions;
411
+ this.runtimeDeps = parts.runtimeDeps;
412
+ this.pipelineRef = parts.pipelineRef;
413
+ this._manifestCacheKey = parts.manifestCacheKey;
384
414
  }
385
415
 
386
416
  /**
@@ -857,16 +887,16 @@ export class EmDashRuntime {
857
887
  ].join("|"),
858
888
  );
859
889
 
860
- return new EmDashRuntime(
890
+ return new EmDashRuntime({
861
891
  db,
862
892
  storage,
863
- deps.plugins,
893
+ configuredPlugins: deps.plugins,
864
894
  sandboxedPlugins,
865
- deps.sandboxedPluginEntries,
866
- pipeline,
895
+ sandboxedPluginEntries: deps.sandboxedPluginEntries,
896
+ hooks: pipeline,
867
897
  enabledPlugins,
868
898
  pluginStates,
869
- deps.config,
899
+ config: deps.config,
870
900
  mediaProviders,
871
901
  mediaProviderEntries,
872
902
  cronExecutor,
@@ -874,10 +904,10 @@ export class EmDashRuntime {
874
904
  emailPipeline,
875
905
  allPipelinePlugins,
876
906
  pipelineFactoryOptions,
877
- deps,
907
+ runtimeDeps: deps,
878
908
  pipelineRef,
879
909
  manifestCacheKey,
880
- );
910
+ });
881
911
  }
882
912
 
883
913
  /**
@@ -1488,7 +1518,7 @@ export class EmDashRuntime {
1488
1518
  label: row.label,
1489
1519
  labelSingular: row.label_singular ?? undefined,
1490
1520
  hierarchical: row.hierarchical === 1,
1491
- collections: row.collections ? (JSON.parse(row.collections) as string[]).toSorted() : [],
1521
+ collections: parseStringArray(row.collections).toSorted(),
1492
1522
  }));
1493
1523
  } catch (error) {
1494
1524
  console.debug("EmDash: Could not load taxonomy definitions:", error);
@@ -1614,11 +1644,75 @@ export class EmDashRuntime {
1614
1644
  }
1615
1645
 
1616
1646
  async handleContentGet(collection: string, id: string, locale?: string) {
1617
- return handleContentGet(this.db, collection, id, locale);
1647
+ const result = await handleContentGet(this.db, collection, id, locale);
1648
+ return this.hydrateDraftData(result);
1618
1649
  }
1619
1650
 
1620
1651
  async handleContentGetIncludingTrashed(collection: string, id: string, locale?: string) {
1621
- return handleContentGetIncludingTrashed(this.db, collection, id, locale);
1652
+ const result = await handleContentGetIncludingTrashed(this.db, collection, id, locale);
1653
+ return this.hydrateDraftData(result);
1654
+ }
1655
+
1656
+ /**
1657
+ * If the response item has a `draftRevisionId`, replace `item.data` with
1658
+ * the draft revision's data and expose the original published values as
1659
+ * `liveData`. This makes the content_get / content_update round-trip
1660
+ * intuitive — read returns the latest content the caller has saved
1661
+ * (their pending draft), with the previously-published values still
1662
+ * accessible for compare-style flows.
1663
+ *
1664
+ * No-op when no draft exists or the response is an error.
1665
+ */
1666
+ private async hydrateDraftData<T>(result: T): Promise<T> {
1667
+ if (!result || typeof result !== "object") return result;
1668
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- shape probed below
1669
+ const r = result as {
1670
+ success?: boolean;
1671
+ data?: { item?: Record<string, unknown> };
1672
+ };
1673
+ if (!r.success || !r.data?.item) return result;
1674
+ const item = r.data.item;
1675
+ const draftRevisionId = typeof item.draftRevisionId === "string" ? item.draftRevisionId : null;
1676
+ if (!draftRevisionId) return result;
1677
+ try {
1678
+ const revision = await new RevisionRepository(this.db).findById(draftRevisionId);
1679
+ if (!revision) return result;
1680
+ const liveData =
1681
+ item.data && typeof item.data === "object"
1682
+ ? // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- narrowed to object above
1683
+ (item.data as Record<string, unknown>)
1684
+ : {};
1685
+ // Strip leading-underscore keys (`_slug`, `_rev`, etc.) from the
1686
+ // revision data — those are handler-internal markers and don't
1687
+ // belong in the surfaced `data` field. Match syncDataColumns at
1688
+ // content.ts:~1119.
1689
+ const revisionData: Record<string, unknown> = {};
1690
+ for (const [key, value] of Object.entries(revision.data)) {
1691
+ if (!key.startsWith("_")) revisionData[key] = value;
1692
+ }
1693
+ const mergedData = { ...liveData, ...revisionData };
1694
+ // Return a clone rather than mutating in place. The response
1695
+ // object isn't retained by the runtime today, but a future
1696
+ // request-cache layer would observe stale-after-mutation bugs;
1697
+ // cloning closes that footgun.
1698
+ // `r.data` was narrowed to `{ item?: ... }` at the top of this
1699
+ // method; spread its other keys (e.g. `_rev`) alongside the
1700
+ // hydrated item without going back through `unknown`.
1701
+ return {
1702
+ ...result,
1703
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- shape preserved; result has been narrowed to the {success,data:{item}} envelope
1704
+ data: {
1705
+ ...r.data,
1706
+ item: { ...item, data: mergedData, liveData },
1707
+ },
1708
+ } as T;
1709
+ } catch (error) {
1710
+ // Non-fatal — fall back to the unhydrated response. Log so the
1711
+ // failure isn't completely silent (the response will look stale
1712
+ // to the caller but no error is raised).
1713
+ console.error("[emdash] draft hydration failed:", error);
1714
+ return result;
1715
+ }
1622
1716
  }
1623
1717
 
1624
1718
  async handleContentCreate(
@@ -1646,6 +1740,20 @@ export class EmDashRuntime {
1646
1740
  // Normalize media fields (fill dimensions, storageKey, etc.)
1647
1741
  processedData = await this.normalizeMediaFields(collection, processedData);
1648
1742
 
1743
+ // Validate against the collection schema. Hook output is validated
1744
+ // rather than `body.data` so plugins that mutate field values can't
1745
+ // sneak invalid data past.
1746
+ const { validateContentData } = await import("./api/handlers/validation.js");
1747
+ const validation = await validateContentData(this.db, collection, processedData, {
1748
+ partial: false,
1749
+ });
1750
+ if (!validation.ok) {
1751
+ return {
1752
+ success: false as const,
1753
+ error: validation.error,
1754
+ };
1755
+ }
1756
+
1649
1757
  // Create the content
1650
1758
  const result = await handleContentCreate(this.db, collection, {
1651
1759
  ...body,
@@ -1719,6 +1827,19 @@ export class EmDashRuntime {
1719
1827
 
1720
1828
  // Normalize media fields (fill dimensions, storageKey, etc.)
1721
1829
  processedData = await this.normalizeMediaFields(collection, processedData);
1830
+
1831
+ // Validate field-level shape BEFORE the draft-revision write so
1832
+ // invalid updates can't silently land in revision history.
1833
+ const { validateContentData } = await import("./api/handlers/validation.js");
1834
+ const validation = await validateContentData(this.db, collection, processedData, {
1835
+ partial: true,
1836
+ });
1837
+ if (!validation.ok) {
1838
+ return {
1839
+ success: false as const,
1840
+ error: validation.error,
1841
+ };
1842
+ }
1722
1843
  }
1723
1844
 
1724
1845
  // Draft-aware revision handling (if collection supports revisions)
@@ -1794,12 +1915,18 @@ export class EmDashRuntime {
1794
1915
  bylines: bodyWithoutRev.bylines,
1795
1916
  });
1796
1917
 
1918
+ // Hydrate draft data BEFORE firing afterSave hooks so the hook sees
1919
+ // the same effective data the response surfaces — for revision-
1920
+ // supporting collections, that's the just-saved draft, not the live
1921
+ // columns.
1922
+ const hydrated = await this.hydrateDraftData(result);
1923
+
1797
1924
  // Run afterSave hooks (fire-and-forget)
1798
- if (result.success && result.data) {
1799
- this.runAfterSaveHooks(contentItemToRecord(result.data.item), collection, false);
1925
+ if (hydrated.success && hydrated.data) {
1926
+ this.runAfterSaveHooks(contentItemToRecord(hydrated.data.item), collection, false);
1800
1927
  }
1801
1928
 
1802
- return result;
1929
+ return hydrated;
1803
1930
  }
1804
1931
 
1805
1932
  async handleContentDelete(collection: string, id: string) {
@@ -1946,6 +2073,7 @@ export class EmDashRuntime {
1946
2073
  contentHash?: string;
1947
2074
  blurhash?: string;
1948
2075
  dominantColor?: string;
2076
+ authorId?: string;
1949
2077
  }) {
1950
2078
  // Run beforeUpload hooks
1951
2079
  let processedInput = input;
@@ -2009,7 +2137,74 @@ export class EmDashRuntime {
2009
2137
  }
2010
2138
 
2011
2139
  async handleRevisionRestore(revisionId: string, callerUserId: string) {
2012
- return handleRevisionRestore(this.db, revisionId, callerUserId);
2140
+ // Discover the parent entry up front so we can branch on whether
2141
+ // the collection uses draft revisions.
2142
+ const revisionRepo = new RevisionRepository(this.db);
2143
+ const revision = await revisionRepo.findById(revisionId);
2144
+ if (!revision) {
2145
+ return {
2146
+ success: false as const,
2147
+ error: {
2148
+ code: "NOT_FOUND",
2149
+ message: `Revision not found: ${revisionId}`,
2150
+ },
2151
+ };
2152
+ }
2153
+
2154
+ const collectionInfo = await this.schemaRegistry.getCollectionWithFields(revision.collection);
2155
+ const usesDraftRevisions = collectionInfo?.supports?.includes("revisions") ?? false;
2156
+
2157
+ // Non-revision collections: keep the legacy behavior of writing the
2158
+ // revision's data straight onto the live row. This preserves
2159
+ // behavior for collections that opt out of the draft model.
2160
+ if (!usesDraftRevisions) {
2161
+ const result = await handleRevisionRestore(this.db, revisionId, callerUserId);
2162
+ return this.hydrateDraftData(result);
2163
+ }
2164
+
2165
+ // Revision-capable collections: restore is "make this revision the
2166
+ // current draft". The live row's data columns are left untouched
2167
+ // (only `draft_revision_id` and `updated_at` change). The caller
2168
+ // must then `content_publish` to promote the restored draft to
2169
+ // live, matching the documented tool contract.
2170
+ try {
2171
+ const newDraft = await revisionRepo.create({
2172
+ collection: revision.collection,
2173
+ entryId: revision.entryId,
2174
+ data: revision.data,
2175
+ authorId: callerUserId,
2176
+ });
2177
+
2178
+ validateIdentifier(revision.collection, "collection");
2179
+ const tableName = `ec_${revision.collection}`;
2180
+ await sql`
2181
+ UPDATE ${sql.ref(tableName)}
2182
+ SET draft_revision_id = ${newDraft.id},
2183
+ updated_at = ${new Date().toISOString()}
2184
+ WHERE id = ${revision.entryId}
2185
+ `.execute(this.db);
2186
+
2187
+ // Fire-and-forget: prune old revisions to prevent unbounded growth
2188
+ void revisionRepo
2189
+ .pruneOldRevisions(revision.collection, revision.entryId, 50)
2190
+ .catch(() => {});
2191
+
2192
+ // Return the freshly-fetched item with the new draft hydrated
2193
+ // onto `data`. Without this the response would echo the live
2194
+ // columns and the next `content_get` would surface different
2195
+ // values (the bug that motivated this rewrite).
2196
+ const refetched = await handleContentGet(this.db, revision.collection, revision.entryId);
2197
+ return this.hydrateDraftData(refetched);
2198
+ } catch (error) {
2199
+ console.error("[emdash] revision restore failed:", error);
2200
+ return {
2201
+ success: false as const,
2202
+ error: {
2203
+ code: "REVISION_RESTORE_ERROR",
2204
+ message: "Failed to restore revision",
2205
+ },
2206
+ };
2207
+ }
2013
2208
  }
2014
2209
 
2015
2210
  // =========================================================================
@@ -2080,6 +2275,7 @@ export class EmDashRuntime {
2080
2275
  const routeRegistry = new PluginRouteRegistry({
2081
2276
  db: this.db,
2082
2277
  emailPipeline: this.email ?? undefined,
2278
+ trustedProxyHeaders: getTrustedProxyHeaders(this.config),
2083
2279
  });
2084
2280
  routeRegistry.register(trustedPlugin);
2085
2281
 
@@ -2220,22 +2416,34 @@ export class EmDashRuntime {
2220
2416
  collection: string,
2221
2417
  isNew: boolean,
2222
2418
  ): void {
2223
- // Trusted plugins
2224
- if (this.hooks.hasHooks("content:afterSave")) {
2225
- this.hooks
2226
- .runContentAfterSave(content, collection, isNew)
2227
- .catch((err) => console.error("EmDash afterSave hook error:", err));
2228
- }
2229
-
2230
- // Sandboxed plugins
2231
- for (const [pluginKey, plugin] of this.sandboxedPlugins) {
2232
- const [id] = pluginKey.split(":");
2233
- if (!id || !this.isPluginEnabled(id)) continue;
2419
+ after(async () => {
2420
+ // Trusted plugins
2421
+ if (this.hooks.hasHooks("content:afterSave")) {
2422
+ try {
2423
+ await this.hooks.runContentAfterSave(content, collection, isNew);
2424
+ } catch (err) {
2425
+ console.error("EmDash afterSave hook error:", err);
2426
+ }
2427
+ }
2234
2428
 
2235
- plugin
2236
- .invokeHook("content:afterSave", { content, collection, isNew })
2237
- .catch((err) => console.error(`EmDash: Sandboxed plugin ${id} afterSave error:`, err));
2238
- }
2429
+ // Sandboxed plugins
2430
+ const tasks: Promise<void>[] = [];
2431
+ for (const [pluginKey, plugin] of this.sandboxedPlugins) {
2432
+ const [id] = pluginKey.split(":");
2433
+ if (!id || !this.isPluginEnabled(id)) continue;
2434
+
2435
+ tasks.push(
2436
+ (async () => {
2437
+ try {
2438
+ await plugin.invokeHook("content:afterSave", { content, collection, isNew });
2439
+ } catch (err) {
2440
+ console.error(`EmDash: Sandboxed plugin ${id} afterSave error:`, err);
2441
+ }
2442
+ })(),
2443
+ );
2444
+ }
2445
+ await Promise.allSettled(tasks);
2446
+ });
2239
2447
  }
2240
2448
 
2241
2449
  private runAfterDeleteHooks(id: string, collection: string, permanent: boolean): void {
@@ -2260,24 +2468,34 @@ export class EmDashRuntime {
2260
2468
  }
2261
2469
 
2262
2470
  private runAfterPublishHooks(content: Record<string, unknown>, collection: string): void {
2263
- // Trusted plugins
2264
- if (this.hooks.hasHooks("content:afterPublish")) {
2265
- this.hooks
2266
- .runContentAfterPublish(content, collection)
2267
- .catch((err) => console.error("EmDash afterPublish hook error:", err));
2268
- }
2269
-
2270
- // Sandboxed plugins
2271
- for (const [pluginKey, plugin] of this.sandboxedPlugins) {
2272
- const [pluginId] = pluginKey.split(":");
2273
- if (!pluginId || !this.isPluginEnabled(pluginId)) continue;
2471
+ after(async () => {
2472
+ // Trusted plugins
2473
+ if (this.hooks.hasHooks("content:afterPublish")) {
2474
+ try {
2475
+ await this.hooks.runContentAfterPublish(content, collection);
2476
+ } catch (err) {
2477
+ console.error("EmDash afterPublish hook error:", err);
2478
+ }
2479
+ }
2274
2480
 
2275
- plugin
2276
- .invokeHook("content:afterPublish", { content, collection })
2277
- .catch((err) =>
2278
- console.error(`EmDash: Sandboxed plugin ${pluginId} afterPublish error:`, err),
2481
+ // Sandboxed plugins
2482
+ const tasks: Promise<void>[] = [];
2483
+ for (const [pluginKey, plugin] of this.sandboxedPlugins) {
2484
+ const [pluginId] = pluginKey.split(":");
2485
+ if (!pluginId || !this.isPluginEnabled(pluginId)) continue;
2486
+
2487
+ tasks.push(
2488
+ (async () => {
2489
+ try {
2490
+ await plugin.invokeHook("content:afterPublish", { content, collection });
2491
+ } catch (err) {
2492
+ console.error(`EmDash: Sandboxed plugin ${pluginId} afterPublish error:`, err);
2493
+ }
2494
+ })(),
2279
2495
  );
2280
- }
2496
+ }
2497
+ await Promise.allSettled(tasks);
2498
+ });
2281
2499
  }
2282
2500
 
2283
2501
  private runAfterUnpublishHooks(content: Record<string, unknown>, collection: string): void {
@@ -2321,7 +2539,7 @@ export class EmDashRuntime {
2321
2539
 
2322
2540
  try {
2323
2541
  const headers = sanitizeHeadersForSandbox(request.headers);
2324
- const meta = extractRequestMeta(request);
2542
+ const meta = extractRequestMeta(request, this.config);
2325
2543
  const result = await plugin.invokeRoute(routeName, body, {
2326
2544
  url: request.url,
2327
2545
  method: request.method,
@@ -4,7 +4,7 @@
4
4
  * Manages available import sources and provides URL probing.
5
5
  */
6
6
 
7
- import { validateExternalUrl } from "./ssrf.js";
7
+ import { resolveAndValidateExternalUrl } from "./ssrf.js";
8
8
  import type { ImportSource, ProbeResult, SourceProbeResult } from "./types.js";
9
9
 
10
10
  // Regex pattern for URL normalization
@@ -63,8 +63,9 @@ export async function probeUrl(url: string): Promise<ProbeResult> {
63
63
  // Remove trailing slash for consistency
64
64
  normalizedUrl = normalizedUrl.replace(TRAILING_SLASHES_PATTERN, "");
65
65
 
66
- // SSRF: reject internal/private network targets
67
- validateExternalUrl(normalizedUrl);
66
+ // SSRF: reject internal/private network targets. DNS resolution
67
+ // catches hostnames that resolve to private addresses.
68
+ await resolveAndValidateExternalUrl(normalizedUrl);
68
69
 
69
70
  const results: SourceProbeResult[] = [];
70
71
  const urlSources = getUrlSources();