emdash 0.7.0 → 0.8.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 (225) 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-5uslYdUu.mjs → apply-x0eMK1lX.mjs} +18 -17
  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 +86 -15
  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 +1 -1
  15. package/dist/astro/middleware/setup.mjs +1 -1
  16. package/dist/astro/middleware.d.mts.map +1 -1
  17. package/dist/astro/middleware.mjs +259 -71
  18. package/dist/astro/middleware.mjs.map +1 -1
  19. package/dist/astro/types.d.mts +16 -8
  20. package/dist/astro/types.d.mts.map +1 -1
  21. package/dist/{byline-C4OVd8b3.mjs → byline-Chbr2GoP.mjs} +3 -3
  22. package/dist/byline-Chbr2GoP.mjs.map +1 -0
  23. package/dist/{bylines-hPTW79hw.mjs → bylines-CRNsVG88.mjs} +4 -4
  24. package/dist/{bylines-hPTW79hw.mjs.map → bylines-CRNsVG88.mjs.map} +1 -1
  25. package/dist/cli/index.mjs +16 -12
  26. package/dist/cli/index.mjs.map +1 -1
  27. package/dist/client/cf-access.d.mts +1 -1
  28. package/dist/client/index.d.mts +1 -1
  29. package/dist/client/index.mjs +1 -1
  30. package/dist/{content-D7J5y73J.mjs → content-BcQPYxdV.mjs} +13 -15
  31. package/dist/content-BcQPYxdV.mjs.map +1 -0
  32. package/dist/db/index.d.mts +3 -3
  33. package/dist/db/libsql.d.mts +1 -1
  34. package/dist/db/postgres.d.mts +1 -1
  35. package/dist/db/sqlite.d.mts +1 -1
  36. package/dist/{db-errors-D0UT85nC.mjs → db-errors-l1Qh2RPR.mjs} +1 -1
  37. package/dist/{db-errors-D0UT85nC.mjs.map → db-errors-l1Qh2RPR.mjs.map} +1 -1
  38. package/dist/{default-CME5YdZ3.mjs → default-DCVqE5ib.mjs} +1 -1
  39. package/dist/{default-CME5YdZ3.mjs.map → default-DCVqE5ib.mjs.map} +1 -1
  40. package/dist/{error-CiYn9yDu.mjs → error-zG5T1UGA.mjs} +1 -1
  41. package/dist/error-zG5T1UGA.mjs.map +1 -0
  42. package/dist/{index-De6_Xv3v.d.mts → index-DIb-CzNx.d.mts} +157 -14
  43. package/dist/index-DIb-CzNx.d.mts.map +1 -0
  44. package/dist/index.d.mts +11 -11
  45. package/dist/index.mjs +22 -20
  46. package/dist/{load-CBcmDIot.mjs → load-CyEoextb.mjs} +1 -1
  47. package/dist/{load-CBcmDIot.mjs.map → load-CyEoextb.mjs.map} +1 -1
  48. package/dist/{loader-DeiBJEMe.mjs → loader-CndGj8kM.mjs} +8 -6
  49. package/dist/loader-CndGj8kM.mjs.map +1 -0
  50. package/dist/{manifest-schema-V30qsMft.mjs → manifest-schema-DH9xhc6t.mjs} +13 -1
  51. package/dist/manifest-schema-DH9xhc6t.mjs.map +1 -0
  52. package/dist/media/index.d.mts +1 -1
  53. package/dist/media/local-runtime.d.mts +7 -7
  54. package/dist/media/local-runtime.mjs +2 -2
  55. package/dist/{media-DqHVh136.mjs → media-D8FbNsl0.mjs} +4 -7
  56. package/dist/media-D8FbNsl0.mjs.map +1 -0
  57. package/dist/{mode-CpNnGkPz.mjs → mode-BnAOqItE.mjs} +1 -1
  58. package/dist/mode-BnAOqItE.mjs.map +1 -0
  59. package/dist/page/index.d.mts +2 -2
  60. package/dist/placeholder-C-fk5hYI.mjs.map +1 -1
  61. package/dist/{placeholder-tzpqGWII.d.mts → placeholder-D29tWZ7o.d.mts} +1 -1
  62. package/dist/{placeholder-tzpqGWII.d.mts.map → placeholder-D29tWZ7o.d.mts.map} +1 -1
  63. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  64. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  65. package/dist/{query-g4Ug-9j9.mjs → query-fqEdLFms.mjs} +9 -9
  66. package/dist/{query-g4Ug-9j9.mjs.map → query-fqEdLFms.mjs.map} +1 -1
  67. package/dist/{redirect-CN0Rt9Ob.mjs → redirect-D_pshWdf.mjs} +4 -4
  68. package/dist/redirect-D_pshWdf.mjs.map +1 -0
  69. package/dist/{registry-Ci3WxVAr.mjs → registry-C3Mr0ODu.mjs} +33 -9
  70. package/dist/registry-C3Mr0ODu.mjs.map +1 -0
  71. package/dist/{request-cache-DiR961CV.mjs → request-cache-Ci7f5pBb.mjs} +1 -1
  72. package/dist/request-cache-Ci7f5pBb.mjs.map +1 -0
  73. package/dist/{runner-BR2xKwhn.d.mts → runner-OURCaApa.d.mts} +2 -2
  74. package/dist/{runner-BR2xKwhn.d.mts.map → runner-OURCaApa.d.mts.map} +1 -1
  75. package/dist/runtime.d.mts +6 -6
  76. package/dist/runtime.mjs +2 -2
  77. package/dist/{search-B0effn3j.mjs → search-BoZYFuUk.mjs} +227 -84
  78. package/dist/search-BoZYFuUk.mjs.map +1 -0
  79. package/dist/seed/index.d.mts +2 -2
  80. package/dist/seed/index.mjs +12 -12
  81. package/dist/seo/index.d.mts +1 -1
  82. package/dist/storage/local.d.mts +1 -1
  83. package/dist/storage/local.mjs +1 -1
  84. package/dist/storage/s3.d.mts +1 -1
  85. package/dist/storage/s3.d.mts.map +1 -1
  86. package/dist/storage/s3.mjs +4 -4
  87. package/dist/storage/s3.mjs.map +1 -1
  88. package/dist/{taxonomies-K2z0Uhnj.mjs → taxonomies-B4IAshV8.mjs} +5 -5
  89. package/dist/{taxonomies-K2z0Uhnj.mjs.map → taxonomies-B4IAshV8.mjs.map} +1 -1
  90. package/dist/{tokens-BFPFx3CA.mjs → tokens-D9vnZqYS.mjs} +1 -1
  91. package/dist/{tokens-BFPFx3CA.mjs.map → tokens-D9vnZqYS.mjs.map} +1 -1
  92. package/dist/{transport-BykRfpyy.mjs → transport-C9ugt2Nr.mjs} +1 -1
  93. package/dist/{transport-BykRfpyy.mjs.map → transport-C9ugt2Nr.mjs.map} +1 -1
  94. package/dist/{transport-H4Iwx7tC.d.mts → transport-CUnEL3Vs.d.mts} +1 -1
  95. package/dist/{transport-H4Iwx7tC.d.mts.map → transport-CUnEL3Vs.d.mts.map} +1 -1
  96. package/dist/types-BIgulNsW.mjs +68 -0
  97. package/dist/types-BIgulNsW.mjs.map +1 -0
  98. package/dist/{types-DDS4MxsT.mjs → types-Bm1dn-q3.mjs} +1 -1
  99. package/dist/{types-DDS4MxsT.mjs.map → types-Bm1dn-q3.mjs.map} +1 -1
  100. package/dist/{types-CnZYHyLW.d.mts → types-BmPPSUEx.d.mts} +1 -1
  101. package/dist/{types-CnZYHyLW.d.mts.map → types-BmPPSUEx.d.mts.map} +1 -1
  102. package/dist/{types-6CUZRrZP.d.mts → types-BrA0xf5I.d.mts} +24 -2
  103. package/dist/{types-6CUZRrZP.d.mts.map → types-BrA0xf5I.d.mts.map} +1 -1
  104. package/dist/{types-C2v0c34j.d.mts → types-CS8FIX7L.d.mts} +1 -1
  105. package/dist/{types-C2v0c34j.d.mts.map → types-CS8FIX7L.d.mts.map} +1 -1
  106. package/dist/{types-BH2L167P.mjs → types-CgqmmMJB.mjs} +1 -1
  107. package/dist/{types-BH2L167P.mjs.map → types-CgqmmMJB.mjs.map} +1 -1
  108. package/dist/{types-CFWjXmus.d.mts → types-DIMwPFub.d.mts} +1 -1
  109. package/dist/{types-CFWjXmus.d.mts.map → types-DIMwPFub.d.mts.map} +1 -1
  110. package/dist/{types-DgrIP0tF.d.mts → types-i36XcA_X.d.mts} +49 -6
  111. package/dist/types-i36XcA_X.d.mts.map +1 -0
  112. package/dist/{validate-CqsNItbt.mjs → validate-CxVsLehf.mjs} +2 -2
  113. package/dist/{validate-CqsNItbt.mjs.map → validate-CxVsLehf.mjs.map} +1 -1
  114. package/dist/{validate-kM8Pjuf7.d.mts → validate-DHxmpFJt.d.mts} +4 -4
  115. package/dist/{validate-kM8Pjuf7.d.mts.map → validate-DHxmpFJt.d.mts.map} +1 -1
  116. package/dist/validation-C-ZpN2GI.mjs +144 -0
  117. package/dist/validation-C-ZpN2GI.mjs.map +1 -0
  118. package/dist/version-Bbq8TCrz.mjs +7 -0
  119. package/dist/{version-BnTKdfam.mjs.map → version-Bbq8TCrz.mjs.map} +1 -1
  120. package/dist/zod-generator-CpwccCIv.mjs +132 -0
  121. package/dist/zod-generator-CpwccCIv.mjs.map +1 -0
  122. package/package.json +18 -5
  123. package/src/api/auth-storage.ts +37 -0
  124. package/src/api/error.ts +6 -0
  125. package/src/api/errors.ts +8 -0
  126. package/src/api/handlers/comments.ts +13 -0
  127. package/src/api/handlers/content.ts +122 -3
  128. package/src/api/handlers/index.ts +2 -0
  129. package/src/api/handlers/media.ts +8 -1
  130. package/src/api/handlers/menus.ts +160 -21
  131. package/src/api/handlers/redirects.ts +16 -3
  132. package/src/api/handlers/sections.ts +8 -1
  133. package/src/api/handlers/taxonomies.ts +128 -16
  134. package/src/api/handlers/validation.ts +212 -0
  135. package/src/api/openapi/document.ts +4 -1
  136. package/src/api/public-url.ts +6 -3
  137. package/src/api/route-utils.ts +14 -0
  138. package/src/api/schemas/common.ts +1 -1
  139. package/src/api/schemas/setup.ts +8 -0
  140. package/src/api/schemas/widgets.ts +12 -10
  141. package/src/api/setup-complete.ts +40 -0
  142. package/src/astro/integration/index.ts +13 -2
  143. package/src/astro/integration/routes.ts +28 -0
  144. package/src/astro/integration/runtime.ts +19 -1
  145. package/src/astro/integration/virtual-modules.ts +41 -0
  146. package/src/astro/integration/vite-config.ts +43 -12
  147. package/src/astro/middleware/auth.ts +21 -0
  148. package/src/astro/middleware.ts +18 -1
  149. package/src/astro/routes/PluginRegistry.tsx +10 -1
  150. package/src/astro/routes/api/auth/mode.ts +57 -0
  151. package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +23 -3
  152. package/src/astro/routes/api/auth/oauth/[provider].ts +10 -4
  153. package/src/astro/routes/api/content/[collection]/[id]/translations.ts +1 -1
  154. package/src/astro/routes/api/content/[collection]/index.ts +1 -9
  155. package/src/astro/routes/api/import/wordpress/media.ts +2 -7
  156. package/src/astro/routes/api/import/wordpress/prepare.ts +10 -0
  157. package/src/astro/routes/api/settings/email.ts +4 -9
  158. package/src/astro/routes/api/setup/admin.ts +8 -2
  159. package/src/astro/routes/api/setup/index.ts +2 -2
  160. package/src/astro/routes/api/setup/status.ts +3 -1
  161. package/src/astro/routes/api/widget-areas/[name]/widgets/[id].ts +4 -1
  162. package/src/astro/routes/api/widget-areas/[name]/widgets.ts +4 -1
  163. package/src/astro/routes/api/widget-areas/[name].ts +4 -1
  164. package/src/astro/routes/api/widget-areas/index.ts +4 -1
  165. package/src/astro/types.ts +9 -0
  166. package/src/auth/mode.ts +15 -3
  167. package/src/auth/providers/github-admin.tsx +29 -0
  168. package/src/auth/providers/github.ts +31 -0
  169. package/src/auth/providers/google-admin.tsx +44 -0
  170. package/src/auth/providers/google.ts +31 -0
  171. package/src/auth/types.ts +114 -4
  172. package/src/cli/commands/bundle.ts +3 -1
  173. package/src/components/EmDashImage.astro +7 -6
  174. package/src/components/Gallery.astro +5 -3
  175. package/src/components/Image.astro +8 -3
  176. package/src/components/InlinePortableTextEditor.tsx +2 -1
  177. package/src/components/LiveSearch.astro +5 -14
  178. package/src/database/repositories/audit.ts +6 -8
  179. package/src/database/repositories/byline.ts +6 -8
  180. package/src/database/repositories/comment.ts +12 -16
  181. package/src/database/repositories/content.ts +40 -40
  182. package/src/database/repositories/index.ts +1 -1
  183. package/src/database/repositories/media.ts +10 -13
  184. package/src/database/repositories/plugin-storage.ts +4 -6
  185. package/src/database/repositories/redirect.ts +12 -16
  186. package/src/database/repositories/taxonomy.ts +14 -3
  187. package/src/database/repositories/types.ts +57 -8
  188. package/src/database/repositories/user.ts +6 -8
  189. package/src/emdash-runtime.ts +306 -90
  190. package/src/index.ts +5 -1
  191. package/src/loader.ts +6 -5
  192. package/src/mcp/server.ts +678 -105
  193. package/src/media/normalize.ts +1 -1
  194. package/src/media/url.ts +78 -0
  195. package/src/plugins/email-console.ts +10 -3
  196. package/src/plugins/hooks.ts +11 -0
  197. package/src/plugins/manifest-schema.ts +12 -0
  198. package/src/plugins/types.ts +23 -2
  199. package/src/query.ts +1 -1
  200. package/src/request-cache.ts +3 -0
  201. package/src/schema/registry.ts +41 -5
  202. package/src/search/fts-manager.ts +0 -2
  203. package/src/search/query.ts +111 -26
  204. package/src/search/types.ts +8 -1
  205. package/src/sections/index.ts +7 -9
  206. package/src/storage/s3.ts +12 -6
  207. package/src/virtual-modules.d.ts +21 -1
  208. package/src/widgets/index.ts +1 -1
  209. package/dist/apply-5uslYdUu.mjs.map +0 -1
  210. package/dist/byline-C4OVd8b3.mjs.map +0 -1
  211. package/dist/content-D7J5y73J.mjs.map +0 -1
  212. package/dist/error-CiYn9yDu.mjs.map +0 -1
  213. package/dist/index-De6_Xv3v.d.mts.map +0 -1
  214. package/dist/loader-DeiBJEMe.mjs.map +0 -1
  215. package/dist/manifest-schema-V30qsMft.mjs.map +0 -1
  216. package/dist/media-DqHVh136.mjs.map +0 -1
  217. package/dist/mode-CpNnGkPz.mjs.map +0 -1
  218. package/dist/redirect-CN0Rt9Ob.mjs.map +0 -1
  219. package/dist/registry-Ci3WxVAr.mjs.map +0 -1
  220. package/dist/request-cache-DiR961CV.mjs.map +0 -1
  221. package/dist/search-B0effn3j.mjs.map +0 -1
  222. package/dist/types-CMMN0pNg.mjs +0 -31
  223. package/dist/types-CMMN0pNg.mjs.map +0 -1
  224. package/dist/types-DgrIP0tF.d.mts.map +0 -1
  225. package/dist/version-BnTKdfam.mjs +0 -7
