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
@@ -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,17 @@ 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
+ // 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
+
56
+ // Mint a fresh session nonce. This binds the follow-up
57
+ // /setup/admin/verify call to the same browser that made this
58
+ // request, so an unauthenticated attacker on another host cannot
59
+ // substitute their own email into the setup state during the
60
+ // setup window. Rotates on every call so a legitimate retry
61
+ // always gets a working session.
62
+ const nonce = generateToken();
56
63
 
57
64
  // Get passkey config
58
65
  const url = new URL(request.url);
@@ -78,12 +85,35 @@ export const POST: APIRoute = async ({ request, locals }) => {
78
85
  challengeStore,
79
86
  );
80
87
 
81
- // Store the temp user ID with the setup state
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.
82
91
  await options.set("emdash:setup_state", {
92
+ ...existingState,
83
93
  step: "admin",
84
94
  email: body.email.toLowerCase(),
85
95
  name: body.name || null,
86
96
  tempUserId: tempUser.id,
97
+ nonce,
98
+ });
99
+
100
+ // HttpOnly + SameSite=Strict + path-scoped. The cookie must not be
101
+ // accessible to JS (nothing in the admin UI needs to read it) and
102
+ // must not be sent on cross-site navigations. The /_emdash/ path
103
+ // scope keeps it away from user-authored frontend code.
104
+ //
105
+ // Derive `secure` from the public origin, not the internal request
106
+ // URL. Behind a TLS-terminating reverse proxy the internal hop is
107
+ // often `http:` while the browser-facing origin is `https:` —
108
+ // using `url.protocol` there would drop the Secure flag on a
109
+ // sensitive cookie over the public HTTPS connection.
110
+ const publicOrigin = new URL(siteUrl);
111
+ cookies.set(SETUP_NONCE_COOKIE, nonce, {
112
+ path: "/_emdash/",
113
+ httpOnly: true,
114
+ sameSite: "strict",
115
+ secure: publicOrigin.protocol === "https:",
116
+ maxAge: SETUP_NONCE_MAX_AGE_SECONDS,
87
117
  });
88
118
 
