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
package/src/auth/types.ts CHANGED
@@ -2,7 +2,13 @@
2
2
  * Auth Provider Types
3
3
  *
4
4
  * Defines the interfaces for pluggable authentication providers.
5
- * Providers like Cloudflare Access implement these interfaces.
5
+ *
6
+ * Two systems coexist:
7
+ * - `AuthDescriptor` — transparent auth (Cloudflare Access) that authenticates
8
+ * every request via headers/cookies. No login UI needed.
9
+ * - `AuthProviderDescriptor` — pluggable login methods (GitHub, Google,
10
+ * AT Protocol, etc.) that appear as options on the login page and setup
11
+ * wizard. Passkey is built-in; providers are additive.
6
12
  */
7
13
 
8
14
  /**
@@ -22,10 +28,10 @@ export interface AuthResult {
22
28
  }
23
29
 
24
30
  /**
25
- * Auth descriptor - returned by auth adapter functions (e.g., access())
31
+ * Auth descriptor transparent auth providers (e.g., Cloudflare Access).
26
32
  *
27
- * Similar to DatabaseDescriptor and StorageDescriptor, this allows
28
- * auth providers to be configured at build time and loaded at runtime.
33
+ * These authenticate every request via headers/cookies. No login UI needed.
34
+ * The module's `authenticate()` function is called by middleware on each request.
29
35
  */