@@ -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
  }
@@ -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) {
@@ -71,16 +71,22 @@ export const GET: APIRoute = async ({ params, request, locals, redirect }) => {
71
71
  const { emdash } = locals;
72
72
  const provider = params.provider;
73
73
 
74
+ // Determine where to redirect errors (setup wizard or login page)
75
+ const referer = request.headers.get("referer") ?? "";
76
+ const errorRedirectBase = referer.includes("/setup")
77
+ ? "/_emdash/admin/setup"
78
+ : "/_emdash/admin/login";
79
+
74
80
  // Validate provider
75
81
  if (!provider || !isValidProvider(provider)) {
76
82
  return redirect(
77
- `/_emdash/admin/login?error=invalid_provider&message=${encodeURIComponent("Invalid OAuth provider")}`,
83
+ `${errorRedirectBase}?error=invalid_provider&message=${encodeURIComponent("Invalid OAuth provider")}`,
78
84
  );
79
85
  }
80
86
 
81
87
  if (!emdash?.db) {
82
88
  return redirect(
83
- `/_emdash/admin/login?error=server_error&message=${encodeURIComponent("Database not configured")}`,
89
+ `${errorRedirectBase}?error=server_error&message=${encodeURIComponent("Database not configured")}`,
84
90
  );
85
91
  }
86
92
 
@@ -97,7 +103,7 @@ export const GET: APIRoute = async ({ params, request, locals, redirect }) => {
97
103
 
98
104
  if (!providers[provider]) {
99
105
  return redirect(
100
- `/_emdash/admin/login?error=provider_not_configured&message=${encodeURIComponent(`OAuth provider ${provider} is not configured`)}`,
106
+ `${errorRedirectBase}?error=provider_not_configured&message=${encodeURIComponent(`OAuth provider ${provider} is not configured. Set either EMDASH_OAUTH_${provider.toUpperCase()}_CLIENT_ID and EMDASH_OAUTH_${provider.toUpperCase()}_CLIENT_SECRET, or ${provider.toUpperCase()}_CLIENT_ID and ${provider.toUpperCase()}_CLIENT_SECRET.`)}`,
101
107
  );
102
108
  }
103
109
 
@@ -114,7 +120,7 @@ export const GET: APIRoute = async ({ params, request, locals, redirect }) => {
114
120
  } catch (error) {
115
121
  console.error("OAuth initiation error:", error);
116
122
  return redirect(
117
- `/_emdash/admin/login?error=oauth_error&message=${encodeURIComponent("Failed to start OAuth flow. Please try again.")}`,
123
+ `${errorRedirectBase}?error=oauth_error&message=${encodeURIComponent("Failed to start OAuth flow. Please try again.")}`,
118
124
  );
119
125
  }
120
126
  };
@@ -6,7 +6,7 @@
6
6
  * Returns all locale variants linked to the same translation group.
7
7
  */
8
8
 
9
- import { hasPermission, type Permission } from "@emdash-cms/auth";
9
+ import { hasPermission } from "@emdash-cms/auth";
10
10
  import type { APIRoute } from "astro";
11
11
 
12
12
  import { requirePerm } from "#api/authorize.js";
@@ -61,15 +61,7 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
61
61
  mapErrorStatus(source.error?.code),
62
62
  );
63
63
  }