89
119
  return apiSuccess({
@@ -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
 
@@ -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
@@ -102,7 +105,7 @@ export const POST: APIRoute = async ({ request, url, locals }) => {
102
105
  await options.set("emdash:site_tagline", body.tagline);
103
106
  }
104
107
  } else {
105
- // Passkey mode: store state for next step (admin creation)
108
+ // Passkey/provider mode: store state for next step (admin creation)
106
109
  await options.set("emdash:setup_state", {
107
110
  step: "site_complete",
108
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
  }),
@@ -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
  /**
@@ -339,6 +348,7 @@ export interface EmDashHandlers {
339
348
  // Direct access to storage and database for advanced use cases
340
349
  storage: import("../index.js").Storage | null;
341
350
  db: Kysely<import("../index.js").Database>;
351
+ getPublicMediaUrl?: (storageKey: string) => string;
342
352
 
343
353
  // Hook pipeline for plugin integrations
344
354
  hooks: import("../plugins/hooks.js").HookPipeline;
@@ -371,4 +381,12 @@ export interface EmDashHandlers {
371
381
  collectPageFragments: (
372
382
  page: import("../plugins/types.js").PublicPageContext,
373
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>;
374
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
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * GitHub OAuth Auth Provider
3
+ *
4
+ * Returns an AuthProviderDescriptor for GitHub OAuth login.
5
+ * Credentials are read from environment variables at runtime.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { github } from "emdash/auth/providers/github";
10
+ *
11
+ * emdash({
12
+ * authProviders: [github()],
13
+ * })
14
+ * ```
15
+ */
16
+
17
+ import type { AuthProviderDescriptor } from "../types.js";
18
+
19
+ /**
20
+ * Configure GitHub OAuth as an auth provider.
21
+ *
22
+ * Requires `EMDASH_OAUTH_GITHUB_CLIENT_ID` and `EMDASH_OAUTH_GITHUB_CLIENT_SECRET`
23
+ * (or `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`) environment variables.
24
+ */
25
+ export function github(): AuthProviderDescriptor {
26
+ return {
27
+ id: "github",
28
+ label: "GitHub",
29
+ adminEntry: "emdash/auth/providers/github-admin",
30
+ };
31
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Google 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 GoogleIcon({ className }: { className?: string }) {
11
+ return (
12
+ <svg className={className} viewBox="0 0 24 24">
13
+ <path
14
+ fill="#4285F4"
15
+ d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
16
+ />
17
+ <path
18
+ fill="#34A853"
19
+ d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
20
+ />
21
+ <path
22
+ fill="#FBBC05"
23
+ d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
24
+ />
25
+ <path
26
+ fill="#EA4335"
27
+ d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
28
+ />
29
+ </svg>
30
+ );
31
+ }
32
+
33
+ export function LoginButton() {
34
+ return (
35
+ <LinkButton
36
+ href="/_emdash/api/auth/oauth/google"
37
+ variant="outline"
38
+ className="w-full justify-center"
39
+ >
40
+ <GoogleIcon className="h-5 w-5" />
41
+ <span>Google</span>
42
+ </LinkButton>
43
+ );
44
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Google OAuth Auth Provider
3
+ *
4
+ * Returns an AuthProviderDescriptor for Google OAuth login.
5
+ * Credentials are read from environment variables at runtime.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { google } from "emdash/auth/providers/google";
10
+ *
11
+ * emdash({
12
+ * authProviders: [google()],
13
+ * })
14
+ * ```
15
+ */
16
+
17
+ import type { AuthProviderDescriptor } from "../types.js";
18
+
19
+ /**
20
+ * Configure Google OAuth as an auth provider.
21
+ *
22
+ * Requires `EMDASH_OAUTH_GOOGLE_CLIENT_ID` and `EMDASH_OAUTH_GOOGLE_CLIENT_SECRET`
23
+ * (or `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`) environment variables.
24
+ */
25
+ export function google(): AuthProviderDescriptor {
26
+ return {
27
+ id: "google",
28
+ label: "Google",
29
+ adminEntry: "emdash/auth/providers/google-admin",
30
+ };
31
+ }
@@ -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;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Resolve the list of client-IP headers the operator trusts.
3
+ *
4
+ * Resolution order:
5
+ * 1. `config.trustedProxyHeaders` — explicit opt-in via astro.config.mjs.
6
+ * An empty array is respected (means "trust nothing, ignore env").
7
+ * 2. `EMDASH_TRUSTED_PROXY_HEADERS` env var — comma-separated header names.
8
+ * 3. `[]` — default, no trusted headers.
9
+ *
10
+ * Operators must only set this when they control the reverse proxy.
11
+ * Untrusted clients can set any header they like; trusting headers from
12
+ * an open network defeats rate limiting.
13
+ *
14
+ * Header names are returned lowercased because HTTP header lookups are
15
+ * case-insensitive.
16
+ */
17
+
18
+ import type { EmDashConfig } from "../astro/integration/runtime.js";
19
+
20
+ /**
21
+ * RFC 7230 token — valid characters for an HTTP header name. Invalid names
22
+ * passed to `Headers.get()` throw a TypeError at runtime, which would
23
+ * otherwise surface as a 500 from every auth route.
24
+ */
25
+ const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9a-z]+$/;
26
+
27
+ /**
28
+ * Normalise a list of header names the way both the config path and any
29
+ * caller passing a pre-resolved list should do: trim, lowercase, drop
30
+ * empty, drop anything that isn't a valid RFC 7230 token. Invalid names
31
+ * would crash `Headers.get()` at runtime.
32
+ */
33
+ export function normalizeTrustedHeaders(names: readonly string[]): string[] {
34
+ return names
35
+ .map((h) => h.trim().toLowerCase())
36
+ .filter((h) => h.length > 0 && HEADER_NAME_PATTERN.test(h));
37
+ }
38
+
39
+ function isValidHeaderName(name: string): boolean {
40
+ return HEADER_NAME_PATTERN.test(name);
41
+ }
42
+
43
+ /** Cache for the env-derived value. `null` means "not yet parsed". */
44
+ let _envCache: string[] | null = null;
45
+
46
+ /** Test-only: clear the env cache so a fresh value is read on next call. */
47
+ export function _resetTrustedProxyHeadersCache(): void {
48
+ _envCache = null;
49
+ }
50
+
51
+ function getEnvTrustedHeaders(): string[] {
52
+ if (_envCache !== null) return _envCache;
53
+ let raw: string | undefined;
54
+ try {
55
+ // Prefer process.env so SSR/container deployments can override this
56
+ // value at runtime (Vite/Astro inline import.meta.env at build time,
57
+ // which locks the value into the bundle). Fall back to import.meta.env
58
+ // for bundler-managed environments where process.env isn't populated.
59
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- import.meta.env shape varies by bundler
60
+ const importMetaEnv = (import.meta as unknown as { env?: Record<string, string | undefined> })
61
+ .env;
62
+ raw =
63
+ (typeof process !== "undefined" ? process.env?.EMDASH_TRUSTED_PROXY_HEADERS : undefined) ||
64
+ importMetaEnv?.EMDASH_TRUSTED_PROXY_HEADERS;
65
+ } catch {
66
+ raw = undefined;
67
+ }
68
+ if (!raw) {
69
+ _envCache = [];
70
+ return _envCache;
71
+ }
72
+ _envCache = raw
73
+ .split(",")
74
+ .map((s) => s.trim().toLowerCase())
75
+ .filter((s) => s.length > 0 && isValidHeaderName(s));
76
+ return _envCache;
77
+ }
78
+
79
+ /**
80
+ * Return the lowercased list of headers to trust for client-IP resolution.
81
+ *
82
+ * When `config?.trustedProxyHeaders` is explicitly set (even to `[]`), it
83
+ * wins. Otherwise fall through to the env var, then to `[]`.
84
+ */
85
+ export function getTrustedProxyHeaders(config: EmDashConfig | null | undefined): string[] {
86
+ if (config && config.trustedProxyHeaders !== undefined) {
87
+ return config.trustedProxyHeaders
88
+ .map((h) => h.trim().toLowerCase())
89
+ .filter((h) => h.length > 0 && isValidHeaderName(h));
90
+ }
91
+ return getEnvTrustedHeaders();
92
+ }