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
@@ -46,6 +46,12 @@ export function injectCoreRoutes(injectRoute: InjectRoute): void {
46
46
  entrypoint: resolveRoute("api/manifest.ts"),
47
47
  });
48
48
 
49
+ // Auth mode endpoint (public — used by the login page to pick the right UI)
50
+ injectRoute({
51
+ pattern: "/_emdash/api/auth/mode",
52
+ entrypoint: resolveRoute("api/auth/mode.ts"),
53
+ });
54
+
49
55
  injectRoute({
50
56
  pattern: "/_emdash/api/dashboard",
51
57
  entrypoint: resolveRoute("api/dashboard.ts"),
@@ -747,6 +753,28 @@ export function injectMcpRoute(injectRoute: InjectRoute): void {
747
753
  });
748
754
  }
749
755
 
756
+ /**
757
+ * Injects routes from pluggable auth providers.
758
+ *
759
+ * Each provider declares the routes it needs in its `AuthProviderDescriptor.routes` array.
760
+ * Routes are injected at build time so Vite can bundle them.
761
+ */
762
+ export function injectAuthProviderRoutes(
763
+ injectRoute: InjectRoute,
764
+ providers: Array<{ routes?: Array<{ pattern: string; entrypoint: string }> }>,
765
+ ): void {
766
+ for (const provider of providers) {
767
+ if (provider.routes) {
768
+ for (const route of provider.routes) {
769
+ injectRoute({
770
+ pattern: route.pattern,
771
+ entrypoint: route.entrypoint,
772
+ });
773
+ }
774
+ }
775
+ }
776
+ }
777
+
750
778
  /**
751
779
  * Injects passkey/oauth/magic-link auth routes.
752
780
  * Only used when NOT using external auth.
@@ -7,7 +7,7 @@
7
7
  * DO NOT import Node.js-only modules here (fs, path, module, etc.)
8
8
  */
9
9
 
10
- import type { AuthDescriptor } from "../../auth/types.js";
10
+ import type { AuthDescriptor, AuthProviderDescriptor } from "../../auth/types.js";
11
11
  import type { DatabaseDescriptor } from "../../db/adapters.js";
12
12
  import type { MediaProviderDescriptor } from "../../media/types.js";
13
13
  import type { ResolvedPlugin } from "../../plugins/types.js";
@@ -222,6 +222,24 @@ export interface EmDashConfig {
222
222
  */
223
223
  auth?: AuthDescriptor;
224
224
 
225
+ /**
226
+ * Pluggable auth providers (login methods on the login page).
227
+ *
228
+ * Auth providers appear as options alongside passkey on the login page
229
+ * and setup wizard. Any provider can be used to create the initial
230
+ * admin account. Passkey is built-in; providers listed here are additive.
231
+ *
232
+ * @example
233
+ * ```ts
234
+ * import { atproto } from "@emdash-cms/auth-atproto";
235
+ *
236
+ * emdash({
237
+ * authProviders: [atproto()],
238
+ * })
239
+ * ```
240
+ */
241
+ authProviders?: AuthProviderDescriptor[];
242
+
225
243
  /**
226
244
  * MCP (Model Context Protocol) server endpoint.
227
245
  *
@@ -284,6 +302,32 @@ export interface EmDashConfig {
284
302
  */
285
303
  siteUrl?: string;
286
304
 
305
+ /**
306
+ * Headers to trust for client IP resolution when running behind a reverse
307
+ * proxy. The first header in this list that is present on the request
308
+ * wins. Applies to rate limiting for auth endpoints and comment
309
+ * submission.
310
+ *
311
+ * Common values:
312
+ * - `x-real-ip` — nginx, Caddy, Traefik
313
+ * - `fly-client-ip` — Fly.io
314
+ * - `x-forwarded-for` — generic (first entry is used)
315
+ *
316
+ * Only set this when you **control the reverse proxy**. Untrusted
317
+ * clients can set any header they like; trusting headers from an open
318
+ * network is an IP-spoofing vulnerability that defeats rate limiting.
319
+ *
320
+ * On Cloudflare the `cf` object on the request is used automatically —
321
+ * you normally don't need to set this. Leave unset (or empty) to
322
+ * preserve the default: IP is resolved only when the request came
323
+ * through Cloudflare's edge.
324
+ *
325
+ * Falls back to `EMDASH_TRUSTED_PROXY_HEADERS` env var (comma-separated)
326
+ * when this option is not set, so operators can configure at deploy
327
+ * time without touching the Astro config.
328
+ */
329
+ trustedProxyHeaders?: string[];
330
+
287
331
  /**
288
332
  * Enable playground mode for ephemeral "try EmDash" sites.
289
333
  *
@@ -378,13 +422,41 @@ export interface EmDashConfig {
378
422
  * Additional Noto Sans script families to include.
379
423
  *
380
424
  * Available scripts: arabic, armenian, bengali, chinese-simplified,
381
- * chinese-traditional, chinese-hongkong, devanagari, ethiopic,
425
+ * chinese-traditional, chinese-hongkong, devanagari, ethiopic, farsi,
382
426
  * georgian, gujarati, gurmukhi, hebrew, japanese, kannada, khmer,
383
427
  * korean, lao, malayalam, myanmar, oriya, sinhala, tamil, telugu,
384
428
  * thai, tibetan.
385
429
  */
386
430
  scripts?: string[];
387
431
  };
