emdash 0.5.0 → 0.7.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 (252) hide show
  1. package/dist/{adapters-C2BzVy0p.d.mts → adapters-Di31kZ28.d.mts} +16 -1
  2. package/dist/adapters-Di31kZ28.d.mts.map +1 -0
  3. package/dist/{apply-Cma_PiF6.mjs → apply-5uslYdUu.mjs} +197 -25
  4. package/dist/apply-5uslYdUu.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 +203 -33
  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 +30 -4
  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.d.mts.map +1 -1
  15. package/dist/astro/middleware/request-context.mjs +11 -4
  16. package/dist/astro/middleware/request-context.mjs.map +1 -1
  17. package/dist/astro/middleware/setup.mjs +1 -1
  18. package/dist/astro/middleware.d.mts.map +1 -1
  19. package/dist/astro/middleware.mjs +467 -186
  20. package/dist/astro/middleware.mjs.map +1 -1
  21. package/dist/astro/types.d.mts +17 -9
  22. package/dist/astro/types.d.mts.map +1 -1
  23. package/dist/{byline-WuOq9MFJ.mjs → byline-C4OVd8b3.mjs} +3 -19
  24. package/dist/byline-C4OVd8b3.mjs.map +1 -0
  25. package/dist/{bylines-C_Wsnz4L.mjs → bylines-hPTW79hw.mjs} +20 -33
  26. package/dist/bylines-hPTW79hw.mjs.map +1 -0
  27. package/dist/{cache-E3Dts-yT.mjs → cache-BkKBuIvS.mjs} +1 -1
  28. package/dist/{cache-E3Dts-yT.mjs.map → cache-BkKBuIvS.mjs.map} +1 -1
  29. package/dist/chunks-HGz06Soa.mjs +19 -0
  30. package/dist/chunks-HGz06Soa.mjs.map +1 -0
  31. package/dist/cli/index.mjs +12 -11
  32. package/dist/cli/index.mjs.map +1 -1
  33. package/dist/client/cf-access.d.mts +1 -1
  34. package/dist/client/index.d.mts +1 -1
  35. package/dist/client/index.mjs +1 -1
  36. package/dist/{config-DkxPrM9l.mjs → config-BXwuX8Bx.mjs} +1 -1
  37. package/dist/{config-DkxPrM9l.mjs.map → config-BXwuX8Bx.mjs.map} +1 -1
  38. package/dist/{connection-B4zVnQIa.mjs → connection-2igzM-AT.mjs} +19 -2
  39. package/dist/connection-2igzM-AT.mjs.map +1 -0
  40. package/dist/{content-BsBoyj8G.mjs → content-D7J5y73J.mjs} +27 -1
  41. package/dist/{content-BsBoyj8G.mjs.map → content-D7J5y73J.mjs.map} +1 -1
  42. package/dist/database/instrumentation.d.mts +45 -0
  43. package/dist/database/instrumentation.d.mts.map +1 -0
  44. package/dist/database/instrumentation.mjs +61 -0
  45. package/dist/database/instrumentation.mjs.map +1 -0
  46. package/dist/db/index.d.mts +3 -3
  47. package/dist/db/index.mjs +1 -1
  48. package/dist/db/index.mjs.map +1 -1
  49. package/dist/db/libsql.d.mts +1 -1
  50. package/dist/db/postgres.d.mts +1 -1
  51. package/dist/db/sqlite.d.mts +1 -1
  52. package/dist/db-errors-D0UT85nC.mjs +41 -0
  53. package/dist/db-errors-D0UT85nC.mjs.map +1 -0
  54. package/dist/{default-PUx9RK6u.mjs → default-CME5YdZ3.mjs} +1 -1
  55. package/dist/{default-PUx9RK6u.mjs.map → default-CME5YdZ3.mjs.map} +1 -1
  56. package/dist/{error-HBeQbVhV.mjs → error-CiYn9yDu.mjs} +1 -1
  57. package/dist/{error-HBeQbVhV.mjs.map → error-CiYn9yDu.mjs.map} +1 -1
  58. package/dist/{index-CCWzlriB.d.mts → index-De6_Xv3v.d.mts} +209 -19
  59. package/dist/index-De6_Xv3v.d.mts.map +1 -0
  60. package/dist/index.d.mts +11 -11
  61. package/dist/index.mjs +23 -21
  62. package/dist/{load-BhSSm-TS.mjs → load-CBcmDIot.mjs} +1 -1
  63. package/dist/{load-BhSSm-TS.mjs.map → load-CBcmDIot.mjs.map} +1 -1
  64. package/dist/{loader-BYzwzORf.mjs → loader-DeiBJEMe.mjs} +18 -12
  65. package/dist/loader-DeiBJEMe.mjs.map +1 -0
  66. package/dist/{manifest-schema-BsXINkQD.mjs → manifest-schema-V30qsMft.mjs} +1 -1
  67. package/dist/{manifest-schema-BsXINkQD.mjs.map → manifest-schema-V30qsMft.mjs.map} +1 -1
  68. package/dist/media/index.d.mts +1 -1
  69. package/dist/media/index.mjs +1 -1
  70. package/dist/media/local-runtime.d.mts +7 -7
  71. package/dist/{mode-CyPLdO3C.mjs → mode-CpNnGkPz.mjs} +1 -1
  72. package/dist/{mode-CyPLdO3C.mjs.map → mode-CpNnGkPz.mjs.map} +1 -1
  73. package/dist/page/index.d.mts +11 -2
  74. package/dist/page/index.d.mts.map +1 -1
  75. package/dist/page/index.mjs +23 -1
  76. package/dist/page/index.mjs.map +1 -1
  77. package/dist/{placeholder-DntBEQo7.mjs → placeholder-C-fk5hYI.mjs} +1 -1
  78. package/dist/{placeholder-DntBEQo7.mjs.map → placeholder-C-fk5hYI.mjs.map} +1 -1
  79. package/dist/{placeholder-BBCtpTES.d.mts → placeholder-tzpqGWII.d.mts} +1 -1
  80. package/dist/{placeholder-BBCtpTES.d.mts.map → placeholder-tzpqGWII.d.mts.map} +1 -1
  81. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  82. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  83. package/dist/{query-B6Vu0d2i.mjs → query-g4Ug-9j9.mjs} +79 -12
  84. package/dist/query-g4Ug-9j9.mjs.map +1 -0
  85. package/dist/{redirect-7lGhLBNZ.mjs → redirect-CN0Rt9Ob.mjs} +66 -10
  86. package/dist/redirect-CN0Rt9Ob.mjs.map +1 -0
  87. package/dist/{registry-BgnP3ysR.mjs → registry-Ci3WxVAr.mjs} +133 -97
  88. package/dist/registry-Ci3WxVAr.mjs.map +1 -0
  89. package/dist/request-cache-DiR961CV.mjs +79 -0
  90. package/dist/request-cache-DiR961CV.mjs.map +1 -0
  91. package/dist/request-context.d.mts +19 -16
  92. package/dist/request-context.d.mts.map +1 -1
  93. package/dist/request-context.mjs.map +1 -1
  94. package/dist/{runner-DYv3rX8P.d.mts → runner-BR2xKwhn.d.mts} +2 -2
  95. package/dist/{runner-DYv3rX8P.d.mts.map → runner-BR2xKwhn.d.mts.map} +1 -1
  96. package/dist/{runner-Cd-_WyDo.mjs → runner-tQ7BJ4T7.mjs} +211 -134
  97. package/dist/runner-tQ7BJ4T7.mjs.map +1 -0
  98. package/dist/runtime.d.mts +6 -6
  99. package/dist/runtime.mjs +1 -1
  100. package/dist/{search-Cn1SYvYF.mjs → search-B0effn3j.mjs} +210 -226
  101. package/dist/search-B0effn3j.mjs.map +1 -0
  102. package/dist/seed/index.d.mts +2 -2
  103. package/dist/seed/index.mjs +10 -9
  104. package/dist/seo/index.d.mts +1 -1
  105. package/dist/storage/local.d.mts +1 -1
  106. package/dist/storage/local.mjs +1 -1
  107. package/dist/storage/s3.d.mts +1 -1
  108. package/dist/storage/s3.mjs +1 -1
  109. package/dist/taxonomies-K2z0Uhnj.mjs +308 -0
  110. package/dist/taxonomies-K2z0Uhnj.mjs.map +1 -0
  111. package/dist/{tokens-DKHiCYCB.mjs → tokens-BFPFx3CA.mjs} +1 -1
  112. package/dist/{tokens-DKHiCYCB.mjs.map → tokens-BFPFx3CA.mjs.map} +1 -1
  113. package/dist/{transport-BtcQ-Z7T.mjs → transport-BykRfpyy.mjs} +1 -1
  114. package/dist/{transport-BtcQ-Z7T.mjs.map → transport-BykRfpyy.mjs.map} +1 -1
  115. package/dist/{transport-CKQA_G44.d.mts → transport-H4Iwx7tC.d.mts} +1 -1
  116. package/dist/{transport-CKQA_G44.d.mts.map → transport-H4Iwx7tC.d.mts.map} +1 -1
  117. package/dist/{types-BmkQR1En.d.mts → types-6CUZRrZP.d.mts} +1 -1
  118. package/dist/{types-BmkQR1En.d.mts.map → types-6CUZRrZP.d.mts.map} +1 -1
  119. package/dist/{types-Dz9_WMS6.mjs → types-BH2L167P.mjs} +1 -1
  120. package/dist/{types-Dz9_WMS6.mjs.map → types-BH2L167P.mjs.map} +1 -1
  121. package/dist/{types-B6BzlZxx.d.mts → types-C2v0c34j.d.mts} +10 -1
  122. package/dist/{types-B6BzlZxx.d.mts.map → types-C2v0c34j.d.mts.map} +1 -1
  123. package/dist/{types-DNZpaCBk.d.mts → types-CFWjXmus.d.mts} +1 -1
  124. package/dist/{types-DNZpaCBk.d.mts.map → types-CFWjXmus.d.mts.map} +1 -1
  125. package/dist/{types-DeG21anB.d.mts → types-CnZYHyLW.d.mts} +55 -5
  126. package/dist/types-CnZYHyLW.d.mts.map +1 -0
  127. package/dist/{types-xxCWI3j0.mjs → types-DDS4MxsT.mjs} +11 -3
  128. package/dist/types-DDS4MxsT.mjs.map +1 -0
  129. package/dist/{types-C3ronwXb.d.mts → types-DgrIP0tF.d.mts} +102 -4
  130. package/dist/types-DgrIP0tF.d.mts.map +1 -0
  131. package/dist/{validate-DuZDIxfy.mjs → validate-CqsNItbt.mjs} +2 -2
  132. package/dist/{validate-DuZDIxfy.mjs.map → validate-CqsNItbt.mjs.map} +1 -1
  133. package/dist/{validate-Db1yNL3i.d.mts → validate-kM8Pjuf7.d.mts} +5 -52
  134. package/dist/validate-kM8Pjuf7.d.mts.map +1 -0
  135. package/dist/version-BnTKdfam.mjs +7 -0
  136. package/dist/{version-CMMjTuqu.mjs.map → version-BnTKdfam.mjs.map} +1 -1
  137. package/package.json +10 -5
  138. package/src/after.ts +62 -0
  139. package/src/api/handlers/content.ts +2 -0
  140. package/src/api/handlers/oauth-authorization.ts +2 -32
  141. package/src/api/handlers/oauth-clients.ts +40 -4
  142. package/src/api/handlers/taxonomies.ts +13 -0
  143. package/src/api/oauth/redirect-uri.ts +34 -0
  144. package/src/api/openapi/document.ts +126 -118
  145. package/src/api/schemas/content.ts +8 -0
  146. package/src/api/schemas/media.ts +26 -15
  147. package/src/api/schemas/schema.ts +1 -0
  148. package/src/astro/integration/font-provider.ts +178 -0
  149. package/src/astro/integration/index.ts +44 -0
  150. package/src/astro/integration/routes.ts +6 -0
  151. package/src/astro/integration/runtime.ts +117 -0
  152. package/src/astro/integration/virtual-modules.ts +41 -39
  153. package/src/astro/integration/vite-config.ts +16 -5
  154. package/src/astro/middleware/auth.ts +33 -1
  155. package/src/astro/middleware/request-context.ts +15 -3
  156. package/src/astro/middleware.ts +340 -263
  157. package/src/astro/routes/admin.astro +21 -10
  158. package/src/astro/routes/api/auth/magic-link/send.ts +2 -1
  159. package/src/astro/routes/api/auth/passkey/options.ts +2 -1
  160. package/src/astro/routes/api/auth/passkey/verify.ts +5 -1
  161. package/src/astro/routes/api/auth/signup/request.ts +26 -8
  162. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +10 -6
  163. package/src/astro/routes/api/content/[collection]/[id]/compare.ts +1 -1
  164. package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +1 -1
  165. package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +1 -1
  166. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +5 -0
  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 +19 -1
  170. package/src/astro/routes/api/content/[collection]/trash.ts +1 -1
  171. package/src/astro/routes/api/import/wordpress/execute.ts +1 -1
  172. package/src/astro/routes/api/import/wordpress-plugin/analyze.ts +4 -3
  173. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +5 -4
  174. package/src/astro/routes/api/manifest.ts +7 -0
  175. package/src/astro/routes/api/media/upload-url.ts +10 -2
  176. package/src/astro/routes/api/media.ts +10 -7
  177. package/src/astro/routes/api/oauth/device/code.ts +2 -1
  178. package/src/astro/routes/api/oauth/device/token.ts +2 -1
  179. package/src/astro/routes/api/oauth/register.ts +178 -0
  180. package/src/astro/routes/api/oauth/token.ts +15 -0
  181. package/src/astro/routes/api/openapi.json.ts +15 -5
  182. package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +2 -0
  183. package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +1 -0
  184. package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +1 -0
  185. package/src/astro/routes/api/search/index.ts +5 -0
  186. package/src/astro/routes/api/search/suggest.ts +3 -0
  187. package/src/astro/routes/api/setup/admin-verify.ts +30 -5
  188. package/src/astro/routes/api/setup/admin.ts +32 -8
  189. package/src/astro/routes/api/setup/index.ts +5 -2
  190. package/src/astro/routes/api/taxonomies/index.ts +1 -0
  191. package/src/astro/routes/api/well-known/oauth-authorization-server.ts +1 -1
  192. package/src/astro/types.ts +9 -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/bylines/index.ts +22 -45
  197. package/src/components/EmDashHead.astro +23 -7
  198. package/src/database/connection.ts +23 -1
  199. package/src/database/instrumentation.ts +98 -0
  200. package/src/database/migrations/035_bounded_404_log.ts +112 -0
  201. package/src/database/migrations/runner.ts +2 -0
  202. package/src/database/repositories/content.ts +39 -0
  203. package/src/database/repositories/options.ts +25 -0
  204. package/src/database/repositories/redirect.ts +111 -8
  205. package/src/database/types.ts +9 -0
  206. package/src/db/adapters.ts +15 -0
  207. package/src/emdash-runtime.ts +312 -92
  208. package/src/import/registry.ts +4 -3
  209. package/src/import/ssrf.ts +253 -12
  210. package/src/index.ts +6 -0
  211. package/src/loader.ts +19 -24
  212. package/src/mcp/server.ts +76 -3
  213. package/src/menus/index.ts +6 -3
  214. package/src/page/index.ts +1 -1
  215. package/src/page/seo-contributions.ts +36 -0
  216. package/src/plugins/context.ts +15 -3
  217. package/src/plugins/manager.ts +6 -0
  218. package/src/plugins/request-meta.ts +66 -15
  219. package/src/plugins/routes.ts +3 -1
  220. package/src/query.ts +104 -7
  221. package/src/request-cache.ts +106 -0
  222. package/src/request-context.ts +19 -0
  223. package/src/schema/query.ts +5 -2
  224. package/src/schema/registry.ts +243 -166
  225. package/src/schema/types.ts +13 -2
  226. package/src/schema/zod-generator.ts +4 -0
  227. package/src/search/fts-manager.ts +19 -5
  228. package/src/search/query.ts +4 -3
  229. package/src/seed/apply.ts +41 -1
  230. package/src/settings/index.ts +24 -5
  231. package/src/taxonomies/index.ts +324 -124
  232. package/src/utils/db-errors.ts +46 -0
  233. package/src/virtual-modules.d.ts +31 -10
  234. package/src/visual-editing/toolbar.ts +6 -1
  235. package/src/widgets/index.ts +54 -25
  236. package/dist/adapters-C2BzVy0p.d.mts.map +0 -1
  237. package/dist/apply-Cma_PiF6.mjs.map +0 -1
  238. package/dist/byline-WuOq9MFJ.mjs.map +0 -1
  239. package/dist/bylines-C_Wsnz4L.mjs.map +0 -1
  240. package/dist/connection-B4zVnQIa.mjs.map +0 -1
  241. package/dist/index-CCWzlriB.d.mts.map +0 -1
  242. package/dist/loader-BYzwzORf.mjs.map +0 -1
  243. package/dist/query-B6Vu0d2i.mjs.map +0 -1
  244. package/dist/redirect-7lGhLBNZ.mjs.map +0 -1
  245. package/dist/registry-BgnP3ysR.mjs.map +0 -1
  246. package/dist/runner-Cd-_WyDo.mjs.map +0 -1
  247. package/dist/search-Cn1SYvYF.mjs.map +0 -1
  248. package/dist/types-C3ronwXb.d.mts.map +0 -1
  249. package/dist/types-DeG21anB.d.mts.map +0 -1
  250. package/dist/types-xxCWI3j0.mjs.map +0 -1
  251. package/dist/validate-Db1yNL3i.d.mts.map +0 -1
  252. package/dist/version-CMMjTuqu.mjs +0 -7
