emdash 0.7.0 → 0.9.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 (354) hide show
  1. package/dist/{adapters-Di31kZ28.d.mts → adapters-DoNJiveC.d.mts} +1 -1
  2. package/dist/{adapters-Di31kZ28.d.mts.map → adapters-DoNJiveC.d.mts.map} +1 -1
  3. package/dist/{apply-5uslYdUu.mjs → apply-BzltprvY.mjs} +90 -139
  4. package/dist/apply-BzltprvY.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 +194 -17
  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 +34 -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 +17 -12
  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 +9 -6
  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 +301 -165
  22. package/dist/astro/middleware.mjs.map +1 -1
  23. package/dist/astro/types.d.mts +34 -10
  24. package/dist/astro/types.d.mts.map +1 -1
  25. package/dist/{base64-MBPo9ozB.mjs → base64-BRICGH2l.mjs} +1 -1
  26. package/dist/{base64-MBPo9ozB.mjs.map → base64-BRICGH2l.mjs.map} +1 -1
  27. package/dist/{byline-C4OVd8b3.mjs → byline-BSaNL1w7.mjs} +5 -5
  28. package/dist/byline-BSaNL1w7.mjs.map +1 -0
  29. package/dist/bylines-CvJ3PYz2.mjs +113 -0
  30. package/dist/bylines-CvJ3PYz2.mjs.map +1 -0
  31. package/dist/cache-C6N_hhN7.mjs +65 -0
  32. package/dist/cache-C6N_hhN7.mjs.map +1 -0
  33. package/dist/{chunks-HGz06Soa.mjs → chunks-NBQVDOci.mjs} +8 -2
  34. package/dist/{chunks-HGz06Soa.mjs.map → chunks-NBQVDOci.mjs.map} +1 -1
  35. package/dist/cli/index.mjs +229 -31
  36. package/dist/cli/index.mjs.map +1 -1
  37. package/dist/client/cf-access.d.mts +1 -1
  38. package/dist/client/index.d.mts +1 -1
  39. package/dist/client/index.mjs +3 -3
  40. package/dist/client/index.mjs.map +1 -1
  41. package/dist/{config-BXwuX8Bx.mjs → config-BI0V3ICQ.mjs} +1 -1
  42. package/dist/{config-BXwuX8Bx.mjs.map → config-BI0V3ICQ.mjs.map} +1 -1
  43. package/dist/{content-D7J5y73J.mjs → content-8lOYF0pr.mjs} +43 -28
  44. package/dist/content-8lOYF0pr.mjs.map +1 -0
  45. package/dist/db/index.d.mts +3 -3
  46. package/dist/db/index.mjs +2 -2
  47. package/dist/db/libsql.d.mts +1 -1
  48. package/dist/db/libsql.d.mts.map +1 -1
  49. package/dist/db/libsql.mjs +7 -2
  50. package/dist/db/libsql.mjs.map +1 -1
  51. package/dist/db/postgres.d.mts +1 -1
  52. package/dist/db/sqlite.d.mts +1 -1
  53. package/dist/db/sqlite.d.mts.map +1 -1
  54. package/dist/db/sqlite.mjs +8 -3
  55. package/dist/db/sqlite.mjs.map +1 -1
  56. package/dist/{db-errors-D0UT85nC.mjs → db-errors-WRezodiz.mjs} +1 -1
  57. package/dist/{db-errors-D0UT85nC.mjs.map → db-errors-WRezodiz.mjs.map} +1 -1
  58. package/dist/{default-CME5YdZ3.mjs → default-D8ksjWhO.mjs} +1 -1
  59. package/dist/{default-CME5YdZ3.mjs.map → default-D8ksjWhO.mjs.map} +1 -1
  60. package/dist/{dialect-helpers-DhTzaUxP.mjs → dialect-helpers-BKCvISIQ.mjs} +19 -2
  61. package/dist/dialect-helpers-BKCvISIQ.mjs.map +1 -0
  62. package/dist/{error-CiYn9yDu.mjs → error-D_-tqP-I.mjs} +1 -1
  63. package/dist/error-D_-tqP-I.mjs.map +1 -0
  64. package/dist/{index-De6_Xv3v.d.mts → index-BFRaVcD6.d.mts} +243 -40
  65. package/dist/index-BFRaVcD6.d.mts.map +1 -0
  66. package/dist/index.d.mts +11 -11
  67. package/dist/index.mjs +29 -25
  68. package/dist/{load-CBcmDIot.mjs → load-DDqMMvZL.mjs} +2 -2
  69. package/dist/{load-CBcmDIot.mjs.map → load-DDqMMvZL.mjs.map} +1 -1
  70. package/dist/{loader-DeiBJEMe.mjs → loader-CKLbBnhK.mjs} +32 -10
  71. package/dist/loader-CKLbBnhK.mjs.map +1 -0
  72. package/dist/{manifest-schema-V30qsMft.mjs → manifest-schema-DqWNC3lM.mjs} +45 -3
  73. package/dist/manifest-schema-DqWNC3lM.mjs.map +1 -0
  74. package/dist/media/index.d.mts +1 -1
  75. package/dist/media/index.mjs +1 -1
  76. package/dist/media/local-runtime.d.mts +7 -7
  77. package/dist/media/local-runtime.mjs +3 -3
  78. package/dist/{media-DqHVh136.mjs → media-BW32b4gi.mjs} +4 -7
  79. package/dist/media-BW32b4gi.mjs.map +1 -0
  80. package/dist/{mode-CpNnGkPz.mjs → mode-ier8jbBk.mjs} +1 -1
  81. package/dist/mode-ier8jbBk.mjs.map +1 -0
  82. package/dist/options-BVp3UsTS.mjs +117 -0
  83. package/dist/options-BVp3UsTS.mjs.map +1 -0
  84. package/dist/page/index.d.mts +2 -2
  85. package/dist/{placeholder-tzpqGWII.d.mts → placeholder-BE4o_2dc.d.mts} +1 -1
  86. package/dist/{placeholder-tzpqGWII.d.mts.map → placeholder-BE4o_2dc.d.mts.map} +1 -1
  87. package/dist/{placeholder-C-fk5hYI.mjs → placeholder-CIJejMlK.mjs} +1 -1
  88. package/dist/placeholder-CIJejMlK.mjs.map +1 -0
  89. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  90. package/dist/plugins/adapt-sandbox-entry.d.mts.map +1 -1
  91. package/dist/plugins/adapt-sandbox-entry.mjs +6 -5
  92. package/dist/plugins/adapt-sandbox-entry.mjs.map +1 -1
  93. package/dist/public-url-DByxYjUw.mjs +51 -0
  94. package/dist/public-url-DByxYjUw.mjs.map +1 -0
  95. package/dist/{query-g4Ug-9j9.mjs → query-Cg9ZKRQ0.mjs} +114 -16
  96. package/dist/query-Cg9ZKRQ0.mjs.map +1 -0
  97. package/dist/{redirect-CN0Rt9Ob.mjs → redirect-BhUBKRc1.mjs} +13 -8
  98. package/dist/redirect-BhUBKRc1.mjs.map +1 -0
  99. package/dist/{registry-Ci3WxVAr.mjs → registry-Dw70ChxB.mjs} +69 -11
  100. package/dist/registry-Dw70ChxB.mjs.map +1 -0
  101. package/dist/{request-cache-DiR961CV.mjs → request-cache-B-bmkipQ.mjs} +1 -1
  102. package/dist/request-cache-B-bmkipQ.mjs.map +1 -0
  103. package/dist/runner-Bnoj7vjK.d.mts +44 -0
  104. package/dist/runner-Bnoj7vjK.d.mts.map +1 -0
  105. package/dist/{runner-tQ7BJ4T7.mjs → runner-C7ADox5q.mjs} +185 -55
  106. package/dist/{runner-tQ7BJ4T7.mjs.map → runner-C7ADox5q.mjs.map} +1 -1
  107. package/dist/runtime.d.mts +6 -6
  108. package/dist/runtime.mjs +4 -4
  109. package/dist/{search-B0effn3j.mjs → search-dOGEccMa.mjs} +341 -152
  110. package/dist/search-dOGEccMa.mjs.map +1 -0
  111. package/dist/secrets-CW3reAnU.mjs +314 -0
  112. package/dist/secrets-CW3reAnU.mjs.map +1 -0
  113. package/dist/seed/index.d.mts +2 -2
  114. package/dist/seed/index.mjs +15 -14
  115. package/dist/seo/index.d.mts +1 -1
  116. package/dist/storage/local.d.mts +1 -1
  117. package/dist/storage/local.mjs +1 -1
  118. package/dist/storage/s3.d.mts +1 -1
  119. package/dist/storage/s3.d.mts.map +1 -1
  120. package/dist/storage/s3.mjs +4 -4
  121. package/dist/storage/s3.mjs.map +1 -1
  122. package/dist/{taxonomies-K2z0Uhnj.mjs → taxonomies-ZlRtD6AG.mjs} +14 -7
  123. package/dist/taxonomies-ZlRtD6AG.mjs.map +1 -0
  124. package/dist/{tokens-BFPFx3CA.mjs → tokens-D7zMmWi2.mjs} +2 -2
  125. package/dist/{tokens-BFPFx3CA.mjs.map → tokens-D7zMmWi2.mjs.map} +1 -1
  126. package/dist/{transport-BykRfpyy.mjs → transport-BeMCmin1.mjs} +6 -5
  127. package/dist/{transport-BykRfpyy.mjs.map → transport-BeMCmin1.mjs.map} +1 -1
  128. package/dist/{transport-H4Iwx7tC.d.mts → transport-DNEfeMaU.d.mts} +1 -1
  129. package/dist/{transport-H4Iwx7tC.d.mts.map → transport-DNEfeMaU.d.mts.map} +1 -1
  130. package/dist/types-4fVtCIm0.mjs +68 -0
  131. package/dist/types-4fVtCIm0.mjs.map +1 -0
  132. package/dist/{types-CnZYHyLW.d.mts → types-BSyXeCFW.d.mts} +24 -2
  133. package/dist/{types-CnZYHyLW.d.mts.map → types-BSyXeCFW.d.mts.map} +1 -1
  134. package/dist/{types-DgrIP0tF.d.mts → types-BuBIptGk.d.mts} +80 -106
  135. package/dist/types-BuBIptGk.d.mts.map +1 -0
  136. package/dist/{types-BH2L167P.mjs → types-CDbKp7ND.mjs} +1 -1
  137. package/dist/{types-BH2L167P.mjs.map → types-CDbKp7ND.mjs.map} +1 -1
  138. package/dist/{types-DDS4MxsT.mjs → types-CIOg5AR8.mjs} +1 -1
  139. package/dist/{types-DDS4MxsT.mjs.map → types-CIOg5AR8.mjs.map} +1 -1
  140. package/dist/{types-6CUZRrZP.d.mts → types-CJsYGpco.d.mts} +24 -2
  141. package/dist/{types-6CUZRrZP.d.mts.map → types-CJsYGpco.d.mts.map} +1 -1
  142. package/dist/types-CRxNbK-Z.mjs +68 -0
  143. package/dist/types-CRxNbK-Z.mjs.map +1 -0
  144. package/dist/{types-C2v0c34j.d.mts → types-CrtWgIvl.d.mts} +1 -1
  145. package/dist/{types-C2v0c34j.d.mts.map → types-CrtWgIvl.d.mts.map} +1 -1
  146. package/dist/{types-CFWjXmus.d.mts → types-M78DQ1lx.d.mts} +1 -1
  147. package/dist/{types-CFWjXmus.d.mts.map → types-M78DQ1lx.d.mts.map} +1 -1
  148. package/dist/{validate-CqsNItbt.mjs → validate-Baqf0slj.mjs} +3 -3
  149. package/dist/{validate-CqsNItbt.mjs.map → validate-Baqf0slj.mjs.map} +1 -1
  150. package/dist/{validate-kM8Pjuf7.d.mts → validate-BfQh_C_y.d.mts} +4 -4
  151. package/dist/{validate-kM8Pjuf7.d.mts.map → validate-BfQh_C_y.d.mts.map} +1 -1
  152. package/dist/validation-BfEI7tNe.mjs +144 -0
  153. package/dist/validation-BfEI7tNe.mjs.map +1 -0
  154. package/dist/version-DoxrVdYf.mjs +7 -0
  155. package/dist/{version-BnTKdfam.mjs.map → version-DoxrVdYf.mjs.map} +1 -1
  156. package/dist/zod-generator-CC0xNe_K.mjs +132 -0
  157. package/dist/zod-generator-CC0xNe_K.mjs.map +1 -0
  158. package/locals.d.ts +1 -6
  159. package/package.json +21 -7
  160. package/src/api/auth-storage.ts +37 -0
  161. package/src/api/error.ts +6 -0
  162. package/src/api/errors.ts +8 -0
  163. package/src/api/handlers/comments.ts +19 -4
  164. package/src/api/handlers/content.ts +151 -4
  165. package/src/api/handlers/device-flow.ts +5 -0
  166. package/src/api/handlers/index.ts +2 -0
  167. package/src/api/handlers/marketplace.ts +11 -4
  168. package/src/api/handlers/media.ts +8 -1
  169. package/src/api/handlers/menus.ts +160 -21
  170. package/src/api/handlers/oauth-authorization.ts +72 -33
  171. package/src/api/handlers/redirects.ts +16 -3
  172. package/src/api/handlers/revision.ts +23 -14
  173. package/src/api/handlers/sections.ts +8 -1
  174. package/src/api/handlers/taxonomies.ts +131 -22
  175. package/src/api/handlers/validation.ts +212 -0
  176. package/src/api/openapi/document.ts +4 -1
  177. package/src/api/public-url.ts +54 -5
  178. package/src/api/route-utils.ts +14 -0
  179. package/src/api/schemas/comments.ts +2 -2
  180. package/src/api/schemas/common.ts +1 -1
  181. package/src/api/schemas/content.ts +17 -0
  182. package/src/api/schemas/sections.ts +3 -3
  183. package/src/api/schemas/setup.ts +8 -0
  184. package/src/api/schemas/users.ts +1 -1
  185. package/src/api/schemas/widgets.ts +12 -10
  186. package/src/api/setup-complete.ts +40 -0
  187. package/src/api/types.ts +5 -1
  188. package/src/astro/integration/index.ts +30 -2
  189. package/src/astro/integration/routes.ts +28 -0
  190. package/src/astro/integration/runtime.ts +49 -1
  191. package/src/astro/integration/virtual-modules.ts +73 -2
  192. package/src/astro/integration/vite-config.ts +49 -13
  193. package/src/astro/middleware/auth.ts +34 -6
  194. package/src/astro/middleware/redirect.ts +29 -16
  195. package/src/astro/middleware/request-context.ts +15 -5
  196. package/src/astro/middleware.ts +41 -10
  197. package/src/astro/routes/PluginRegistry.tsx +10 -1
  198. package/src/astro/routes/api/auth/invite/complete.ts +6 -1
  199. package/src/astro/routes/api/auth/mode.ts +57 -0
  200. package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +23 -3
  201. package/src/astro/routes/api/auth/oauth/[provider].ts +10 -4
  202. package/src/astro/routes/api/auth/passkey/register/verify.ts +6 -1
  203. package/src/astro/routes/api/auth/passkey/verify.ts +6 -1
  204. package/src/astro/routes/api/auth/signup/complete.ts +6 -1
  205. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +2 -2
  206. package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +4 -2
  207. package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +34 -12
  208. package/src/astro/routes/api/content/[collection]/[id]/publish.ts +32 -2
  209. package/src/astro/routes/api/content/[collection]/[id]/restore.ts +4 -2
  210. package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +3 -2
  211. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +8 -4
  212. package/src/astro/routes/api/content/[collection]/[id]/translations.ts +1 -1
  213. package/src/astro/routes/api/content/[collection]/[id].ts +12 -0
  214. package/src/astro/routes/api/content/[collection]/index.ts +1 -9
  215. package/src/astro/routes/api/import/wordpress/execute.ts +3 -1
  216. package/src/astro/routes/api/import/wordpress/media.ts +2 -7
  217. package/src/astro/routes/api/import/wordpress/prepare.ts +9 -0
  218. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +3 -1
  219. package/src/astro/routes/api/manifest.ts +62 -45
  220. package/src/astro/routes/api/media/[id]/confirm.ts +10 -1
  221. package/src/astro/routes/api/media/providers/[providerId]/index.ts +12 -3
  222. package/src/astro/routes/api/openapi.json.ts +27 -10
  223. package/src/astro/routes/api/redirects/404s/index.ts +10 -4
  224. package/src/astro/routes/api/redirects/404s/summary.ts +4 -2
  225. package/src/astro/routes/api/redirects/[id].ts +10 -4
  226. package/src/astro/routes/api/redirects/index.ts +7 -3
  227. package/src/astro/routes/api/revisions/[revisionId]/index.ts +1 -1
  228. package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +0 -2
  229. package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +0 -1
  230. package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +0 -1
  231. package/src/astro/routes/api/schema/collections/[slug]/index.ts +2 -2
  232. package/src/astro/routes/api/schema/collections/index.ts +1 -1
  233. package/src/astro/routes/api/search/index.ts +10 -2
  234. package/src/astro/routes/api/sections/[slug].ts +10 -4
  235. package/src/astro/routes/api/sections/index.ts +7 -3
  236. package/src/astro/routes/api/settings/email.ts +4 -9
  237. package/src/astro/routes/api/setup/admin-verify.ts +6 -1
  238. package/src/astro/routes/api/setup/admin.ts +8 -2
  239. package/src/astro/routes/api/setup/index.ts +2 -2
  240. package/src/astro/routes/api/setup/status.ts +3 -1
  241. package/src/astro/routes/api/snapshot.ts +44 -18
  242. package/src/astro/routes/api/taxonomies/index.ts +0 -1
  243. package/src/astro/routes/api/themes/preview.ts +11 -5
  244. package/src/astro/routes/api/widget-areas/[name]/widgets/[id].ts +4 -1
  245. package/src/astro/routes/api/widget-areas/[name]/widgets.ts +4 -1
  246. package/src/astro/routes/api/widget-areas/[name].ts +4 -1
  247. package/src/astro/routes/api/widget-areas/index.ts +4 -1
  248. package/src/astro/types.ts +32 -3
  249. package/src/auth/allowed-origins.ts +168 -0
  250. package/src/auth/mode.ts +15 -3
  251. package/src/auth/passkey-config.ts +35 -13
  252. package/src/auth/providers/github-admin.tsx +29 -0
  253. package/src/auth/providers/github.ts +31 -0
  254. package/src/auth/providers/google-admin.tsx +44 -0
  255. package/src/auth/providers/google.ts +31 -0
  256. package/src/auth/types.ts +114 -4
  257. package/src/bylines/index.ts +37 -88
  258. package/src/cli/commands/auth.ts +28 -6
  259. package/src/cli/commands/bundle-utils.ts +11 -2
  260. package/src/cli/commands/bundle.ts +31 -9
  261. package/src/cli/commands/content.ts +13 -0
  262. package/src/cli/commands/login.ts +8 -1
  263. package/src/cli/commands/publish.ts +24 -0
  264. package/src/cli/commands/secrets.ts +183 -0
  265. package/src/cli/credentials.ts +1 -1
  266. package/src/cli/index.ts +5 -1
  267. package/src/client/index.ts +4 -4
  268. package/src/client/transport.ts +17 -7
  269. package/src/components/Break.astro +2 -2
  270. package/src/components/EmDashHead.astro +18 -13
  271. package/src/components/EmDashImage.astro +7 -6
  272. package/src/components/Embed.astro +1 -1
  273. package/src/components/Gallery.astro +6 -4
  274. package/src/components/Image.astro +9 -4
  275. package/src/components/InlinePortableTextEditor.tsx +106 -19
  276. package/src/components/LiveSearch.astro +5 -14
  277. package/src/config/secrets.ts +528 -0
  278. package/src/database/dialect-helpers.ts +50 -0
  279. package/src/database/migrations/034_published_at_index.ts +1 -1
  280. package/src/database/migrations/035_bounded_404_log.ts +56 -39
  281. package/src/database/migrations/runner.ts +156 -23
  282. package/src/database/repositories/audit.ts +6 -8
  283. package/src/database/repositories/byline.ts +6 -8
  284. package/src/database/repositories/comment.ts +12 -16
  285. package/src/database/repositories/content.ts +76 -52
  286. package/src/database/repositories/index.ts +1 -1
  287. package/src/database/repositories/media.ts +10 -13
  288. package/src/database/repositories/plugin-storage.ts +4 -6
  289. package/src/database/repositories/redirect.ts +26 -19
  290. package/src/database/repositories/taxonomy.ts +40 -3
  291. package/src/database/repositories/types.ts +57 -8
  292. package/src/database/repositories/user.ts +6 -8
  293. package/src/db/libsql.ts +1 -3
  294. package/src/db/sqlite.ts +2 -5
  295. package/src/emdash-runtime.ts +388 -247
  296. package/src/index.ts +14 -1
  297. package/src/loader.ts +30 -6
  298. package/src/mcp/server.ts +781 -141
  299. package/src/media/normalize.ts +1 -1
  300. package/src/media/url.ts +78 -0
  301. package/src/page/site-identity.ts +58 -0
  302. package/src/plugins/adapt-sandbox-entry.ts +22 -10
  303. package/src/plugins/context.ts +13 -10
  304. package/src/plugins/define-plugin.ts +40 -12
  305. package/src/plugins/email-console.ts +10 -3
  306. package/src/plugins/hooks.ts +34 -19
  307. package/src/plugins/index.ts +9 -0
  308. package/src/plugins/manifest-schema.ts +49 -2
  309. package/src/plugins/types.ts +174 -13
  310. package/src/preview/urls.ts +23 -3
  311. package/src/query.ts +149 -6
  312. package/src/redirects/cache.ts +38 -18
  313. package/src/request-cache.ts +3 -0
  314. package/src/schema/registry.ts +97 -5
  315. package/src/schema/zod-generator.ts +27 -5
  316. package/src/search/fts-manager.ts +0 -2
  317. package/src/search/query.ts +111 -26
  318. package/src/search/types.ts +8 -1
  319. package/src/sections/index.ts +7 -9
  320. package/src/seed/apply.ts +2 -0
  321. package/src/settings/index.ts +80 -6
  322. package/src/settings/types.ts +23 -1
  323. package/src/storage/s3.ts +12 -6
  324. package/src/taxonomies/index.ts +11 -1
  325. package/src/virtual-modules.d.ts +21 -1
  326. package/src/widgets/index.ts +1 -1
  327. package/dist/apply-5uslYdUu.mjs.map +0 -1
  328. package/dist/byline-C4OVd8b3.mjs.map +0 -1
  329. package/dist/bylines-hPTW79hw.mjs +0 -157
  330. package/dist/bylines-hPTW79hw.mjs.map +0 -1
  331. package/dist/cache-BkKBuIvS.mjs +0 -56
  332. package/dist/cache-BkKBuIvS.mjs.map +0 -1
  333. package/dist/chunk-ClPoSABd.mjs +0 -21
  334. package/dist/content-D7J5y73J.mjs.map +0 -1
  335. package/dist/dialect-helpers-DhTzaUxP.mjs.map +0 -1
  336. package/dist/error-CiYn9yDu.mjs.map +0 -1
  337. package/dist/index-De6_Xv3v.d.mts.map +0 -1
  338. package/dist/loader-DeiBJEMe.mjs.map +0 -1
  339. package/dist/manifest-schema-V30qsMft.mjs.map +0 -1
  340. package/dist/media-DqHVh136.mjs.map +0 -1
  341. package/dist/mode-CpNnGkPz.mjs.map +0 -1
  342. package/dist/placeholder-C-fk5hYI.mjs.map +0 -1
  343. package/dist/query-g4Ug-9j9.mjs.map +0 -1
  344. package/dist/redirect-CN0Rt9Ob.mjs.map +0 -1
  345. package/dist/registry-Ci3WxVAr.mjs.map +0 -1
  346. package/dist/request-cache-DiR961CV.mjs.map +0 -1
  347. package/dist/runner-BR2xKwhn.d.mts +0 -34
  348. package/dist/runner-BR2xKwhn.d.mts.map +0 -1
  349. package/dist/search-B0effn3j.mjs.map +0 -1
  350. package/dist/taxonomies-K2z0Uhnj.mjs.map +0 -1
  351. package/dist/types-CMMN0pNg.mjs +0 -31
  352. package/dist/types-CMMN0pNg.mjs.map +0 -1
  353. package/dist/types-DgrIP0tF.d.mts.map +0 -1
  354. package/dist/version-BnTKdfam.mjs +0 -7
