emdash 0.7.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 (225) 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-5uslYdUu.mjs → apply-x0eMK1lX.mjs} +18 -17
  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 +86 -15
  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 +1 -1
  15. package/dist/astro/middleware/setup.mjs +1 -1
  16. package/dist/astro/middleware.d.mts.map +1 -1
  17. package/dist/astro/middleware.mjs +259 -71
  18. package/dist/astro/middleware.mjs.map +1 -1
  19. package/dist/astro/types.d.mts +16 -8
  20. package/dist/astro/types.d.mts.map +1 -1
  21. package/dist/{byline-C4OVd8b3.mjs → byline-Chbr2GoP.mjs} +3 -3
  22. package/dist/byline-Chbr2GoP.mjs.map +1 -0
  23. package/dist/{bylines-hPTW79hw.mjs → bylines-CRNsVG88.mjs} +4 -4
  24. package/dist/{bylines-hPTW79hw.mjs.map → bylines-CRNsVG88.mjs.map} +1 -1
  25. package/dist/cli/index.mjs +16 -12
  26. package/dist/cli/index.mjs.map +1 -1
  27. package/dist/client/cf-access.d.mts +1 -1
  28. package/dist/client/index.d.mts +1 -1
  29. package/dist/client/index.mjs +1 -1
  30. package/dist/{content-D7J5y73J.mjs → content-BcQPYxdV.mjs} +13 -15
  31. package/dist/content-BcQPYxdV.mjs.map +1 -0
  32. package/dist/db/index.d.mts +3 -3
  33. package/dist/db/libsql.d.mts +1 -1
  34. package/dist/db/postgres.d.mts +1 -1
  35. package/dist/db/sqlite.d.mts +1 -1
  36. package/dist/{db-errors-D0UT85nC.mjs → db-errors-l1Qh2RPR.mjs} +1 -1
  37. package/dist/{db-errors-D0UT85nC.mjs.map → db-errors-l1Qh2RPR.mjs.map} +1 -1
  38. package/dist/{default-CME5YdZ3.mjs → default-DCVqE5ib.mjs} +1 -1
  39. package/dist/{default-CME5YdZ3.mjs.map → default-DCVqE5ib.mjs.map} +1 -1
  40. package/dist/{error-CiYn9yDu.mjs → error-zG5T1UGA.mjs} +1 -1
  41. package/dist/error-zG5T1UGA.mjs.map +1 -0
  42. package/dist/{index-De6_Xv3v.d.mts → index-DIb-CzNx.d.mts} +157 -14
  43. package/dist/index-DIb-CzNx.d.mts.map +1 -0
  44. package/dist/index.d.mts +11 -11
  45. package/dist/index.mjs +22 -20
  46. package/dist/{load-CBcmDIot.mjs → load-CyEoextb.mjs} +1 -1
  47. package/dist/{load-CBcmDIot.mjs.map → load-CyEoextb.mjs.map} +1 -1
  48. package/dist/{loader-DeiBJEMe.mjs → loader-CndGj8kM.mjs} +8 -6
  49. package/dist/loader-CndGj8kM.mjs.map +1 -0
  50. package/dist/{manifest-schema-V30qsMft.mjs → manifest-schema-DH9xhc6t.mjs} +13 -1
  51. package/dist/manifest-schema-DH9xhc6t.mjs.map +1 -0
  52. package/dist/media/index.d.mts +1 -1
  53. package/dist/media/local-runtime.d.mts +7 -7
  54. package/dist/media/local-runtime.mjs +2 -2
  55. package/dist/{media-DqHVh136.mjs → media-D8FbNsl0.mjs} +4 -7
  56. package/dist/media-D8FbNsl0.mjs.map +1 -0
  57. package/dist/{mode-CpNnGkPz.mjs → mode-BnAOqItE.mjs} +1 -1
  58. package/dist/mode-BnAOqItE.mjs.map +1 -0
  59. package/dist/page/index.d.mts +2 -2
  60. package/dist/placeholder-C-fk5hYI.mjs.map +1 -1
  61. package/dist/{placeholder-tzpqGWII.d.mts → placeholder-D29tWZ7o.d.mts} +1 -1
  62. package/dist/{placeholder-tzpqGWII.d.mts.map → placeholder-D29tWZ7o.d.mts.map} +1 -1
  63. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  64. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  65. package/dist/{query-g4Ug-9j9.mjs → query-fqEdLFms.mjs} +9 -9
  66. package/dist/{query-g4Ug-9j9.mjs.map → query-fqEdLFms.mjs.map} +1 -1
  67. package/dist/{redirect-CN0Rt9Ob.mjs → redirect-D_pshWdf.mjs} +4 -4
  68. package/dist/redirect-D_pshWdf.mjs.map +1 -0
  69. package/dist/{registry-Ci3WxVAr.mjs → registry-C3Mr0ODu.mjs} +33 -9
  70. package/dist/registry-C3Mr0ODu.mjs.map +1 -0
  71. package/dist/{request-cache-DiR961CV.mjs → request-cache-Ci7f5pBb.mjs} +1 -1
  72. package/dist/request-cache-Ci7f5pBb.mjs.map +1 -0
  73. package/dist/{runner-BR2xKwhn.d.mts → runner-OURCaApa.d.mts} +2 -2
  74. package/dist/{runner-BR2xKwhn.d.mts.map → runner-OURCaApa.d.mts.map} +1 -1
  75. package/dist/runtime.d.mts +6 -6
  76. package/dist/runtime.mjs +2 -2
  77. package/dist/{search-B0effn3j.mjs → search-BoZYFuUk.mjs} +227 -84
  78. package/dist/search-BoZYFuUk.mjs.map +1 -0
  79. package/dist/seed/index.d.mts +2 -2
  80. package/dist/seed/index.mjs +12 -12
  81. package/dist/seo/index.d.mts +1 -1
  82. package/dist/storage/local.d.mts +1 -1
  83. package/dist/storage/local.mjs +1 -1
  84. package/dist/storage/s3.d.mts +1 -1
  85. package/dist/storage/s3.d.mts.map +1 -1
  86. package/dist/storage/s3.mjs +4 -4
  87. package/dist/storage/s3.mjs.map +1 -1
  88. package/dist/{taxonomies-K2z0Uhnj.mjs → taxonomies-B4IAshV8.mjs} +5 -5
  89. package/dist/{taxonomies-K2z0Uhnj.mjs.map → taxonomies-B4IAshV8.mjs.map} +1 -1
  90. package/dist/{tokens-BFPFx3CA.mjs → tokens-D9vnZqYS.mjs} +1 -1
  91. package/dist/{tokens-BFPFx3CA.mjs.map → tokens-D9vnZqYS.mjs.map} +1 -1
  92. package/dist/{transport-BykRfpyy.mjs → transport-C9ugt2Nr.mjs} +1 -1
  93. package/dist/{transport-BykRfpyy.mjs.map → transport-C9ugt2Nr.mjs.map} +1 -1
  94. package/dist/{transport-H4Iwx7tC.d.mts → transport-CUnEL3Vs.d.mts} +1 -1
  95. package/dist/{transport-H4Iwx7tC.d.mts.map → transport-CUnEL3Vs.d.mts.map} +1 -1
  96. package/dist/types-BIgulNsW.mjs +68 -0
  97. package/dist/types-BIgulNsW.mjs.map +1 -0
  98. package/dist/{types-DDS4MxsT.mjs → types-Bm1dn-q3.mjs} +1 -1
  99. package/dist/{types-DDS4MxsT.mjs.map → types-Bm1dn-q3.mjs.map} +1 -1
  100. package/dist/{types-CnZYHyLW.d.mts → types-BmPPSUEx.d.mts} +1 -1
  101. package/dist/{types-CnZYHyLW.d.mts.map → types-BmPPSUEx.d.mts.map} +1 -1
  102. package/dist/{types-6CUZRrZP.d.mts → types-BrA0xf5I.d.mts} +24 -2
  103. package/dist/{types-6CUZRrZP.d.mts.map → types-BrA0xf5I.d.mts.map} +1 -1
  104. package/dist/{types-C2v0c34j.d.mts → types-CS8FIX7L.d.mts} +1 -1
  105. package/dist/{types-C2v0c34j.d.mts.map → types-CS8FIX7L.d.mts.map} +1 -1
  106. package/dist/{types-BH2L167P.mjs → types-CgqmmMJB.mjs} +1 -1
  107. package/dist/{types-BH2L167P.mjs.map → types-CgqmmMJB.mjs.map} +1 -1
  108. package/dist/{types-CFWjXmus.d.mts → types-DIMwPFub.d.mts} +1 -1
  109. package/dist/{types-CFWjXmus.d.mts.map → types-DIMwPFub.d.mts.map} +1 -1
  110. package/dist/{types-DgrIP0tF.d.mts → types-i36XcA_X.d.mts} +49 -6
  111. package/dist/types-i36XcA_X.d.mts.map +1 -0
  112. package/dist/{validate-CqsNItbt.mjs → validate-CxVsLehf.mjs} +2 -2
  113. package/dist/{validate-CqsNItbt.mjs.map → validate-CxVsLehf.mjs.map} +1 -1
  114. package/dist/{validate-kM8Pjuf7.d.mts → validate-DHxmpFJt.d.mts} +4 -4
  115. package/dist/{validate-kM8Pjuf7.d.mts.map → validate-DHxmpFJt.d.mts.map} +1 -1
  116. package/dist/validation-C-ZpN2GI.mjs +144 -0
  117. package/dist/validation-C-ZpN2GI.mjs.map +1 -0
  118. package/dist/version-Bbq8TCrz.mjs +7 -0
  119. package/dist/{version-BnTKdfam.mjs.map → version-Bbq8TCrz.mjs.map} +1 -1
  120. package/dist/zod-generator-CpwccCIv.mjs +132 -0
  121. package/dist/zod-generator-CpwccCIv.mjs.map +1 -0
  122. package/package.json +18 -5
  123. package/src/api/auth-storage.ts +37 -0
  124. package/src/api/error.ts +6 -0
  125. package/src/api/errors.ts +8 -0
  126. package/src/api/handlers/comments.ts +13 -0
  127. package/src/api/handlers/content.ts +122 -3
  128. package/src/api/handlers/index.ts +2 -0
  129. package/src/api/handlers/media.ts +8 -1
  130. package/src/api/handlers/menus.ts +160 -21
  131. package/src/api/handlers/redirects.ts +16 -3
  132. package/src/api/handlers/sections.ts +8 -1
  133. package/src/api/handlers/taxonomies.ts +128 -16
  134. package/src/api/handlers/validation.ts +212 -0
  135. package/src/api/openapi/document.ts +4 -1
  136. package/src/api/public-url.ts +6 -3
  137. package/src/api/route-utils.ts +14 -0
  138. package/src/api/schemas/common.ts +1 -1
  139. package/src/api/schemas/setup.ts +8 -0
  140. package/src/api/schemas/widgets.ts +12 -10
  141. package/src/api/setup-complete.ts +40 -0
  142. package/src/astro/integration/index.ts +13 -2
  143. package/src/astro/integration/routes.ts +28 -0
  144. package/src/astro/integration/runtime.ts +19 -1
  145. package/src/astro/integration/virtual-modules.ts +41 -0
  146. package/src/astro/integration/vite-config.ts +43 -12
  147. package/src/astro/middleware/auth.ts +21 -0
  148. package/src/astro/middleware.ts +18 -1
  149. package/src/astro/routes/PluginRegistry.tsx +10 -1
  150. package/src/astro/routes/api/auth/mode.ts +57 -0
  151. package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +23 -3
  152. package/src/astro/routes/api/auth/oauth/[provider].ts +10 -4
  153. package/src/astro/routes/api/content/[collection]/[id]/translations.ts +1 -1
  154. package/src/astro/routes/api/content/[collection]/index.ts +1 -9
  155. package/src/astro/routes/api/import/wordpress/media.ts +2 -7
  156. package/src/astro/routes/api/import/wordpress/prepare.ts +10 -0
  157. package/src/astro/routes/api/settings/email.ts +4 -9
  158. package/src/astro/routes/api/setup/admin.ts +8 -2
  159. package/src/astro/routes/api/setup/index.ts +2 -2
  160. package/src/astro/routes/api/setup/status.ts +3 -1
  161. package/src/astro/routes/api/widget-areas/[name]/widgets/[id].ts +4 -1
  162. package/src/astro/routes/api/widget-areas/[name]/widgets.ts +4 -1
  163. package/src/astro/routes/api/widget-areas/[name].ts +4 -1
  164. package/src/astro/routes/api/widget-areas/index.ts +4 -1
  165. package/src/astro/types.ts +9 -0
  166. package/src/auth/mode.ts +15 -3
  167. package/src/auth/providers/github-admin.tsx +29 -0
  168. package/src/auth/providers/github.ts +31 -0
  169. package/src/auth/providers/google-admin.tsx +44 -0
  170. package/src/auth/providers/google.ts +31 -0
  171. package/src/auth/types.ts +114 -4
  172. package/src/cli/commands/bundle.ts +3 -1
  173. package/src/components/EmDashImage.astro +7 -6
  174. package/src/components/Gallery.astro +5 -3
  175. package/src/components/Image.astro +8 -3
  176. package/src/components/InlinePortableTextEditor.tsx +2 -1
  177. package/src/components/LiveSearch.astro +5 -14
  178. package/src/database/repositories/audit.ts +6 -8
  179. package/src/database/repositories/byline.ts +6 -8
  180. package/src/database/repositories/comment.ts +12 -16
  181. package/src/database/repositories/content.ts +40 -40
  182. package/src/database/repositories/index.ts +1 -1
  183. package/src/database/repositories/media.ts +10 -13
  184. package/src/database/repositories/plugin-storage.ts +4 -6
  185. package/src/database/repositories/redirect.ts +12 -16
  186. package/src/database/repositories/taxonomy.ts +14 -3
  187. package/src/database/repositories/types.ts +57 -8
  188. package/src/database/repositories/user.ts +6 -8
  189. package/src/emdash-runtime.ts +306 -90
  190. package/src/index.ts +5 -1
  191. package/src/loader.ts +6 -5
  192. package/src/mcp/server.ts +678 -105
  193. package/src/media/normalize.ts +1 -1
  194. package/src/media/url.ts +78 -0
  195. package/src/plugins/email-console.ts +10 -3
  196. package/src/plugins/hooks.ts +11 -0
  197. package/src/plugins/manifest-schema.ts +12 -0
  198. package/src/plugins/types.ts +23 -2
  199. package/src/query.ts +1 -1
  200. package/src/request-cache.ts +3 -0
  201. package/src/schema/registry.ts +41 -5
  202. package/src/search/fts-manager.ts +0 -2
  203. package/src/search/query.ts +111 -26
  204. package/src/search/types.ts +8 -1
  205. package/src/sections/index.ts +7 -9
  206. package/src/storage/s3.ts +12 -6
  207. package/src/virtual-modules.d.ts +21 -1
  208. package/src/widgets/index.ts +1 -1
  209. package/dist/apply-5uslYdUu.mjs.map +0 -1
  210. package/dist/byline-C4OVd8b3.mjs.map +0 -1
  211. package/dist/content-D7J5y73J.mjs.map +0 -1
  212. package/dist/error-CiYn9yDu.mjs.map +0 -1
  213. package/dist/index-De6_Xv3v.d.mts.map +0 -1
  214. package/dist/loader-DeiBJEMe.mjs.map +0 -1
  215. package/dist/manifest-schema-V30qsMft.mjs.map +0 -1
  216. package/dist/media-DqHVh136.mjs.map +0 -1
  217. package/dist/mode-CpNnGkPz.mjs.map +0 -1
  218. package/dist/redirect-CN0Rt9Ob.mjs.map +0 -1
  219. package/dist/registry-Ci3WxVAr.mjs.map +0 -1
  220. package/dist/request-cache-DiR961CV.mjs.map +0 -1
  221. package/dist/search-B0effn3j.mjs.map +0 -1
  222. package/dist/types-CMMN0pNg.mjs +0 -31
  223. package/dist/types-CMMN0pNg.mjs.map +0 -1
  224. package/dist/types-DgrIP0tF.d.mts.map +0 -1
  225. package/dist/version-BnTKdfam.mjs +0 -7