30
36
  export interface AuthDescriptor {
31
37
  /**
@@ -64,6 +70,110 @@ export interface AuthProviderModule {
64
70
  authenticate(request: Request, config: unknown): Promise<AuthResult>;
65
71
  }
66
72
 
73
+ // ---------------------------------------------------------------------------
74
+ // Pluggable Auth Providers (additive login methods)
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * Descriptor for a pluggable auth provider.
79
+ *
80
+ * Auth providers appear as login options on the login page and setup wizard.
81
+ * They coexist with passkey (which is built-in) and with each other.
82
+ * Any provider can be used to create the initial admin account.
83
+ *
84
+ * @example
85
+ * ```ts
86
+ * // astro.config.ts
87
+ * import { atproto } from "@emdash-cms/auth-atproto";
88
+ *
89
+ * emdash({
90
+ * authProviders: [atproto(), github(), google()],
91
+ * })
92
+ * ```
93
+ */
94
+ export interface AuthProviderDescriptor {
95
+ /** Unique provider ID (e.g., "github", "atproto") */
96
+ id: string;
97
+
98
+ /** Human-readable label for UI (e.g., "GitHub", "AT Protocol") */
99
+ label: string;
100
+
101
+ /** Provider-specific config (JSON-serializable) */
102
+ config?: unknown;
103
+
104
+ /**
105
+ * Module exporting React components for the admin UI.
106
+ * Statically imported at build time via virtual module.
107
+ *
108
+ * The module should export components matching `AuthProviderAdminExports`.
109
+ */
110
+ adminEntry?: string;
111
+
112
+ /**
113
+ * Astro route handlers this provider needs injected at build time.
114
+ * Used for login initiation, OAuth callbacks, well-known endpoints, etc.
115
+ */
116
+ routes?: AuthRouteDescriptor[];
117
+
118
+ /**
119
+ * URL prefixes/paths that should bypass auth middleware.
120
+ * Added to the public routes set so login/callback endpoints work
121
+ * for unauthenticated users.
122
+ */
123
+ publicRoutes?: string[];
124
+
125
+ /**
126
+ * Storage collections for persistent auth state (e.g., OAuth sessions).
127
+ * Same format as plugin storage — collections are stored in the shared
128
+ * `_plugin_storage` table namespaced under `auth:<providerId>`.
129
+ *
130
+ * Access via `getAuthProviderStorage()` from `emdash/api/route-utils`.
131
+ */
132
+ storage?: Record<
133
+ string,
134
+ { indexes?: Array<string | string[]>; uniqueIndexes?: Array<string | string[]> }
135
+ >;
136
+ }
137
+
138
+ /**
139
+ * A route that an auth provider needs injected into the Astro app.
140
+ */
141
+ export interface AuthRouteDescriptor {
142
+ /** URL pattern (e.g., "/_emdash/api/auth/atproto/login") */
143
+ pattern: string;
144
+ /** Module specifier for the Astro route handler */
145
+ entrypoint: string;
146
+ }
147
+
148
+ /**
149
+ * Expected exports from an auth provider's `adminEntry` module.
150
+ *
151
+ * All exports are optional. Providers export whichever components
152
+ * make sense for their auth flow.
153
+ */
154
+ export interface AuthProviderAdminExports {
155
+ /**
156
+ * Compact button for the login page (icon + label).
157
+ * Used for providers with a simple redirect flow (GitHub, Google).
158
+ * Rendered in the "Or continue with" section.
159
+ */
160
+ LoginButton?: import("react").ComponentType;
161
+
162
+ /**
163
+ * Full login form for providers that need custom input.
164
+ * Used for providers like AT Protocol that need a handle field.
165
+ * Rendered as an expandable section on the login page.
166
+ */
167
+ LoginForm?: import("react").ComponentType;
168
+
169
+ /**
170
+ * Setup wizard step for creating the admin account via this provider.
171
+ * When present, this provider appears as an option in the setup wizard's
172
+ * "Create admin account" step.
173
+ */
174
+ SetupStep?: import("react").ComponentType<{ onComplete: () => void }>;
175
+ }
176
+
67
177
  /**
68
178
  * Configuration options common to external auth providers
69
179
  */
@@ -38,7 +38,7 @@ import {
38
38
  ICON_SIZE,
39
39
  } from "./bundle-utils.js";
40
40
 
41
- const TS_EXT_RE = /\.tsx?$/;
41
+ const TS_EXT_RE = /\.(tsx?|[mc]?js)$/;
42
42
  const SLASH_RE = /\//g;
43
43
  const LEADING_AT_RE = /^@/;
44
44
  const emdash_SCOPE_RE = /^@emdash-cms\//;
@@ -163,6 +163,8 @@ export const bundleCommand = defineCommand({
163
163
  const tmpDir = join(pluginDir, ".emdash-bundle-tmp");
164
164
 
165
165
  try {
166
+ // Clean up any stale temp directory from a previous failed run
167
+ await rm(tmpDir, { recursive: true, force: true });
166
168
  await mkdir(tmpDir, { recursive: true });
167
169
 
168
170
  // Build main entry to extract manifest.
@@ -20,6 +20,7 @@ import type { MediaValue } from "../fields/types.js";
20
20
  import type { HTMLAttributes } from "astro/types";
21
21
  import type { ImageEmbed } from "../media/types.js";
22
22
  import { getMediaProvider } from "../media/provider-loader.js";
23
+ import { buildRenderMediaUrl } from "../media/url.js";
23
24
  // Standard responsive breakpoints
24
25
  const BREAKPOINTS = [640, 750, 828, 960, 1080, 1280, 1600, 1920];
25
26
 
@@ -53,14 +54,14 @@ function normalizeImage(
53
54
  }
54
55
 
55
56
  /**
56
- * Build the URL for a local image
57
+ * Build the URL for a local image. Prefers `meta.storageKey`; falls back to
58
+ * the internal proxy with `img.id` when no storage key is available.
57
59
  */
58
60
  function buildLocalImageUrl(img: MediaValue): string {
59
- const storageKey = (img.meta?.storageKey as string) || img.id;
60
- if (storageKey) {
61
- return `/_emdash/api/media/file/${storageKey}`;
62
- }
63
- return "";
61
+ return buildRenderMediaUrl(Astro.locals.emdash?.getPublicMediaUrl, {
62
+ storageKey: img.meta?.storageKey as string | undefined,
63
+ id: img.id,
64
+ });
64
65
  }
65
66
 
66
67
  /**
@@ -6,6 +6,7 @@
6
6
  * Uses Astro's Image component for optimization when dimensions are available.
7
7
  */
8
8
  import { Image as AstroImage } from "astro:assets";
9
+ import { buildRenderMediaUrl } from "../media/url.js";
9
10
 
10
11
  export interface Props {
11
12
  node: {
@@ -39,9 +40,10 @@ if (!images.length) {
39
40
  <div class="emdash-gallery" style={`--columns: ${columns}`}>
40
41
  {
41
42
  images.map((image) => {
42
- const src =
43
- image.asset.url ||
44
- `/_emdash/api/media/file/${image.asset._ref}`;
43
+ const src = buildRenderMediaUrl(Astro.locals.emdash?.getPublicMediaUrl, {
44
+ url: image.asset.url,
45
+ id: image.asset._ref,
46
+ });
45
47
  const hasSize = image.width && image.height;
46
48
  return (
47
49
  <figure class="emdash-gallery-item">
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { ImageEmbed } from "../media/types.js";
9
9
  import { getMediaProvider } from "../media/provider-loader.js";
10
+ import { buildRenderMediaUrl } from "../media/url.js";
10
11
  // Standard responsive breakpoints
11
12
  const BREAKPOINTS = [640, 750, 828, 960, 1080, 1280, 1600, 1920];
12
13
 
@@ -122,10 +123,14 @@ if (providerId && providerId !== "local") {
122
123
  }
123
124
  }
124
125
 
125
- // Fallback for local provider prefer stored URL (includes storage key with extension),
126
- // fall back to _ref (bare ULID, works if media file endpoint supports ID lookup)
126
+ // Fallback for local provider. `asset.url` carries the storage key with
127
+ // extension when present; `asset._ref` is a bare ULID that only the internal
128
+ // `/file/{id}` route can resolve. `buildRenderMediaUrl` picks the right shape.
127
129
  if (!src) {
128
- src = asset.url || `/_emdash/api/media/file/${asset._ref}`;
130
+ src = buildRenderMediaUrl(Astro.locals.emdash?.getPublicMediaUrl, {
131
+ url: asset.url,
132
+ id: asset._ref,
133
+ });
129
134
  }
130
135
 
131
136
  // Build placeholder background style
@@ -1741,6 +1741,7 @@ export function InlinePortableTextEditor({
1741
1741
  editorProps: {
1742
1742
  attributes: {
1743
1743
  class: "prose prose-sm sm:prose-base dark:prose-invert max-w-none emdash-inline-editor",
1744
+ dir: "auto",
1744
1745
  },
1745
1746
  },
1746
1747
  onUpdate: () => {
@@ -1795,7 +1796,7 @@ export function InlinePortableTextEditor({
1795
1796
  // Don't save if focus moved to the slash menu (portalled to body)
1796
1797
  if (related?.closest(".emdash-slash-menu")) return;
1797
1798
  if (related?.closest(".emdash-media-picker")) return;
1798
- save();
1799
+ void save();
1799
1800
  },
1800
1801
  [save, mediaPickerOpen],
1801
1802
  );
@@ -109,13 +109,6 @@ const config = {
109
109
  </emdash-live-search>
110
110
 
111
111
  <script>
112
- // Sanitization patterns for search snippets (allow only <mark> tags from FTS5)
113
- const SNIPPET_AMP_RE = /&/g;
114
- const SNIPPET_LT_RE = /</g;
115
- const SNIPPET_GT_RE = />/g;
116
- const SNIPPET_MARK_OPEN_RE = /&lt;mark&gt;/g;
117
- const SNIPPET_MARK_CLOSE_RE = /&lt;\/mark&gt;/g;
118
-
119
112
  interface SearchResult {
120
113
  collection: string;
121
114
  id: string;
@@ -396,17 +389,15 @@ const config = {
396
389
  collectionEl.textContent = result.collection;
397
390
  }
398
391
 
399
- // Fill in snippet (sanitize to allow only <mark> tags from FTS5 highlighting)
392
+ // Snippets returned by /api/search are already sanitised
393
+ // server-side by sanitizeSnippet() — they contain only
394
+ // HTML-escaped text plus literal <mark>...</mark> tags
395
+ // around matched terms.
400
396
  const snippetEl = link.querySelector(
401
397
  ".emdash-live-search-result-snippet"
402
398
  );
403
399
  if (snippetEl && this.config.showSnippets && result.snippet) {
404
- snippetEl.innerHTML = result.snippet
405
- .replace(SNIPPET_AMP_RE, "&amp;")
406
- .replace(SNIPPET_LT_RE, "&lt;")
407
- .replace(SNIPPET_GT_RE, "&gt;")
408
- .replace(SNIPPET_MARK_OPEN_RE, "<mark>")
409
- .replace(SNIPPET_MARK_CLOSE_RE, "</mark>");
400
+ snippetEl.innerHTML = result.snippet;
410
401
  } else if (snippetEl) {
411
402
  snippetEl.remove();
412
403
  }
@@ -0,0 +1,112 @@
1
+ import type { Kysely } from "kysely";
2
+ import { sql } from "kysely";
3
+
4
+ /**
5
+ * Migration: Bounded 404 logging
6
+ *
7
+ * Hardens `_emdash_404_log` against unauthenticated DoS. Previously every 404
8
+ * inserted a new row, so an attacker could grow the table without bound.
9
+ *
10
+ * Changes:
11
+ * - Adds `hits` (default 1, NOT NULL)
12
+ * - Adds `last_seen_at` (nullable; SQLite can't add NOT NULL with a
13
+ * non-constant default to a populated table, so the column is nullable
14
+ * at the schema level and backfilled from `created_at` for existing rows;
15
+ * new inserts via `log404` always set it)
16
+ * - Deduplicates existing rows by path, keeping the most recent row per
17
+ * path and summing hits
18
+ * - Adds a UNIQUE index on `path` so upsert semantics work
19
+ */
20
+
21
+ export async function up(db: Kysely<unknown>): Promise<void> {
22
+ // 1. Add columns.
23
+ await db.schema
24
+ .alterTable("_emdash_404_log")
25
+ .addColumn("hits", "integer", (col) => col.notNull().defaultTo(1))
26
+ .execute();
27
+
28
+ // SQLite won't accept a non-constant default when adding a NOT NULL column
29
+ // to a table with existing rows, so backfill in two steps: add nullable,
30
+ // populate, then rely on the application layer / future inserts to set it.
31
+ await db.schema.alterTable("_emdash_404_log").addColumn("last_seen_at", "text").execute();
32
+
33
+ // Backfill last_seen_at from created_at for existing rows.
34
+ await sql`
35
+ UPDATE _emdash_404_log
36
+ SET last_seen_at = created_at
37
+ WHERE last_seen_at IS NULL
38
+ `.execute(db);
39
+
40
+ // 2. Deduplicate existing rows by path.
41
+ // For each path, roll up hits and pick the freshest last_seen_at onto
42
+ // a single keeper row, then delete the non-keepers. Uses window
43
+ // functions (ROW_NUMBER) so the dedup SQL is valid on both SQLite
44
+ // (3.25+, 2018) and Postgres. The previous GROUP BY approach was
45
+ // accepted by SQLite but invalid on Postgres because `id` wasn't in
46
+ // the GROUP BY or wrapped in an aggregate.
47
+ await sql`
48
+ WITH ranked AS (
49
+ SELECT
50
+ id,
51
+ path,
52
+ ROW_NUMBER() OVER (
53
+ PARTITION BY path
54
+ ORDER BY created_at DESC, id DESC
55
+ ) AS rn,
56
+ COUNT(*) OVER (PARTITION BY path) AS path_count,
57
+ MAX(created_at) OVER (PARTITION BY path) AS latest_created_at
58
+ FROM _emdash_404_log
59
+ )
60
+ UPDATE _emdash_404_log
61
+ SET
62
+ hits = (SELECT path_count FROM ranked WHERE ranked.id = _emdash_404_log.id),
63
+ last_seen_at = (SELECT latest_created_at FROM ranked WHERE ranked.id = _emdash_404_log.id)
64
+ WHERE id IN (SELECT id FROM ranked WHERE rn = 1)
65
+ `.execute(db);
66
+
67
+ // Delete the non-keepers (every row except the freshest per path).
68
+ await sql`
69
+ DELETE FROM _emdash_404_log
70
+ WHERE id IN (
71
+ SELECT id FROM (
72
+ SELECT
73
+ id,
74
+ ROW_NUMBER() OVER (
75
+ PARTITION BY path
76
+ ORDER BY created_at DESC, id DESC
77
+ ) AS rn
78
+ FROM _emdash_404_log
79
+ ) AS ranked
80
+ WHERE rn > 1
81
+ )
82
+ `.execute(db);
83
+
84
+ // 3. Add unique index on path for upsert semantics.
85
+ await db.schema
86
+ .createIndex("idx_404_log_path_unique")
87
+ .on("_emdash_404_log")
88
+ .column("path")
89
+ .unique()
90
+ .execute();
91
+
92
+ // Drop the old non-unique index; the unique one covers the same lookups.
93
+ await db.schema.dropIndex("idx_404_log_path").execute();
94
+
95
+ // 4. Index on last_seen_at for eviction ordering.
96
+ await db.schema
97
+ .createIndex("idx_404_log_last_seen")
98
+ .on("_emdash_404_log")
99
+ .column("last_seen_at")
100
+ .execute();
101
+ }
102
+
103
+ export async function down(db: Kysely<unknown>): Promise<void> {
104
+ await db.schema.dropIndex("idx_404_log_last_seen").execute();
105
+ await db.schema.dropIndex("idx_404_log_path_unique").execute();
106
+
107
+ // Restore the original non-unique path index.
108
+ await db.schema.createIndex("idx_404_log_path").on("_emdash_404_log").column("path").execute();
109
+
110
+ await db.schema.alterTable("_emdash_404_log").dropColumn("last_seen_at").execute();
111
+ await db.schema.alterTable("_emdash_404_log").dropColumn("hits").execute();
112
+ }
@@ -35,6 +35,7 @@ import * as m031 from "./031_bylines.js";
35
35
  import * as m032 from "./032_rate_limits.js";
36
36
  import * as m033 from "./033_optimize_content_indexes.js";
37
37
  import * as m034 from "./034_published_at_index.js";
38
+ import * as m035 from "./035_bounded_404_log.js";
38
39
 
39
40
  const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
40
41
  "001_initial": m001,
@@ -70,6 +71,7 @@ const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
70
71
  "032_rate_limits": m032,
71
72
  "033_optimize_content_indexes": m033,
72
73
  "034_published_at_index": m034,
74
+ "035_bounded_404_log": m035,
73
75
  });
74
76
 
75
77
  /** Total number of registered migrations. Exported for use in tests. */
@@ -143,14 +143,12 @@ export class AuditRepository {
143
143
 
144
144
  if (query.cursor) {
145
145
  const decoded = decodeCursor(query.cursor);
146
- if (decoded) {
147
- q = q.where((eb) =>
148
- eb.or([
149
- eb("timestamp", "<", decoded.orderValue),
150
- eb.and([eb("timestamp", "=", decoded.orderValue), eb("id", "<", decoded.id)]),
151
- ]),
152
- );
153
- }
146
+ q = q.where((eb) =>
147
+ eb.or([
148
+ eb("timestamp", "<", decoded.orderValue),
149
+ eb.and([eb("timestamp", "=", decoded.orderValue), eb("id", "<", decoded.id)]),
150
+ ]),
151
+ );
154
152
  }
155
153
 
156
154
  const rows = await q.execute();
@@ -123,14 +123,12 @@ export class BylineRepository {
123
123
 
124
124
  if (options?.cursor) {
125
125
  const decoded = decodeCursor(options.cursor);
126
- if (decoded) {
127
- query = query.where((eb) =>
128
- eb.or([
129
- eb("created_at", "<", decoded.orderValue),
130
- eb.and([eb("created_at", "=", decoded.orderValue), eb("id", "<", decoded.id)]),
131
- ]),
132
- );
133
- }
126
+ query = query.where((eb) =>
127
+ eb.or([
128
+ eb("created_at", "<", decoded.orderValue),
129
+ eb.and([eb("created_at", "=", decoded.orderValue), eb("id", "<", decoded.id)]),
130
+ ]),
131
+ );
134
132
  }
135
133
 
136
134
  const rows = await query.execute();
@@ -143,14 +143,12 @@ export class CommentRepository {
143
143
  // Cursor pagination (ascending by created_at)
144
144
  if (options.cursor) {
145
145
  const decoded = decodeCursor(options.cursor);
146
- if (decoded) {
147
- query = query.where((eb: ExpressionBuilder<Database, "_emdash_comments">) =>
148
- eb.or([
149
- eb("created_at", ">", decoded.orderValue),
150
- eb.and([eb("created_at", "=", decoded.orderValue), eb("id", ">", decoded.id)]),
151
- ]),
152
- );
153
- }
146
+ query = query.where((eb: ExpressionBuilder<Database, "_emdash_comments">) =>
147
+ eb.or([
148
+ eb("created_at", ">", decoded.orderValue),
149
+ eb.and([eb("created_at", "=", decoded.orderValue), eb("id", ">", decoded.id)]),
150
+ ]),
151
+ );
154
152
  }
155
153
 
156
154
  query = query
@@ -202,14 +200,12 @@ export class CommentRepository {
202
200
  // Cursor pagination (descending by created_at)
203
201
  if (options.cursor) {
204
202
  const decoded = decodeCursor(options.cursor);
205
- if (decoded) {
206
- query = query.where((eb: ExpressionBuilder<Database, "_emdash_comments">) =>
207
- eb.or([
208
- eb("created_at", "<", decoded.orderValue),
209
- eb.and([eb("created_at", "=", decoded.orderValue), eb("id", "<", decoded.id)]),
210
- ]),
211
- );
212
- }
203
+ query = query.where((eb: ExpressionBuilder<Database, "_emdash_comments">) =>
204
+ eb.or([
205
+ eb("created_at", "<", decoded.orderValue),
206
+ eb.and([eb("created_at", "=", decoded.orderValue), eb("id", "<", decoded.id)]),
207
+ ]),
208
+ );
213
209
  }
214
210
 
215
211
  query = query
@@ -489,27 +489,26 @@ export class ContentRepository {
489
489
  query = query.where("locale" as any, "=", options.where.locale);
490
490
  }
491
491
 
492
- // Handle cursor pagination
492
+ // Handle cursor pagination — decodeCursor throws InvalidCursorError
493
+ // on malformed input; let it propagate so handlers surface a
494
+ // structured INVALID_CURSOR rather than silently returning page 1.
493
495
  if (options.cursor) {
494
- const decoded = decodeCursor(options.cursor);
495
- if (decoded) {
496
- const { orderValue, id: cursorId } = decoded;
497
-
498
- if (safeOrderDirection === "DESC") {
499
- query = query.where((eb) =>
500
- eb.or([
501
- eb(dbField as any, "<", orderValue),
502
- eb.and([eb(dbField as any, "=", orderValue), eb("id", "<", cursorId)]),
503
- ]),
504
- );
505
- } else {
506
- query = query.where((eb) =>
507
- eb.or([
508
- eb(dbField as any, ">", orderValue),
509
- eb.and([eb(dbField as any, "=", orderValue), eb("id", ">", cursorId)]),
510
- ]),
511
- );
512
- }
496
+ const { orderValue, id: cursorId } = decodeCursor(options.cursor);
497
+
498
+ if (safeOrderDirection === "DESC") {
499
+ query = query.where((eb) =>
500
+ eb.or([
501
+ eb(dbField as any, "<", orderValue),
502
+ eb.and([eb(dbField as any, "=", orderValue), eb("id", "<", cursorId)]),
503
+ ]),
504
+ );
505
+ } else {
506
+ query = query.where((eb) =>
507
+ eb.or([
508
+ eb(dbField as any, ">", orderValue),
509
+ eb.and([eb(dbField as any, "=", orderValue), eb("id", ">", cursorId)]),
510
+ ]),
511
+ );
513
512
  }
514
513
  }
515
514
 
@@ -671,27 +670,24 @@ export class ContentRepository {
671
670
  .selectAll()
672
671
  .where("deleted_at" as never, "is not", null);
673
672
 
674
- // Handle cursor pagination
673
+ // Handle cursor pagination — decodeCursor throws on invalid input.
675
674
  if (options.cursor) {
676
- const decoded = decodeCursor(options.cursor);
677
- if (decoded) {
678
- const { orderValue, id: cursorId } = decoded;
679
-
680
- if (safeOrderDirection === "DESC") {
681
- query = query.where((eb) =>
682
- eb.or([
683
- eb(dbField as any, "<", orderValue),
684
- eb.and([eb(dbField as any, "=", orderValue), eb("id", "<", cursorId)]),
685
- ]),
686
- );
687
- } else {
688
- query = query.where((eb) =>
689
- eb.or([
690
- eb(dbField as any, ">", orderValue),
691
- eb.and([eb(dbField as any, "=", orderValue), eb("id", ">", cursorId)]),
692
- ]),
693
- );
694
- }
675
+ const { orderValue, id: cursorId } = decodeCursor(options.cursor);
676
+
677
+ if (safeOrderDirection === "DESC") {
678
+ query = query.where((eb) =>
679
+ eb.or([
680
+ eb(dbField as any, "<", orderValue),
681
+ eb.and([eb(dbField as any, "=", orderValue), eb("id", "<", cursorId)]),
682
+ ]),
683
+ );
684
+ } else {
685
+ query = query.where((eb) =>
686
+ eb.or([
687
+ eb(dbField as any, ">", orderValue),
688
+ eb.and([eb(dbField as any, "=", orderValue), eb("id", ">", cursorId)]),
689
+ ]),
690
+ );
695
691
  }
696
692
  }
697
693
 
@@ -1018,6 +1014,7 @@ export class ContentRepository {
1018
1014
  UPDATE ${sql.ref(tableName)}
1019
1015
  SET live_revision_id = NULL,
1020
1016
  status = 'draft',
1017
+ published_at = NULL,
1021
1018
  updated_at = ${now}
1022
1019
  WHERE id = ${id}
1023
1020
  AND deleted_at IS NULL
@@ -1031,6 +1028,45 @@ export class ContentRepository {
1031
1028
  return updated;
1032
1029
  }
1033
1030
 
1031
+ /**
1032
+ * Set the draft revision pointer for a content item.
1033
+ *
1034
+ * Used by seed/import paths that stage a new revision's data before
1035
+ * promoting it to live via `publish()`.
1036
+ *
1037
+ * Validates that the content item exists and is not soft-deleted, that
1038
+ * the revision exists, and that the revision belongs to the same
1039
+ * collection and entry. Without these checks, a caller could leave the
1040
+ * content row pointing at a missing or unrelated revision.
1041
+ */
1042
+ async setDraftRevision(type: string, id: string, revisionId: string): Promise<void> {
1043
+ const tableName = getTableName(type);
1044
+ const now = new Date().toISOString();
1045
+
1046
+ const existing = await this.findById(type, id);
1047
+ if (!existing) {
1048
+ throw new EmDashValidationError("Content item not found");
1049
+ }
1050
+
1051
+ const revisionRepo = new RevisionRepository(this.db);
1052
+ const revision = await revisionRepo.findById(revisionId);
1053
+ if (!revision) {
1054
+ throw new EmDashValidationError("Revision not found");
1055
+ }
1056
+
1057
+ if (revision.collection !== type || revision.entryId !== id) {
1058
+ throw new EmDashValidationError("Revision does not belong to the specified content item");
1059
+ }
1060
+
1061
+ await sql`
1062
+ UPDATE ${sql.ref(tableName)}
1063
+ SET draft_revision_id = ${revisionId},
1064
+ updated_at = ${now}
1065
+ WHERE id = ${id}
1066
+ AND deleted_at IS NULL
1067
+ `.execute(this.db);
1068
+ }
1069
+
1034
1070
  /**
1035
1071
  * Discard pending draft changes
1036
1072
  *
@@ -1159,7 +1195,10 @@ export class ContentRepository {
1159
1195
  scheduledAt: "scheduled_at",
1160
1196
  deletedAt: "deleted_at",
1161
1197
  title: "title",
1198
+ name: "name",
1162
1199
  slug: "slug",
1200
+ status: "status",
1201
+ locale: "locale",
1163
1202
  };
1164
1203
 
1165
1204
  const mapped = mapping[field];
@@ -27,4 +27,4 @@ export { RedirectRepository } from "./redirect.js";
27
27
  export { BylineRepository } from "./byline.js";
28
28
  export type { CreateBylineInput, UpdateBylineInput, ContentBylineInput } from "./byline.js";
29
29
  export type * from "./types.js";
30
- export { EmDashValidationError, encodeCursor, decodeCursor } from "./types.js";
30
+ export { EmDashValidationError, InvalidCursorError, encodeCursor, decodeCursor } from "./types.js";