@@ -0,0 +1,528 @@
1
+ /**
2
+ * Centralized secrets module
3
+ *
4
+ * Single source of truth for site-level cryptographic secrets:
5
+ *
6
+ * - `EMDASH_ENCRYPTION_KEY` — primary key for encrypting plugin secrets at
7
+ * rest. Multi-key (comma-separated) for rotation forward-compat. v1 ships
8
+ * single-key. Format: `emdash_enc_v1_<43 base64url chars>` representing
9
+ * 32 random bytes. **Operator-provided; never stored in the database.**
10
+ * Losing the key means losing every secret encrypted with it. Validated
11
+ * at runtime startup via `validateEncryptionKeyAtStartup` — request-time
12
+ * resolution does not depend on it, so a malformed key can't 500 the
13
+ * preview/comment hot paths for unrelated visitors.
14
+ * - `EMDASH_IP_SALT` (optional) / DB-stored `emdash:ip_salt` — site-specific
15
+ * salt for hashing commenter IPs. Generated and persisted on first need
16
+ * if no env override is set. Replaces the previous hardcoded
17
+ * `"emdash-ip-salt"` constant which was correlatable across installs.
18
+ * - `EMDASH_PREVIEW_SECRET` (optional) / DB-stored `emdash:preview_secret` —
19
+ * HMAC secret for signing preview URLs. Generated and persisted on first
20
+ * need if no env override is set. Replaces the previous empty-string
21
+ * fallback which silently disabled preview-token verification.
22
+ *
23
+ * The `EMDASH_AUTH_SECRET` env var is consulted only as a legacy fallback
24
+ * source for the IP salt — that's the only path the prior code actually
25
+ * read it from. New deployments don't need to set it.
26
+ *
27
+ * Modeled on `resolveS3Config` in `../storage/s3.ts`.
28
+ */
29
+
30
+ import { sha256 } from "@oslojs/crypto/sha2";
31
+ import { encodeHexLowerCase } from "@oslojs/encoding";
32
+ import type { Kysely } from "kysely";
33
+
34
+ import { OptionsRepository } from "../database/repositories/options.js";
35
+ import type { Database } from "../database/types.js";
36
+ import { decodeBase64url, encodeBase64url } from "../utils/base64.js";
37
+
38
+ /** v1 encryption key prefix. Bumping requires a separate KDF version. */
39
+ export const ENCRYPTION_KEY_PREFIX = "emdash_enc_v1_";
40
+
41
+ /** 32 random bytes encoded as unpadded base64url = 43 chars. */
42
+ const ENCRYPTION_KEY_BODY_LENGTH = 43;
43
+
44
+ const REGEX_META_PATTERN = /[.*+?^${}()|[\]\\]/g;
45
+
46
+ /**
47
+ * Built from the prefix constant via interpolation. The prefix has no regex
48
+ * metacharacters today (`emdash_enc_v1_`), but escaping is cheap defense
49
+ * against anyone changing the prefix in a future bump without remembering.
50
+ */
51
+ const ENCRYPTION_KEY_PATTERN = new RegExp(
52
+ `^${ENCRYPTION_KEY_PREFIX.replace(REGEX_META_PATTERN, "\\$&")}[A-Za-z0-9_-]{${ENCRYPTION_KEY_BODY_LENGTH}}$`,
53
+ );
54
+
55
+ /** Options-table key for the persisted commenter-IP salt. */
56
+ export const IP_SALT_OPTION_KEY = "emdash:ip_salt";
57
+
58
+ /** Options-table key for the persisted preview HMAC secret. */
59
+ export const PREVIEW_SECRET_OPTION_KEY = "emdash:preview_secret";
60
+
61
+ /** Length in bytes of generated values. 32 bytes = 256 bits. */
62
+ const GENERATED_SECRET_BYTES = 32;
63
+
64
+ /**
65
+ * A parsed encryption key with its kid (key id) fingerprint.
66
+ *
67
+ * `kid` is the first 8 chars of the SHA-256 hash of the decoded key bytes
68
+ * (lowercase hex), used to tag envelopes so the decryptor can pick the right
69
+ * key during rotation.
70
+ */
71
+ export interface ParsedEncryptionKey {
72
+ /** 8-char lowercase hex fingerprint derived from the decoded key bytes. */
73
+ kid: string;
74
+ /** The 32 raw key bytes, ready for `crypto.subtle.importKey`. */
75
+ key: Uint8Array;
76
+ /** The original env-var-formatted string (kept for re-emit; never log). */
77
+ raw: string;
78
+ }
79
+
80
+ /** Resolved site secrets. */
81
+ export interface ResolvedSecrets {
82
+ /** HMAC secret for preview URLs. Always non-empty after resolution. */
83
+ previewSecret: string;
84
+ /**
85
+ * Source of `previewSecret`. Useful for diagnostics; never expose the
86
+ * value itself, only the source.
87
+ */
88
+ previewSecretSource: "env" | "db";
89
+ /** Salt for hashing commenter IPs. Always non-empty after resolution. */
90
+ ipSalt: string;
91
+ /** Source of `ipSalt`. */
92
+ ipSaltSource: "env" | "db";
93
+ }
94
+
95
+ /** Inputs for `resolveSecrets`. */
96
+ export interface ResolveSecretsOptions {
97
+ /**
98
+ * The Kysely DB used to persist (and read back) generated salt/preview
99
+ * secret values. Required — these values must be stable across requests
100
+ * within a deployment.
101
+ */
102
+ db: Kysely<Database>;
103
+ /**
104
+ * Optional explicit env override map. When omitted, falls back to
105
+ * `import.meta.env` via the global accessor below. Tests pass an
106
+ * explicit map to avoid leaking process state.
107
+ */
108
+ env?: SecretsEnv;
109
+ /**
110
+ * @internal Test seam: inject a custom OptionsRepository to exercise
111
+ * the lost-race re-read branch. Production callers never set this.
112
+ */
113
+ _repo?: OptionsRepository;
114
+ }
115
+
116
+ /** Environment-variable shape consulted by the resolver. */
117
+ export interface SecretsEnv {
118
+ /**
119
+ * Read by `validateEncryptionKeyAtStartup` and (in a follow-up PR) by the
120
+ * plugin-secret encryption layer. **Not** consulted by `resolveSecrets`,
121
+ * so a malformed value can't 500 the preview/comment hot paths.
122
+ */
123
+ EMDASH_ENCRYPTION_KEY?: string;
124
+ EMDASH_PREVIEW_SECRET?: string;
125
+ /** Legacy alias; new docs point at EMDASH_PREVIEW_SECRET. */
126
+ PREVIEW_SECRET?: string;
127
+ EMDASH_IP_SALT?: string;
128
+ /**
129
+ * Legacy fallback. Prior code derived the IP salt from
130
+ * `EMDASH_AUTH_SECRET || AUTH_SECRET || "emdash-ip-salt"`. We preserve
131
+ * the env-var fallback (so existing installs keep their stable salt)
132
+ * but no longer read it from `import.meta.env` in route handlers.
133
+ */
134
+ EMDASH_AUTH_SECRET?: string;
135
+ /** Legacy alias. */
136
+ AUTH_SECRET?: string;
137
+ }
138
+
139
+ /**
140
+ * Class of validation failures raised by this module.
141
+ *
142
+ * Errors here are operator-facing config problems (malformed key, etc.).
143
+ * They are thrown rather than soft-skipped so misconfiguration fails loudly
144
+ * at startup instead of silently degrading at request time.
145
+ */
146
+ export class EmDashSecretsError extends Error {
147
+ override readonly name = "EmDashSecretsError";
148
+ readonly code: string;
149
+
150
+ constructor(message: string, code: string) {
151
+ super(message);
152
+ this.code = code;
153
+ }
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Encryption key parsing
158
+ // ---------------------------------------------------------------------------
159
+
160
+ /**
161
+ * Parse the `EMDASH_ENCRYPTION_KEY` env var.
162
+ *
163
+ * Accepts a single key or a comma-separated list. The first entry is the
164
+ * primary (used for new writes); all entries are tried for decryption,
165
+ * matched by `kid`. Whitespace around commas is tolerated. Empty entries
166
+ * (e.g. trailing comma) are ignored.
167
+ *
168
+ * Returns `null` for an unset/empty input. Throws `EmDashSecretsError` on
169
+ * any malformed entry — silent skipping would mask deployment mistakes.
170
+ */
171
+ export async function parseEncryptionKeys(
172
+ raw: string | undefined,
173
+ ): Promise<ParsedEncryptionKey[] | null> {
174
+ if (!raw) return null;
175
+
176
+ const entries = raw
177
+ .split(",")
178
+ .map((entry) => entry.trim())
179
+ .filter((entry) => entry.length > 0);
180
+
181
+ if (entries.length === 0) return null;
182
+
183
+ const parsed: ParsedEncryptionKey[] = [];
184
+ const seenKids = new Set<string>();
185
+
186
+ for (const entry of entries) {
187
+ if (!ENCRYPTION_KEY_PATTERN.test(entry)) {
188
+ throw new EmDashSecretsError(
189
+ `EMDASH_ENCRYPTION_KEY entry is malformed (expected "${ENCRYPTION_KEY_PREFIX}" followed by ${ENCRYPTION_KEY_BODY_LENGTH} base64url chars). Generate one with \`emdash secrets generate\`.`,
190
+ "INVALID_ENCRYPTION_KEY",
191
+ );
192
+ }
193
+
194
+ const body = entry.slice(ENCRYPTION_KEY_PREFIX.length);
195
+ const key = decodeBase64urlStrict(body);
196
+ if (!key) {
197
+ throw new EmDashSecretsError(
198
+ "EMDASH_ENCRYPTION_KEY body is not valid base64url",
199
+ "INVALID_ENCRYPTION_KEY",
200
+ );
201
+ }
202
+ if (key.length !== GENERATED_SECRET_BYTES) {
203
+ throw new EmDashSecretsError(
204
+ `EMDASH_ENCRYPTION_KEY must decode to ${GENERATED_SECRET_BYTES} bytes, got ${key.length}`,
205
+ "INVALID_ENCRYPTION_KEY",
206
+ );
207
+ }
208
+
209
+ // Reject non-canonical base64url. 43 chars decode to 32 bytes but
210
+ // the last char only carries 2 information bits — multiple raw
211
+ // strings can decode to the same bytes. Forcing canonical form
212
+ // guarantees `kid` (derived from bytes) is stable per key
213
+ // material, regardless of how the operator pasted it.
214
+ const canonical = encodeBase64url(key);
215
+ if (canonical !== body) {
216
+ throw new EmDashSecretsError(
217
+ "EMDASH_ENCRYPTION_KEY body is not canonical base64url. Generate one with `emdash secrets generate`.",
218
+ "INVALID_ENCRYPTION_KEY",
219
+ );
220
+ }
221
+
222
+ const kid = fingerprintKeyBytes(key);
223
+ if (seenKids.has(kid)) {
224
+ // Duplicate keys are user error (paste mistake during rotation).
225
+ // We dedupe rather than throw — the rotation flow is forgiving.
226
+ continue;
227
+ }
228
+ seenKids.add(kid);
229
+ parsed.push({ kid, key, raw: entry });
230
+ }
231
+
232
+ // `parsed` always has at least one entry here: `entries` was non-empty
233
+ // after filtering, the loop runs at least once, the first iteration
234
+ // always passes the empty-`seenKids` check.
235
+ return parsed;
236
+ }
237
+
238
+ /**
239
+ * Compute the kid for a raw key string (the env-var form including the
240
+ * `emdash_enc_v1_` prefix). Public so the CLI's `fingerprint` subcommand
241
+ * and admin endpoints can show kids without exposing raw keys.
242
+ *
243
+ * The kid is derived from the decoded key **bytes**, not the raw string,
244
+ * so admin endpoints / future rotation flows can match envelope kids
245
+ * against bytes regardless of how the env var was originally spelled.
246
+ *
247
+ * Validates the same shape as `parseEncryptionKeys` — including canonical
248
+ * base64url — so the CLI can't print a kid for a key the runtime would
249
+ * later refuse to load.
250
+ *
251
+ * Throws `EmDashSecretsError` for malformed or non-canonical input.
252
+ */
253
+ export async function fingerprintKey(raw: string): Promise<string> {
254
+ if (!ENCRYPTION_KEY_PATTERN.test(raw)) {
255
+ throw new EmDashSecretsError(
256
+ `Key must match "${ENCRYPTION_KEY_PREFIX}" followed by ${ENCRYPTION_KEY_BODY_LENGTH} base64url chars`,
257
+ "INVALID_ENCRYPTION_KEY",
258
+ );
259
+ }
260
+ const body = raw.slice(ENCRYPTION_KEY_PREFIX.length);
261
+ const bytes = decodeBase64urlStrict(body);
262
+ if (!bytes || bytes.length !== GENERATED_SECRET_BYTES || encodeBase64url(bytes) !== body) {
263
+ throw new EmDashSecretsError(
264
+ `Key body must decode to ${GENERATED_SECRET_BYTES} canonical base64url bytes`,
265
+ "INVALID_ENCRYPTION_KEY",
266
+ );
267
+ }
268
+ return fingerprintKeyBytes(bytes);
269
+ }
270
+
271
+ /**
272
+ * Internal: kid derivation from raw key bytes. The single source of truth
273
+ * for what makes two keys "the same key" — used by both `parseEncryptionKeys`
274
+ * and `fingerprintKey`.
275
+ */
276
+ function fingerprintKeyBytes(key: Uint8Array): string {
277
+ return encodeHexLowerCase(sha256(key)).slice(0, 8);
278
+ }
279
+
280
+ /**
281
+ * Generate a fresh `EMDASH_ENCRYPTION_KEY` value. Used by the CLI's
282
+ * `secrets generate` subcommand and by `create-emdash` scaffolding.
283
+ */
284
+ export function generateEncryptionKey(): string {
285
+ const bytes = new Uint8Array(GENERATED_SECRET_BYTES);
286
+ crypto.getRandomValues(bytes);
287
+ return `${ENCRYPTION_KEY_PREFIX}${encodeBase64url(bytes)}`;
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Site-secret resolution (DB-backed with env override)
292
+ // ---------------------------------------------------------------------------
293
+
294
+ /**
295
+ * Resolve site secrets. Reads env vars; for IP salt and preview secret,
296
+ * falls back to a DB-stored value, generating one atomically on first need.
297
+ *
298
+ * Idempotent. Concurrent callers race on the atomic `setIfAbsent`; whichever
299
+ * wins, all callers converge on the same stored value.
300
+ *
301
+ * Note: `EMDASH_ENCRYPTION_KEY` is **not** consumed here. It's validated
302
+ * separately at runtime startup (see `validateEncryptionKeyAtStartup`) so a
303
+ * malformed key can't take down preview-token verification or comment
304
+ * submission for unrelated visitors. Future plugin-secret encryption code
305
+ * will read it via its own dedicated helper.
306
+ */
307
+ export async function resolveSecrets(options: ResolveSecretsOptions): Promise<ResolvedSecrets> {
308
+ const env = options.env ?? readDefaultEnv();
309
+ const repo = options._repo ?? new OptionsRepository(options.db);
310
+
311
+ const previewEnvOverride = pickFirstNonEmpty(env.EMDASH_PREVIEW_SECRET, env.PREVIEW_SECRET);
312
+ const ipSaltEnvOverride = pickFirstNonEmpty(
313
+ env.EMDASH_IP_SALT,
314
+ env.EMDASH_AUTH_SECRET,
315
+ env.AUTH_SECRET,
316
+ );
317
+
318
+ const [previewSecret, ipSalt] = await Promise.all([
319
+ previewEnvOverride !== null
320
+ ? Promise.resolve({ value: previewEnvOverride, source: "env" as const })
321
+ : ensureGeneratedOption(repo, PREVIEW_SECRET_OPTION_KEY),
322
+ ipSaltEnvOverride !== null
323
+ ? Promise.resolve({ value: ipSaltEnvOverride, source: "env" as const })
324
+ : ensureGeneratedOption(repo, IP_SALT_OPTION_KEY),
325
+ ]);
326
+
327
+ return {
328
+ previewSecret: previewSecret.value,
329
+ previewSecretSource: previewSecret.source,
330
+ ipSalt: ipSalt.value,
331
+ ipSaltSource: ipSalt.source,
332
+ };
333
+ }
334
+
335
+ /**
336
+ * Validate `EMDASH_ENCRYPTION_KEY` once at runtime startup. Logs an
337
+ * operator-facing error if the value is malformed but does **not** throw —
338
+ * the key is currently inert (no consumers), and the follow-up PR that
339
+ * actually uses it will throw at point of use. This way, deployment
340
+ * mistakes surface immediately in startup logs without wedging unrelated
341
+ * request paths in the meantime.
342
+ *
343
+ * Returns `true` if the key is unset or valid, `false` if it was malformed.
344
+ */
345
+ export async function validateEncryptionKeyAtStartup(env?: SecretsEnv): Promise<boolean> {
346
+ const resolved = env ?? readDefaultEnv();
347
+ try {
348
+ await parseEncryptionKeys(resolved.EMDASH_ENCRYPTION_KEY);
349
+ return true;
350
+ } catch (error) {
351
+ if (error instanceof EmDashSecretsError) {
352
+ console.error(
353
+ `[emdash] EMDASH_ENCRYPTION_KEY is invalid: ${error.message} ` +
354
+ "Plugin-secret encryption will fail once it ships. " +
355
+ "Generate a fresh key with `emdash secrets generate`.",
356
+ );
357
+ return false;
358
+ }
359
+ throw error;
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Per-DB cache of resolved secrets, keyed by Kysely instance identity.
365
+ *
366
+ * The resolved values are stable for the lifetime of the deployment (env
367
+ * vars don't change without a restart, and DB-stored values are written
368
+ * once via `setIfAbsent`). Caching avoids one options-table read per
369
+ * request on the hot paths (preview verification, comment hashing).
370
+ *
371
+ * Lives on `globalThis` so module-duplication during SSR bundling can't
372
+ * fragment the cache. See `request-context.ts` for the same pattern.
373
+ */
374
+ // Versioned to prevent cache fragmentation if `ResolvedSecrets`'s shape
375
+ // ever changes. Bump the suffix on incompatible changes so a co-resident
376
+ // older build doesn't read a newer-shape value.
377
+ const SECRETS_CACHE_KEY = Symbol.for("@emdash-cms/core/secrets-cache@1");
378
+
379
+ interface SecretsCacheHolder {
380
+ cache: WeakMap<Kysely<Database>, Promise<ResolvedSecrets>>;
381
+ }
382
+
383
+ function getSecretsCache(): WeakMap<Kysely<Database>, Promise<ResolvedSecrets>> {
384
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- globalThis singleton pattern
385
+ const holder = globalThis as Record<symbol, SecretsCacheHolder | undefined>;
386
+ let entry = holder[SECRETS_CACHE_KEY];
387
+ if (!entry) {
388
+ entry = { cache: new WeakMap() };
389
+ holder[SECRETS_CACHE_KEY] = entry;
390
+ }
391
+ return entry.cache;
392
+ }
393
+
394
+ /**
395
+ * Memoized wrapper around `resolveSecrets`. Use this from request-time hot
396
+ * paths (preview verification, comment IP hashing) so they don't reread
397
+ * env / re-query options on every request.
398
+ *
399
+ * The cache is keyed by `Kysely` instance, so playground / per-DO / per-test
400
+ * databases each get their own resolution.
401
+ */
402
+ export function resolveSecretsCached(db: Kysely<Database>): Promise<ResolvedSecrets> {
403
+ const cache = getSecretsCache();
404
+ const cached = cache.get(db);
405
+ if (cached) return cached;
406
+ const promise = resolveSecrets({ db }).catch((error) => {
407
+ // Don't poison the cache on transient failure; next caller retries.
408
+ cache.delete(db);
409
+ throw error;
410
+ });
411
+ cache.set(db, promise);
412
+ return promise;
413
+ }
414
+
415
+ /**
416
+ * Test-only helper: clear the secrets cache. Tests that mutate env between
417
+ * cases need this so a stale resolution doesn't leak across cases.
418
+ *
419
+ * @internal
420
+ */
421
+ export function _clearSecretsCacheForTesting(): void {
422
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- globalThis singleton pattern
423
+ const holder = globalThis as Record<symbol, SecretsCacheHolder | undefined>;
424
+ holder[SECRETS_CACHE_KEY] = undefined;
425
+ }
426
+
427
+ // ---------------------------------------------------------------------------
428
+ // Internals
429
+ // ---------------------------------------------------------------------------
430
+
431
+ /**
432
+ * Read or generate-and-persist a random base64url secret stored in the
433
+ * options table.
434
+ *
435
+ * Concurrency: `setIfAbsent` is an atomic INSERT...ON CONFLICT DO NOTHING.
436
+ * On race, the loser re-reads to converge on the winner's value.
437
+ */
438
+ async function ensureGeneratedOption(
439
+ repo: OptionsRepository,
440
+ optionKey: string,
441
+ ): Promise<{ value: string; source: "db" }> {
442
+ const existing = await repo.get<string>(optionKey);
443
+ if (typeof existing === "string" && existing.length > 0) {
444
+ return { value: existing, source: "db" };
445
+ }
446
+
447
+ const generated = generateRandomSecret();
448
+ const inserted = await repo.setIfAbsent(optionKey, generated);
449
+ if (inserted) {
450
+ return { value: generated, source: "db" };
451
+ }
452
+
453
+ // Lost the race — another process inserted first. Re-read to pick up
454
+ // the winner. If the row is somehow still missing or empty, treat that
455
+ // as a real error rather than looping.
456
+ const winner = await repo.get<string>(optionKey);
457
+ if (typeof winner !== "string" || winner.length === 0) {
458
+ throw new EmDashSecretsError(
459
+ `Failed to persist generated secret for "${optionKey}"`,
460
+ "SECRET_PERSIST_FAILED",
461
+ );
462
+ }
463
+ return { value: winner, source: "db" };
464
+ }
465
+
466
+ /** Generate 32 random bytes encoded as unpadded base64url. */
467
+ function generateRandomSecret(): string {
468
+ const bytes = new Uint8Array(GENERATED_SECRET_BYTES);
469
+ crypto.getRandomValues(bytes);
470
+ return encodeBase64url(bytes);
471
+ }
472
+
473
+ /** Return the first non-empty string from `values`, or `null` if all are empty. */
474
+ function pickFirstNonEmpty(...values: (string | undefined)[]): string | null {
475
+ for (const value of values) {
476
+ if (typeof value === "string" && value.length > 0) {
477
+ return value;
478
+ }
479
+ }
480
+ return null;
481
+ }
482
+
483
+ const BASE64URL_CHARSET_PATTERN = /^[A-Za-z0-9_-]+$/;
484
+
485
+ /**
486
+ * Validate base64url shape and decode. Returns `null` on malformed input
487
+ * (rather than throwing) so the caller can produce a config-specific error.
488
+ */
489
+ function decodeBase64urlStrict(input: string): Uint8Array | null {
490
+ // `decodeBase64url` accepts padded input too; the env-var format is
491
+ // strictly unpadded base64url, so we do a charset check first.
492
+ if (!BASE64URL_CHARSET_PATTERN.test(input)) return null;
493
+ try {
494
+ return decodeBase64url(input);
495
+ } catch {
496
+ return null;
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Default env reader.
502
+ *
503
+ * Note: this is the **only** code path in core that reads both
504
+ * `import.meta.env` and `process.env`. Route handlers should not — they
505
+ * always run inside the Astro/Vite bundle where `import.meta.env` is
506
+ * the correct source. This resolver is shared with the CLI surface (via
507
+ * `cli/commands/secrets.ts`) which runs outside the bundle, so we
508
+ * deliberately consult both. `import.meta.env` wins so build-time
509
+ * substitutions are honored when present.
510
+ *
511
+ * The convention documented in AGENTS.md ("import.meta.env.EMDASH_X ||
512
+ * import.meta.env.X") is the route-handler convention; this is the
513
+ * shared-with-CLI exception.
514
+ */
515
+ function readDefaultEnv(): SecretsEnv {
516
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- import.meta.env is loose by design
517
+ const meta = (import.meta.env ?? {}) as Record<string, string | undefined>;
518
+ const proc = typeof process !== "undefined" && process.env ? process.env : {};
519
+
520
+ return {
521
+ EMDASH_ENCRYPTION_KEY: meta.EMDASH_ENCRYPTION_KEY ?? proc.EMDASH_ENCRYPTION_KEY,
522
+ EMDASH_PREVIEW_SECRET: meta.EMDASH_PREVIEW_SECRET ?? proc.EMDASH_PREVIEW_SECRET,
523
+ PREVIEW_SECRET: meta.PREVIEW_SECRET ?? proc.PREVIEW_SECRET,
524
+ EMDASH_IP_SALT: meta.EMDASH_IP_SALT ?? proc.EMDASH_IP_SALT,
525
+ EMDASH_AUTH_SECRET: meta.EMDASH_AUTH_SECRET ?? proc.EMDASH_AUTH_SECRET,
526
+ AUTH_SECRET: meta.AUTH_SECRET ?? proc.AUTH_SECRET,
527
+ };
528
+ }
@@ -90,6 +90,56 @@ export async function tableExists(db: Kysely<any>, tableName: string): Promise<b
90
90
  return result.rows.length > 0;
91
91
  }
92
92
 
93
+ /**
94
+ * Check if an index exists in the database.
95
+ */
96
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance
97
+ export async function indexExists(db: Kysely<any>, indexName: string): Promise<boolean> {
98
+ if (isPostgres(db)) {
99
+ const result = await sql<{ exists: boolean }>`
100
+ SELECT EXISTS(
101
+ SELECT 1 FROM pg_indexes
102
+ WHERE schemaname = current_schema() AND indexname = ${indexName}
103
+ ) as exists
104
+ `.execute(db);
105
+ return result.rows[0]?.exists === true;
106
+ }
107
+
108
+ const result = await sql<{ name: string }>`
109
+ SELECT name FROM sqlite_master
110
+ WHERE type = 'index' AND name = ${indexName}
111
+ `.execute(db);
112
+ return result.rows.length > 0;
113
+ }
114
+
115
+ /**
116
+ * Check if a column exists in the database.
117
+ */
118
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance
119
+ export async function columnExists(
120
+ db: Kysely<any>,
121
+ tableName: string,
122
+ columnName: string,
123
+ ): Promise<boolean> {
124
+ if (isPostgres(db)) {
125
+ const result = await sql<{ exists: boolean }>`
126
+ SELECT EXISTS(
127
+ SELECT 1 FROM information_schema.columns
128
+ WHERE table_schema = current_schema()
129
+ AND table_name = ${tableName}
130
+ AND column_name = ${columnName}
131
+ ) as exists
132
+ `.execute(db);
133
+ return result.rows[0]?.exists === true;
134
+ }
135
+
136
+ const result = await sql<{ name: string }>`
137
+ SELECT name FROM pragma_table_info(${tableName})
138
+ WHERE name = ${columnName}
139
+ `.execute(db);
140
+ return result.rows.length > 0;
141
+ }
142
+
93
143
  /**
94
144
  * List tables matching a LIKE pattern.
95
145
  */
@@ -10,7 +10,7 @@ export async function up(db: Kysely<unknown>): Promise<void> {
10
10
  const table = { name: tableName };
11
11
 
12
12
  await sql`
13
- CREATE INDEX ${sql.ref(`idx_${table.name}_deleted_published_id`)}
13
+ CREATE INDEX IF NOT EXISTS ${sql.ref(`idx_${table.name}_deleted_published_id`)}
14
14
  ON ${sql.ref(table.name)} (deleted_at, published_at DESC, id DESC)
15
15
  `.execute(db);
16
16
  }