emdash 0.8.0 → 0.10.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 (317) hide show
  1. package/dist/{adapters-BKSf3T9R.d.mts → adapters-BktHA7EO.d.mts} +1 -1
  2. package/dist/{adapters-BKSf3T9R.d.mts.map → adapters-BktHA7EO.d.mts.map} +1 -1
  3. package/dist/{apply-x0eMK1lX.mjs → apply-UsrFuO7l.mjs} +207 -355
  4. package/dist/apply-UsrFuO7l.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 +118 -4
  8. package/dist/astro/index.mjs.map +1 -1
  9. package/dist/astro/middleware/auth.d.mts +6 -7
  10. package/dist/astro/middleware/auth.d.mts.map +1 -1
  11. package/dist/astro/middleware/auth.mjs +14 -57
  12. package/dist/astro/middleware/auth.mjs.map +1 -1
  13. package/dist/astro/middleware/redirect.d.mts.map +1 -1
  14. package/dist/astro/middleware/redirect.mjs +15 -10
  15. package/dist/astro/middleware/redirect.mjs.map +1 -1
  16. package/dist/astro/middleware/request-context.d.mts.map +1 -1
  17. package/dist/astro/middleware/request-context.mjs +8 -5
  18. package/dist/astro/middleware/request-context.mjs.map +1 -1
  19. package/dist/astro/middleware/setup.mjs +1 -1
  20. package/dist/astro/middleware.d.mts.map +1 -1
  21. package/dist/astro/middleware.mjs +70 -121
  22. package/dist/astro/middleware.mjs.map +1 -1
  23. package/dist/astro/types.d.mts +25 -10
  24. package/dist/astro/types.d.mts.map +1 -1
  25. package/dist/{byline-Chbr2GoP.mjs → byline-C3vnhIpU.mjs} +4 -4
  26. package/dist/{byline-Chbr2GoP.mjs.map → byline-C3vnhIpU.mjs.map} +1 -1
  27. package/dist/bylines-esI7ioa9.mjs +113 -0
  28. package/dist/bylines-esI7ioa9.mjs.map +1 -0
  29. package/dist/cache-fTzxgMFJ.mjs +65 -0
  30. package/dist/cache-fTzxgMFJ.mjs.map +1 -0
  31. package/dist/{chunks-HGz06Soa.mjs → chunks-Da2-b-oA.mjs} +8 -2
  32. package/dist/{chunks-HGz06Soa.mjs.map → chunks-Da2-b-oA.mjs.map} +1 -1
  33. package/dist/cli/index.mjs +456 -90
  34. package/dist/cli/index.mjs.map +1 -1
  35. package/dist/client/cf-access.d.mts +1 -1
  36. package/dist/client/index.d.mts +1 -1
  37. package/dist/client/index.mjs +3 -3
  38. package/dist/client/index.mjs.map +1 -1
  39. package/dist/{config-BXwuX8Bx.mjs → config-CVssduLe.mjs} +1 -1
  40. package/dist/{config-BXwuX8Bx.mjs.map → config-CVssduLe.mjs.map} +1 -1
  41. package/dist/{content-BcQPYxdV.mjs → content-C7G4QXkK.mjs} +42 -14
  42. package/dist/content-C7G4QXkK.mjs.map +1 -0
  43. package/dist/db/index.d.mts +3 -3
  44. package/dist/db/index.mjs +2 -2
  45. package/dist/db/libsql.d.mts +1 -1
  46. package/dist/db/libsql.d.mts.map +1 -1
  47. package/dist/db/libsql.mjs +7 -2
  48. package/dist/db/libsql.mjs.map +1 -1
  49. package/dist/db/postgres.d.mts +1 -1
  50. package/dist/db/sqlite.d.mts +1 -1
  51. package/dist/db/sqlite.d.mts.map +1 -1
  52. package/dist/db/sqlite.mjs +8 -3
  53. package/dist/db/sqlite.mjs.map +1 -1
  54. package/dist/{db-errors-l1Qh2RPR.mjs → db-errors-B7P2pSCn.mjs} +1 -1
  55. package/dist/{db-errors-l1Qh2RPR.mjs.map → db-errors-B7P2pSCn.mjs.map} +1 -1
  56. package/dist/{default-DCVqE5ib.mjs → default-pHuz9WF6.mjs} +1 -1
  57. package/dist/{default-DCVqE5ib.mjs.map → default-pHuz9WF6.mjs.map} +1 -1
  58. package/dist/{dialect-helpers-DhTzaUxP.mjs → dialect-helpers-BKCvISIQ.mjs} +19 -2
  59. package/dist/dialect-helpers-BKCvISIQ.mjs.map +1 -0
  60. package/dist/{error-zG5T1UGA.mjs → error-DqnRMM5z.mjs} +1 -1
  61. package/dist/{error-zG5T1UGA.mjs.map → error-DqnRMM5z.mjs.map} +1 -1
  62. package/dist/{index-DIb-CzNx.d.mts → index-DjPMOfO0.d.mts} +162 -87
  63. package/dist/index-DjPMOfO0.d.mts.map +1 -0
  64. package/dist/index.d.mts +11 -11
  65. package/dist/index.mjs +27 -24
  66. package/dist/{load-CyEoextb.mjs → load-sXRuM7Us.mjs} +2 -2
  67. package/dist/{load-CyEoextb.mjs.map → load-sXRuM7Us.mjs.map} +1 -1
  68. package/dist/{loader-CndGj8kM.mjs → loader-Bx2_9-5e.mjs} +53 -8
  69. package/dist/loader-Bx2_9-5e.mjs.map +1 -0
  70. package/dist/{manifest-schema-DH9xhc6t.mjs → manifest-schema-CXAbd1vH.mjs} +33 -3
  71. package/dist/manifest-schema-CXAbd1vH.mjs.map +1 -0
  72. package/dist/media/index.d.mts +1 -1
  73. package/dist/media/index.mjs +1 -1
  74. package/dist/media/local-runtime.d.mts +7 -7
  75. package/dist/{mode-BnAOqItE.mjs → mode-YhqNVef_.mjs} +1 -1
  76. package/dist/{mode-BnAOqItE.mjs.map → mode-YhqNVef_.mjs.map} +1 -1
  77. package/dist/options-nPxWnrya.mjs +117 -0
  78. package/dist/options-nPxWnrya.mjs.map +1 -0
  79. package/dist/page/index.d.mts +2 -2
  80. package/dist/{patterns-CrCYkMBb.mjs → patterns-DsUZ4uxI.mjs} +1 -1
  81. package/dist/{patterns-CrCYkMBb.mjs.map → patterns-DsUZ4uxI.mjs.map} +1 -1
  82. package/dist/{placeholder-D29tWZ7o.d.mts → placeholder-CDPtkelt.d.mts} +1 -1
  83. package/dist/{placeholder-D29tWZ7o.d.mts.map → placeholder-CDPtkelt.d.mts.map} +1 -1
  84. package/dist/{placeholder-C-fk5hYI.mjs → placeholder-Ci0RLeCk.mjs} +1 -1
  85. package/dist/{placeholder-C-fk5hYI.mjs.map → placeholder-Ci0RLeCk.mjs.map} +1 -1
  86. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  87. package/dist/plugins/adapt-sandbox-entry.d.mts.map +1 -1
  88. package/dist/plugins/adapt-sandbox-entry.mjs +6 -5
  89. package/dist/plugins/adapt-sandbox-entry.mjs.map +1 -1
  90. package/dist/public-url-B1AxbbbQ.mjs +51 -0
  91. package/dist/public-url-B1AxbbbQ.mjs.map +1 -0
  92. package/dist/{query-fqEdLFms.mjs → query-Bo-msrmu.mjs} +114 -16
  93. package/dist/query-Bo-msrmu.mjs.map +1 -0
  94. package/dist/{redirect-D_pshWdf.mjs → redirect-C5H7VGIX.mjs} +11 -6
  95. package/dist/redirect-C5H7VGIX.mjs.map +1 -0
  96. package/dist/{registry-C3Mr0ODu.mjs → registry-Beb7wxFc.mjs} +39 -5
  97. package/dist/registry-Beb7wxFc.mjs.map +1 -0
  98. package/dist/{request-cache-Ci7f5pBb.mjs → request-cache-C-tIpYIw.mjs} +1 -1
  99. package/dist/{request-cache-Ci7f5pBb.mjs.map → request-cache-C-tIpYIw.mjs.map} +1 -1
  100. package/dist/runner-Clwe4Mme.d.mts +44 -0
  101. package/dist/runner-Clwe4Mme.d.mts.map +1 -0
  102. package/dist/{runner-tQ7BJ4T7.mjs → runner-DMnlIkh4.mjs} +616 -191
  103. package/dist/runner-DMnlIkh4.mjs.map +1 -0
  104. package/dist/runtime.d.mts +6 -6
  105. package/dist/runtime.mjs +2 -2
  106. package/dist/{search-BoZYFuUk.mjs → search-DkN-BqsS.mjs} +270 -152
  107. package/dist/search-DkN-BqsS.mjs.map +1 -0
  108. package/dist/secrets-CZ8rxLX3.mjs +314 -0
  109. package/dist/secrets-CZ8rxLX3.mjs.map +1 -0
  110. package/dist/seed/index.d.mts +2 -2
  111. package/dist/seed/index.mjs +13 -11
  112. package/dist/seo/index.d.mts +1 -1
  113. package/dist/storage/local.d.mts +1 -1
  114. package/dist/storage/local.mjs +1 -1
  115. package/dist/storage/s3.d.mts +1 -1
  116. package/dist/storage/s3.mjs +1 -1
  117. package/dist/taxonomies-CTtewrSQ.mjs +407 -0
  118. package/dist/taxonomies-CTtewrSQ.mjs.map +1 -0
  119. package/dist/taxonomy-DSxx2K2L.mjs +218 -0
  120. package/dist/taxonomy-DSxx2K2L.mjs.map +1 -0
  121. package/dist/{tokens-D9vnZqYS.mjs → tokens-CyRDPVW2.mjs} +1 -1
  122. package/dist/{tokens-D9vnZqYS.mjs.map → tokens-CyRDPVW2.mjs.map} +1 -1
  123. package/dist/{transaction-Cn2rjY78.mjs → transaction-D44LBXvU.mjs} +1 -1
  124. package/dist/{transaction-Cn2rjY78.mjs.map → transaction-D44LBXvU.mjs.map} +1 -1
  125. package/dist/{transport-CUnEL3Vs.d.mts → transport-DX_5rpsq.d.mts} +1 -1
  126. package/dist/{transport-CUnEL3Vs.d.mts.map → transport-DX_5rpsq.d.mts.map} +1 -1
  127. package/dist/{transport-C9ugt2Nr.mjs → transport-xpzIjCIB.mjs} +6 -5
  128. package/dist/{transport-C9ugt2Nr.mjs.map → transport-xpzIjCIB.mjs.map} +1 -1
  129. package/dist/{types-BrA0xf5I.d.mts → types-B_CXXnzh.d.mts} +1 -1
  130. package/dist/{types-BrA0xf5I.d.mts.map → types-B_CXXnzh.d.mts.map} +1 -1
  131. package/dist/{types-DIMwPFub.d.mts → types-C-aFbqmA.d.mts} +1 -1
  132. package/dist/{types-DIMwPFub.d.mts.map → types-C-aFbqmA.d.mts.map} +1 -1
  133. package/dist/types-CoO6mpV3.mjs +68 -0
  134. package/dist/types-CoO6mpV3.mjs.map +1 -0
  135. package/dist/{types-i36XcA_X.d.mts → types-D19uBYWn.d.mts} +83 -7
  136. package/dist/types-D19uBYWn.d.mts.map +1 -0
  137. package/dist/{types-BmPPSUEx.d.mts → types-Dl1fgFjn.d.mts} +24 -2
  138. package/dist/{types-BmPPSUEx.d.mts.map → types-Dl1fgFjn.d.mts.map} +1 -1
  139. package/dist/{types-CS8FIX7L.d.mts → types-Dtx1mSMX.d.mts} +9 -1
  140. package/dist/types-Dtx1mSMX.d.mts.map +1 -0
  141. package/dist/{types-Bm1dn-q3.mjs → types-Eg829jj9.mjs} +1 -1
  142. package/dist/{types-Bm1dn-q3.mjs.map → types-Eg829jj9.mjs.map} +1 -1
  143. package/dist/{types-CgqmmMJB.mjs → types-K-EkEQCI.mjs} +1 -1
  144. package/dist/{types-CgqmmMJB.mjs.map → types-K-EkEQCI.mjs.map} +1 -1
  145. package/dist/{validate-CxVsLehf.mjs → validate-CBIbxM3L.mjs} +14 -10
  146. package/dist/validate-CBIbxM3L.mjs.map +1 -0
  147. package/dist/{validate-DHxmpFJt.d.mts → validate-DHGwADqO.d.mts} +18 -5
  148. package/dist/validate-DHGwADqO.d.mts.map +1 -0
  149. package/dist/{validation-C-ZpN2GI.mjs → validation-B1NYiEos.mjs} +6 -6
  150. package/dist/{validation-C-ZpN2GI.mjs.map → validation-B1NYiEos.mjs.map} +1 -1
  151. package/dist/version-CMD42IRC.mjs +7 -0
  152. package/dist/{version-Bbq8TCrz.mjs.map → version-CMD42IRC.mjs.map} +1 -1
  153. package/dist/{zod-generator-CpwccCIv.mjs → zod-generator-BNJDQBSZ.mjs} +11 -6
  154. package/dist/{zod-generator-CpwccCIv.mjs.map → zod-generator-BNJDQBSZ.mjs.map} +1 -1
  155. package/locals.d.ts +1 -6
  156. package/package.json +9 -8
  157. package/src/api/handlers/comments.ts +6 -4
  158. package/src/api/handlers/content.ts +40 -1
  159. package/src/api/handlers/dashboard.ts +29 -36
  160. package/src/api/handlers/device-flow.ts +5 -0
  161. package/src/api/handlers/marketplace.ts +11 -4
  162. package/src/api/handlers/menus.ts +256 -75
  163. package/src/api/handlers/oauth-authorization.ts +72 -33
  164. package/src/api/handlers/revision.ts +23 -14
  165. package/src/api/handlers/taxonomies.ts +273 -100
  166. package/src/api/public-url.ts +48 -2
  167. package/src/api/schemas/comments.ts +2 -2
  168. package/src/api/schemas/common.ts +7 -0
  169. package/src/api/schemas/content.ts +17 -0
  170. package/src/api/schemas/menus.ts +23 -0
  171. package/src/api/schemas/sections.ts +3 -3
  172. package/src/api/schemas/taxonomies.ts +39 -0
  173. package/src/api/schemas/users.ts +1 -1
  174. package/src/api/types.ts +5 -1
  175. package/src/astro/integration/index.ts +17 -0
  176. package/src/astro/integration/routes.ts +10 -0
  177. package/src/astro/integration/runtime.ts +30 -0
  178. package/src/astro/integration/virtual-modules.ts +32 -2
  179. package/src/astro/integration/vite-config.ts +6 -1
  180. package/src/astro/middleware/auth.ts +13 -6
  181. package/src/astro/middleware/redirect.ts +29 -16
  182. package/src/astro/middleware/request-context.ts +15 -5
  183. package/src/astro/middleware.ts +23 -9
  184. package/src/astro/routes/api/auth/invite/complete.ts +6 -1
  185. package/src/astro/routes/api/auth/passkey/register/verify.ts +6 -1
  186. package/src/astro/routes/api/auth/passkey/verify.ts +6 -1
  187. package/src/astro/routes/api/auth/signup/complete.ts +6 -1
  188. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +2 -2
  189. package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +4 -2
  190. package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +1 -1
  191. package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +34 -12
  192. package/src/astro/routes/api/content/[collection]/[id]/publish.ts +32 -2
  193. package/src/astro/routes/api/content/[collection]/[id]/restore.ts +4 -2
  194. package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +3 -2
  195. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +8 -4
  196. package/src/astro/routes/api/content/[collection]/[id].ts +12 -0
  197. package/src/astro/routes/api/import/wordpress/execute.ts +3 -1
  198. package/src/astro/routes/api/import/wordpress/prepare.ts +7 -8
  199. package/src/astro/routes/api/import/wordpress/rewrite-url-helpers.ts +196 -0
  200. package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +9 -177
  201. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +3 -1
  202. package/src/astro/routes/api/manifest.ts +62 -45
  203. package/src/astro/routes/api/media/[id]/confirm.ts +10 -1
  204. package/src/astro/routes/api/media/providers/[providerId]/index.ts +12 -3
  205. package/src/astro/routes/api/menus/[name]/items.ts +16 -6
  206. package/src/astro/routes/api/menus/[name]/reorder.ts +8 -3
  207. package/src/astro/routes/api/menus/[name]/translations.ts +82 -0
  208. package/src/astro/routes/api/menus/[name].ts +19 -10
  209. package/src/astro/routes/api/menus/index.ts +9 -6
  210. package/src/astro/routes/api/openapi.json.ts +27 -10
  211. package/src/astro/routes/api/redirects/404s/index.ts +10 -4
  212. package/src/astro/routes/api/redirects/404s/summary.ts +4 -2
  213. package/src/astro/routes/api/redirects/[id].ts +10 -4
  214. package/src/astro/routes/api/redirects/index.ts +7 -3
  215. package/src/astro/routes/api/revisions/[revisionId]/index.ts +1 -1
  216. package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +0 -2
  217. package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +0 -1
  218. package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +0 -1
  219. package/src/astro/routes/api/schema/collections/[slug]/index.ts +2 -2
  220. package/src/astro/routes/api/schema/collections/index.ts +1 -1
  221. package/src/astro/routes/api/search/index.ts +10 -2
  222. package/src/astro/routes/api/sections/[slug].ts +10 -4
  223. package/src/astro/routes/api/sections/index.ts +7 -3
  224. package/src/astro/routes/api/setup/admin-verify.ts +6 -1
  225. package/src/astro/routes/api/snapshot.ts +44 -18
  226. package/src/astro/routes/api/taxonomies/[name]/terms/[slug]/translations.ts +89 -0
  227. package/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts +22 -22
  228. package/src/astro/routes/api/taxonomies/[name]/terms/index.ts +11 -14
  229. package/src/astro/routes/api/taxonomies/index.ts +9 -7
  230. package/src/astro/routes/api/themes/preview.ts +11 -5
  231. package/src/astro/types.ts +23 -3
  232. package/src/auth/allowed-origins.ts +168 -0
  233. package/src/auth/passkey-config.ts +35 -13
  234. package/src/bylines/index.ts +37 -88
  235. package/src/cli/commands/auth.ts +28 -6
  236. package/src/cli/commands/bundle-utils.ts +11 -2
  237. package/src/cli/commands/bundle.ts +28 -8
  238. package/src/cli/commands/content.ts +13 -0
  239. package/src/cli/commands/export-seed.ts +82 -21
  240. package/src/cli/commands/login.ts +8 -1
  241. package/src/cli/commands/plugin-init.ts +216 -90
  242. package/src/cli/commands/publish.ts +24 -0
  243. package/src/cli/commands/secrets.ts +183 -0
  244. package/src/cli/credentials.ts +1 -1
  245. package/src/cli/index.ts +5 -1
  246. package/src/client/index.ts +4 -4
  247. package/src/client/transport.ts +17 -7
  248. package/src/components/Break.astro +2 -2
  249. package/src/components/EmDashHead.astro +18 -13
  250. package/src/components/Embed.astro +1 -1
  251. package/src/components/Gallery.astro +1 -1
  252. package/src/components/Image.astro +1 -1
  253. package/src/components/InlinePortableTextEditor.tsx +104 -18
  254. package/src/config/secrets.ts +528 -0
  255. package/src/database/dialect-helpers.ts +50 -0
  256. package/src/database/migrations/034_published_at_index.ts +1 -1
  257. package/src/database/migrations/035_bounded_404_log.ts +56 -39
  258. package/src/database/migrations/036_i18n_menus_and_taxonomies.ts +477 -0
  259. package/src/database/migrations/runner.ts +158 -23
  260. package/src/database/repositories/content.ts +47 -12
  261. package/src/database/repositories/redirect.ts +14 -3
  262. package/src/database/repositories/taxonomy.ts +212 -82
  263. package/src/database/types.ts +10 -2
  264. package/src/db/libsql.ts +1 -3
  265. package/src/db/sqlite.ts +2 -5
  266. package/src/emdash-runtime.ts +84 -159
  267. package/src/i18n/resolve.ts +37 -0
  268. package/src/index.ts +9 -0
  269. package/src/loader.ts +73 -3
  270. package/src/mcp/server.ts +180 -54
  271. package/src/menus/index.ts +143 -124
  272. package/src/menus/types.ts +15 -1
  273. package/src/page/site-identity.ts +58 -0
  274. package/src/plugins/adapt-sandbox-entry.ts +22 -10
  275. package/src/plugins/context.ts +13 -10
  276. package/src/plugins/define-plugin.ts +40 -12
  277. package/src/plugins/hooks.ts +23 -19
  278. package/src/plugins/index.ts +9 -0
  279. package/src/plugins/manifest-schema.ts +37 -2
  280. package/src/plugins/types.ts +151 -11
  281. package/src/preview/urls.ts +23 -3
  282. package/src/query.ts +148 -5
  283. package/src/redirects/cache.ts +38 -18
  284. package/src/schema/registry.ts +56 -0
  285. package/src/schema/zod-generator.ts +39 -7
  286. package/src/seed/apply.ts +142 -54
  287. package/src/seed/types.ts +14 -1
  288. package/src/seed/validate.ts +27 -13
  289. package/src/settings/index.ts +80 -6
  290. package/src/settings/types.ts +23 -1
  291. package/src/taxonomies/index.ts +237 -210
  292. package/src/taxonomies/types.ts +10 -0
  293. package/dist/apply-x0eMK1lX.mjs.map +0 -1
  294. package/dist/bylines-CRNsVG88.mjs +0 -157
  295. package/dist/bylines-CRNsVG88.mjs.map +0 -1
  296. package/dist/cache-BkKBuIvS.mjs +0 -56
  297. package/dist/cache-BkKBuIvS.mjs.map +0 -1
  298. package/dist/chunk-ClPoSABd.mjs +0 -21
  299. package/dist/content-BcQPYxdV.mjs.map +0 -1
  300. package/dist/dialect-helpers-DhTzaUxP.mjs.map +0 -1
  301. package/dist/index-DIb-CzNx.d.mts.map +0 -1
  302. package/dist/loader-CndGj8kM.mjs.map +0 -1
  303. package/dist/manifest-schema-DH9xhc6t.mjs.map +0 -1
  304. package/dist/query-fqEdLFms.mjs.map +0 -1
  305. package/dist/redirect-D_pshWdf.mjs.map +0 -1
  306. package/dist/registry-C3Mr0ODu.mjs.map +0 -1
  307. package/dist/runner-OURCaApa.d.mts +0 -34
  308. package/dist/runner-OURCaApa.d.mts.map +0 -1
  309. package/dist/runner-tQ7BJ4T7.mjs.map +0 -1
  310. package/dist/search-BoZYFuUk.mjs.map +0 -1
  311. package/dist/taxonomies-B4IAshV8.mjs +0 -308
  312. package/dist/taxonomies-B4IAshV8.mjs.map +0 -1
  313. package/dist/types-CS8FIX7L.d.mts.map +0 -1
  314. package/dist/types-i36XcA_X.d.mts.map +0 -1
  315. package/dist/validate-CxVsLehf.mjs.map +0 -1
  316. package/dist/validate-DHxmpFJt.d.mts.map +0 -1
  317. package/dist/version-Bbq8TCrz.mjs +0 -7
