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
@@ -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
  };
@@ -20,6 +20,7 @@ import { passkeyOptionsBody } from "#api/schemas.js";
20
20
  import { createChallengeStore, cleanupExpiredChallenges } from "#auth/challenge-store.js";
21
21
  import { getPasskeyConfig } from "#auth/passkey-config.js";
22
22
  import { checkRateLimit, getClientIp, rateLimitResponse } from "#auth/rate-limit.js";
23
+ import { getTrustedProxyHeaders } from "#auth/trusted-proxy.js";
23
24
  import { OptionsRepository } from "#db/repositories/options.js";
24
25
 
25
26
  export const POST: APIRoute = async ({ request, locals }) => {
@@ -38,7 +39,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
38
39
  if (isParseError(body)) return body;
39
40
 
40
41
  // Rate limit: 10 requests per 60 seconds per IP
41
- const ip = getClientIp(request);
42
+ const ip = getClientIp(request, getTrustedProxyHeaders(emdash.config));
42
43
  const rateLimit = await checkRateLimit(emdash.db, ip, "passkey/options", 10, 60);
43
44
  if (!rateLimit.allowed) {
44
45
  return rateLimitResponse(60);
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Request self-signup. Sends verification email if domain is allowed.
5
5
  * Always returns 200 to prevent email enumeration.
6
+ *
7
+ * Rate limited: 3 requests per 5 minutes per IP. Mirrors magic-link/send.
6
8
  */
7
9
 
8
10
  import type { APIRoute } from "astro";
@@ -16,8 +18,18 @@ import { apiError, apiSuccess } from "#api/error.js";
16
18
  import { isParseError, parseBody } from "#api/parse.js";
17
19
  import { signupRequestBody } from "#api/schemas.js";
18
20
  import { getSiteBaseUrl } from "#api/site-url.js";
21
+ import { checkRateLimit, getClientIp } from "#auth/rate-limit.js";
22
+ import { getTrustedProxyHeaders } from "#auth/trusted-proxy.js";
19
23
  import { OptionsRepository } from "#db/repositories/options.js";
20
24
 
25
+ // Generic response body used for both the real success path and the
26
+ // rate-limited / domain-disallowed paths. Keeping them identical prevents
27
+ // the caller from distinguishing between them.
28
+ const GENERIC_SUCCESS = {
29
+ success: true,
30
+ message: "If your email domain is allowed, you'll receive a verification email.",
31
+ };
32
+
21
33
  export const POST: APIRoute = async ({ request, locals }) => {
22
34
  const { emdash } = locals;
23
35
 
@@ -35,9 +47,21 @@ export const POST: APIRoute = async ({ request, locals }) => {
35
47
  }
36
48
 
37
49
  try {
50
+ // Parse the body first — this avoids burning a rate-limit slot on
51
+ // malformed input and keeps the timing of the rate-limited and
52
+ // real paths aligned.
38
53
  const body = await parseBody(request, signupRequestBody);
39
54
  if (isParseError(body)) return body;
40
55
 
56
+ // Rate limit: 3 requests per 300 seconds per IP. Matches magic-link/send.
57
+ const ip = getClientIp(request, getTrustedProxyHeaders(emdash.config));
58
+ const rateLimit = await checkRateLimit(emdash.db, ip, "signup/request", 3, 300);
59
+ if (!rateLimit.allowed) {
60
+ // Return success-shaped response to avoid revealing rate limiting
61
+ // (and by extension, the fact that the caller is probing).
62
+ return apiSuccess(GENERIC_SUCCESS);
63
+ }
64
+
41
65
  const adapter = createKyselyAdapter(emdash.db);
42
66
 
43
67
  // Get site config for signup email
@@ -60,18 +84,12 @@ export const POST: APIRoute = async ({ request, locals }) => {
60
84
  );
61
85
 
62
86
  // Always return success to prevent email enumeration
63
- return apiSuccess({
64
- success: true,
65
- message: "If your email domain is allowed, you'll receive a verification email.",
66
- });
87
+ return apiSuccess(GENERIC_SUCCESS);
67
88
  } catch (error) {
68
89
  console.error("Signup request error:", error);
69
90
 
70
91
  // Don't reveal internal errors - just return generic success
71
92
  // to prevent information leakage
72
- return apiSuccess({
73
- success: true,
74
- message: "If your email domain is allowed, you'll receive a verification email.",
75
- });
93
+ return apiSuccess(GENERIC_SUCCESS);
76
94
  }
77
95
  };
@@ -139,18 +139,22 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
139
139
  }
140
140
 
141
141
  // Anti-spam: Rate limiting
142
- const meta = extractRequestMeta(request);
142
+ const meta = extractRequestMeta(request, emdash.config);
143
143
  const ipSalt =
144
144
  import.meta.env.EMDASH_AUTH_SECRET || import.meta.env.AUTH_SECRET || "emdash-ip-salt";
145
145
  let ipHash: string;
146
146
  if (meta.ip) {
147
147
  ipHash = await hashIp(meta.ip, ipSalt);
148
- } else if (meta.userAgent) {
149
- // Fallback: hash user-agent as a rough identifier when IP is unavailable
150
- ipHash = await hashIp(`ua:${meta.userAgent}`, ipSalt);
151
148
  } else {
152
- // Fail closed: all unidentifiable requests share one rate-limit bucket.
153
- // Use a larger limit since this bucket is shared across all anonymous users.
149
+ // No trusted IP fail closed by bucketing all unidentifiable
150
+ // requests together. A larger limit reflects the shared bucket.
151
+ //
152
+ // Self-hosted operators behind a reverse proxy should set
153
+ // `trustedProxyHeaders` in the EmDash config (or the
154
+ // EMDASH_TRUSTED_PROXY_HEADERS env var) so this path isn't hit
155
+ // for legitimate traffic. UA-hashing was previously used here
156
+ // but was trivially rotatable — the shared bucket is stricter
157
+ // and forces operators toward a real fix.
154
158
  ipHash = "unknown";
155
159
  }
156
160
  const unknownBucketLimit = ipHash === "unknown" ? 20 : undefined;
@@ -13,7 +13,7 @@ export const prerender = false;
13
13
 
14
14
  export const GET: APIRoute = async ({ params, locals }) => {
15
15
  const { emdash, user } = locals;
16
- const denied = requirePerm(user, "content:read");
16
+ const denied = requirePerm(user, "content:read_drafts");
17
17
  if (denied) return denied;
18
18
  const collection = params.collection!;
19
19
  const id = params.id!;
@@ -30,7 +30,7 @@ const DURATION_PATTERN = /^(\d+)([smhdw])$/;
30
30
 
31
31
  export const POST: APIRoute = async ({ params, request, locals }) => {
32
32
  const { emdash, user } = locals;
33
- const denied = requirePerm(user, "content:read");
33
+ const denied = requirePerm(user, "content:read_drafts");
34
34
  if (denied) return denied;
35
35
  const collection = params.collection!;
36
36
  const id = params.id!;
@@ -13,7 +13,7 @@ export const prerender = false;
13
13
 
14
14
  export const GET: APIRoute = async ({ params, url, locals }) => {
15
15
  const { emdash, user } = locals;
16
- const denied = requirePerm(user, "content:read");
16
+ const denied = requirePerm(user, "content:read_drafts");
17
17
  if (denied) return denied;
18
18
  const collection = params.collection!;
19
19
  const id = params.id!;
@@ -6,6 +6,7 @@
6
6
  * Returns all locale variants linked to the same translation group.
7
7
  */
8
8
 
9
+ import { hasPermission } from "@emdash-cms/auth";
9
10
  import type { APIRoute } from "astro";
10
11
 
11
12
  import { requirePerm } from "#api/authorize.js";
@@ -13,6 +14,15 @@ import { apiError, unwrapResult } from "#api/error.js";
13
14
 
14
15
  export const prerender = false;
15
16
 
17
+ function isPublished(t: unknown): boolean {
18
+ return (
19
+ typeof t === "object" &&
20
+ t !== null &&
21
+ "status" in t &&
22
+ (t as Record<string, unknown>).status === "published"
23
+ );
24
+ }
25
+
16
26
  export const GET: APIRoute = async ({ params, locals }) => {
17
27
  const { emdash, user } = locals;
18
28
  const denied = requirePerm(user, "content:read");
@@ -26,5 +36,21 @@ export const GET: APIRoute = async ({ params, locals }) => {
26
36
 
27
37
  const result = await emdash.handleContentTranslations(collection, id);
28
38
 
39
+ // Filter out non-published translations for users without read_drafts so a
40
+ // subscriber can't enumerate locales that aren't yet live.
41
+ if (result.success && !hasPermission(user, "content:read_drafts")) {
42
+ const data =
43
+ result.data && typeof result.data === "object"
44
+ ? // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- handler returns unknown data; narrowed by typeof check
45
+ (result.data as Record<string, unknown>)
46
+ : undefined;
47
+ const translations = Array.isArray(data?.translations) ? data.translations : [];
48
+ const filtered = translations.filter(isPublished);
49
+ return unwrapResult({
50
+ success: true,
51
+ data: { ...data, translations: filtered },
52
+ });
53
+ }
54
+
29
55
  return unwrapResult(result);
30
56
  };
@@ -6,7 +6,7 @@
6
6
  * DELETE /_emdash/api/content/{collection}/{id} - Delete content
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, requireOwnerPerm } from "#api/authorize.js";
@@ -30,6 +30,25 @@ export const GET: APIRoute = async ({ params, url, locals }) => {
30
30
 
31
31
  const result = await emdash.handleContentGet(collection, id, locale);
32
32
 
33
+ // Hide non-published items from users without content:read_drafts. Return
34
+ // 404 (not 403) so subscribers can't enumerate draft IDs by status code.
35
+ if (result.success && !hasPermission(user, "content:read_drafts")) {
36
+ const data =
37
+ result.data && typeof result.data === "object"
38
+ ? // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- handler returns unknown data; narrowed by typeof check
39
+ (result.data as Record<string, unknown>)
40
+ : undefined;
41
+ const item =
42
+ data?.item && typeof data.item === "object"
43
+ ? // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- narrowed by typeof check
44
+ (data.item as Record<string, unknown>)
45
+ : undefined;
46
+ const status = typeof item?.status === "string" ? item.status : null;
47
+ if (status !== "published") {
48
+ return apiError("NOT_FOUND", `Content item not found: ${id}`, 404);
49
+ }
50
+ }
51
+
33
52
  return unwrapResult(result);
34
53
  };
35
54
 
@@ -69,12 +88,21 @@ export const PUT: APIRoute = async ({ params, request, locals, cache }) => {
69
88
  const editDenied = requireOwnerPerm(user, authorId, "content:edit_own", "content:edit_any");
70
89
  if (editDenied) return editDenied;
71
90
 
91
+ // Only EDITOR+ can write publishedAt directly — incl. clearing to null.
92
+ if (body.publishedAt !== undefined && !hasPermission(user, "content:publish_any")) {
93
+ return apiError(
94
+ "FORBIDDEN",
95
+ "Writing publishedAt requires content:publish_any permission",
96
+ 403,
97
+ );
98
+ }
99
+
72
100
  // Use the resolved ID (handles slug → ID resolution)
73
101
  const resolvedId = typeof existingItem?.id === "string" ? existingItem.id : id;
74
102
 
75
103
  // Only allow authorId changes if user has content:edit_any permission (editor+)
76
104
  const canChangeAuthor =
77
- body.authorId !== undefined && user && hasPermission(user, "content:edit_any" as Permission);
105
+ body.authorId !== undefined && user && hasPermission(user, "content:edit_any");
78
106
  const updateBody = canChangeAuthor ? body : { ...body, authorId: undefined };
79
107
 
80
108
  // Pass _rev through for optimistic concurrency validation
@@ -5,6 +5,7 @@
5
5
  * POST /_emdash/api/content/{collection} - Create content
6
6
  */
7
7
 
8
+ import { hasPermission } from "@emdash-cms/auth";
8
9
  import type { APIRoute } from "astro";
9
10
 
10
11
  import { requirePerm, requireOwnerPerm } from "#api/authorize.js";
@@ -26,7 +27,14 @@ export const GET: APIRoute = async ({ params, url, locals }) => {
26
27
  return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
27
28
  }
28
29
 
29
- const result = await emdash.handleContentList(collection, query);
30
+ // Subscribers must only see published content; force the status filter
31
+ // regardless of caller-supplied value. Any user with content:read_drafts
32
+ // (CONTRIBUTOR+) keeps the requested filter.
33
+ const params_ = hasPermission(user, "content:read_drafts")
34
+ ? query
35
+ : { ...query, status: "published" };
36
+
37
+ const result = await emdash.handleContentList(collection, params_);
30
38
 
31
39
  return unwrapResult(result);
32
40
  };
@@ -53,15 +61,7 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
53
61
  mapErrorStatus(source.error?.code),
54
62
  );
55
63
  }
56
- const sourceData =
57
- source.data && typeof source.data === "object"
58
- ? (source.data as Record<string, unknown>)
59
- : undefined;
60
- const sourceItem =
61
- sourceData?.item && typeof sourceData.item === "object"
62
- ? (sourceData.item as Record<string, unknown>)
63
- : sourceData;
64
- const sourceAuthor = typeof sourceItem?.authorId === "string" ? sourceItem.authorId : "";
64
+ const sourceAuthor = source.data.item.authorId ?? "";
65
65
  const translationDenied = requireOwnerPerm(
66
66
  user,
67
67
  sourceAuthor,
@@ -71,6 +71,16 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
71
71
  if (translationDenied) return translationDenied;
72
72
  }
73
73
 
74
+ // Only EDITOR+ can write publishedAt / createdAt directly — incl. clearing to null.
75
+ const hasDateOverride = body.publishedAt !== undefined || body.createdAt !== undefined;
76
+ if (hasDateOverride && !hasPermission(user, "content:publish_any")) {
77
+ return apiError(
78
+ "FORBIDDEN",
79
+ "Writing publishedAt or createdAt requires content:publish_any permission",
80
+ 403,
81
+ );
82
+ }
83
+
74
84
  // Auto-set authorId to current user when creating content
75
85
  const result = await emdash.handleContentCreate(collection, {
76
86
  ...body,
@@ -17,7 +17,7 @@ export const GET: APIRoute = async ({ params, url, locals }) => {
17
17
  const { emdash, user } = locals;
18
18
  const collection = params.collection!;
19
19
 
20
- const denied = requirePerm(user, "content:read");
20
+ const denied = requirePerm(user, "content:read_drafts");
21
21
  if (denied) return denied;
22
22
 
23
23
  if (!emdash?.handleContentListTrashed) {
@@ -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");
@@ -15,7 +15,7 @@ import { apiError, apiSuccess, handleError } from "#api/error.js";
15
15
  import { isParseError, parseBody } from "#api/parse.js";
16
16
  import { wpPluginAnalyzeBody } from "#api/schemas.js";
17
17
  import { getSource } from "#import/index.js";
18
- import { validateExternalUrl, SsrfError } from "#import/ssrf.js";
18
+ import { resolveAndValidateExternalUrl, SsrfError } from "#import/ssrf.js";
19
19
  import type { ImportAnalysis } from "#import/types.js";
20
20
  import type { EmDashHandlers } from "#types";
21
21
 
@@ -37,9 +37,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
37
37
  const body = await parseBody(request, wpPluginAnalyzeBody);
38
38
  if (isParseError(body)) return body;
39
39
 
40
- // SSRF: reject internal/private network targets
40
+ // SSRF: reject internal/private network targets. Uses DNS resolution
41
+ // to catch hostnames that resolve to private addresses.
41
42
  try {
42
- validateExternalUrl(body.url);
43
+ await resolveAndValidateExternalUrl(body.url);
43
44
  } catch (e) {
44
45
  const msg = e instanceof SsrfError ? e.message : "Invalid URL";
45
46
  return apiError("SSRF_BLOCKED", msg, 400);
@@ -15,7 +15,7 @@ import { isParseError, parseBody } from "#api/parse.js";
15
15
  import { wpPluginExecuteBody } from "#api/schemas.js";
16
16
  import { BylineRepository } from "#db/repositories/byline.js";
17
17
  import { getSource } from "#import/index.js";
18
- import { validateExternalUrl, SsrfError } from "#import/ssrf.js";
18
+ import { resolveAndValidateExternalUrl, SsrfError } from "#import/ssrf.js";
19
19
  import type { ImportConfig, ImportResult, NormalizedItem } from "#import/types.js";
20
20
  import { resolveImportByline } from "#import/utils.js";
21
21
  import type { FieldType } from "#schema/types.js";
@@ -49,9 +49,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
49
49
  const body = await parseBody(request, wpPluginExecuteBody);
50
50
  if (isParseError(body)) return body;
51
51
 
52
- // SSRF: reject internal/private network targets
52
+ // SSRF: reject internal/private network targets. Uses DNS resolution
53
+ // to catch hostnames that resolve to private addresses.
53
54
  try {
54
- validateExternalUrl(body.url);
55
+ await resolveAndValidateExternalUrl(body.url);
55
56
  } catch (e) {
56
57
  const msg = e instanceof SsrfError ? e.message : "Invalid URL";
57
58
  return apiError("SSRF_BLOCKED", msg, 400);
@@ -12,6 +12,7 @@ import type { APIRoute } from "astro";
12
12
  import { getAuthMode } from "#auth/mode.js";
13
13
 
14
14
  import { COMMIT, VERSION } from "../../../version.js";
15
+ import { getStoredConfig } from "../../integration/runtime.js";
15
16
  import type { EmDashManifest } from "../../types.js";
16
17
 
17
18
  export const prerender = false;
@@ -22,6 +23,10 @@ export const GET: APIRoute = async ({ locals }) => {
22
23
  // Determine auth mode from config
23
24
  const authMode = getAuthMode(emdash?.config);
24
25
 
26
+ // Read admin branding from build-time config
27
+ const storedConfig = getStoredConfig();
28
+ const adminBranding = storedConfig?.admin;
29
+
25
30
  // Check if self-signup is enabled (any allowed domain with enabled = 1)
26
31
  // Only relevant for passkey auth — external auth providers handle their own signup
27
32
  let signupEnabled = false;
@@ -42,6 +47,7 @@ export const GET: APIRoute = async ({ locals }) => {
42
47
  ...emdashManifest,
43
48
  authMode: authMode.type === "external" ? authMode.providerType : "passkey",
44
49
  signupEnabled,
50
+ admin: adminBranding,
45
51
  }
46
52
  : {
47
53
  version: VERSION,
@@ -52,6 +58,7 @@ export const GET: APIRoute = async ({ locals }) => {
52
58
  taxonomies: [],
53
59
  authMode: "passkey",
54
60
  signupEnabled,
61
+ admin: adminBranding,
55
62
  };
56
63
 
57
64
  return Response.json(
@@ -15,6 +15,7 @@ import { handleDeviceCodeRequest } from "#api/handlers/device-flow.js";
15
15
  import { isParseError, parseBody } from "#api/parse.js";
16
16
  import { getPublicOrigin } from "#api/public-url.js";
17
17
  import { checkRateLimit, getClientIp, rateLimitResponse } from "#auth/rate-limit.js";
18
+ import { getTrustedProxyHeaders } from "#auth/trusted-proxy.js";
18
19
 
19
20
  export const prerender = false;
20
21
 
@@ -35,7 +36,7 @@ export const POST: APIRoute = async ({ request, locals, url }) => {
35
36
  if (isParseError(body)) return body;
36
37
 
37
38
  // Rate limit: 10 requests per 60 seconds per IP
38
- const ip = getClientIp(request);
39
+ const ip = getClientIp(request, getTrustedProxyHeaders(emdash.config));
39
40
  const rateLimit = await checkRateLimit(emdash.db, ip, "device/code", 10, 60);
40
41
  if (!rateLimit.allowed) {
41
42
  return rateLimitResponse(60);
@@ -17,6 +17,7 @@ import { apiError, handleError, unwrapResult } from "#api/error.js";
17
17
  import { handleDeviceTokenExchange } from "#api/handlers/device-flow.js";
18
18
  import { isParseError, parseBody } from "#api/parse.js";
19
19
  import { checkRateLimit, getClientIp, rateLimitResponse } from "#auth/rate-limit.js";
20
+ import { getTrustedProxyHeaders } from "#auth/trusted-proxy.js";
20
21
 
21
22
  export const prerender = false;
22
23
 
@@ -37,7 +38,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
37
38
  if (isParseError(body)) return body;
38
39
 
39
40
  // Rate limit: 12 requests per 60 seconds per IP
40
- const ip = getClientIp(request);
41
+ const ip = getClientIp(request, getTrustedProxyHeaders(emdash.config));
41
42
  const rateLimit = await checkRateLimit(emdash.db, ip, "device/token", 12, 60);
42
43
  if (!rateLimit.allowed) {
43
44
  return rateLimitResponse(60);
@@ -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) => ({
@@ -8,7 +8,7 @@ import type { APIRoute } from "astro";
8
8
 
9
9
  export const prerender = false;
10
10
 
11
- import { Role } from "@emdash-cms/auth";
11
+ import { Role, secureCompare } from "@emdash-cms/auth";
12
12
  import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
13
13
  import { verifyRegistrationResponse, registerPasskey } from "@emdash-cms/auth/passkey";
14
14
 
@@ -18,9 +18,10 @@ import { getPublicOrigin } from "#api/public-url.js";
18
18
  import { setupAdminVerifyBody } from "#api/schemas.js";
19
19
  import { createChallengeStore } from "#auth/challenge-store.js";
20
20
  import { getPasskeyConfig } from "#auth/passkey-config.js";
21
+ import { SETUP_NONCE_COOKIE } from "#auth/setup-nonce.js";
21
22
  import { OptionsRepository } from "#db/repositories/options.js";
22
23
 
23
- export const POST: APIRoute = async ({ request, locals }) => {
24
+ export const POST: APIRoute = async ({ cookies, request, locals }) => {
24
25
  const { emdash } = locals;
25
26
 
26
27
  if (!emdash?.db) {
@@ -45,12 +46,35 @@ export const POST: APIRoute = async ({ request, locals }) => {
45
46
  }
46
47
 
47
48
  // Get setup state
48
- const setupState = await options.get("emdash:setup_state");
49
+ const setupState = await options.get<{
50
+ step?: string;
51
+ email?: string;
52
+ name?: string | null;
53
+ nonce?: string;
54
+ }>("emdash:setup_state");
49
55
 
50
56
  if (!setupState || setupState.step !== "admin") {
51
57
  return apiError("INVALID_STATE", "Invalid setup state. Please restart setup.", 400);
52
58
  }
53
59
 
60
+ // Verify the session nonce. The cookie was minted by POST /setup/admin
61
+ // and stored alongside setup_state; presenting a matching cookie is
62
+ // proof that this verify call comes from the same browser that
63
+ // started the admin step. Constant-time compare to avoid leaking the
64
+ // stored value through timing.
65
+ const cookieNonce = cookies.get(SETUP_NONCE_COOKIE)?.value;
66
+ if (!setupState.nonce || !cookieNonce || !secureCompare(cookieNonce, setupState.nonce)) {
67
+ return apiError(
68
+ "INVALID_STATE",
69
+ "Setup session expired or tampered with. Please restart the admin step.",
70
+ 400,
71
+ );
72
+ }
73
+
74
+ if (!setupState.email) {
75
+ return apiError("INVALID_STATE", "Invalid setup state. Please restart setup.", 400);
76
+ }
77
+
54
78
  // Parse request body
55
79
  const body = await parseBody(request, setupAdminVerifyBody);
56
80
  if (isParseError(body)) return body;
@@ -73,7 +97,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
73
97
  // Create the admin user
74
98
  const user = await adapter.createUser({
75
99
  email: setupState.email,
76
- name: setupState.name,
100
+ name: setupState.name ?? null,
77
101
  role: Role.ADMIN,
78
102
  emailVerified: false, // No email verification for first user
79
103
  });
@@ -84,8 +108,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
84
108
  // Mark setup as complete
85
109
  await options.set("emdash:setup_complete", true);
86
110
 
87
- // Clean up setup state
111
+ // Clean up setup state and the session nonce cookie
88
112
  await options.delete("emdash:setup_state");
113
+ cookies.delete(SETUP_NONCE_COOKIE, { path: "/_emdash/" });
89
114
 
90
115
  return apiSuccess({
91
116
  success: true,