emdash 0.6.0 → 1.0.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-DJrV1K0M.mjs +7 -0
  123. package/dist/{version-Uaf2ynPX.mjs.map → version-DJrV1K0M.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 +19 -6
  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
@@ -122,16 +122,27 @@ export async function handleTaxonomyList(
122
122
  db: Kysely<Database>,
123
123
  ): Promise<ApiResult<TaxonomyListResponse>> {
124
124
  try {
125
- const rows = await db.selectFrom("_emdash_taxonomy_defs").selectAll().execute();
126
-
127
- const taxonomies: TaxonomyDef[] = rows.map((row) => ({
128
- id: row.id,
129
- name: row.name,
130
- label: row.label,
131
- labelSingular: row.label_singular ?? undefined,
132
- hierarchical: row.hierarchical === 1,
133
- collections: row.collections ? JSON.parse(row.collections) : [],
134
- }));
125
+ const [rows, collectionRows] = await Promise.all([
126
+ db.selectFrom("_emdash_taxonomy_defs").selectAll().execute(),
127
+ db.selectFrom("_emdash_collections").select("slug").execute(),
128
+ ]);
129
+
130
+ // Filter orphan collection references on read so the response stays
131
+ // consistent with `schema_list_collections`. Storage is untouched —
132
+ // re-creating the collection re-links automatically.
133
+ const realCollections = new Set(collectionRows.map((r) => r.slug));
134
+
135
+ const taxonomies: TaxonomyDef[] = rows.map((row) => {
136
+ const stored: string[] = row.collections ? JSON.parse(row.collections) : [];
137
+ return {
138
+ id: row.id,
139
+ name: row.name,
140
+ label: row.label,
141
+ labelSingular: row.label_singular ?? undefined,
142
+ hierarchical: row.hierarchical === 1,
143
+ collections: stored.filter((slug) => realCollections.has(slug)),
144
+ };
145
+ });
135
146
 
136
147
  return { success: true, data: { taxonomies } };
137
148
  } catch {
@@ -290,6 +301,84 @@ export async function handleTermList(
290
301
  }
291
302
  }
292
303
 
304
+ /**
305
+ * Validate a parent term reference for create/update.
306
+ *
307
+ * Returns `null` on success or a structured error message that callers
308
+ * wrap in their own ApiResult.
309
+ *
310
+ * - `parentId === undefined` -> no-op (no parent change requested).
311
+ * - `parentId === null` -> caller intends to detach; no-op here.
312
+ * - parent must exist (FK exists -> term row not soft-deleted).
313
+ * - parent must live in the same taxonomy.
314
+ * - if `termId` is provided (update path), reject `parentId === termId`
315
+ * (self-parent) and walk up the parent chain to detect cycles.
316
+ */
317
+ async function validateParentTerm(
318
+ repo: TaxonomyRepository,
319
+ taxonomyName: string,
320
+ termId: string | undefined,
321
+ parentId: string | null | undefined,
322
+ ): Promise<{ code: "VALIDATION_ERROR"; message: string } | null> {
323
+ if (parentId === undefined || parentId === null) return null;
324
+
325
+ if (termId !== undefined && parentId === termId) {
326
+ return {
327
+ code: "VALIDATION_ERROR",
328
+ message: "A term cannot be its own parent",
329
+ };
330
+ }
331
+
332
+ const parent = await repo.findById(parentId);
333
+ if (!parent) {
334
+ return {
335
+ code: "VALIDATION_ERROR",
336
+ message: `Parent term '${parentId}' not found`,
337
+ };
338
+ }
339
+ if (parent.name !== taxonomyName) {
340
+ return {
341
+ code: "VALIDATION_ERROR",
342
+ message: `Parent term '${parentId}' belongs to taxonomy '${parent.name}', not '${taxonomyName}'`,
343
+ };
344
+ }
345
+
346
+ // Walk up the parent chain. Two checks fold into one walk:
347
+ // - Cycle detection (only on update — a non-existent term-being-
348
+ // created can't be its own ancestor): if the walk revisits termId
349
+ // the proposed parent makes the term a descendant of itself.
350
+ // - Depth bound: refuse to extend a chain past MAX_DEPTH ancestors.
351
+ // Runs on both create and update so a malicious or buggy caller
352
+ // can't grow the tree without limit.
353
+ //
354
+ // The depth-exceeded error fires only when we hit the limit AND there
355
+ // was still chain to walk — a legitimate chain of exactly MAX_DEPTH
356
+ // ancestors exits with `cursor === null` and is accepted.
357
+ const MAX_DEPTH = 100;
358
+ let cursor: string | null = parent.parentId;
359
+ let steps = 0;
360
+ while (cursor !== null && steps < MAX_DEPTH) {
361
+ if (termId !== undefined && cursor === termId) {
362
+ return {
363
+ code: "VALIDATION_ERROR",
364
+ message: "Cycle detected: cannot make a descendant the parent",
365
+ };
366
+ }
367
+ const next = await repo.findById(cursor);
368
+ if (!next) break;
369
+ cursor = next.parentId;
370
+ steps++;
371
+ }
372
+ if (cursor !== null && steps >= MAX_DEPTH) {
373
+ return {
374
+ code: "VALIDATION_ERROR",
375
+ message: "Parent chain exceeds maximum depth",
376
+ };
377
+ }
378
+
379
+ return null;
380
+ }
381
+
293
382
  /**
294
383
  * Create a new term in a taxonomy
295
384
  */
@@ -304,6 +393,10 @@ export async function handleTermCreate(
304
393
 
305
394
  const repo = new TaxonomyRepository(db);
306
395
 
396
+ // Coerce empty-string parentId to undefined (treat as "no parent").
397
+ const parentId =
398
+ input.parentId === "" || input.parentId === undefined ? undefined : input.parentId;
399
+
307
400
  // Check for slug conflict
308
401
  const existing = await repo.findBySlug(taxonomyName, input.slug);
309
402
  if (existing) {
@@ -316,11 +409,18 @@ export async function handleTermCreate(
316
409
  };
317
410
  }
318
411
 
412
+ // Validate parentId: must exist AND belong to the same taxonomy.
413
+ // (Cycle check is N/A on create — the term doesn't exist yet.)
414
+ const parentError = await validateParentTerm(repo, taxonomyName, undefined, parentId);
415
+ if (parentError) {
416
+ return { success: false, error: parentError };
417
+ }
418
+
319
419
  const term = await repo.create({
320
420
  name: taxonomyName,
321
421
  slug: input.slug,
322
422
  label: input.label,
323
- parentId: input.parentId ?? undefined,
423
+ parentId: parentId ?? undefined,
324
424
  data: input.description ? { description: input.description } : undefined,
325
425
  });
326
426
 
@@ -426,24 +526,36 @@ export async function handleTermUpdate(
426
526
  };
427
527
  }
428
528
 
529
+ // Coerce empty-string slug/parentId to undefined (treat as "no change").
530
+ // `null` parentId is a valid request meaning "detach from parent".
531
+ const newSlug = input.slug === "" || input.slug === undefined ? undefined : input.slug;
532
+ const newParentId =
533
+ input.parentId === "" || input.parentId === undefined ? undefined : input.parentId;
534
+
429
535
  // Check if new slug conflicts
430
- if (input.slug && input.slug !== termSlug) {
431
- const existing = await repo.findBySlug(taxonomyName, input.slug);
536
+ if (newSlug !== undefined && newSlug !== termSlug) {
537
+ const existing = await repo.findBySlug(taxonomyName, newSlug);
432
538
  if (existing && existing.id !== term.id) {
433
539
  return {
434
540
  success: false,
435
541
  error: {
436
542
  code: "CONFLICT",
437
- message: `Term with slug '${input.slug}' already exists in taxonomy '${taxonomyName}'`,
543
+ message: `Term with slug '${newSlug}' already exists in taxonomy '${taxonomyName}'`,
438
544
  },
439
545
  };
440
546
  }
441
547
  }
442
548
 
549
+ // Validate parentId: existence, same-taxonomy, no self-parent, no cycle.
550
+ const parentError = await validateParentTerm(repo, taxonomyName, term.id, newParentId);
551
+ if (parentError) {
552
+ return { success: false, error: parentError };
553
+ }
554
+
443
555
  const updated = await repo.update(term.id, {
444
- slug: input.slug,
556
+ slug: newSlug,
445
557
  label: input.label,
446
- parentId: input.parentId,
558
+ parentId: newParentId,
447
559
  data: input.description !== undefined ? { description: input.description } : undefined,
448
560
  });
449
561
 
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Field-level validation for content create / update.
3
+ *
4
+ * Wires the existing `generateZodSchema()` pipeline (`schema/zod-generator.ts`)
5
+ * into the handler boundary so REST and MCP both get the same enforcement:
6
+ *
7
+ * - required fields must be present and non-empty
8
+ * - select / multiSelect values must match the configured options
9
+ * - reference fields must resolve to a real, non-trashed target
10
+ *
11
+ * Errors surface as `{ code: "VALIDATION_ERROR", message }` with all
12
+ * offending fields listed in one message so callers can fix everything in
13
+ * a single round trip.
14
+ */
15
+
16
+ import { sql, type Kysely } from "kysely";
17
+
18
+ import type { Database } from "../../database/types.js";
19
+ import { validateIdentifier } from "../../database/validate.js";
20
+ import { SchemaRegistry } from "../../schema/registry.js";
21
+ import type { Field } from "../../schema/types.js";
22
+ import { generateZodSchema } from "../../schema/zod-generator.js";
23
+ import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js";
24
+ import { isMissingTableError } from "../../utils/db-errors.js";
25
+
26
+ type ValidationResult =
27
+ | { ok: true }
28
+ | { ok: false; error: { code: "VALIDATION_ERROR" | "COLLECTION_NOT_FOUND"; message: string } };
29
+
30
+ /** Treat `undefined`, `null`, and `""` as "not set". */
31
+ function isMissing(value: unknown): boolean {
32
+ return value === undefined || value === null || value === "";
33
+ }
34
+
35
+ /**
36
+ * Resolve the target collection slug for a reference field.
37
+ *
38
+ * Schema-defined reference fields (the static `reference()` factory in
39
+ * `fields/reference.ts`) put the target in `options.collection`. The MCP
40
+ * `schema_create_field` tool also puts it there. Tests and some admin paths
41
+ * stash it inside `validation.collection` directly; we accept both.
42
+ */
43
+ function getReferenceTargetCollection(field: Field): string | undefined {
44
+ const fromOptions = field.options?.collection;
45
+ if (typeof fromOptions === "string" && fromOptions.length > 0) return fromOptions;
46
+ const validation = field.validation;
47
+ if (validation && "collection" in validation) {
48
+ const fromValidation: unknown = (validation as { collection?: unknown }).collection;
49
+ if (typeof fromValidation === "string" && fromValidation.length > 0) return fromValidation;
50
+ }
51
+ return undefined;
52
+ }
53
+
54
+ /**
55
+ * Format a Zod issue path into a human-readable field reference, e.g.
56
+ * `tags`, `tags.1`, `image.alt`.
57
+ */
58
+ function formatIssuePath(path: ReadonlyArray<PropertyKey>): string {
59
+ if (path.length === 0) return "(root)";
60
+ return path.map((seg) => String(seg)).join(".");
61
+ }
62
+
63
+ /**
64
+ * Validate `data` against the collection's field definitions.
65
+ *
66
+ * `partial: true` switches Zod into partial mode so updates can include
67
+ * only the fields being changed without tripping required-field errors on
68
+ * fields the caller didn't touch. Required fields that ARE present in
69
+ * partial-mode data still get the empty-string check below.
70
+ */
71
+ export async function validateContentData(
72
+ db: Kysely<Database>,
73
+ collection: string,
74
+ data: Record<string, unknown>,
75
+ options: { partial?: boolean } = {},
76
+ ): Promise<ValidationResult> {
77
+ const registry = new SchemaRegistry(db);
78
+ const collectionWithFields = await registry.getCollectionWithFields(collection);
79
+ if (!collectionWithFields) {
80
+ return {
81
+ ok: false,
82
+ error: {
83
+ code: "COLLECTION_NOT_FOUND",
84
+ message: `Collection '${collection}' not found`,
85
+ },
86
+ };
87
+ }
88
+
89
+ const issues: string[] = [];
90
+
91
+ // Detect unknown keys explicitly so callers get a useful error rather
92
+ // than silently dropped data. Leading-underscore keys (e.g. `_slug`,
93
+ // `_rev`) are reserved for internal handler/runtime use and aren't real
94
+ // fields; skip them.
95
+ const knownFields = new Set(collectionWithFields.fields.map((f) => f.slug));
96
+ for (const key of Object.keys(data)) {
97
+ if (key.startsWith("_")) continue;
98
+ if (!knownFields.has(key)) {
99
+ issues.push(`${key}: unknown field on collection '${collection}'`);
100
+ }
101
+ }
102
+
103
+ // Zod handles type, enum, length and missing-required (in non-partial
104
+ // mode) checks. Empty-string handling for required string fields is
105
+ // done as a separate pass below since Zod's `z.string()` accepts "".
106
+ const baseSchema = generateZodSchema(collectionWithFields);
107
+ const schema = options.partial ? baseSchema.partial() : baseSchema;
108
+ const parsed = schema.safeParse(data);
109
+ if (!parsed.success) {
110
+ for (const issue of parsed.error.issues) {
111
+ issues.push(`${formatIssuePath(issue.path)}: ${issue.message}`);
112
+ }
113
+ }
114
+
115
+ // Empty-string-on-required check. In create mode (partial=false) Zod
116
+ // already catches missing/null for required fields, but `z.string()`
117
+ // happily accepts "". In update mode (partial=true) the field is only
118
+ // checked if it's present in `data`.
119
+ for (const field of collectionWithFields.fields) {
120
+ if (!field.required) continue;
121
+ const present = Object.hasOwn(data, field.slug);
122
+ if (options.partial && !present) continue;
123
+ if (data[field.slug] === "") {
124
+ issues.push(`${field.slug}: required (empty value not allowed)`);
125
+ }
126
+ }
127
+
128
+ // Reference target existence. Only check fields that:
129
+ // - have a value (non-missing) in `data`
130
+ // - have a resolvable target collection
131
+ // - in partial mode: are present in `data`
132
+ // Batch one IN-query per target collection to keep round-trips low.
133
+ const refsByTarget = new Map<string, { field: string; id: string }[]>();
134
+ for (const field of collectionWithFields.fields) {
135
+ if (field.type !== "reference") continue;
136
+ if (options.partial && !Object.hasOwn(data, field.slug)) continue;
137
+ const value = data[field.slug];
138
+ if (isMissing(value)) continue;
139
+ if (typeof value !== "string") continue; // Zod will have flagged this already
140
+ const target = getReferenceTargetCollection(field);
141
+ if (!target) continue;
142
+ const list = refsByTarget.get(target) ?? [];
143
+ list.push({ field: field.slug, id: value });
144
+ refsByTarget.set(target, list);
145
+ }
146
+
147
+ for (const [target, refs] of refsByTarget) {
148
+ // Validate the target collection slug before interpolating into raw
149
+ // SQL — defense-in-depth even though slugs are already validated at
150
+ // schema-create time.
151
+ try {
152
+ validateIdentifier(target, "reference target collection");
153
+ } catch {
154
+ for (const ref of refs) {
155
+ issues.push(`${ref.field}: invalid reference target collection '${target}'`);
156
+ }
157
+ continue;
158
+ }
159
+
160
+ const ids = [...new Set(refs.map((r) => r.id))];
161
+ const tableName = `ec_${target}`;
162
+
163
+ // Chunk the IN clause to stay below D1's bind-parameter limit. One
164
+ // reference per request is the common case today; chunking makes the
165
+ // helper safe if a future multiSelect-of-references is added.
166
+ const found = new Set<string>();
167
+ let targetTableMissing = false;
168
+ for (const idChunk of chunks(ids, SQL_BATCH_SIZE)) {
169
+ try {
170
+ const rows = await sql<{ id: string }>`
171
+ SELECT id FROM ${sql.ref(tableName)}
172
+ WHERE id IN (${sql.join(idChunk)})
173
+ AND deleted_at IS NULL
174
+ `.execute(db);
175
+ for (const row of rows.rows) {
176
+ found.add(row.id);
177
+ }
178
+ } catch (error) {
179
+ // Missing table = the target collection table doesn't exist
180
+ // (orphan reference). Treat all those references as missing.
181
+ // Any other DB error (permissions, connection, syntax) must
182
+ // propagate — silently dropping data integrity errors as
183
+ // "not found" is exactly the bug F5 fixes.
184
+ if (isMissingTableError(error)) {
185
+ targetTableMissing = true;
186
+ break;
187
+ }
188
+ throw error;
189
+ }
190
+ }
191
+ if (targetTableMissing) {
192
+ for (const ref of refs) {
193
+ issues.push(`${ref.field}: target '${ref.id}' not found in collection '${target}'`);
194
+ }
195
+ continue;
196
+ }
197
+ for (const ref of refs) {
198
+ if (!found.has(ref.id)) {
199
+ issues.push(`${ref.field}: target '${ref.id}' not found in collection '${target}'`);
200
+ }
201
+ }
202
+ }
203
+
204
+ if (issues.length === 0) return { ok: true };
205
+ return {
206
+ ok: false,
207
+ error: {
208
+ code: "VALIDATION_ERROR",
209
+ message: issues.join("; "),
210
+ },
211
+ };
212
+ }
@@ -122,6 +122,7 @@ import {
122
122
  reorderWidgetsBody,
123
123
  updateWidgetBody,
124
124
  widgetAreaSchema,
125
+ widgetAreaWithWidgetsAndCountSchema,
125
126
  widgetAreaWithWidgetsSchema,
126
127
  widgetSchema,
127
128
  } from "../schemas/widgets.js";
@@ -1581,7 +1582,9 @@ const widgetPaths = {
1581
1582
  description: "Widget area list",
1582
1583
  content: {
1583
1584
  [JSON_CONTENT]: {
1584
- schema: successEnvelope(z.object({ items: z.array(widgetAreaSchema) })),
1585
+ schema: successEnvelope(
1586
+ z.object({ items: z.array(widgetAreaWithWidgetsAndCountSchema) }),
1587
+ ),
1585
1588
  },
1586
1589
  },
1587
1590
  },
@@ -9,7 +9,10 @@
9
9
  * Workers-safe: no Node.js imports.
10
10
  */
11
11
 
12
- import type { EmDashConfig } from "../astro/integration/runtime.js";
12
+ /** Minimal config shape — avoids importing the full EmDashConfig type tree. */
13
+ interface SiteUrlConfig {
14
+ siteUrl?: string;
15
+ }
13
16
 
14
17
  /**
15
18
  * Resolve siteUrl from runtime environment variables.
@@ -67,7 +70,7 @@ function getEnvSiteUrl(): string | undefined {
67
70
  * @param config The EmDash config (from `locals.emdash?.config`)
68
71
  * @returns Origin string, e.g. `"https://mysite.example.com"`
69
72
  */
70
- export function getPublicOrigin(url: URL, config?: EmDashConfig): string {
73
+ export function getPublicOrigin(url: URL, config?: SiteUrlConfig): string {
71
74
  return config?.siteUrl || getEnvSiteUrl() || url.origin;
72
75
  }
73
76
 
@@ -79,6 +82,6 @@ export function getPublicOrigin(url: URL, config?: EmDashConfig): string {
79
82
  * @param path Path to append (must start with `/`)
80
83
  * @returns Full URL string, e.g. `"https://mysite.example.com/_emdash/admin/login"`
81
84
  */
82
- export function getPublicUrl(url: URL, config: EmDashConfig | undefined, path: string): string {
85
+ export function getPublicUrl(url: URL, config: SiteUrlConfig | undefined, path: string): string {
83
86
  return `${getPublicOrigin(url, config)}${path}`;
84
87
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Public API route utilities for auth provider routes.
3
+ *
4
+ * This module re-exports the utilities that auth provider route handlers
5
+ * need from core. Auth providers (plugins) import these via `emdash/api/route-utils`.
6
+ */
7
+
8
+ export { apiError, apiSuccess, handleError } from "./error.js";
9
+ export { parseBody, parseQuery, isParseError } from "./parse.js";
10
+ export type { ParseResult } from "./parse.js";
11
+ export { finalizeSetup } from "./setup-complete.js";
12
+ export { OptionsRepository } from "../database/repositories/options.js";
13
+ export { getAuthProviderStorage } from "./auth-storage.js";
14
+ export { getPublicOrigin } from "./public-url.js";
@@ -22,7 +22,7 @@ export const roleLevel = z.coerce
22
22
  /** Pagination query params — cursor-based */
23
23
  export const cursorPaginationQuery = z
24
24
  .object({
25
- cursor: z.string().optional().meta({ description: "Opaque cursor for pagination" }),
25
+ cursor: z.string().max(2048).optional().meta({ description: "Opaque cursor for pagination" }),
26
26
  limit: z.coerce.number().int().min(1).max(100).optional().default(50).meta({
27
27
  description: "Maximum number of items to return (1-100, default 50)",
28
28
  }),
@@ -27,6 +27,11 @@ export const contentListQuery = cursorPaginationQuery
27
27
  })
28
28
  .meta({ id: "ContentListQuery" });
29
29
 
30
+ /** ISO 8601 datetime for `publishedAt` / `createdAt`. Routes gate writes behind `content:publish_any`. */
31
+ const contentDateOverride = z.iso
32
+ .datetime({ offset: true, message: "must be an ISO 8601 datetime" })
33
+ .nullish();
34
+
30
35
  export const contentCreateBody = z
31
36
  .object({
32
37
  data: z.record(z.string(), z.unknown()),
@@ -36,6 +41,8 @@ export const contentCreateBody = z
36
41
  locale: localeCode.optional(),
37
42
  translationOf: z.string().optional(),
38
43
  seo: contentSeoInput.optional(),
44
+ publishedAt: contentDateOverride,
45
+ createdAt: contentDateOverride,
39
46
  })
40
47
  .meta({ id: "ContentCreateBody" });
41
48
 
@@ -52,6 +59,7 @@ export const contentUpdateBody = z
52
59
  .meta({ description: "Opaque revision token for optimistic concurrency" }),
53
60
  skipRevision: z.boolean().optional(),
54
61
  seo: contentSeoInput.optional(),
62
+ publishedAt: contentDateOverride,
55
63
  })
56
64
  .meta({ id: "ContentUpdateBody" });
57
65
 
@@ -35,3 +35,11 @@ export const setupAdminBody = z.object({
35
35
  export const setupAdminVerifyBody = z.object({
36
36
  credential: registrationCredential,
37
37
  });
38
+
39
+ export const atprotoLoginBody = z.object({
40
+ handle: z.string().trim().min(1),
41
+ });
42
+
43
+ export const setupAtprotoAdminBody = z.object({
44
+ handle: z.string().trim().min(1),
45
+ });
@@ -60,16 +60,12 @@ export const widgetAreaSchema = z
60
60
  export const widgetSchema = z
61
61
  .object({
62
62
  id: z.string(),
63
- area_id: z.string(),
64
- type: z.string(),
65
- title: z.string().nullable(),
66
- content: z.string().nullable(),
67
- menu_name: z.string().nullable(),
68
- component_id: z.string().nullable(),
69
- component_props: z.string().nullable(),
70
- sort_order: z.number().int(),
71
- created_at: z.string(),
72
- updated_at: z.string(),
63
+ type: widgetType,
64
+ title: z.string().optional(),
65
+ content: z.array(z.record(z.string(), z.unknown())).optional(),
66
+ menuName: z.string().optional(),
67
+ componentId: z.string().optional(),
68
+ componentProps: z.record(z.string(), z.unknown()).optional(),
73
69
  })
74
70
  .meta({ id: "Widget" });
75
71
 
@@ -78,3 +74,9 @@ export const widgetAreaWithWidgetsSchema = widgetAreaSchema
78
74
  widgets: z.array(widgetSchema),
79
75
  })
80
76
  .meta({ id: "WidgetAreaWithWidgets" });
77
+
78
+ export const widgetAreaWithWidgetsAndCountSchema = widgetAreaWithWidgetsSchema
79
+ .extend({
80
+ widgetCount: z.number().int(),
81
+ })
82
+ .meta({ id: "WidgetAreaWithWidgetsAndCount" });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Shared setup completion logic.
3
+ *
4
+ * Called by OAuth callbacks and the passkey verify step when the first user
5
+ * is created during setup. Persists site title/tagline from setup state
6
+ * and marks setup as complete.
7
+ */
8
+
9
+ import type { Kysely } from "kysely";
10
+
11
+ import { OptionsRepository } from "../database/repositories/options.js";
12
+ import type { Database } from "../database/types.js";
13
+
14
+ /**
15
+ * Finalize setup after the first admin user is created.
16
+ *
17
+ * Reads the setup_state option (written by the setup wizard's step 1),
18
+ * persists site_title and site_tagline, then marks setup complete.
19
+ *
20
+ * Safe to call multiple times — checks setup_complete first and no-ops
21
+ * if already done.
22
+ */
23
+ export async function finalizeSetup(db: Kysely<Database>): Promise<void> {
24
+ const options = new OptionsRepository(db);
25
+
26
+ const setupComplete = await options.get("emdash:setup_complete");
27
+ if (setupComplete === true || setupComplete === "true") return;
28
+
29
+ // Persist site title/tagline from setup state (stored in step 1)
30
+ const setupState = await options.get<Record<string, unknown>>("emdash:setup_state");
31
+ if (setupState?.title && typeof setupState.title === "string") {
32
+ await options.set("emdash:site_title", setupState.title);
33
+ }
34
+ if (setupState?.tagline && typeof setupState.tagline === "string") {
35
+ await options.set("emdash:site_tagline", setupState.tagline);
36
+ }
37
+
38
+ await options.set("emdash:setup_complete", true);
39
+ await options.delete("emdash:setup_state");
40
+ }
@@ -30,6 +30,7 @@ const ALL_GOOGLE_SUBSETS = [
30
30
  "cyrillic-ext",
31
31
  "devanagari",
32
32
  "ethiopic",
33
+ "farsi",
33
34
  "georgian",
34
35
  "greek",
35
36
  "greek-ext",
@@ -57,7 +58,7 @@ const ALL_GOOGLE_SUBSETS = [
57
58
  ];
58
59
 
59
60
  /**
60
- * Known Noto Sans script families on Google Fonts.
61
+ * Known Noto Sans and Sans script families on Google Fonts.
61
62
  * Maps user-friendly script names to Google Fonts family names.
62
63
  */
63
64
  const NOTO_SCRIPT_FAMILIES: Record<string, string> = {
@@ -69,6 +70,7 @@ const NOTO_SCRIPT_FAMILIES: Record<string, string> = {
69
70
  "chinese-hongkong": "Noto Sans HK",
70
71
  devanagari: "Noto Sans Devanagari",
71
72
  ethiopic: "Noto Sans Ethiopic",
73
+ farsi: "Vazirmatn",
72
74
  georgian: "Noto Sans Georgian",
73
75
  gujarati: "Noto Sans Gujarati",
74
76
  gurmukhi: "Noto Sans Gurmukhi",
@@ -15,7 +15,12 @@ import type { AstroIntegration, AstroIntegrationLogger } from "astro";
15
15
  import type { ResolvedPlugin } from "../../plugins/types.js";
16
16
  import { local } from "../storage/adapters.js";
17
17
  import { notoSans } from "./font-provider.js";
18
- import { injectCoreRoutes, injectBuiltinAuthRoutes, injectMcpRoute } from "./routes.js";
18
+ import {
19
+ injectCoreRoutes,
20
+ injectBuiltinAuthRoutes,
21
+ injectAuthProviderRoutes,
22
+ injectMcpRoute,
23
+ } from "./routes.js";
19
24
  import type { EmDashConfig, PluginDescriptor } from "./runtime.js";
20
25
  import { createViteConfig } from "./vite-config.js";
21
26
 
@@ -157,9 +162,12 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
157
162
  database: resolvedConfig.database,
158
163
  storage: resolvedConfig.storage,
159
164
  auth: resolvedConfig.auth,
165
+ authProviders: resolvedConfig.authProviders,
160
166
  marketplace: resolvedConfig.marketplace,
161
167
  siteUrl: resolvedConfig.siteUrl,
168
+ trustedProxyHeaders: resolvedConfig.trustedProxyHeaders,
162
169
  maxUploadSize: resolvedConfig.maxUploadSize,
170
+ admin: resolvedConfig.admin,
163
171
  };
164
172
 
165
173
  // Determine auth mode for route injection
@@ -265,7 +273,12 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
265
273
  // Inject all core routes
266
274
  injectCoreRoutes(injectRoute);
267
275
 
268
- // Only inject passkey/oauth/magic-link routes when NOT using external auth
276
+ // Inject routes from pluggable auth providers (authProviders config)
277
+ if (resolvedConfig.authProviders?.length) {
278
+ injectAuthProviderRoutes(injectRoute, resolvedConfig.authProviders);
279
+ }
280
+
281
+ // Inject passkey/oauth/magic-link routes unless transparent external auth is active
269
282
  if (!useExternalAuth) {
270
283
  injectBuiltinAuthRoutes(injectRoute);
271
284
  }