@@ -7,6 +7,7 @@
7
7
  * - Excludes auth/user/session/token tables
8
8
  */
9
9
 
10
+ import type { User } from "@emdash-cms/auth";
10
11
  import type { APIRoute } from "astro";
11
12
 
12
13
  import { requirePerm } from "#api/authorize.js";
@@ -17,11 +18,31 @@ import {
17
18
  verifyPreviewSignature,
18
19
  } from "#api/handlers/snapshot.js";
19
20
  import { getPublicOrigin } from "#api/public-url.js";
21
+ import { resolveSecretsCached } from "#config/secrets.js";
20
22
 
21
23
  export const prerender = false;
22
24
 
23
- export const GET: APIRoute = async ({ request, locals, url }) => {
24
- const { emdash, user } = locals;
25
+ export const GET: APIRoute = async ({ request, locals, url, session }) => {
26
+ const { emdash } = locals;
27
+ // This route is in PUBLIC_API_EXACT (for preview-signature callers with no session),
28
+ // so auth middleware skips user resolution. Manually resolve the session user here
29
+ // to support session-authenticated admin users alongside preview-signature auth.
30
+ let user: User | undefined = (locals as { user?: User }).user;
31
+ if (!user && session && emdash?.db) {
32
+ try {
33
+ const { createKyselyAdapter } = await import("@emdash-cms/auth/adapters/kysely");
34
+ const sessionUser = await session.get("user");
35
+ if (sessionUser?.id) {
36
+ const adapter = createKyselyAdapter(emdash.db);
37
+ const resolved = await adapter.getUserById(sessionUser.id);
38
+ if (resolved && !resolved.disabled) {
39
+ user = resolved;
40
+ }
41
+ }
42
+ } catch {
43
+ // Session resolution failed, continue to preview-signature check
44
+ }
45
+ }
25
46
 
26
47
  if (!emdash?.db) {
27
48
  return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -32,24 +53,29 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
32
53
  let authorized = false;
33
54
 
34
55
  if (previewSig) {
35
- const secret = import.meta.env.EMDASH_PREVIEW_SECRET || import.meta.env.PREVIEW_SECRET || "";
36
- if (!secret) {
37
- console.warn(
38
- "[snapshot] X-Preview-Signature header present but no PREVIEW_SECRET configured",
39
- );
56
+ // Resolves env override or DB-stored value. Always non-empty after
57
+ // resolution, so the signature path is never silently disabled.
58
+ // Note: a signing process without access to this database (e.g. a
59
+ // remote preview Worker) must set the same `EMDASH_PREVIEW_SECRET`
60
+ // env var on both sides.
61
+ const { previewSecret: secret, previewSecretSource } = await resolveSecretsCached(emdash.db);
62
+ const parsed = parsePreviewSignatureHeader(previewSig);
63
+ if (!parsed) {
64
+ console.warn("[snapshot] Failed to parse X-Preview-Signature header");
40
65
  } else {
41
- const parsed = parsePreviewSignatureHeader(previewSig);
42
- if (!parsed) {
43
- console.warn("[snapshot] Failed to parse X-Preview-Signature header");
44
- } else {
45
- authorized = await verifyPreviewSignature(parsed.source, parsed.exp, parsed.sig, secret);
46
- if (!authorized) {
47
- console.warn("[snapshot] Preview signature verification failed", {
48
- source: parsed.source,
49
- exp: parsed.exp,
50
- expired: parsed.exp < Date.now() / 1000,
51
- });
66
+ authorized = await verifyPreviewSignature(parsed.source, parsed.exp, parsed.sig, secret);
67
+ if (!authorized) {
68
+ const fields: Record<string, unknown> = {
69
+ source: parsed.source,
70
+ exp: parsed.exp,
71
+ expired: parsed.exp < Date.now() / 1000,
72
+ secretSource: previewSecretSource,
73
+ };
74
+ if (previewSecretSource === "db") {
75
+ fields.hint =
76
+ "Set EMDASH_PREVIEW_SECRET in both this process and the signing process to share secrets across deployments";
52
77
  }
78
+ console.warn("[snapshot] Preview signature verification failed", fields);
53
79
  }
54
80
  }
55
81
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Term translation endpoints
3
+ *
4
+ * GET /_emdash/api/taxonomies/:name/terms/:slug/translations[?locale=xx]
5
+ * POST /_emdash/api/taxonomies/:name/terms/:slug/translations
6
+ * body: { locale, label?, slug? }
7
+ */
8
+
9
+ import type { APIRoute } from "astro";
10
+ import { z } from "zod";
11
+
12
+ import { requirePerm } from "#api/authorize.js";
13
+ import { apiError, handleError, requireDb, unwrapResult } from "#api/error.js";
14
+ import {
15
+ handleTermCreate,
16
+ handleTermGet,
17
+ handleTermTranslations,
18
+ } from "#api/handlers/taxonomies.js";
19
+ import { isParseError, parseBody, parseQuery } from "#api/parse.js";
20
+ import { localeFilterQuery } from "#api/schemas.js";
21
+
22
+ export const prerender = false;
23
+
24
+ const createTermTranslationBody = z
25
+ .object({
26
+ locale: z.string().min(1),
27
+ label: z.string().min(1).optional(),
28
+ slug: z.string().min(1).optional(),
29
+ })
30
+ .meta({ id: "CreateTermTranslationBody" });
31
+
32
+ export const GET: APIRoute = async ({ params, request, locals }) => {
33
+ const { emdash, user } = locals;
34
+ const { name, slug } = params;
35
+ if (!name || !slug) return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
36
+
37
+ const dbErr = requireDb(emdash?.db);
38
+ if (dbErr) return dbErr;
39
+
40
+ const denied = requirePerm(user, "taxonomies:read");
41
+ if (denied) return denied;
42
+
43
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
44
+ if (isParseError(query)) return query;
45
+
46
+ try {
47
+ const anchor = await handleTermGet(emdash.db, name, slug, { locale: query.locale });
48
+ if (!anchor.success) return unwrapResult(anchor);
49
+ const result = await handleTermTranslations(emdash.db, anchor.data.term.id);
50
+ return unwrapResult(result);
51
+ } catch (error) {
52
+ return handleError(error, "Failed to list term translations", "TERM_TRANSLATIONS_ERROR");
53
+ }
54
+ };
55
+
56
+ export const POST: APIRoute = async ({ params, request, locals }) => {
57
+ const { emdash, user } = locals;
58
+ const { name, slug } = params;
59
+ if (!name || !slug) return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
60
+
61
+ const dbErr = requireDb(emdash?.db);
62
+ if (dbErr) return dbErr;
63
+
64
+ const denied = requirePerm(user, "taxonomies:manage");
65
+ if (denied) return denied;
66
+
67
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
68
+ if (isParseError(query)) return query;
69
+
70
+ try {
71
+ const body = await parseBody(request, createTermTranslationBody);
72
+ if (isParseError(body)) return body;
73
+
74
+ const source = await handleTermGet(emdash.db, name, slug, { locale: query.locale });
75
+ if (!source.success) return unwrapResult(source);
76
+
77
+ const result = await handleTermCreate(emdash.db, name, {
78
+ slug: body.slug ?? source.data.term.slug,
79
+ label: body.label ?? source.data.term.label,
80
+ parentId: source.data.term.parentId,
81
+ description: source.data.term.description,
82
+ locale: body.locale,
83
+ translationOf: source.data.term.id,
84
+ });
85
+ return unwrapResult(result, 201);
86
+ } catch (error) {
87
+ return handleError(error, "Failed to create term translation", "TERM_TRANSLATION_CREATE_ERROR");
88
+ }
89
+ };
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Single term endpoint
3
3
  *
4
- * GET /_emdash/api/taxonomies/:name/terms/:slug - Get a single term
5
- * PUT /_emdash/api/taxonomies/:name/terms/:slug - Update a term
6
- * DELETE /_emdash/api/taxonomies/:name/terms/:slug - Delete a term
4
+ * GET /_emdash/api/taxonomies/:name/terms/:slug[?locale=xx]
5
+ * PUT /_emdash/api/taxonomies/:name/terms/:slug[?locale=xx]
6
+ * DELETE /_emdash/api/taxonomies/:name/terms/:slug[?locale=xx]
7
7
  */
8
8
 
9
9
  import type { APIRoute } from "astro";
@@ -11,21 +11,18 @@ import type { APIRoute } from "astro";
11
11
  import { requirePerm } from "#api/authorize.js";
12
12
  import { apiError, handleError, requireDb, unwrapResult } from "#api/error.js";
13
13
  import { handleTermDelete, handleTermGet, handleTermUpdate } from "#api/handlers/taxonomies.js";
14
- import { isParseError, parseBody } from "#api/parse.js";
15
- import { updateTermBody } from "#api/schemas.js";
14
+ import { isParseError, parseBody, parseQuery } from "#api/parse.js";
15
+ import { localeFilterQuery, updateTermBody } from "#api/schemas.js";
16
16
 
17
17
  export const prerender = false;
18
18
 
19
19
  /**
20
20
  * Get a single term
21
21
  */
22
- export const GET: APIRoute = async ({ params, locals }) => {
22
+ export const GET: APIRoute = async ({ params, request, locals }) => {
23
23
  const { emdash, user } = locals;
24
24
  const { name, slug } = params;
25
-
26
- if (!name || !slug) {
27
- return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
28
- }
25
+ if (!name || !slug) return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
29
26
 
30
27
  const dbErr = requireDb(emdash?.db);
31
28
  if (dbErr) return dbErr;
@@ -33,8 +30,11 @@ export const GET: APIRoute = async ({ params, locals }) => {
33
30
  const denied = requirePerm(user, "taxonomies:read");
34
31
  if (denied) return denied;
35
32
 
33
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
34
+ if (isParseError(query)) return query;
35
+
36
36
  try {
37
- const result = await handleTermGet(emdash.db, name, slug);
37
+ const result = await handleTermGet(emdash.db, name, slug, { locale: query.locale });
38
38
  return unwrapResult(result);
39
39
  } catch (error) {
40
40
  return handleError(error, "Failed to get term", "TERM_GET_ERROR");
@@ -47,10 +47,7 @@ export const GET: APIRoute = async ({ params, locals }) => {
47
47
  export const PUT: APIRoute = async ({ params, request, locals }) => {
48
48
  const { emdash, user } = locals;
49
49
  const { name, slug } = params;
50
-
51
- if (!name || !slug) {
52
- return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
53
- }
50
+ if (!name || !slug) return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
54
51
 
55
52
  const dbErr = requireDb(emdash?.db);
56
53
  if (dbErr) return dbErr;
@@ -58,11 +55,14 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
58
55
  const denied = requirePerm(user, "taxonomies:manage");
59
56
  if (denied) return denied;
60
57
 
58
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
59
+ if (isParseError(query)) return query;
60
+
61
61
  try {
62
62
  const body = await parseBody(request, updateTermBody);
63
63
  if (isParseError(body)) return body;
64
64
 
65
- const result = await handleTermUpdate(emdash.db, name, slug, body);
65
+ const result = await handleTermUpdate(emdash.db, name, slug, body, { locale: query.locale });
66
66
  return unwrapResult(result);
67
67
  } catch (error) {
68
68
  return handleError(error, "Failed to update term", "TERM_UPDATE_ERROR");
@@ -72,13 +72,10 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
72
72
  /**
73
73
  * Delete a term
74
74
  */
75
- export const DELETE: APIRoute = async ({ params, locals }) => {
75
+ export const DELETE: APIRoute = async ({ params, request, locals }) => {
76
76
  const { emdash, user } = locals;
77
77
  const { name, slug } = params;
78
-
79
- if (!name || !slug) {
80
- return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
81
- }
78
+ if (!name || !slug) return apiError("VALIDATION_ERROR", "Taxonomy name and slug required", 400);
82
79
 
83
80
  const dbErr = requireDb(emdash?.db);
84
81
  if (dbErr) return dbErr;
@@ -86,8 +83,11 @@ export const DELETE: APIRoute = async ({ params, locals }) => {
86
83
  const denied = requirePerm(user, "taxonomies:manage");
87
84
  if (denied) return denied;
88
85
 
86
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
87
+ if (isParseError(query)) return query;
88
+
89
89
  try {
90
- const result = await handleTermDelete(emdash.db, name, slug);
90
+ const result = await handleTermDelete(emdash.db, name, slug, { locale: query.locale });
91
91
  return unwrapResult(result);
92
92
  } catch (error) {
93
93
  return handleError(error, "Failed to delete term", "TERM_DELETE_ERROR");
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Taxonomy terms list and create endpoint
3
3
  *
4
- * GET /_emdash/api/taxonomies/:name/terms - List all terms (tree for hierarchical)
5
- * POST /_emdash/api/taxonomies/:name/terms - Create a new term
4
+ * GET /_emdash/api/taxonomies/:name/terms[?locale=xx] - List terms (tree for hierarchical)
5
+ * POST /_emdash/api/taxonomies/:name/terms - Create a new term (body may include locale & translationOf)
6
6
  */
7
7
 
8
8
  import type { APIRoute } from "astro";
@@ -10,21 +10,18 @@ import type { APIRoute } from "astro";
10
10
  import { requirePerm } from "#api/authorize.js";
11
11
  import { apiError, handleError, requireDb, unwrapResult } from "#api/error.js";
12
12
  import { handleTermCreate, handleTermList } from "#api/handlers/taxonomies.js";
13
- import { isParseError, parseBody } from "#api/parse.js";
14
- import { createTermBody } from "#api/schemas.js";
13
+ import { isParseError, parseBody, parseQuery } from "#api/parse.js";
14
+ import { createTermBody, localeFilterQuery } from "#api/schemas.js";
15
15
 
16
16
  export const prerender = false;
17
17
 
18
18
  /**
19
19
  * List all terms for a taxonomy
20
20
  */
21
- export const GET: APIRoute = async ({ params, locals }) => {
21
+ export const GET: APIRoute = async ({ params, request, locals }) => {
22
22
  const { emdash, user } = locals;
23
23
  const { name } = params;
24
-
25
- if (!name) {
26
- return apiError("VALIDATION_ERROR", "Taxonomy name required", 400);
27
- }
24
+ if (!name) return apiError("VALIDATION_ERROR", "Taxonomy name required", 400);
28
25
 
29
26
  const dbErr = requireDb(emdash?.db);
30
27
  if (dbErr) return dbErr;
@@ -32,8 +29,11 @@ export const GET: APIRoute = async ({ params, locals }) => {
32
29
  const denied = requirePerm(user, "taxonomies:read");
33
30
  if (denied) return denied;
34
31
 
32
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
33
+ if (isParseError(query)) return query;
34
+
35
35
  try {
36
- const result = await handleTermList(emdash.db, name);
36
+ const result = await handleTermList(emdash.db, name, { locale: query.locale });
37
37
  return unwrapResult(result);
38
38
  } catch (error) {
39
39
  return handleError(error, "Failed to list terms", "TERM_LIST_ERROR");
@@ -46,10 +46,7 @@ export const GET: APIRoute = async ({ params, locals }) => {
46
46
  export const POST: APIRoute = async ({ params, request, locals }) => {
47
47
  const { emdash, user } = locals;
48
48
  const { name } = params;
49
-
50
- if (!name) {
51
- return apiError("VALIDATION_ERROR", "Taxonomy name required", 400);
52
- }
49
+ if (!name) return apiError("VALIDATION_ERROR", "Taxonomy name required", 400);
53
50
 
54
51
  const dbErr = requireDb(emdash?.db);
55
52
  if (dbErr) return dbErr;
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Taxonomy definitions endpoint
3
3
  *
4
- * GET /_emdash/api/taxonomies - List all taxonomy definitions
5
- * POST /_emdash/api/taxonomies - Create a custom taxonomy definition
4
+ * GET /_emdash/api/taxonomies[?locale=xx] - List taxonomy definitions
5
+ * POST /_emdash/api/taxonomies - Create a custom taxonomy definition
6
6
  */
7
7
 
8
8
  import type { APIRoute } from "astro";
@@ -10,15 +10,15 @@ import type { APIRoute } from "astro";
10
10
  import { requirePerm } from "#api/authorize.js";
11
11
  import { handleError, requireDb, unwrapResult } from "#api/error.js";
12
12
  import { handleTaxonomyCreate, handleTaxonomyList } from "#api/handlers/taxonomies.js";
13
- import { isParseError, parseBody } from "#api/parse.js";
14
- import { createTaxonomyDefBody } from "#api/schemas.js";
13
+ import { isParseError, parseBody, parseQuery } from "#api/parse.js";
14
+ import { createTaxonomyDefBody, localeFilterQuery } from "#api/schemas.js";
15
15
 
16
16
  export const prerender = false;
17
17
 
18
18
  /**
19
19
  * List taxonomy definitions
20
20
  */
21
- export const GET: APIRoute = async ({ locals }) => {
21
+ export const GET: APIRoute = async ({ request, locals }) => {
22
22
  const { emdash, user } = locals;
23
23
 
24
24
  const dbErr = requireDb(emdash?.db);
@@ -27,8 +27,11 @@ export const GET: APIRoute = async ({ locals }) => {
27
27
  const denied = requirePerm(user, "taxonomies:read");
28
28
  if (denied) return denied;
29
29
 
30
+ const query = parseQuery(new URL(request.url), localeFilterQuery);
31
+ if (isParseError(query)) return query;
32
+
30
33
  try {
31
- const result = await handleTaxonomyList(emdash.db);
34
+ const result = await handleTaxonomyList(emdash.db, { locale: query.locale });
32
35
  return unwrapResult(result);
33
36
  } catch (error) {
34
37
  return handleError(error, "Failed to list taxonomies", "TAXONOMY_LIST_ERROR");
@@ -52,7 +55,6 @@ export const POST: APIRoute = async ({ request, locals }) => {
52
55
  if (isParseError(body)) return body;
53
56
 
54
57
  const result = await handleTaxonomyCreate(emdash.db, body);
55
- if (result.success) emdash.invalidateManifest();
56
58
  return unwrapResult(result, 201);
57
59
  } catch (error) {
58
60
  return handleError(error, "Failed to create taxonomy", "TAXONOMY_CREATE_ERROR");
@@ -4,7 +4,13 @@
4
4
  * POST /_emdash/api/themes/preview
5
5
  *
6
6
  * Generates a signed preview URL for the "Try with my data" feature.
7
- * The PREVIEW_SECRET must be set in the environment (shared with preview Workers).
7
+ *
8
+ * Uses the resolved preview secret: env override (`EMDASH_PREVIEW_SECRET`)
9
+ * wins, otherwise an auto-generated stable per-site value persisted in the
10
+ * options table is used. Processes that share the same database converge on
11
+ * the same auto-generated value; only set `EMDASH_PREVIEW_SECRET` in both
12
+ * processes when the verifying side runs without access to the EmDash DB
13
+ * (e.g. a remote preview Worker).
8
14
  */
9
15
 
10
16
  import type { APIRoute } from "astro";
@@ -12,6 +18,7 @@ import type { APIRoute } from "astro";
12
18
  import { requirePerm } from "#api/authorize.js";
13
19
  import { apiError, apiSuccess } from "#api/error.js";
14
20
  import { getPublicOrigin } from "#api/public-url.js";
21
+ import { resolveSecretsCached } from "#config/secrets.js";
15
22
 
16
23
  export const prerender = false;
17
24
 
@@ -25,10 +32,9 @@ export const POST: APIRoute = async ({ request, url, locals }) => {
25
32
  const denied = requirePerm(user, "plugins:read");
26
33
  if (denied) return denied;
27
34
 
28
- const secret = import.meta.env.EMDASH_PREVIEW_SECRET || import.meta.env.PREVIEW_SECRET || "";
29
- if (!secret) {
30
- return apiError("NOT_CONFIGURED", "PREVIEW_SECRET is not configured", 500);
31
- }
35
+ // Always non-empty after resolution; env override wins, otherwise a
36
+ // stable DB-stored value is used.
37
+ const { previewSecret: secret } = await resolveSecretsCached(emdash.db);
32
38
 
33
39
  let body: { previewUrl: string };
34
40
  try {
@@ -228,6 +228,15 @@ export interface EmDashHandlers {
228
228
  slug?: string;
229
229
  status?: string;
230
230
  authorId?: string | null;
231
+ bylines?: Array<{ bylineId: string; roleLabel?: string | null }>;
232
+ seo?: {
233
+ title?: string | null;
234
+ description?: string | null;
235
+ image?: string | null;
236
+ canonical?: string | null;
237
+ noIndex?: boolean;
238
+ };
239
+ publishedAt?: string | null;
231
240
  _rev?: string;
232
241
  },
233
242
  ) => Promise<HandlerResponse>;
@@ -255,7 +264,11 @@ export interface EmDashHandlers {
255
264
  ) => Promise<HandlerResponse>;
256
265
 
257
266
  // Publishing & Scheduling handlers
258
- handleContentPublish: (collection: string, id: string) => Promise<HandlerResponse>;
267
+ handleContentPublish: (
268
+ collection: string,
269
+ id: string,
270
+ options?: { publishedAt?: string },
271
+ ) => Promise<HandlerResponse>;
259
272
 
260
273
  handleContentUnpublish: (collection: string, id: string) => Promise<HandlerResponse>;
261
274
 
@@ -362,8 +375,15 @@ export interface EmDashHandlers {
362
375
  // Configuration (for checking database type, auth mode, etc.)
363
376
  config: import("./integration/runtime.js").EmDashConfig;
364
377
 
365
- // Manifest invalidation (call after schema changes)
366
- invalidateManifest: () => void;
378
+ // Build the admin manifest from the live database. Only used by admin
379
+ // routes; logged-out requests don't need it. Per-request, deduplicated
380
+ // by `requestCached`.
381
+ getManifest: () => Promise<EmDashManifest>;
382
+
383
+ // Clear the cached URL patterns used by `resolveEmDashPath`. Call after
384
+ // any schema mutation that creates/updates/deletes a collection's
385
+ // `urlPattern` so public routing picks up the change immediately.
386
+ invalidateUrlPatternCache: () => void;
367
387
 
368
388
  // Sandbox runner (for marketplace plugin install/update)
369
389
  getSandboxRunner: () => import("../plugins/sandbox/types.js").SandboxRunner | null;
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Resolution and validation of multi-origin passkey verification.
3
+ *
4
+ * `allowedOrigins` lets one EmDash deployment accept passkey assertions from
5
+ * several hostnames sharing the same `rpId` (e.g. apex + preview/staging
6
+ * subdomains under one registrable parent). Origins come from two sources:
7
+ *
8
+ * - `EmDashConfig.allowedOrigins` (declared in `astro.config.mjs`)
9
+ * - `EMDASH_ALLOWED_ORIGINS` (comma-separated runtime env var)
10
+ *
11
+ * Sources are merged (union of permissions, deduplicated). Each entry is
12
+ * validated against `siteUrl` to fail loud on dead config the browser would
13
+ * never honor.
14
+ */
15
+
16
+ import { getEnvAllowedOrigins } from "../api/public-url.js";
17
+ import type { EmDashConfig } from "../astro/integration/runtime.js";
18
+
19
+ export type AllowedOriginSource = "config.allowedOrigins" | "EMDASH_ALLOWED_ORIGINS";
20
+
21
+ export interface TaggedOrigin {
22
+ /** Raw entry as declared by the operator. */
23
+ origin: string;
24
+ /** Where the entry came from (used for source-attributed errors). */
25
+ source: AllowedOriginSource;
26
+ }
27
+
28
+ /**
29
+ * Collect raw allowedOrigins from config and env, source-tagged.
30
+ *
31
+ * Returns raw values — the caller is expected to pass the result through
32
+ * `validateAllowedOrigins()` before use in passkey verification.
33
+ */
34
+ export function getConfiguredAllowedOrigins(config?: EmDashConfig): TaggedOrigin[] {
35
+ const tagged: TaggedOrigin[] = [];
36
+ if (config?.allowedOrigins) {
37
+ for (const origin of config.allowedOrigins) {
38
+ if (origin) tagged.push({ origin, source: "config.allowedOrigins" });
39
+ }
40
+ }
41
+ for (const origin of getEnvAllowedOrigins()) {
42
+ tagged.push({ origin, source: "EMDASH_ALLOWED_ORIGINS" });
43
+ }
44
+ return tagged;
45
+ }
46
+
47
+ /**
48
+ * Validate per-entry shape rules (no `siteUrl` needed):
49
+ * - parses as `URL`
50
+ * - protocol is `http:` or `https:`
51
+ * - hostname has no trailing dot (`example.com.` rejected)
52
+ * - hostname has no empty labels (`foo..example.com` rejected)
53
+ *
54
+ * Returns the deduplicated, normalized origin form (`URL.origin`) of every
55
+ * input, in input order. Throws on the first violation with a source-tagged
56
+ * error message.
57
+ */
58
+ export function validateOriginShape(tagged: TaggedOrigin[]): string[] {
59
+ const normalized: string[] = [];
60
+ const seen = new Set<string>();
61
+ for (const { origin, source } of tagged) {
62
+ let parsed: URL;
63
+ try {
64
+ parsed = new URL(origin);
65
+ } catch (e) {
66
+ throw configError(source, `invalid URL: "${origin}"`, e);
67
+ }
68
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
69
+ throw configError(
70
+ source,
71
+ `origin must be http or https: "${origin}" (got ${parsed.protocol})`,
72
+ );
73
+ }
74
+ if (parsed.hostname.endsWith(".")) {
75
+ throw configError(
76
+ source,
77
+ `hostname has a trailing dot: "${origin}". Remove the trailing dot — assertion origins from the browser do not include it.`,
78
+ );
79
+ }
80
+ if (parsed.hostname.split(".").includes("")) {
81
+ throw configError(source, `hostname has empty labels: "${origin}"`);
82
+ }
83
+ if (!seen.has(parsed.origin)) {
84
+ seen.add(parsed.origin);
85
+ normalized.push(parsed.origin);
86
+ }
87
+ }
88
+ return normalized;
89
+ }
90
+
91
+ /**
92
+ * Validate the effective merged allowedOrigins set against `siteUrl`.
93
+ *
94
+ * Performs `validateOriginShape()` plus the siteUrl-dependent rules:
95
+ * - Rule A: non-empty origins ⇒ `siteUrl` is set
96
+ * - `siteUrl` hostname is not an IP literal (multi-origin requires a domain)
97
+ * - `siteUrl` hostname has no trailing dot (cannot match assertion origins)
98
+ * - Rule B: each origin's hostname is `siteHost` exactly or a subdomain
99
+ *
100
+ * Throws on first violation. Returns the deduplicated normalized origins.
101
+ *
102
+ * Use this at the runtime chokepoint (where config + env are merged into the
103
+ * effective set). At Astro integration init, prefer `validateOriginShape()`
104
+ * for shape-only checks on `config.allowedOrigins`, since `siteUrl` may be
105
+ * supplied at runtime via `EMDASH_SITE_URL`.
106
+ */
107
+ export function validateAllowedOrigins(
108
+ siteUrl: string | undefined,
109
+ tagged: TaggedOrigin[],
110
+ ): string[] {
111
+ const normalized = validateOriginShape(tagged);
112
+ if (normalized.length === 0) return normalized;
113
+
114
+ if (!siteUrl) {
115
+ throw new Error(
116
+ `EmDash config error: allowedOrigins is set (${normalized.length} ${
117
+ normalized.length === 1 ? "entry" : "entries"
118
+ }) but siteUrl is not. Without a canonical siteUrl, rpId is derived from the request hostname, defeating multi-origin passkeys. Set siteUrl in astro.config.mjs or via EMDASH_SITE_URL.`,
119
+ );
120
+ }
121
+
122
+ let siteHost: string;
123
+ try {
124
+ siteHost = new URL(siteUrl).hostname;
125
+ } catch (e) {
126
+ throw new Error(`EmDash config error: siteUrl is not a valid URL: "${siteUrl}"`, {
127
+ cause: e,
128
+ });
129
+ }
130
+
131
+ if (siteHost.endsWith(".")) {
132
+ throw new Error(
133
+ `EmDash config error: siteUrl "${siteUrl}" has a trailing-dot hostname, which cannot match assertion origins. Remove the trailing dot when using allowedOrigins.`,
134
+ );
135
+ }
136
+ if (isIPLiteralHostname(siteHost)) {
137
+ throw new Error(
138
+ `EmDash config error: siteUrl "${siteUrl}" uses an IP-literal hostname. Multi-origin passkeys require a domain-based siteUrl — IP addresses cannot have valid subdomains for WebAuthn rpId.`,
139
+ );
140
+ }
141
+
142
+ for (const { origin, source } of tagged) {
143
+ const h = new URL(origin).hostname;
144
+ if (h !== siteHost && !h.endsWith("." + siteHost)) {
145
+ throw configError(
146
+ source,
147
+ `"${origin}" is not a subdomain of siteUrl "${siteUrl}". Allowed origins must be the same hostname as siteUrl or a subdomain of it.`,
148
+ );
149
+ }
150
+ }
151
+
152
+ return normalized;
153
+ }
154
+
155
+ function configError(source: AllowedOriginSource, detail: string, cause?: unknown): Error {
156
+ const err = new Error(`EmDash config error in ${source}: ${detail}`);
157
+ if (cause !== undefined) (err as Error & { cause?: unknown }).cause = cause;
158
+ return err;
159
+ }
160
+
161
+ const IPV4_DOTTED_DECIMAL_RE = /^\d+(\.\d+){3}$/;
162
+
163
+ function isIPLiteralHostname(h: string): boolean {
164
+ // IPv6 hostnames are bracketed by URL.hostname, e.g. "[::1]"
165
+ if (h.startsWith("[")) return true;
166
+ // IPv4 dotted-decimal
167
+ return IPV4_DOTTED_DECIMAL_RE.test(h);
168
+ }