64
- const sourceData =
65
- source.data && typeof source.data === "object"
66
- ? (source.data as Record<string, unknown>)
67
- : undefined;
68
- const sourceItem =
69
- sourceData?.item && typeof sourceData.item === "object"
70
- ? (sourceData.item as Record<string, unknown>)
71
- : sourceData;
72
- const sourceAuthor = typeof sourceItem?.authorId === "string" ? sourceItem.authorId : "";
64
+ const sourceAuthor = source.data.item.authorId ?? "";
73
65
  const translationDenied = requireOwnerPerm(
74
66
  user,
75
67
  sourceAuthor,
@@ -92,7 +92,6 @@ export const POST: APIRoute = async ({ request, locals }) => {
92
92
  attachments,
93
93
  emdash.db,
94
94
  emdash.storage,
95
- request.url,
96
95
  sendProgress,
97
96
  );
98
97
 
@@ -117,7 +116,6 @@ export const POST: APIRoute = async ({ request, locals }) => {
117
116
  attachments,
118
117
  emdash.db,
119
118
  emdash.storage,
120
- request.url,
121
119
  () => {}, // No-op progress callback
122
120
  );
123
121
 
@@ -131,12 +129,9 @@ async function importMediaWithProgress(
131
129
  attachments: AttachmentInfo[],
132
130
  db: NonNullable<EmDashHandlers["db"]>,
133
131
  storage: NonNullable<EmDashHandlers["storage"]>,
134
- requestUrl: string,
135
132
  onProgress: (progress: MediaImportProgress) => void,
136
133
  ): Promise<MediaImportResult> {
137
134
  const repo = new MediaRepository(db);
138
- const url = new URL(requestUrl);
139
- const baseUrl = `${url.protocol}//${url.host}`;
140
135
  const total = attachments.length;
141
136
 
142
137
  const result: MediaImportResult = {
@@ -237,7 +232,7 @@ async function importMediaWithProgress(
237
232
  const existing = await repo.findByContentHash(contentHash);
238
233
  if (existing) {
239
234
  // Same content already exists - reuse it
240
- const existingUrl = `${baseUrl}/_emdash/api/media/file/${existing.storageKey}`;
235
+ const existingUrl = `/_emdash/api/media/file/${existing.storageKey}`;
241
236
  result.urlMap[attachment.url] = existingUrl;
242
237
  result.imported.push({
243
238
  wpId: attachment.id,
@@ -290,7 +285,7 @@ async function importMediaWithProgress(
290
285
  });
291
286
 
292
287
  // Build the new URL
293
- const newUrl = `${baseUrl}/_emdash/api/media/file/${storageKey}`;
288
+ const newUrl = `/_emdash/api/media/file/${storageKey}`;
294
289
 
295
290
  result.imported.push({
296
291
  wpId: attachment.id,
@@ -58,6 +58,16 @@ export const POST: APIRoute = async ({ request, locals }) => {
58
58
  // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Zod schema output narrowed to PrepareRequest
59
59
  const result = await prepareImport(emdash.db, body as PrepareRequest);
60
60
 
61
+ // If prepare created any new collections or fields, invalidate the
62
+ // persisted manifest cache (`emdash:manifest_cache` in the options
63
+ // table) so that the execute endpoint -- a separate request -- sees
64
+ // the new schema. Without this the execute step reads a stale
65
+ // manifest and reports `Collection "<slug>" does not exist` for
66
+ // every item destined for a freshly-created collection. See #747.
67
+ if (result.collectionsCreated.length > 0 || result.fieldsCreated.length > 0) {
68
+ emdash.invalidateManifest();
69
+ }
70
+
61
71
  return apiSuccess(result, result.success ? 200 : 400);
62
72
  } catch (error) {
63
73
  return handleError(error, "Failed to prepare import", "WXR_PREPARE_ERROR");
@@ -49,20 +49,15 @@ export const GET: APIRoute = async ({ locals }) => {
49
49
  `emdash:exclusive_hook:${EMAIL_DELIVER_HOOK}`,
50
50
  );
51
51
 
52
- // Get middleware hooks (beforeSend / afterSend)
52
+ // Get middleware hooks (beforeSend / afterSend). These are non-exclusive —
53
+ // many plugins can subscribe — so we enumerate non-exclusive providers.
53
54
  const beforeSendPlugins = pipeline
54
- .getExclusiveHookProviders(EMAIL_BEFORE_SEND_HOOK)
55
+ .getHookProviders(EMAIL_BEFORE_SEND_HOOK)
55
56
  .map((p) => p.pluginId);
56
57
  const afterSendPlugins = pipeline
57
- .getExclusiveHookProviders(EMAIL_AFTER_SEND_HOOK)
58
+ .getHookProviders(EMAIL_AFTER_SEND_HOOK)
58
59
  .map((p) => p.pluginId);
59
60
 
60
- // Note: beforeSend/afterSend are NOT exclusive hooks, but getExclusiveHookProviders
61
- // only finds exclusive ones. We need all hooks for those names.
62
- // For now, report what we can from the exclusive hook system.
63
- // Middleware is non-exclusive so we'd need a different query.
64
- // TODO: Add getHookProviders() for non-exclusive hooks to the pipeline.
65
-
66
61
  return apiSuccess({
67
62
  available: emdash.email?.isAvailable() ?? false,
68
63
  providers: providers.map((p) => ({
@@ -49,6 +49,10 @@ export const POST: APIRoute = async ({ cookies, request, locals }) => {
49
49
  const body = await parseBody(request, setupAdminBody);
50
50
  if (isParseError(body)) return body;
51
51
 
52
+ // Preserve title/tagline from step 1 by reading existing setup state
53
+ // before we overwrite it below.
54
+ const existingState = await options.get<Record<string, unknown>>("emdash:setup_state");
55
+
52
56
  // Mint a fresh session nonce. This binds the follow-up
53
57
  // /setup/admin/verify call to the same browser that made this
54
58
  // request, so an unauthenticated attacker on another host cannot
@@ -81,9 +85,11 @@ export const POST: APIRoute = async ({ cookies, request, locals }) => {
81
85
  challengeStore,
82
86
  );
83
87
 
84
- // Store the nonce alongside the rest of the setup state. The verify
85
- // endpoint will constant-time compare this with the incoming cookie.
88
+ // Store the nonce alongside the rest of the setup state, preserving
89
+ // title/tagline from step 1. The verify endpoint will constant-time
90
+ // compare the nonce with the incoming cookie.
86
91
  await options.set("emdash:setup_state", {
92
+ ...existingState,
87
93
  step: "admin",
88
94
  email: body.email.toLowerCase(),
89
95
  name: body.name || null,
@@ -81,7 +81,7 @@ export const POST: APIRoute = async ({ request, url, locals }) => {
81
81
 
82
82
  // 5. Store setup state
83
83
  // In external auth mode, mark setup complete immediately (first user to login becomes admin)
84
- // In passkey mode, setup_complete is set after admin user is created
84
+ // Otherwise, setup_complete is set after admin user is created (passkey or auth provider)
85
85
  const authMode = getAuthMode(emdash.config);
86
86
  const useExternalAuth = authMode.type === "external";
87
87
 
@@ -105,7 +105,7 @@ export const POST: APIRoute = async ({ request, url, locals }) => {
105
105
  await options.set("emdash:site_tagline", body.tagline);
106
106
  }
107
107
  } else {
108
- // Passkey mode: store state for next step (admin creation)
108
+ // Passkey/provider mode: store state for next step (admin creation)
109
109
  await options.set("emdash:setup_state", {
110
110
  step: "site_complete",
111
111
  title: body.title,
@@ -91,7 +91,7 @@ export const GET: APIRoute = async ({ locals }) => {
91
91
  const authMode = getAuthMode(emdash.config);
92
92
  const useExternalAuth = authMode.type === "external";
93
93
 
94
- // In external auth mode, setup is complete if flag is set (no users required initially)
94
+ // In external auth mode (not atproto), setup is complete if flag is set (no users required initially)
95
95
  if (useExternalAuth && isComplete) {
96
96
  return apiSuccess({
97
97
  needsSetup: false,
@@ -106,6 +106,8 @@ export const GET: APIRoute = async ({ locals }) => {
106
106
  description: seed.meta?.description || "",
107
107
  collections: seed.collections?.length || 0,
108
108
  hasContent: !!(seed.content && Object.keys(seed.content).length > 0),
109
+ title: seed.settings?.title,
110
+ tagline: seed.settings?.tagline,
109
111
  }
110
112
  : null;
111
113
 
@@ -11,6 +11,8 @@ import { requirePerm } from "#api/authorize.js";
11
11
  import { apiError, apiSuccess, handleError } from "#api/error.js";
12
12
  import { isParseError, parseBody } from "#api/parse.js";
13
13
  import { updateWidgetBody } from "#api/schemas.js";
14
+ import { rowToWidget } from "#widgets/index.js";
15
+ import type { WidgetRow } from "#widgets/types.js";
14
16
 
15
17
  export const prerender = false;
16
18
 
@@ -73,10 +75,11 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
73
75
  const widget = await db
74
76
  .selectFrom("_emdash_widgets")
75
77
  .selectAll()
78
+ .$castTo<WidgetRow>()
76
79
  .where("id", "=", id)
77
80
  .executeTakeFirstOrThrow();
78
81
 
79
- return apiSuccess(widget);
82
+ return apiSuccess(rowToWidget(widget));
80
83
  } catch (error) {
81
84
  return handleError(error, "Failed to update widget", "WIDGET_UPDATE_ERROR");
82
85
  }
@@ -11,6 +11,8 @@ import { requirePerm } from "#api/authorize.js";
11
11
  import { apiError, apiSuccess, handleError } from "#api/error.js";
12
12
  import { isParseError, parseBody } from "#api/parse.js";
13
13
  import { createWidgetBody } from "#api/schemas.js";
14
+ import { rowToWidget } from "#widgets/index.js";
15
+ import type { WidgetRow } from "#widgets/types.js";
14
16
 
15
17
  export const prerender = false;
16
18
 
@@ -70,10 +72,11 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
70
72
  const widget = await db
71
73
  .selectFrom("_emdash_widgets")
72
74
  .selectAll()
75
+ .$castTo<WidgetRow>()
73
76
  .where("id", "=", id)
74
77
  .executeTakeFirstOrThrow();
75
78
 
76
- return apiSuccess(widget, 201);
79
+ return apiSuccess(rowToWidget(widget), 201);
77
80
  } catch (error) {
78
81
  return handleError(error, "Failed to create widget", "WIDGET_CREATE_ERROR");
79
82
  }
@@ -9,6 +9,8 @@ import type { APIRoute } from "astro";
9
9
 
10
10
  import { requirePerm } from "#api/authorize.js";
11
11
  import { apiError, apiSuccess, handleError } from "#api/error.js";
12
+ import { rowToWidget } from "#widgets/index.js";
13
+ import type { WidgetRow } from "#widgets/types.js";
12
14
 
13
15
  export const prerender = false;
14
16
 
@@ -40,13 +42,14 @@ export const GET: APIRoute = async ({ params, locals }) => {
40
42
  const widgets = await db
41
43
  .selectFrom("_emdash_widgets")
42
44
  .selectAll()
45
+ .$castTo<WidgetRow>()
43
46
  .where("area_id", "=", area.id)
44
47
  .orderBy("sort_order", "asc")
45
48
  .execute();
46
49
 
47
50
  return apiSuccess({
48
51
  ...area,
49
- widgets,
52
+ widgets: widgets.map((row) => rowToWidget(row)),
50
53
  });
51
54
  } catch (error) {
52
55
  return handleError(error, "Failed to fetch widget area", "WIDGET_AREA_GET_ERROR");
@@ -12,6 +12,8 @@ import { requirePerm } from "#api/authorize.js";
12
12
  import { apiError, apiSuccess, handleError } from "#api/error.js";
13
13
  import { isParseError, parseBody } from "#api/parse.js";
14
14
  import { createWidgetAreaBody } from "#api/schemas.js";
15
+ import { rowToWidget } from "#widgets/index.js";
16
+ import type { WidgetRow } from "#widgets/types.js";
15
17
 
16
18
  export const prerender = false;
17
19
 
@@ -35,13 +37,14 @@ export const GET: APIRoute = async ({ locals }) => {
35
37
  const widgets = await db
36
38
  .selectFrom("_emdash_widgets")
37
39
  .selectAll()
40
+ .$castTo<WidgetRow>()
38
41
  .where("area_id", "=", area.id)
39
42
  .orderBy("sort_order", "asc")
40
43
  .execute();
41
44
 
42
45
  return {
43
46
  ...area,
44
- widgets,
47
+ widgets: widgets.map((row) => rowToWidget(row)),
45
48
  widgetCount: widgets.length,
46
49
  };
47
50
  }),
@@ -348,6 +348,7 @@ export interface EmDashHandlers {
348
348
  // Direct access to storage and database for advanced use cases
349
349
  storage: import("../index.js").Storage | null;
350
350
  db: Kysely<import("../index.js").Database>;
351
+ getPublicMediaUrl?: (storageKey: string) => string;
351
352
 
352
353
  // Hook pipeline for plugin integrations
353
354
  hooks: import("../plugins/hooks.js").HookPipeline;
@@ -380,4 +381,12 @@ export interface EmDashHandlers {
380
381
  collectPageFragments: (
381
382
  page: import("../plugins/types.js").PublicPageContext,
382
383
  ) => Promise<import("../plugins/types.js").PageFragmentContribution[]>;
384
+
385
+ /**
386
+ * Lazy search index health check. Search routes call this before
387
+ * querying so a crash-corrupted index gets repaired on first use
388
+ * rather than stalling cold start. Optional because it's only
389
+ * meaningful when an FTS5-capable runtime is wired in.
390
+ */
391
+ ensureSearchHealthy?: () => Promise<void>;
383
392
  }
package/src/auth/mode.ts CHANGED
@@ -6,9 +6,21 @@
6
6
  */
7
7
 
8
8
  import type { EmDashConfig } from "../astro/integration/runtime.js";
9
- import type { AuthDescriptor, AuthResult, ExternalAuthConfig } from "./types.js";
9
+ import type {
10
+ AuthDescriptor,
11
+ AuthProviderDescriptor,
12
+ AuthRouteDescriptor,
13
+ AuthResult,
14
+ ExternalAuthConfig,
15
+ } from "./types.js";
10
16
 
11
- export type { AuthDescriptor, AuthResult, ExternalAuthConfig };
17
+ export type {
18
+ AuthDescriptor,
19
+ AuthProviderDescriptor,
20
+ AuthRouteDescriptor,
21
+ AuthResult,
22
+ ExternalAuthConfig,
23
+ };
12
24
 
13
25
  /**
14
26
  * Passkey auth mode (default)
@@ -59,7 +71,7 @@ export function getAuthMode(
59
71
  ): AuthMode {
60
72
  const auth = config?.auth;
61
73
 
62
- // Check for AuthDescriptor (new style)
74
+ // Check for AuthDescriptor (transparent external auth like Cloudflare Access)
63
75
  if (auth && "entrypoint" in auth && auth.entrypoint) {
64
76
  return {
65
77
  type: "external",
@@ -0,0 +1,29 @@
1
+ /**
2
+ * GitHub OAuth Admin Components
3
+ *
4
+ * LoginButton for the login page, rendered via the auth provider virtual module.
5
+ */
6
+
7
+ import { LinkButton } from "@cloudflare/kumo";
8
+ import * as React from "react";
9
+
10
+ function GitHubIcon({ className }: { className?: string }) {
11
+ return (
12
+ <svg className={className} viewBox="0 0 24 24" fill="currentColor">
13
+ <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
14
+ </svg>
15
+ );
16
+ }
17
+
18
+ export function LoginButton() {
19
+ return (
20
+ <LinkButton
21
+ href="/_emdash/api/auth/oauth/github"
22
+ variant="outline"
23
+ className="w-full justify-center"
24
+ >
25
+ <GitHubIcon className="h-5 w-5" />
26
+ <span>GitHub</span>
27
+ </LinkButton>
28
+ );
29
+ }