@@ -0,0 +1,178 @@
1
+ /**
2
+ * POST /_emdash/api/oauth/register
3
+ *
4
+ * RFC 7591 Dynamic Client Registration. Public, unauthenticated.
5
+ * MCP clients (e.g. Claude Code) call this to register themselves
6
+ * before starting the OAuth authorization flow.
7
+ */
8
+
9
+ import type { APIRoute } from "astro";
10
+
11
+ import { apiError, handleError } from "#api/error.js";
12
+ import { handleOAuthClientCreate } from "#api/handlers/oauth-clients.js";
13
+
14
+ export const prerender = false;
15
+
16
+ const OAUTH_REGISTRATION_HEADERS: HeadersInit = {
17
+ "Cache-Control": "no-store",
18
+ Pragma: "no-cache",
19
+ // RFC 7591 dynamic client registration is called cross-origin by MCP clients,
20
+ // CLIs, and native apps. The endpoint is anonymous and carries no ambient
21
+ // credentials, so CORS `*` is safe.
22
+ "Access-Control-Allow-Origin": "*",
23
+ };
24
+
25
+ const OAUTH_PREFLIGHT_HEADERS: HeadersInit = {
26
+ "Access-Control-Allow-Origin": "*",
27
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
28
+ "Access-Control-Allow-Headers": "Content-Type",
29
+ "Access-Control-Max-Age": "86400",
30
+ };
31
+
32
+ const SUPPORTED_GRANT_TYPES = new Set([
33
+ "authorization_code",
34
+ "refresh_token",
35
+ "urn:ietf:params:oauth:grant-type:device_code",
36
+ ]);
37
+ const SUPPORTED_RESPONSE_TYPES = new Set(["code"]);
38
+
39
+ function registrationError(description: string, status = 400): Response {
40
+ return Response.json(
41
+ {
42
+ error: "invalid_client_metadata",
43
+ error_description: description,
44
+ },
45
+ { status, headers: OAUTH_REGISTRATION_HEADERS },
46
+ );
47
+ }
48
+
49
+ function isRecord(value: unknown): value is Record<string, unknown> {
50
+ return typeof value === "object" && value !== null && !Array.isArray(value);
51
+ }
52
+
53
+ function isStringArray(value: unknown): value is string[] {
54
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
55
+ }
56
+
57
+ function parseScope(value: unknown): string[] | Response | undefined {
58
+ if (value === undefined) return undefined;
59
+ if (typeof value === "string") {
60
+ const scopes = value.split(" ").filter(Boolean);
61
+ return scopes.length > 0 ? scopes : undefined;
62
+ }
63
+ if (isStringArray(value)) {
64
+ const scopes = value.filter(Boolean);
65
+ return scopes.length > 0 ? scopes : undefined;
66
+ }
67
+ return registrationError("scope must be a string or array of strings");
68
+ }
69
+
70
+ function parseSupportedStringArray(
71
+ value: unknown,
72
+ field: string,
73
+ supported: ReadonlySet<string>,
74
+ ): string[] | Response | undefined {
75
+ if (value === undefined) return undefined;
76
+ if (!isStringArray(value)) {
77
+ return registrationError(`${field} must be an array of strings`);
78
+ }
79
+ const invalidValue = value.find((item) => !supported.has(item));
80
+ if (invalidValue) {
81
+ return registrationError(`${field} contains unsupported value: ${invalidValue}`);
82
+ }
83
+ return value;
84
+ }
85
+
86
+ export const OPTIONS: APIRoute = () => {
87
+ return new Response(null, { status: 204, headers: OAUTH_PREFLIGHT_HEADERS });
88
+ };
89
+
90
+ export const POST: APIRoute = async ({ request, locals }) => {
91
+ const { emdash } = locals;
92
+
93
+ if (!emdash?.db) {
94
+ return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
95
+ }
96
+
97
+ try {
98
+ let body: unknown;
99
+ try {
100
+ body = await request.json();
101
+ } catch {
102
+ return registrationError("Request body must be valid JSON");
103
+ }
104
+
105
+ if (!isRecord(body)) {
106
+ return registrationError("Request body must be a JSON object");
107
+ }
108
+
109
+ // redirect_uris is the only required field per RFC 7591 §2
110
+ if (!isStringArray(body.redirect_uris) || body.redirect_uris.length === 0) {
111
+ return registrationError("redirect_uris must be a non-empty array of strings");
112
+ }
113
+
114
+ if (
115
+ body.token_endpoint_auth_method !== undefined &&
116
+ body.token_endpoint_auth_method !== "none"
117
+ ) {
118
+ return registrationError("Only token_endpoint_auth_method=none is supported");
119
+ }
120
+
121
+ const grantTypes = parseSupportedStringArray(
122
+ body.grant_types,
123
+ "grant_types",
124
+ SUPPORTED_GRANT_TYPES,
125
+ );
126
+ if (grantTypes instanceof Response) {
127
+ return grantTypes;
128
+ }
129
+
130
+ const responseTypes = parseSupportedStringArray(
131
+ body.response_types,
132
+ "response_types",
133
+ SUPPORTED_RESPONSE_TYPES,
134
+ );
135
+ if (responseTypes instanceof Response) {
136
+ return responseTypes;
137
+ }
138
+
139
+ const scopes = parseScope(body.scope);
140
+ if (scopes instanceof Response) {
141
+ return scopes;
142
+ }
143
+
144
+ const clientId = crypto.randomUUID();
145
+ const clientName =
146
+ typeof body.client_name === "string" && body.client_name
147
+ ? body.client_name
148
+ : `dynamic-${clientId.slice(0, 8)}`;
149
+
150
+ const result = await handleOAuthClientCreate(emdash.db, {
151
+ id: clientId,
152
+ name: clientName,
153
+ redirectUris: body.redirect_uris,
154
+ scopes,
155
+ });
156
+
157
+ if (!result.success) {
158
+ return registrationError(result.error.message);
159
+ }
160
+
161
+ // RFC 7591 §3.2.1 response
162
+ return Response.json(
163
+ {
164
+ client_id: result.data.id,
165
+ client_id_issued_at: Math.floor(new Date(result.data.createdAt).getTime() / 1000),
166
+ redirect_uris: result.data.redirectUris,
167
+ client_name: result.data.name,
168
+ grant_types: grantTypes ?? ["authorization_code", "refresh_token"],
169
+ response_types: responseTypes ?? ["code"],
170
+ token_endpoint_auth_method: "none",
171
+ scope: result.data.scopes ? result.data.scopes.join(" ") : undefined,
172
+ },
173
+ { status: 201, headers: OAUTH_REGISTRATION_HEADERS },
174
+ );
175
+ } catch (error) {
176
+ return handleError(error, "Failed to register OAuth client", "CLIENT_REGISTER_ERROR");
177
+ }
178
+ };
@@ -87,6 +87,10 @@ const refreshSchema = z.object({
87
87
  // Handler
88
88
  // ---------------------------------------------------------------------------
89
89
 
90
+ export const OPTIONS: APIRoute = () => {
91
+ return new Response(null, { status: 204, headers: OAUTH_PREFLIGHT_HEADERS });
92
+ };
93
+
90
94
  export const POST: APIRoute = async ({ request, locals }) => {
91
95
  const { emdash } = locals;
92
96
 
@@ -166,6 +170,17 @@ const OAUTH_TOKEN_HEADERS: HeadersInit = {
166
170
  "Content-Type": "application/json",
167
171
  "Cache-Control": "no-store",
168
172
  Pragma: "no-cache",
173
+ // OAuth 2.1 token endpoint is called cross-origin by external clients. Caller
174
+ // must present PKCE code_verifier / device_code / refresh_token on each request,
175
+ // so there is no ambient credential for CSRF to exploit.
176
+ "Access-Control-Allow-Origin": "*",
177
+ };
178
+
179
+ const OAUTH_PREFLIGHT_HEADERS: HeadersInit = {
180
+ "Access-Control-Allow-Origin": "*",
181
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
182
+ "Access-Control-Allow-Headers": "Content-Type",
183
+ "Access-Control-Max-Age": "86400",
169
184
  };
170
185
 
171
186
  function oauthSuccess(data: unknown): Response {
@@ -15,13 +15,23 @@ export const prerender = false;
15
15
 
16
16
  let cachedSpec: string | null = null;
17
17
 
18
- export const GET: APIRoute = async () => {
19
- if (!cachedSpec) {
20
- const doc = generateOpenApiDocument();
21
- cachedSpec = JSON.stringify(doc);
18
+ export const GET: APIRoute = async ({ locals }) => {
19
+ const { emdash } = locals;
20
+ if (!cachedSpec && emdash) {
21
+ try {
22
+ const doc = generateOpenApiDocument({ maxUploadSize: emdash.config.maxUploadSize });
23
+ cachedSpec = JSON.stringify(doc);
24
+ } catch {
25
+ return new Response(
26
+ JSON.stringify({ error: "Failed to generate OpenAPI document: invalid configuration" }),
27
+ { status: 500, headers: { "Content-Type": "application/json" } },
28
+ );
29
+ }
22
30
  }
23
31
 
24
- return new Response(cachedSpec, {
32
+ const spec = cachedSpec ?? JSON.stringify(generateOpenApiDocument());
33
+
34
+ return new Response(spec, {
25
35
  status: 200,
26
36
  headers: {
27
37
  "Content-Type": "application/json",
@@ -57,6 +57,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
57
57
  fieldSlug,
58
58
  body as UpdateFieldInput,
59
59
  );
60
+ if (result.success) emdash!.invalidateManifest();
60
61
  return unwrapResult(result);
61
62
  };
62
63
 
@@ -72,5 +73,6 @@ export const DELETE: APIRoute = async ({ params, locals }) => {
72
73
  if (denied) return denied;
73
74
 
74
75
  const result = await handleSchemaFieldDelete(emdash!.db, collectionSlug, fieldSlug);
76
+ if (result.success) emdash!.invalidateManifest();
75
77
  return unwrapResult(result);
76
78
  };
@@ -48,5 +48,6 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
48
48
  collectionSlug,
49
49
  body as CreateFieldInput,
50
50
  );
51
+ if (result.success) emdash!.invalidateManifest();
51
52
  return unwrapResult(result, 201);
52
53
  };
@@ -28,5 +28,6 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
28
28
  if (isParseError(body)) return body;
29
29
 
30
30
  const result = await handleSchemaFieldReorder(emdash!.db, collectionSlug, body.fieldSlugs);
31
+ if (result.success) emdash!.invalidateManifest();
31
32
  return unwrapResult(result);
32
33
  };
@@ -37,6 +37,11 @@ export const GET: APIRoute = async ({ url, locals }) => {
37
37
  : undefined;
38
38
 
39
39
  try {
40
+ // Verify FTS indexes are healthy on first use. At most once per worker
41
+ // lifetime; no-op after that. Moved off the cold-start hot path to
42
+ // keep anonymous public reads fast.
43
+ await emdash.ensureSearchHealthy?.();
44
+
40
45
  const result = await searchWithDb(emdash.db, query.q, {
41
46
  collections,
42
47
  status: query.status,
@@ -36,6 +36,9 @@ export const GET: APIRoute = async ({ url, locals }) => {
36
36
  : undefined;
37
37
 
38
38
  try {
39
+ // Verify FTS indexes are healthy on first use. See search/index.ts.
40
+ await emdash.ensureSearchHealthy?.();
41
+
39
42
  const suggestions = await getSuggestions(emdash.db, query.q, {
40
43
  collections,
41
44
  locale: query.locale,
@@ -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,
@@ -8,6 +8,7 @@ import type { APIRoute } from "astro";
8
8
 
9
9
  export const prerender = false;
10
10
 
11
+ import { generateToken } from "@emdash-cms/auth";
11
12
  import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
12
13
  import { generateRegistrationOptions } from "@emdash-cms/auth/passkey";
13
14
 
@@ -17,9 +18,10 @@ import { getPublicOrigin } from "#api/public-url.js";
17
18
  import { setupAdminBody } from "#api/schemas.js";
18
19
  import { createChallengeStore } from "#auth/challenge-store.js";
19
20
  import { getPasskeyConfig } from "#auth/passkey-config.js";
21
+ import { SETUP_NONCE_COOKIE, SETUP_NONCE_MAX_AGE_SECONDS } from "#auth/setup-nonce.js";
20
22
  import { OptionsRepository } from "#db/repositories/options.js";
21
23
 
22
- export const POST: APIRoute = async ({ request, locals }) => {
24
+ export const POST: APIRoute = async ({ cookies, request, locals }) => {
23
25
  const { emdash } = locals;
24
26
 
25
27
  if (!emdash?.db) {
@@ -47,12 +49,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
47
49
  const body = await parseBody(request, setupAdminBody);
48
50
  if (isParseError(body)) return body;
49
51
 
50
- // Store admin info in setup state for later
51
- await options.set("emdash:setup_state", {
52
- step: "admin",
53
- email: body.email.toLowerCase(),
54
- name: body.name || null,
55
- });
52
+ // Mint a fresh session nonce. This binds the follow-up
53
+ // /setup/admin/verify call to the same browser that made this
54
+ // request, so an unauthenticated attacker on another host cannot
55
+ // substitute their own email into the setup state during the
56
+ // setup window. Rotates on every call so a legitimate retry
57
+ // always gets a working session.
58
+ const nonce = generateToken();
56
59
 
57
60
  // Get passkey config
58
61
  const url = new URL(request.url);
@@ -78,12 +81,33 @@ export const POST: APIRoute = async ({ request, locals }) => {
78
81
  challengeStore,
79
82
  );
80
83
 
81
- // Store the temp user ID with the setup state
84
+ // Store the nonce alongside the rest of the setup state. The verify
85
+ // endpoint will constant-time compare this with the incoming cookie.
82
86
  await options.set("emdash:setup_state", {
83
87
  step: "admin",
84
88
  email: body.email.toLowerCase(),
85
89
  name: body.name || null,
86
90
  tempUserId: tempUser.id,
91
+ nonce,
92
+ });
93
+
94
+ // HttpOnly + SameSite=Strict + path-scoped. The cookie must not be
95
+ // accessible to JS (nothing in the admin UI needs to read it) and
96
+ // must not be sent on cross-site navigations. The /_emdash/ path
97
+ // scope keeps it away from user-authored frontend code.
98
+ //
99
+ // Derive `secure` from the public origin, not the internal request
100
+ // URL. Behind a TLS-terminating reverse proxy the internal hop is
101
+ // often `http:` while the browser-facing origin is `https:` —
102
+ // using `url.protocol` there would drop the Secure flag on a
103
+ // sensitive cookie over the public HTTPS connection.
104
+ const publicOrigin = new URL(siteUrl);
105
+ cookies.set(SETUP_NONCE_COOKIE, nonce, {
106
+ path: "/_emdash/",
107
+ httpOnly: true,
108
+ sameSite: "strict",
109
+ secure: publicOrigin.protocol === "https:",
110
+ maxAge: SETUP_NONCE_MAX_AGE_SECONDS,
87
111
  });
88
112
 
89
113
  return apiSuccess({
@@ -89,9 +89,12 @@ export const POST: APIRoute = async ({ request, url, locals }) => {
89
89
  const options = new OptionsRepository(emdash.db);
90
90
 
91
91
  // Store the canonical site URL from the setup request.
92
- // This is trusted because setup runs on the real domain.
92
+ // Write-once at the DB level so concurrent setup POSTs can't both
93
+ // observe an empty value and race to write. A spoofed Host header
94
+ // on a later call during the wizard window must not be able to
95
+ // replace the first value.
93
96
  const siteUrl = getPublicOrigin(url, emdash.config);
94
- await options.set("emdash:site_url", siteUrl);
97
+ await options.setIfAbsent("emdash:site_url", siteUrl);
95
98
 
96
99
  if (useExternalAuth) {
97
100
  // External auth mode: mark setup complete now
@@ -52,6 +52,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
52
52
  if (isParseError(body)) return body;
53
53
 
54
54
  const result = await handleTaxonomyCreate(emdash.db, body);
55
+ if (result.success) emdash.invalidateManifest();
55
56
  return unwrapResult(result, 201);
56
57
  } catch (error) {
57
58
  return handleError(error, "Failed to create taxonomy", "TAXONOMY_CREATE_ERROR");
@@ -33,8 +33,8 @@ export const GET: APIRoute = async ({ url, locals }) => {
33
33
  "urn:ietf:params:oauth:grant-type:device_code",
34
34
  ],
35
35
  code_challenge_methods_supported: ["S256"],
36
+ registration_endpoint: `${origin}/_emdash/api/oauth/register`,
36
37
  token_endpoint_auth_methods_supported: ["none"],
37
- client_id_metadata_document_supported: true,
38
38
  device_authorization_endpoint: `${origin}/_emdash/api/oauth/device/code`,
39
39
  },
40
40
  {
@@ -142,6 +142,15 @@ export interface EmDashManifest {
142
142
  * When true, the admin UI can show marketplace browse/install features.
143
143
  */
144
144
  marketplace?: boolean;
145
+ /**
146
+ * Admin branding overrides for white-labeling.
147
+ * Set via the `admin` config in `astro.config.mjs`.
148
+ */
149
+ admin?: {
150
+ logo?: string;
151
+ siteName?: string;
152
+ favicon?: string;
153
+ };
145
154
  }
146
155
 
147
156
  /**
@@ -100,44 +100,72 @@ export function rateLimitResponse(retryAfterSeconds: number): Response {
100
100
  *
101
101
  * Resolution order:
102
102
  * 1. `CF-Connecting-IP` — trusted only when the Cloudflare `cf` object is
103
- * present (proving the request traversed Cloudflare's edge, which
104
- * strips/overwrites client-supplied values).
105
- * 2. `X-Forwarded-For` (first entry) — also trusted only on Cloudflare.
106
- * Without a trusted reverse proxy the header is trivially spoofable,
107
- * so we don't use it for standalone deployments.
108
- * 3. `null` no trusted IP available. Callers must handle this gracefully
103
+ * present. CF edge overwrites any client-supplied value, so this is the
104
+ * cryptographically trustworthy path on Workers. Operator-declared
105
+ * trusted headers cannot override it.
106
+ * 2. `X-Forwarded-For` (first entry) — trusted only when the `cf` object
107
+ * is present (CF sets this reliably).
108
+ * 3. Operator-declared trusted proxy headers (ordered list) used as a
109
+ * fallback for non-CF deployments behind a reverse proxy the operator
110
+ * controls. Also applies as a fill-in on CF when the CF headers are
111
+ * absent (e.g. internal cron handlers).
112
+ * 4. `null` — no trusted IP available. Callers must handle this gracefully
109
113
  * (e.g. skip rate limiting).
110
114
  *
115
+ * Pass `trustedHeaders` from `getTrustedProxyHeaders(emdash.config)` so
116
+ * self-hosted non-CF deployments can opt into reading a specific header.
117
+ *
111
118
  * Aligned with `extractRequestMeta` in `plugins/request-meta.ts`.
112
119
  */
113
- export function getClientIp(request: Request): string | null {
120
+ export function getClientIp(request: Request, trustedHeaders: string[] = []): string | null {
114
121
  const headers = request.headers;
115
122
  // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- CF Workers runtime shape
116
123
  const cf = (request as unknown as { cf?: Record<string, unknown> }).cf;
117
124
 
118
- if (!cf) {
119
- // Not on Cloudflareno trusted source of client IP
120
- return null;
121
- }
125
+ // On Cloudflare, prefer the cryptographically trustworthy headers. An
126
+ // attacker can't spoof these the CF edge strips/overwrites them.
127
+ if (cf) {
128
+ const cfIp = headers.get("cf-connecting-ip")?.trim();
129
+ if (cfIp && IP_PATTERN.test(cfIp)) {
130
+ return cfIp;
131
+ }
122
132
 
123
- // Trust CF-Connecting-IP when the cf object confirms Cloudflare
124
- const cfIp = headers.get("cf-connecting-ip")?.trim();
125
- if (cfIp && IP_PATTERN.test(cfIp)) {
126
- return cfIp;
133
+ const xff = headers.get("x-forwarded-for");
134
+ if (xff) {
135
+ const first = xff.split(",")[0]?.trim();
136
+ if (first && IP_PATTERN.test(first)) {
137
+ return first;
138
+ }
139
+ }
127
140
  }
128
141
 
129
- // Fallback to XFF on Cloudflare (CF sets this reliably)
130
- const xff = headers.get("x-forwarded-for");
131
- if (xff) {
132
- const first = xff.split(",")[0]?.trim();
133
- if (first && IP_PATTERN.test(first)) {
134
- return first;
135
- }
142
+ // Fall through to operator-declared trusted headers. On CF this fills
143
+ // in when the CF headers are absent; off-CF it's the primary source.
144
+ for (const name of trustedHeaders) {
145
+ const value = readIpFromHeader(headers, name);
146
+ if (value) return value;
136
147
  }
137
148
 
138
149
  return null;
139
150
  }
140
151
 
152
+ /**
153
+ * Read an IP from an operator-declared trusted header. XFF-style headers
154
+ * are parsed as comma-separated lists and the first entry is used.
155
+ */
156
+ function readIpFromHeader(headers: Headers, name: string): string | null {
157
+ const value = headers.get(name);
158
+ if (!value) return null;
159
+ if (name.toLowerCase().endsWith("forwarded-for")) {
160
+ const first = value.split(",")[0]?.trim();
161
+ if (!first) return null;
162
+ return IP_PATTERN.test(first) ? first : null;
163
+ }
164
+ const trimmed = value.trim();
165
+ if (!trimmed) return null;
166
+ return IP_PATTERN.test(trimmed) ? trimmed : null;
167
+ }
168
+
141
169
  /**
142
170
  * Delete expired rate limit entries.
143
171
  *
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Session binding for the first-setup admin-creation flow.
3
+ *
4
+ * Shared constants for the nonce cookie that ties /_emdash/api/setup/admin
5
+ * and /_emdash/api/setup/admin/verify to the same browser. Without this
6
+ * binding, any unauthenticated caller could POST /setup/admin during the
7
+ * setup window and substitute their own email into the stored setup state
8
+ * before the legitimate admin completes passkey verification.
9
+ *
10
+ * Implementation lives in the two route handlers; this module is just
11
+ * the name / lifetime so both ends agree.
12
+ */
13
+
14
+ /** Cookie name carrying the setup-admin session nonce. */
15
+ export const SETUP_NONCE_COOKIE = "emdash_setup_nonce";
16
+
17
+ /**
18
+ * Cookie max-age in seconds. One hour is plenty of time to complete
19
+ * a passkey registration; if the user lingers longer the admin step
20
+ * can simply be retried.
21
+ */
22
+ export const SETUP_NONCE_MAX_AGE_SECONDS = 60 * 60;