432
+
433
+ /**
434
+ * Admin UI branding (white-labeling).
435
+ *
436
+ * Overrides the default EmDash logo and name in the admin panel.
437
+ * Use this to white-label the CMS for agency or enterprise deployments.
438
+ * These settings are separate from the public site settings (title, logo,
439
+ * favicon) which remain available for SEO and front-end use.
440
+ *
441
+ * @example
442
+ * ```ts
443
+ * emdash({
444
+ * admin: {
445
+ * logo: "/images/agency-logo.webp",
446
+ * siteName: "AgencyX CMS",
447
+ * favicon: "/favicon.ico",
448
+ * },
449
+ * })
450
+ * ```
451
+ */
452
+ admin?: {
453
+ /** URL or path to a custom logo image for the admin UI (login page, sidebar). */
454
+ logo?: string;
455
+ /** Custom name displayed in the admin sidebar and browser tab. */
456
+ siteName?: string;
457
+ /** URL or path to a custom favicon for the admin panel. */
458
+ favicon?: string;
459
+ };
388
460
  }
389
461
 
390
462
  /**
@@ -10,6 +10,7 @@ import { readFileSync } from "node:fs";
10
10
  import { createRequire } from "node:module";
11
11
  import { resolve } from "node:path";
12
12
 
13
+ import type { AuthProviderDescriptor } from "../../auth/types.js";
13
14
  import type { MediaProviderDescriptor } from "../../media/types.js";
14
15
  import { defaultSeed } from "../../seed/default.js";
15
16
  import type { PluginDescriptor } from "./runtime.js";
@@ -47,6 +48,9 @@ export const RESOLVED_VIRTUAL_SANDBOXED_PLUGINS_ID = "\0" + VIRTUAL_SANDBOXED_PL
47
48
  export const VIRTUAL_AUTH_ID = "virtual:emdash/auth";
48
49
  export const RESOLVED_VIRTUAL_AUTH_ID = "\0" + VIRTUAL_AUTH_ID;
49
50
 
51
+ export const VIRTUAL_AUTH_PROVIDERS_ID = "virtual:emdash/auth-providers";
52
+ export const RESOLVED_VIRTUAL_AUTH_PROVIDERS_ID = "\0" + VIRTUAL_AUTH_PROVIDERS_ID;
53
+
50
54
  export const VIRTUAL_MEDIA_PROVIDERS_ID = "virtual:emdash/media-providers";
51
55
  export const RESOLVED_VIRTUAL_MEDIA_PROVIDERS_ID = "\0" + VIRTUAL_MEDIA_PROVIDERS_ID;
52
56
 
@@ -135,6 +139,43 @@ export const authenticate = _authenticate;
135
139
  `;
136
140
  }
137
141
 
142
+ /**
143
+ * Generates the auth providers module.
144
+ *
145
+ * Statically imports each auth provider's `adminEntry` module and exports
146
+ * a registry keyed by provider ID. The admin UI uses this to render
147
+ * provider-specific login buttons/forms and setup steps.
148
+ *
149
+ * Follows the same pattern as `generateAdminRegistryModule()` for plugins.
150
+ */
151
+ export function generateAuthProvidersModule(descriptors: AuthProviderDescriptor[]): string {
152
+ const withAdmin = descriptors.filter((d) => d.adminEntry);
153
+
154
+ if (withAdmin.length === 0) {
155
+ return `export const authProviders = {};`;
156
+ }
157
+
158
+ const imports: string[] = [];
159
+ const entries: string[] = [];
160
+
161
+ withAdmin.forEach((descriptor, index) => {
162
+ const varName = `authProvider${index}`;
163
+ imports.push(`import * as ${varName} from ${JSON.stringify(descriptor.adminEntry)};`);
164
+ entries.push(
165
+ ` ${JSON.stringify(descriptor.id)}: { ...${varName}, id: ${JSON.stringify(descriptor.id)}, label: ${JSON.stringify(descriptor.label)} },`,
166
+ );
167
+ });
168
+
169
+ return `
170
+ // Auto-generated auth provider registry
171
+ ${imports.join("\n")}
172
+
173
+ export const authProviders = {
174
+ ${entries.join("\n")}
175
+ };
176
+ `;
177
+ }
178
+
138
179
  /**
139
180
  * Generates the plugins module.
140
181
  * Imports and instantiates all plugins at runtime.
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { existsSync } from "node:fs";
9
9
  import { createRequire } from "node:module";
10
- import { dirname, resolve } from "node:path";
10
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
 
13
13
  import type { AstroConfig } from "astro";
@@ -32,6 +32,8 @@ import {
32
32
  RESOLVED_VIRTUAL_SANDBOXED_PLUGINS_ID,
33
33
  VIRTUAL_AUTH_ID,
34
34
  RESOLVED_VIRTUAL_AUTH_ID,
35
+ VIRTUAL_AUTH_PROVIDERS_ID,
36
+ RESOLVED_VIRTUAL_AUTH_PROVIDERS_ID,
35
37
  VIRTUAL_MEDIA_PROVIDERS_ID,
36
38
  RESOLVED_VIRTUAL_MEDIA_PROVIDERS_ID,
37
39
  VIRTUAL_BLOCK_COMPONENTS_ID,
@@ -46,6 +48,7 @@ import {
46
48
  generateDialectModule,
47
49
  generateStorageModule,
48
50
  generateAuthModule,
51
+ generateAuthProvidersModule,
49
52
  generatePluginsModule,
50
53
  generateAdminRegistryModule,
51
54
  generateSandboxRunnerModule,
@@ -104,24 +107,34 @@ function resolveAdminDist(): string {
104
107
  return dirname(adminPath);
105
108
  }
106
109
 
110
+ /**
111
+ * Check whether child is inside parent without relying on simple prefix checks.
112
+ */
113
+ function isInside(parent: string, child: string): boolean {
114
+ const relativePath = relative(parent, child);
115
+ return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
116
+ }
117
+
107
118
  /**
108
119
  * Resolve path to the admin package source directory.
109
- * In dev mode, we alias @emdash-cms/admin to the source so Vite processes it
110
- * directly — giving instant HMR instead of requiring a rebuild + restart.
120
+ * In dev mode inside this repo, we alias @emdash-cms/admin to the source so
121
+ * Vite processes it directly — giving instant HMR instead of requiring a
122
+ * rebuild + restart. External apps should use the built package surface.
111
123
  */