@@ -46,6 +46,20 @@ import { COMMIT, VERSION } from "./version.js";
46
46
 
47
47
  const LEADING_SLASH_PATTERN = /^\//;
48
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
+
49
63
  /** Combined result from a single-pass page contribution collection */
50
64
  interface PageContributions {
51
65
  metadata: PageMetadataContribution[];
@@ -237,6 +251,45 @@ export interface RuntimeDependencies {
237
251
  createSandboxRunner: ((opts: { db: Kysely<Database> }) => SandboxRunner) | null;
238
252
  }
239
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
+
240
293
  /**
241
294
  * Convert a ContentItem to Record<string, unknown> for hook consumption.
242
295
  * Hooks receive the full item as a flat record.
@@ -337,51 +390,27 @@ export class EmDashRuntime {
337
390
  return this._db;
338
391
  }
339
392
 
340
- private constructor(
341
- db: Kysely<Database>,
342
- storage: Storage | null,
343
- configuredPlugins: ResolvedPlugin[],
344
- sandboxedPlugins: Map<string, SandboxedPlugin>,
345
- sandboxedPluginEntries: SandboxedPluginEntry[],
346
- hooks: HookPipeline,
347
- enabledPlugins: Set<string>,
348
- pluginStates: Map<string, string>,
349
- config: EmDashConfig,
350
- mediaProviders: Map<string, MediaProvider>,
351
- mediaProviderEntries: MediaProviderEntry[],
352
- cronExecutor: CronExecutor | null,
353
- cronScheduler: CronScheduler | null,
354
- emailPipeline: EmailPipeline | null,
355
- allPipelinePlugins: ResolvedPlugin[],
356
- pipelineFactoryOptions: {
357
- db: Kysely<Database>;
358
- storage?: Storage;
359
- siteInfo?: { siteName?: string; siteUrl?: string; locale?: string };
360
- },
361
- runtimeDeps: RuntimeDependencies,
362
- pipelineRef: { current: HookPipeline },
363
- manifestCacheKey: string,
364
- ) {
365
- this._db = db;
366
- this.storage = storage;
367
- this.configuredPlugins = configuredPlugins;
368
- this.sandboxedPlugins = sandboxedPlugins;
369
- this.sandboxedPluginEntries = sandboxedPluginEntries;
370
- this.schemaRegistry = new SchemaRegistry(db);
371
- this._hooks = hooks;
372
- this.enabledPlugins = enabledPlugins;
373
- this.pluginStates = pluginStates;
374
- this.config = config;
375
- this.mediaProviders = mediaProviders;
376
- this.mediaProviderEntries = mediaProviderEntries;
377
- this.cronExecutor = cronExecutor;
378
- this.cronScheduler = cronScheduler;
379
- this.email = emailPipeline;
380
- this.allPipelinePlugins = allPipelinePlugins;
381
- this.pipelineFactoryOptions = pipelineFactoryOptions;
382
- this.runtimeDeps = runtimeDeps;
383
- this.pipelineRef = pipelineRef;
384
- 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;
385
414
  }
386
415
 
387
416
  /**
@@ -858,16 +887,16 @@ export class EmDashRuntime {
858
887
  ].join("|"),
859
888
  );
860
889
 
861
- return new EmDashRuntime(
890
+ return new EmDashRuntime({
862
891
  db,
863
892
  storage,
864
- deps.plugins,
893
+ configuredPlugins: deps.plugins,
865
894
  sandboxedPlugins,
866
- deps.sandboxedPluginEntries,
867
- pipeline,
895
+ sandboxedPluginEntries: deps.sandboxedPluginEntries,
896
+ hooks: pipeline,
868
897
  enabledPlugins,
869
898
  pluginStates,
870
- deps.config,
899
+ config: deps.config,
871
900
  mediaProviders,
872
901
  mediaProviderEntries,
873
902
  cronExecutor,
@@ -875,10 +904,10 @@ export class EmDashRuntime {
875
904
  emailPipeline,
876
905
  allPipelinePlugins,
877
906
  pipelineFactoryOptions,
878
- deps,
907
+ runtimeDeps: deps,
879
908
  pipelineRef,
880
909
  manifestCacheKey,
881
- );
910
+ });
882
911
  }
883
912
 
884
913
  /**
@@ -1489,7 +1518,7 @@ export class EmDashRuntime {
1489
1518
  label: row.label,
1490
1519
  labelSingular: row.label_singular ?? undefined,
1491
1520
  hierarchical: row.hierarchical === 1,
1492
- collections: row.collections ? (JSON.parse(row.collections) as string[]).toSorted() : [],
1521
+ collections: parseStringArray(row.collections).toSorted(),
1493
1522
  }));
1494
1523
  } catch (error) {
1495
1524
  console.debug("EmDash: Could not load taxonomy definitions:", error);
@@ -1615,11 +1644,75 @@ export class EmDashRuntime {
1615
1644
  }
1616
1645
 
1617
1646
  async handleContentGet(collection: string, id: string, locale?: string) {
1618
- return handleContentGet(this.db, collection, id, locale);
1647
+ const result = await handleContentGet(this.db, collection, id, locale);
1648
+ return this.hydrateDraftData(result);
1619
1649
  }
1620
1650
 
1621
1651
  async handleContentGetIncludingTrashed(collection: string, id: string, locale?: string) {
1622
- 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
+ }
1623
1716
  }
1624
1717
 
1625
1718
  async handleContentCreate(
@@ -1647,6 +1740,20 @@ export class EmDashRuntime {
1647
1740
  // Normalize media fields (fill dimensions, storageKey, etc.)
1648
1741
  processedData = await this.normalizeMediaFields(collection, processedData);
1649
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
+
1650
1757
  // Create the content
1651
1758
  const result = await handleContentCreate(this.db, collection, {
1652
1759
  ...body,
@@ -1720,6 +1827,19 @@ export class EmDashRuntime {
1720
1827
 
1721
1828
  // Normalize media fields (fill dimensions, storageKey, etc.)
1722
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
+ }
1723
1843
  }
1724
1844
 
1725
1845
  // Draft-aware revision handling (if collection supports revisions)
@@ -1795,12 +1915,18 @@ export class EmDashRuntime {
1795
1915
  bylines: bodyWithoutRev.bylines,
1796
1916
  });
1797
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
+
1798
1924
  // Run afterSave hooks (fire-and-forget)
1799
- if (result.success && result.data) {
1800
- this.runAfterSaveHooks(contentItemToRecord(result.data.item), collection, false);
1925
+ if (hydrated.success && hydrated.data) {
1926
+ this.runAfterSaveHooks(contentItemToRecord(hydrated.data.item), collection, false);
1801
1927
  }
1802
1928
 
1803
- return result;
1929
+ return hydrated;
1804
1930
  }
1805
1931
 
1806
1932
  async handleContentDelete(collection: string, id: string) {
@@ -1947,6 +2073,7 @@ export class EmDashRuntime {
1947
2073
  contentHash?: string;
1948
2074
  blurhash?: string;
1949
2075
  dominantColor?: string;
2076
+ authorId?: string;
1950
2077
  }) {
1951
2078
  // Run beforeUpload hooks
1952
2079
  let processedInput = input;
@@ -2010,7 +2137,74 @@ export class EmDashRuntime {
2010
2137
  }
2011
2138
 
2012
2139
  async handleRevisionRestore(revisionId: string, callerUserId: string) {
2013
- 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
+ }
2014
2208
  }
2015
2209
 
2016
2210
  // =========================================================================
@@ -2222,22 +2416,34 @@ export class EmDashRuntime {
2222
2416
  collection: string,
2223
2417
  isNew: boolean,
2224
2418
  ): void {
2225
- // Trusted plugins
2226
- if (this.hooks.hasHooks("content:afterSave")) {
2227
- this.hooks
2228
- .runContentAfterSave(content, collection, isNew)
2229
- .catch((err) => console.error("EmDash afterSave hook error:", err));
2230
- }
2231
-
2232
- // Sandboxed plugins
2233
- for (const [pluginKey, plugin] of this.sandboxedPlugins) {
2234
- const [id] = pluginKey.split(":");
2235
- 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
+ }
2236
2428
 
2237
- plugin
2238
- .invokeHook("content:afterSave", { content, collection, isNew })
2239
- .catch((err) => console.error(`EmDash: Sandboxed plugin ${id} afterSave error:`, err));
2240
- }
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
+ });
2241
2447
  }
2242
2448
 
2243
2449
  private runAfterDeleteHooks(id: string, collection: string, permanent: boolean): void {
@@ -2262,24 +2468,34 @@ export class EmDashRuntime {
2262
2468
  }
2263
2469
 
2264
2470
  private runAfterPublishHooks(content: Record<string, unknown>, collection: string): void {
2265
- // Trusted plugins
2266
- if (this.hooks.hasHooks("content:afterPublish")) {
2267
- this.hooks
2268
- .runContentAfterPublish(content, collection)
2269
- .catch((err) => console.error("EmDash afterPublish hook error:", err));
2270
- }
2271
-
2272
- // Sandboxed plugins
2273
- for (const [pluginKey, plugin] of this.sandboxedPlugins) {
2274
- const [pluginId] = pluginKey.split(":");
2275
- 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
+ }
2276
2480
 
2277
- plugin
2278
- .invokeHook("content:afterPublish", { content, collection })
2279
- .catch((err) =>
2280
- 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
+ })(),
2281
2495
  );
2282
- }
2496
+ }
2497
+ await Promise.allSettled(tasks);
2498
+ });
2283
2499
  }
2284
2500
 
2285
2501
  private runAfterUnpublishHooks(content: Record<string, unknown>, collection: string): void {
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export {
13
13
  ContentRepository,
14
14
  MediaRepository,
15
15
  EmDashValidationError,
16
+ InvalidCursorError,
16
17
  } from "./database/repositories/index.js";
17
18
  export type {
18
19
  ContentItem,
@@ -480,11 +481,14 @@ export type {
480
481
  SearchStats,
481
482
  } from "./search/index.js";
482
483
 
483
- // Auth types (for platform-specific auth providers)
484
+ // Auth types (for platform-specific auth providers and pluggable login methods)
484
485
  export type {
485
486
  AuthDescriptor,
487
+ AuthProviderDescriptor,
488
+ AuthProviderAdminExports,
486
489
  AuthProviderModule,
487
490
  AuthResult,
491
+ AuthRouteDescriptor,
488
492
  ExternalAuthConfig,
489
493
  } from "./auth/types.js";
490
494
 
package/src/loader.ts CHANGED
@@ -318,16 +318,17 @@ function buildOrderByClause(
318
318
  /**
319
319
  * Build a cursor WHERE condition for keyset pagination.
320
320
  * Uses the primary sort field + id as tiebreaker for stable ordering.
321
+ *
322
+ * Throws `InvalidCursorError` if the cursor is malformed; callers should
323
+ * let this propagate so users see a real error rather than silently
324
+ * falling back to the first page.
321
325
  */
322
326
  function buildCursorCondition(
323
327
  cursor: string,
324
328
  orderBy: OrderBySpec | undefined,
325
329
  tablePrefix?: string,
326
- ): ReturnType<typeof sql> | null {
327
- const decoded = decodeCursor(cursor);
328
- if (!decoded) return null;
329
-
330
- const { orderValue, id: cursorId } = decoded;
330
+ ): ReturnType<typeof sql> {
331
+ const { orderValue, id: cursorId } = decodeCursor(cursor);
331
332
  const primary = getPrimarySort(orderBy, tablePrefix);
332
333
  const idField = tablePrefix ? `${tablePrefix}.id` : "id";
333
334