112
- function resolveAdminSource(): string | undefined {
124
+ function resolveAdminSource(projectRoot: string): string | undefined {
113
125
  const require = createRequire(import.meta.url);
114
126
  const adminPath = require.resolve("@emdash-cms/admin");
115
127
  // dist/index.js -> go up to package root, then into src/
116
128
  const packageRoot = resolve(dirname(adminPath), "..");
129
+ const repoRoot = resolve(packageRoot, "..", "..");
117
130
  const srcEntry = resolve(packageRoot, "src", "index.ts");
118
131
 
119
132
  try {
120
- if (existsSync(srcEntry)) {
133
+ if (existsSync(srcEntry) && isInside(repoRoot, projectRoot)) {
121
134
  return resolve(packageRoot, "src");
122
135
  }
123
136
  } catch {
124
- // Not in monorepo — fall back to dist
137
+ // Not in local repo — fall back to dist
125
138
  }
126
139
  return undefined;
127
140
  }
@@ -170,6 +183,9 @@ export function createVirtualModulesPlugin(options: VitePluginOptions): Plugin {
170
183
  if (id === VIRTUAL_AUTH_ID) {
171
184
  return RESOLVED_VIRTUAL_AUTH_ID;
172
185
  }
186
+ if (id === VIRTUAL_AUTH_PROVIDERS_ID) {
187
+ return RESOLVED_VIRTUAL_AUTH_PROVIDERS_ID;
188
+ }
173
189
  if (id === VIRTUAL_MEDIA_PROVIDERS_ID) {
174
190
  return RESOLVED_VIRTUAL_MEDIA_PROVIDERS_ID;
175
191
  }
@@ -228,6 +244,10 @@ export function createVirtualModulesPlugin(options: VitePluginOptions): Plugin {
228
244
  }
229
245
  return generateAuthModule(authDescriptor.entrypoint);
230
246
  }
247
+ // Generate auth providers module (pluggable login methods)
248
+ if (id === RESOLVED_VIRTUAL_AUTH_PROVIDERS_ID) {
249
+ return generateAuthProvidersModule(resolvedConfig.authProviders ?? []);
250
+ }
231
251
  // Generate media providers module
232
252
  if (id === RESOLVED_VIRTUAL_MEDIA_PROVIDERS_ID) {
233
253
  return generateMediaProvidersModule(resolvedConfig.mediaProviders ?? []);
@@ -281,12 +301,9 @@ export function createViteConfig(
281
301
  const adminDistPath = resolveAdminDist();
282
302
  const cloudflare = isCloudflareAdapter(options.astroConfig);
283
303
  const isDev = command === "dev";
304
+ const projectRoot = fileURLToPath(options.astroConfig.root);
284
305
 
285
- // In dev mode within the monorepo, alias JS imports to source for instant HMR.
286
- // CSS always comes from dist/ (pre-compiled by @tailwindcss/cli) since Tailwind's
287
- // Vite plugin has native deps that don't bundle well. Run `pnpm dev` in packages/admin
288
- // alongside the demo server to get CSS watch-rebuilds too.
289
- const adminSourcePath = isDev ? resolveAdminSource() : undefined;
306
+ const adminSourcePath = isDev ? resolveAdminSource(projectRoot) : undefined;
290
307
  const useSource = adminSourcePath !== undefined;
291
308
 
292
309
  return {
@@ -308,6 +325,20 @@ export function createViteConfig(
308
325
  alias: [
309
326
  { find: "@emdash-cms/admin/styles.css", replacement: resolve(adminDistPath, "styles.css") },
310
327
  { find: "@emdash-cms/admin", replacement: useSource ? adminSourcePath : adminDistPath },
328
+ // `use-sync-external-store/shim` is a React <18 polyfill that ships
329
+ // only as CJS. It's pulled in transitively by `@tiptap/react`. With
330
+ // pnpm's virtual store the file lives under .pnpm/, where Vite's
331
+ // dep scanner can't reach it for pre-bundling — so the browser is
332
+ // served raw `module.exports` and hydration fails with
333
+ // `SyntaxError: ... does not provide an export named
334
+ // 'useSyncExternalStore'`. Redirect both shim entry points to the
335
+ // main `use-sync-external-store` package, which on React >=18
336
+ // (our peer-dep floor) delegates to React's built-in hook.
337
+ {
338
+ find: "use-sync-external-store/shim/index.js",
339
+ replacement: "use-sync-external-store",
340
+ },
341
+ { find: "use-sync-external-store/shim", replacement: "use-sync-external-store" },
311
342
  ],
312
343
  },
313
344
  // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Monorepo has both vite 6 (docs) and vite 7 (core). tsgo resolves correctly.
@@ -316,7 +347,7 @@ export function createViteConfig(
316
347
  // In dev mode with source alias, compile Lingui macros on the fly
317
348
  // and redirect locale .mjs imports to dist/.
318
349
  // In production, macros are pre-compiled by tsdown in the admin package.
319
- ...(useSource ? [linguiMacroPlugin(adminSourcePath!, adminDistPath)] : []),
350
+ ...(useSource ? [linguiMacroPlugin(adminSourcePath, adminDistPath)] : []),
320
351
  ] as NonNullable<AstroConfig["vite"]>["plugins"],
321
352
  // Handle native modules for SSR.
322
353
  // On Node: external keeps native addons out of the SSR bundle.
@@ -17,6 +17,8 @@ import { ulid } from "ulidx";
17
17
  // Import auth provider via virtual module (statically bundled)
18
18
  // This avoids dynamic import issues in Cloudflare Workers
19
19
  import { authenticate as virtualAuthenticate } from "virtual:emdash/auth";
20
+ // @ts-ignore - virtual module
21
+ import virtualConfig from "virtual:emdash/config";
20
22
 
21
23
  import { checkPublicCsrf } from "../../api/csrf.js";
22
24
  import { apiError } from "../../api/error.js";
@@ -111,6 +113,7 @@ const PUBLIC_API_PREFIXES = [
111
113
  const PUBLIC_API_EXACT = new Set([
112
114
  "/_emdash/api/auth/passkey/options",
113
115
  "/_emdash/api/auth/passkey/verify",
116
+ "/_emdash/api/auth/mode",
114
117
  "/_emdash/api/oauth/token",
115
118
  "/_emdash/api/snapshot",
116
119
  // Public site search — read-only. The query layer hardcodes status='published'
@@ -119,6 +122,22 @@ const PUBLIC_API_EXACT = new Set([
119
122
  "/_emdash/api/search",
120
123
  ]);
121
124
 
125
+ // Build merged public routes at module load from auth provider descriptors.
126
+ // Routes ending with "/" are treated as prefixes; all others are exact matches.
127
+ const { exact: _providerExactRoutes, prefixes: _providerPrefixRoutes } = (() => {
128
+ const exact = new Set<string>();
129
+ const prefixes: string[] = [];
130
+ if (!virtualConfig?.authProviders) return { exact, prefixes };
131
+ for (const route of virtualConfig.authProviders.flatMap((p) => p.publicRoutes ?? [])) {
132
+ if (route.endsWith("/")) {
133
+ prefixes.push(route);
134
+ } else {
135
+ exact.add(route);
136
+ }
137
+ }
138
+ return { exact, prefixes };
139
+ })();
140
+
122
141
  /**
123
142
  * OAuth protocol endpoints that are CSRF-exempt by design.
124
143
  *
@@ -146,6 +165,8 @@ const CSRF_EXEMPT_PUBLIC_ROUTES = new Set([
146
165
  function isPublicEmDashRoute(pathname: string): boolean {
147
166
  if (PUBLIC_API_EXACT.has(pathname)) return true;
148
167
  if (PUBLIC_API_PREFIXES.some((p) => pathname.startsWith(p))) return true;
168
+ if (_providerExactRoutes.has(pathname)) return true;
169
+ if (_providerPrefixRoutes.some((p) => pathname.startsWith(p))) return true;
149
170
  if (import.meta.env.DEV && pathname === "/_emdash/api/typegen") return true;
150
171
  return false;
151
172
  }
@@ -43,6 +43,7 @@ import {
43
43
  } from "../emdash-runtime.js";
44
44
  import { setI18nConfig } from "../i18n/config.js";
45
45
  import type { Database, Storage } from "../index.js";
46
+ import { createPublicMediaUrlResolver } from "../media/url.js";
46
47
  import type { SandboxRunner } from "../plugins/sandbox/types.js";
47
48
  import type { ResolvedPlugin } from "../plugins/types.js";
48
49
  import { getRequestContext, runWithContext } from "../request-context.js";
@@ -232,6 +233,20 @@ export const onRequest = defineMiddleware(async (context, next) => {
232
233
  const { request, locals, cookies } = context;
233
234
  const url = context.url;
234
235
 
236
+ // Fast path: routes outside /_emdash/ that plugins inject (e.g.,
237
+ // /.well-known/atproto-client-metadata.json) skip the entire runtime
238
+ // init + middleware chain. External servers fetch these with tight
239
+ // timeouts (~1-2s) so they must respond quickly even on cold starts.
240
+ if (!url.pathname.startsWith("/_emdash") && virtualConfig?.authProviders) {
241
+ const isPluginFastRoute = virtualConfig.authProviders.some(
242
+ (p: { routes?: { pattern?: string }[] }) =>
243
+ p.routes?.some((r: { pattern?: string }) => r.pattern && url.pathname === r.pattern),
244
+ );
245
+ if (isPluginFastRoute) {
246
+ return finalizeResponse(await next());
247
+ }
248
+ }
249
+
235
250
  const queryRecorder = isInstrumentationEnabled()
236
251
  ? createRecorder(url.pathname, request.method, request.headers.get("x-perf-phase") ?? "default")
237
252
  : undefined;
@@ -301,10 +316,11 @@ export const onRequest = defineMiddleware(async (context, next) => {
301
316
  try {
302
317
  const runtime = await getRuntime(config, initSubTimings);
303
318
  setupVerified = true;
304
- // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- partial object; getPageRuntime() only checks for these two methods
319
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- partial object; getPageRuntime() only checks for the page-contribution methods
305
320
  locals.emdash = {
306
321
  collectPageMetadata: runtime.collectPageMetadata.bind(runtime),
307
322
  collectPageFragments: runtime.collectPageFragments.bind(runtime),
323
+ getPublicMediaUrl: createPublicMediaUrlResolver(runtime.storage),
308
324
  } as EmDashHandlers;
309
325
  } catch {
310
326
  // Non-fatal — EmDashHead will fall back to base SEO contributions
@@ -445,6 +461,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
445
461
  // Direct access (for advanced use cases)
446
462
  storage: runtime.storage,
447
463
  db: runtime.db,
464
+ getPublicMediaUrl: createPublicMediaUrlResolver(runtime.storage),
448
465
  hooks: runtime.hooks,
449
466
  email: runtime.email,
450
467
  configuredPlugins: runtime.configuredPlugins,
@@ -10,6 +10,8 @@ import { AdminApp } from "@emdash-cms/admin";
10
10
  import type { Messages } from "@lingui/core";
11
11
  // @ts-ignore - virtual module generated by integration
12
12
  import { pluginAdmins } from "virtual:emdash/admin-registry";
13
+ // @ts-ignore - virtual module generated by integration
14
+ import { authProviders } from "virtual:emdash/auth-providers";
13
15
 
14
16
  interface AdminWrapperProps {
15
17
  locale: string;
@@ -17,5 +19,12 @@ interface AdminWrapperProps {
17
19
  }
18
20
 
19
21
  export default function AdminWrapper({ locale, messages }: AdminWrapperProps) {
20
- return <AdminApp pluginAdmins={pluginAdmins} locale={locale} messages={messages} />;
22
+ return (
23
+ <AdminApp
24
+ pluginAdmins={pluginAdmins}
25
+ authProviders={authProviders}
26
+ locale={locale}
27
+ messages={messages}
28
+ />
29
+ );
21
30
  }
@@ -18,6 +18,9 @@ import { resolveLocale, loadMessages, getLocaleDir } from "@emdash-cms/admin/loc
18
18
  const resolvedLocale = resolveLocale(Astro.request);
19
19
  const resolvedDir = getLocaleDir(resolvedLocale);
20
20
  const messages = await loadMessages(resolvedLocale);
21
+
22
+ const adminConfig = Astro.locals.emdash?.config?.admin;
23
+ const pageTitle = adminConfig?.siteName ? `${adminConfig.siteName} Admin` : "EmDash Admin";
21
24
  ---
22
25
 
23
26
  <!doctype html>
@@ -26,13 +29,17 @@ const messages = await loadMessages(resolvedLocale);
26
29
  <meta charset="UTF-8" />
27
30
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
28
31
  <Font cssVariable="--font-emdash" />
29
- <link
30
- rel="icon"
31
- href="data:image/svg+xml,<svg width='75' height='75' viewBox='0 0 75 75' fill='none' xmlns='http://www.w3.org/2000/svg'> <g clip-path='url(%23clip0_50_99)'> <rect x='3' y='3' width='69' height='69' rx='10.518' stroke='url(%23paint0_linear_50_99)' stroke-width='6'/> <rect x='18' y='34' width='39.3661' height='6.56101' fill='url(%23paint1_linear_50_99)'/> </g> <defs> <linearGradient id='paint0_linear_50_99' x1='-42.9996' y1='124' x2='92.4233' y2='-41.7456' gradientUnits='userSpaceOnUse'> <stop stop-color='%230F006B'/> <stop offset='0.0833333' stop-color='%23281A81'/> <stop offset='0.166667' stop-color='%235D0C83'/> <stop offset='0.25' stop-color='%23911475'/> <stop offset='0.333333' stop-color='%23CE2F55'/> <stop offset='0.416667' stop-color='%23FF6633'/> <stop offset='0.5' stop-color='%23F6821F'/> <stop offset='0.583333' stop-color='%23FBAD41'/> <stop offset='0.666667' stop-color='%23FFCD89'/> <stop offset='0.75' stop-color='%23FFE9CB'/> <stop offset='0.833333' stop-color='%23FFF7EC'/> <stop offset='0.916667' stop-color='%23FFF8EE'/> <stop offset='1' stop-color='white'/> </linearGradient> <linearGradient id='paint1_linear_50_99' x1='91.4992' y1='27.4982' x2='28.1217' y2='54.1775' gradientUnits='userSpaceOnUse'> <stop stop-color='white'/> <stop offset='0.129253' stop-color='%23FFF8EE'/> <stop offset='0.617058' stop-color='%23FBAD41'/> <stop offset='0.848019' stop-color='%23F6821F'/> <stop offset='1' stop-color='%23FF6633'/> </linearGradient> <clipPath id='clip0_50_99'> <rect width='75' height='75' fill='white'/> </clipPath> </defs> </svg>"
32
- />
33
- <title>EmDash Admin</title>
32
+ {adminConfig?.favicon ? (
33
+ <link rel="icon" href={adminConfig.favicon} />
34
+ ) : (
35
+ <link
36
+ rel="icon"
37
+ href="data:image/svg+xml,<svg width='75' height='75' viewBox='0 0 75 75' fill='none' xmlns='http://www.w3.org/2000/svg'> <g clip-path='url(%23clip0_50_99)'> <rect x='3' y='3' width='69' height='69' rx='10.518' stroke='url(%23paint0_linear_50_99)' stroke-width='6'/> <rect x='18' y='34' width='39.3661' height='6.56101' fill='url(%23paint1_linear_50_99)'/> </g> <defs> <linearGradient id='paint0_linear_50_99' x1='-42.9996' y1='124' x2='92.4233' y2='-41.7456' gradientUnits='userSpaceOnUse'> <stop stop-color='%230F006B'/> <stop offset='0.0833333' stop-color='%23281A81'/> <stop offset='0.166667' stop-color='%235D0C83'/> <stop offset='0.25' stop-color='%23911475'/> <stop offset='0.333333' stop-color='%23CE2F55'/> <stop offset='0.416667' stop-color='%23FF6633'/> <stop offset='0.5' stop-color='%23F6821F'/> <stop offset='0.583333' stop-color='%23FBAD41'/> <stop offset='0.666667' stop-color='%23FFCD89'/> <stop offset='0.75' stop-color='%23FFE9CB'/> <stop offset='0.833333' stop-color='%23FFF7EC'/> <stop offset='0.916667' stop-color='%23FFF8EE'/> <stop offset='1' stop-color='white'/> </linearGradient> <linearGradient id='paint1_linear_50_99' x1='91.4992' y1='27.4982' x2='28.1217' y2='54.1775' gradientUnits='userSpaceOnUse'> <stop stop-color='white'/> <stop offset='0.129253' stop-color='%23FFF8EE'/> <stop offset='0.617058' stop-color='%23FBAD41'/> <stop offset='0.848019' stop-color='%23F6821F'/> <stop offset='1' stop-color='%23FF6633'/> </linearGradient> <clipPath id='clip0_50_99'> <rect width='75' height='75' fill='white'/> </clipPath> </defs> </svg>"
38
+ />
39
+ )}
40
+ <title>{pageTitle}</title>
34
41
  </head>
35
- <body>
42
+ <body class="isolate">
36
43
  <div id="admin-root" class="min-h-screen">
37
44
  <div id="emdash-boot-loader">
38
45
  <style>
@@ -82,7 +89,7 @@ const messages = await loadMessages(resolvedLocale);
82
89
  </style>
83
90
  <div class="loader-inner">
84
91
  <div class="spinner"></div>
85
- <p>Loading EmDash...</p>
92
+ <p>{adminConfig?.siteName ? `Loading ${adminConfig.siteName}...` : "Loading EmDash..."}</p>
86
93
  </div>
87
94
  </div>
88
95
  <AdminWrapper client:only="react" locale={resolvedLocale} messages={messages} />
@@ -19,6 +19,7 @@ import { isParseError, parseBody } from "#api/parse.js";
19
19
  import { magicLinkSendBody } from "#api/schemas.js";
20
20
  import { getSiteBaseUrl } from "#api/site-url.js";
21
21
  import { checkRateLimit, getClientIp } from "#auth/rate-limit.js";
22
+ import { getTrustedProxyHeaders } from "#auth/trusted-proxy.js";
22
23
  import { OptionsRepository } from "#db/repositories/options.js";
23
24
 
24
25
  export const POST: APIRoute = async ({ request, locals }) => {
@@ -36,7 +37,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
36
37
  if (isParseError(body)) return body;
37
38
 
38
39
  // Rate limit: 3 requests per 300 seconds (5 minutes) per IP
39
- const ip = getClientIp(request);
40
+ const ip = getClientIp(request, getTrustedProxyHeaders(emdash.config));
40
41
  const rateLimit = await checkRateLimit(emdash.db, ip, "magic-link/send", 3, 300);
41
42
  if (!rateLimit.allowed) {
42
43
  // Return success-shaped response to avoid revealing rate limit
@@ -0,0 +1,57 @@
1
+ /**
2
+ * GET /_emdash/api/auth/mode
3
+ *
4
+ * Public endpoint that returns the active authentication mode.
5
+ * Used by the login page to determine which login UI to render.
6
+ *
7
+ * Unlike the full manifest endpoint, this is intentionally public
8
+ * and returns only the auth mode — no collection schemas, plugin
9
+ * info, or other internal details.
10
+ */
11
+
12
+ import type { APIRoute } from "astro";
13
+
14
+ import { getAuthMode } from "#auth/mode.js";
15
+
16
+ export const prerender = false;
17
+
18
+ export const GET: APIRoute = async ({ locals }) => {
19
+ const { emdash } = locals;
20
+
21
+ const authMode = getAuthMode(emdash?.config);
22
+
23
+ // Only check signup for passkey auth (external providers handle their own)
24
+ let signupEnabled = false;
25
+ if (emdash?.db && authMode.type === "passkey") {
26
+ try {
27
+ const { sql } = await import("kysely");
28
+ const result = await sql<{ cnt: unknown }>`
29
+ SELECT COUNT(*) as cnt FROM allowed_domains WHERE enabled = 1
30
+ `.execute(emdash.db);
31
+ signupEnabled = Number(result.rows[0]?.cnt ?? 0) > 0;
32
+ } catch {
33
+ // Table may not exist yet
34
+ }
35
+ }
36
+
37
+ // Collect pluggable auth providers (from authProviders config)
38
+ const providers = (emdash?.config?.authProviders ?? []).map((p) => ({
39
+ id: p.id,
40
+ label: p.label,
41
+ }));
42
+
43
+ return Response.json(
44
+ {
45
+ data: {
46
+ authMode: authMode.type === "external" ? authMode.providerType : "passkey",
47
+ signupEnabled,
48
+ providers,
49
+ },
50
+ },
51
+ {
52
+ headers: {
53
+ "Cache-Control": "private, no-store",
54
+ },
55
+ },
56
+ );
57
+ };
@@ -18,7 +18,9 @@ import {
18
18
  import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
19
19
 
20
20
  import { getPublicOrigin } from "#api/public-url.js";
21
+ import { finalizeSetup } from "#api/setup-complete.js";
21
22
  import { createOAuthStateStore } from "#auth/oauth-state-store.js";
23
+ import { OptionsRepository } from "#db/repositories/options.js";
22
24
 
23
25
  type ProviderName = "github" | "google";
24
26
 
@@ -126,10 +128,22 @@ export const GET: APIRoute = async ({ params, request, locals, session, redirect
126
128
  );
127
129
  }
128
130
 
131
+ const adapter = createKyselyAdapter(emdash.db);
132
+ const stateStore = createOAuthStateStore(emdash.db);
133
+
129
134
  const config: OAuthConsumerConfig = {
130
135
  baseUrl: `${getPublicOrigin(url, emdash?.config)}/_emdash`,
131
136
  providers,
132
137
  canSelfSignup: async (email: string) => {
138
+ // During setup: first user becomes admin.
139
+ // Check setup_complete flag instead of countUsers() to avoid
140
+ // a TOCTOU race where concurrent callbacks both see 0 users.
141
+ const options = new OptionsRepository(emdash.db);
142
+ const setupComplete = await options.get("emdash:setup_complete");
143
+ if (setupComplete !== true && setupComplete !== "true") {
144
+ return { allowed: true, role: Role.ADMIN };
145
+ }
146
+
133
147
  // Extract domain from email
134
148
  const domain = email.split("@")[1]?.toLowerCase();
135
149
  if (!domain) {
@@ -168,10 +182,16 @@ export const GET: APIRoute = async ({ params, request, locals, session, redirect
168
182
  },
169
183
  };
170
184
 
171
- const adapter = createKyselyAdapter(emdash.db);
172
- const stateStore = createOAuthStateStore(emdash.db);
173
-
185
+ const options = new OptionsRepository(emdash.db);
186
+ const setupCompleteBefore = await options.get("emdash:setup_complete");
174
187
  const user = await handleOAuthCallback(config, adapter, provider, code, state, stateStore);
188
+ const isFirstUser = setupCompleteBefore !== true && setupCompleteBefore !== "true";
189
+
190
+ // Finalize setup outside the transaction (idempotent, safe if two callbacks race).
191
+ if (isFirstUser) {
192
+ await finalizeSetup(emdash.db);
193
+ console.log(`[oauth] Setup complete: created admin user via ${provider} (${user.email})`);
194
+ }
175
195
 
176
196
  // Create session
177
197
  if (session) {