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
@@ -16,7 +16,11 @@ import { SeoRepository } from "../database/repositories/seo.js";
16
16
  import { UserRepository } from "../database/repositories/user.js";
17
17
  import { withTransaction } from "../database/transaction.js";
18
18
  import type { Database } from "../database/types.js";
19
- import { validateExternalUrl, SsrfError, stripCredentialHeaders } from "../import/ssrf.js";
19
+ import {
20
+ resolveAndValidateExternalUrl,
21
+ SsrfError,
22
+ stripCredentialHeaders,
23
+ } from "../import/ssrf.js";
20
24
  import type { Storage } from "../storage/types.js";
21
25
  import { CronAccessImpl } from "./cron.js";
22
26
  import type { EmailPipeline } from "./email.js";
@@ -599,9 +603,10 @@ export function createUnrestrictedHttpAccess(pluginId: string): HttpAccess {
599
603
  let currentInit = init;
600
604
 
601
605
  for (let i = 0; i <= MAX_PLUGIN_REDIRECTS; i++) {
602
- // Validate each URL against SSRF rules (private IPs, metadata endpoints)
606
+ // Validate each URL against SSRF rules (private IPs, metadata
607
+ // endpoints, wildcard DNS, resolved-IP private ranges).
603
608
  try {
604
- validateExternalUrl(currentUrl);
609
+ await resolveAndValidateExternalUrl(currentUrl);
605
610
  } catch (e) {
606
611
  const msg = e instanceof SsrfError ? e.message : "SSRF validation failed";
607
612
  throw new Error(
@@ -849,6 +854,13 @@ export interface PluginContextFactoryOptions {
849
854
  * If not provided (or no provider configured), ctx.email will be undefined.
850
855
  */
851
856
  emailPipeline?: EmailPipeline;
857
+ /**
858
+ * Pre-resolved list of trusted proxy header names (from the runtime
859
+ * `EmDashConfig.trustedProxyHeaders` or the env var). Plugin route
860
+ * handlers pass this to `extractRequestMeta` so plugins see the same
861
+ * client IP the core auth path does.
862
+ */
863
+ trustedProxyHeaders?: string[];
852
864
  }
853
865
 
854
866
  /**
@@ -62,6 +62,11 @@ export interface PluginManagerOptions {
62
62
  filename: string,
63
63
  contentType: string,
64
64
  ) => Promise<{ uploadUrl: string; mediaId: string }>;
65
+ /**
66
+ * Pre-resolved list of trusted proxy header names for client-IP
67
+ * resolution in plugin route handlers. Thread through from the runtime.
68
+ */
69
+ trustedProxyHeaders?: string[];
65
70
  }
66
71
 
67
72
  /**
@@ -81,6 +86,7 @@ export class PluginManager {
81
86
  db: options.db,
82
87
  storage: options.storage,
83
88
  getUploadUrl: options.getUploadUrl,
89
+ trustedProxyHeaders: options.trustedProxyHeaders,
84
90
  };
85
91
  }
86
92
 
@@ -7,6 +7,8 @@
7
7
  *
8
8
  */
9
9
 
10
+ import type { EmDashConfig } from "../astro/integration/runtime.js";
11
+ import { getTrustedProxyHeaders, normalizeTrustedHeaders } from "../auth/trusted-proxy.js";
10
12
  import type { GeoInfo, RequestMeta } from "./types.js";
11
13
 
12
14
  /**
@@ -40,6 +42,23 @@ function parseFirstForwardedIp(header: string): string | null {
40
42
  return IP_PATTERN.test(trimmed) ? trimmed : null;
41
43
  }
42
44
 
45
+ /**
46
+ * Read an IP from an operator-declared trusted header. XFF-style headers
47
+ * (any name ending in `forwarded-for`) are parsed as comma-separated lists
48
+ * and the first entry is used; everything else is treated as a single
49
+ * trimmed value.
50
+ */
51
+ function readIpFromHeader(headers: Headers, name: string): string | null {
52
+ const value = headers.get(name);
53
+ if (!value) return null;
54
+ if (name.endsWith("forwarded-for")) {
55
+ return parseFirstForwardedIp(value);
56
+ }
57
+ const trimmed = value.trim();
58
+ if (!trimmed) return null;
59
+ return IP_PATTERN.test(trimmed) ? trimmed : null;
60
+ }
61
+
43
62
  /**
44
63
  * Get the Cloudflare `cf` object from the request, if present.
45
64
  * Returns undefined when not running on Cloudflare Workers.
@@ -69,32 +88,52 @@ function extractGeo(cf: CfProperties | undefined): GeoInfo | null {
69
88
  * Extract normalized request metadata from a Request object.
70
89
  *
71
90
  * IP resolution order:
72
- * 1. `CF-Connecting-IP` header — only trusted when a `cf` object is
73
- * present on the request (proving the request came through Cloudflare's
74
- * edge, which strips/overwrites client-supplied values).
75
- * 2. `X-Forwarded-For` header (first entry) — best-effort, spoofable
76
- * when there is no trusted reverse proxy.
77
- * 3. `null`
91
+ * 1. `CF-Connecting-IP` — trusted only when a `cf` object is present on the
92
+ * request. CF edge overwrites any client-supplied value, so this is the
93
+ * cryptographically trustworthy path on Workers. Operator-declared
94
+ * trusted headers cannot override it.
95
+ * 2. `X-Forwarded-For` first entry — trusted only with a `cf` object.
96
+ * 3. Operator-declared trusted proxy headers (from `config.trustedProxyHeaders`
97
+ * or the `EMDASH_TRUSTED_PROXY_HEADERS` env var), tried in order. Used as
98
+ * the primary source off-CF and as a fill-in on CF.
99
+ * 4. `null`
100
+ *
101
+ * The second argument accepts either the EmDash config or a pre-resolved
102
+ * list of trusted headers, so callers that already have the list don't have
103
+ * to round-trip through the config every request.
78
104
  */
79
- export function extractRequestMeta(request: Request): RequestMeta {
105
+ export function extractRequestMeta(
106
+ request: Request,
107
+ configOrTrustedHeaders?: EmDashConfig | null | { trustedProxyHeaders?: string[] } | string[],
108
+ ): RequestMeta {
80
109
  const headers = request.headers;
81
110
  const cf = getCfObject(request);
111
+ const trusted = resolveTrustedHeaders(configOrTrustedHeaders);
82
112
 
83
- // IP: only trust headers when the cf object confirms we're on Cloudflare.
84
- // Without a trusted reverse proxy, X-Forwarded-For is trivially spoofable.
85
113
  let ip: string | null = null;
114
+
115
+ // On Cloudflare, prefer the cryptographically trustworthy headers first.
86
116
  if (cf) {
87
117
  const cfIp = headers.get("cf-connecting-ip")?.trim();
88
118
  if (cfIp && IP_PATTERN.test(cfIp)) {
89
119
  ip = cfIp;
90
120
  }
121
+ if (!ip) {
122
+ const xff = headers.get("x-forwarded-for");
123
+ ip = xff ? parseFirstForwardedIp(xff) : null;
124
+ }
91
125
  }
92
- if (!ip && cf) {
93
- // Only trust X-Forwarded-For when we're behind Cloudflare (which
94
- // overwrites the header). In standalone deployments without a trusted
95
- // proxy, XFF is trivially spoofable.
96
- const xff = headers.get("x-forwarded-for");
97
- ip = xff ? parseFirstForwardedIp(xff) : null;
126
+
127
+ // Fall through to operator-declared trusted headers. On CF this fills
128
+ // in when the CF headers are absent; off-CF it's the primary source.
129
+ if (!ip) {
130
+ for (const name of trusted) {
131
+ const value = readIpFromHeader(headers, name);
132
+ if (value) {
133
+ ip = value;
134
+ break;
135
+ }
136
+ }
98
137
  }
99
138
 
100
139
  const userAgent = headers.get("user-agent")?.trim() || null;
@@ -104,6 +143,18 @@ export function extractRequestMeta(request: Request): RequestMeta {
104
143
  return { ip, userAgent, referer, geo };
105
144
  }
106
145
 
146
+ function resolveTrustedHeaders(
147
+ value: EmDashConfig | null | { trustedProxyHeaders?: string[] } | string[] | undefined,
148
+ ): string[] {
149
+ if (Array.isArray(value)) {
150
+ // Apply the same RFC 7230 validation the config/env path does so a
151
+ // caller passing a pre-resolved list with bad entries can't crash
152
+ // `Headers.get()` downstream.
153
+ return normalizeTrustedHeaders(value);
154
+ }
155
+ return getTrustedProxyHeaders(value);
156
+ }
157
+
107
158
  // =============================================================================
108
159
  // Header Sanitization for Sandbox
109
160
  // =============================================================================
@@ -50,10 +50,12 @@ export interface InvokeRouteOptions {
50
50
  export class PluginRouteHandler {
51
51
  private contextFactory: PluginContextFactory;
52
52
  private plugin: ResolvedPlugin;
53
+ private trustedProxyHeaders: string[];
53
54
 
54
55
  constructor(plugin: ResolvedPlugin, factoryOptions: PluginContextFactoryOptions) {
55
56
  this.plugin = plugin;
56
57
  this.contextFactory = new PluginContextFactory(factoryOptions);
58
+ this.trustedProxyHeaders = factoryOptions.trustedProxyHeaders ?? [];
57
59
  }
58
60
 
59
61
  /**
@@ -99,7 +101,7 @@ export class PluginRouteHandler {
99
101
  ...baseContext,
100
102
  input: validatedInput,
101
103
  request: options.request,
102
- requestMeta: extractRequestMeta(options.request),
104
+ requestMeta: extractRequestMeta(options.request, this.trustedProxyHeaders),
103
105
  };
104
106
 
105
107
  // Execute handler
package/src/query.ts CHANGED
@@ -13,7 +13,9 @@
13
13
  */
14
14
 
15
15
  import { getFallbackChain, getI18nConfig, isI18nEnabled } from "./i18n/config.js";
16
+ import { requestCached } from "./request-cache.js";
16
17
  import { getRequestContext } from "./request-context.js";
18
+ import { isMissingTableError } from "./utils/db-errors.js";
17
19
  import {
18
20
  createEditable,
19
21
  createNoop,
@@ -269,6 +271,51 @@ function entryEditOptions(entry: { data?: unknown }): EditableOptions {
269
271
  export async function getEmDashCollection<T extends string, D = InferCollectionData<T>>(
270
272
  type: T,
271
273
  filter?: CollectionFilter,
274
+ ): Promise<CollectionResult<D>> {
275
+ // Cache per (type, filter) within a single request. Edit mode and
276
+ // preview are request-scoped and stable, so they don't need to be
277
+ // part of the key. Widgets and layouts frequently request the same
278
+ // collection shape as the page itself (e.g. a "recent posts" list
279
+ // appears on the home page AND in the sidebar) — caching collapses
280
+ // those duplicate queries, along with the bylines and taxonomy-term
281
+ // hydration each call would otherwise re-do.
282
+ return requestCached(collectionCacheKey(type, filter), () =>
283
+ getEmDashCollectionUncached<T, D>(type, filter),
284
+ );
285
+ }
286
+
287
+ /**
288
+ * Build a canonical cache key for `getEmDashCollection`.
289
+ *
290
+ * `JSON.stringify` is insertion-order-sensitive, so two callers passing
291
+ * semantically identical filters with different key orders would miss
292
+ * the cache. We fix the top-level field order and sort `where` keys
293
+ * (order there is irrelevant), while preserving `orderBy` key order
294
+ * because that's the sort priority.
295
+ */
296
+ function collectionCacheKey(type: string, filter?: CollectionFilter): string {
297
+ if (!filter) return `collection:${type}:`;
298
+ const parts = [
299
+ filter.status ?? "",
300
+ filter.limit ?? "",
301
+ filter.cursor ?? "",
302
+ filter.where ? stableStringify(filter.where) : "",
303
+ filter.orderBy ? JSON.stringify(filter.orderBy) : "",
304
+ filter.locale ?? "",
305
+ ];
306
+ return `collection:${type}:${parts.join("|")}`;
307
+ }
308
+
309
+ function stableStringify(value: Record<string, unknown>): string {
310
+ const keys = Object.keys(value).toSorted();
311
+ const ordered: Record<string, unknown> = {};
312
+ for (const k of keys) ordered[k] = value[k];
313
+ return JSON.stringify(ordered);
314
+ }
315
+
316
+ async function getEmDashCollectionUncached<T extends string, D = InferCollectionData<T>>(
317
+ type: T,
318
+ filter?: CollectionFilter,
272
319
  ): Promise<CollectionResult<D>> {
273
320
  // Dynamic import to avoid build-time issues
274
321
  const { getLiveCollection } = await import("astro:content");
@@ -313,8 +360,13 @@ export async function getEmDashCollection<T extends string, D = InferCollectionD
313
360
  };
314
361
  });
315
362
 
316
- // Eagerly hydrate bylines for all entries
317
- await hydrateEntryBylines(type, entriesWithEdit);
363
+ // Eagerly hydrate bylines and taxonomy terms for all entries in parallel.
364
+ // Both are independent queries, so running them concurrently halves the
365
+ // round-trip cost on remote databases (D1 replicas, etc.).
366
+ await Promise.all([
367
+ hydrateEntryBylines(type, entriesWithEdit),
368
+ hydrateEntryTerms(type, entriesWithEdit),
369
+ ]);
318
370
 
319
371
  return { entries: entriesWithEdit, nextCursor, cacheHint: cacheHint ?? {} };
320
372
  }
@@ -386,12 +438,12 @@ export async function getEmDashEntry<T extends string, D = InferCollectionData<T
386
438
  const localeChain =
387
439
  requestedLocale && isI18nEnabled() ? getFallbackChain(requestedLocale) : [requestedLocale];
388
440
 
389
- /** Return a successful EntryResult with bylines hydrated */
441
+ /** Return a successful EntryResult with bylines and taxonomy terms hydrated */
390
442
  async function successResult(
391
443
  wrapped: ContentEntry<D>,
392
444
  opts: { isPreview: boolean; fallbackLocale?: string; cacheHint: CacheHint },
393
445
  ): Promise<EntryResult<D>> {
394
- await hydrateEntryBylines(type, [wrapped]);
446
+ await Promise.all([hydrateEntryBylines(type, [wrapped]), hydrateEntryTerms(type, [wrapped])]);
395
447
  return {
396
448
  entry: wrapped,
397
449
  isPreview: opts.isPreview,
@@ -525,14 +577,59 @@ async function hydrateEntryBylines<D>(type: string, entries: ContentEntry<D>[]):
525
577
  data.byline = credits[0]?.byline ?? null;
526
578
  }
527
579
  } catch (err) {
528
- // Only swallow "table not found" errors from pre-migration databases
529
- const msg = err instanceof Error ? err.message : "";
530
- if (!msg.includes("no such table")) {
580
+ // Only swallow "table not found" errors from pre-migration databases.
581
+ // Matches SQLite/D1 ("no such table") and PostgreSQL ("relation/table
582
+ // ... does not exist") via the shared helper.
583
+ if (!isMissingTableError(err)) {
584
+ const msg = err instanceof Error ? err.message : String(err);
531
585
  console.warn("[emdash] Failed to hydrate bylines:", msg);
532
586
  }
533
587
  }
534
588
  }
535
589
 
590
+ /**
591
+ * Eagerly hydrate taxonomy term data onto entry.data for one or more entries.
592
+ *
593
+ * Attaches `terms` (Record keyed by taxonomy name with an array of TaxonomyTerm
594
+ * values) to each entry's data object. Uses a single batched JOIN query across
595
+ * all taxonomies so the cost is O(1) regardless of the number of entries or
596
+ * taxonomies on the site.
597
+ *
598
+ * This eliminates the common N+1 pattern where templates loop over list
599
+ * results and call getEntryTerms() per entry. With hydration, the list page
600
+ * stays at a single round-trip for term data.
601
+ *
602
+ * Fails silently if the taxonomy tables don't exist yet (pre-migration).
603
+ */
604
+ async function hydrateEntryTerms<D>(type: string, entries: ContentEntry<D>[]): Promise<void> {
605
+ if (entries.length === 0) return;
606
+
607
+ try {
608
+ const { getAllTermsForEntries } = await import("./taxonomies/index.js");
609
+
610
+ const ids = entries.map((e) => dataStr(entryData(e), "id")).filter(Boolean);
611
+ if (ids.length === 0) return;
612
+
613
+ const termsMap = await getAllTermsForEntries(type, ids);
614
+
615
+ for (const entry of entries) {
616
+ const data = entryData(entry);
617
+ const dbId = dataStr(data, "id");
618
+ if (!dbId) continue;
619
+
620
+ data.terms = termsMap.get(dbId) ?? {};
621
+ }
622
+ } catch (err) {
623
+ // Only swallow "table not found" errors from pre-migration databases.
624
+ // Matches SQLite/D1 ("no such table") and PostgreSQL ("relation/table
625
+ // ... does not exist") via the shared helper.
626
+ if (!isMissingTableError(err)) {
627
+ const msg = err instanceof Error ? err.message : String(err);
628
+ console.warn("[emdash] Failed to hydrate terms:", msg);
629
+ }
630
+ }
631
+ }
632
+
536
633
  /**
537
634
  * Translation summary for a single locale variant
538
635
  */
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Per-request query cache
3
+ *
4
+ * Deduplicates identical database queries within a single page render.
5
+ * Uses the ALS request context as a WeakMap key so the cache is
6
+ * automatically GC'd when the request completes.
7
+ *
8
+ * When no request context is available (e.g. local dev without D1
9
+ * replicas), queries bypass the cache — local SQLite is fast enough
10
+ * that deduplication doesn't matter.
11
+ *
12
+ * The WeakMap is stored on globalThis with a Symbol key to guarantee
13
+ * a singleton even when bundlers duplicate this module across chunks
14
+ * (same pattern as request-context.ts).
15
+ */
16
+
17
+ import type { EmDashRequestContext } from "./request-context.js";
18
+ import { getRequestContext } from "./request-context.js";
19
+
20
+ type CacheStore = WeakMap<EmDashRequestContext, Map<string, Promise<unknown>>>;
21
+
22
+ const STORE_KEY = Symbol.for("emdash:request-cache");
23
+ const g = globalThis as Record<symbol, unknown>;
24
+ const store: CacheStore =
25
+ (g[STORE_KEY] as CacheStore | undefined) ??
26
+ (() => {
27
+ const wm: CacheStore = new WeakMap();
28
+ g[STORE_KEY] = wm;
29
+ return wm;
30
+ })();
31
+
32
+ /**
33
+ * Return a cached result for `key` if one exists in the current
34
+ * request scope, otherwise call `fn`, cache its promise, and return it.
35
+ *
36
+ * Caches the *promise*, not the resolved value, so concurrent calls
37
+ * with the same key share a single in-flight query.
38
+ */
39
+ export function requestCached<T>(key: string, fn: () => Promise<T>): Promise<T> {
40
+ const ctx = getRequestContext();
41
+ if (!ctx) return fn();
42
+
43
+ let cache = store.get(ctx);
44
+ if (!cache) {
45
+ cache = new Map();
46
+ store.set(ctx, cache);
47
+ }
48
+
49
+ const existing = cache.get(key);
50
+ if (existing) return existing as Promise<T>;
51
+
52
+ const promise = Promise.resolve()
53
+ .then(fn)
54
+ .catch((error) => {
55
+ cache.delete(key);
56
+ throw error;
57
+ });
58
+ cache.set(key, promise);
59
+ return promise;
60
+ }
61
+
62
+ /**
63
+ * Look up an entry in the request-scoped cache without inserting one.
64
+ *
65
+ * Returns the in-flight or resolved promise if the key exists in the
66
+ * current request, otherwise `undefined`. Callers can use this to
67
+ * opportunistically satisfy a narrower query (e.g. `getSiteSetting("seo")`)
68
+ * from a broader one (`getSiteSettings()`) that's already been loaded
69
+ * by a parent template — avoiding a redundant round-trip.
70
+ *
71
+ * No-ops outside a request context.
72
+ */
73
+ export function peekRequestCache<T>(key: string): Promise<T> | undefined {
74
+ const ctx = getRequestContext();
75
+ if (!ctx) return undefined;
76
+ const cache = store.get(ctx);
77
+ return cache?.get(key) as Promise<T> | undefined;
78
+ }
79
+
80
+ /**
81
+ * Pre-populate the request-scoped cache with a resolved value.
82
+ *
83
+ * Internal helper shared between hydration paths (taxonomy terms,
84
+ * bylines, etc.) that already have the data in hand and want downstream
85
+ * callers using `requestCached(key, ...)` to skip the database entirely.
86
+ * Not exported from the package entrypoint — keep it internal until we
87
+ * have a documented plugin/extension surface for hydration.
88
+ *
89
+ * No-ops outside a request context (local dev without ALS).
90
+ *
91
+ * Does not overwrite an existing entry — if a query for this key is already
92
+ * in flight, its promise wins.
93
+ */
94
+ export function setRequestCacheEntry<T>(key: string, value: T): void {
95
+ const ctx = getRequestContext();
96
+ if (!ctx) return;
97
+
98
+ let cache = store.get(ctx);
99
+ if (!cache) {
100
+ cache = new Map();
101
+ store.set(ctx, cache);
102
+ }
103
+
104
+ if (cache.has(key)) return;
105
+ cache.set(key, Promise.resolve(value));
106
+ }
@@ -17,6 +17,8 @@
17
17
 
18
18
  import { AsyncLocalStorage } from "node:async_hooks";
19
19
 
20
+ import type { QueryRecorder } from "./database/instrumentation.js";
21
+
20
22
  export interface EmDashRequestContext {
21
23
  /** Whether the current request is in visual editing mode */
22
24
  editMode: boolean;
@@ -35,6 +37,23 @@ export interface EmDashRequestContext {
35
37
  * the singleton instance. Also used by the DO preview pattern.
36
38
  */
37
39
  db?: unknown;
40
+ /**
41
+ * Indicates the per-request `db` points at an isolated database
42
+ * instance whose schema may diverge from the configured one
43
+ * (playground, DO preview sessions). When true, schema-derived caches
44
+ * (manifest, taxonomy defs, etc.) must not be reused across requests.
45
+ *
46
+ * Plain D1 Sessions API routing does NOT set this — sessions are just
47
+ * a routing hint over the same schema, so the module-scoped manifest
48
+ * cache remains valid.
49
+ */
50
+ dbIsIsolated?: boolean;
51
+ /**
52
+ * Query recorder attached by middleware when EMDASH_QUERY_LOG_FILE is set.
53
+ * The Kysely `log` hook appends an event per query; middleware flushes
54
+ * to NDJSON after the response.
55
+ */
56
+ queryRecorder?: QueryRecorder;
38
57
  }
39
58
 
40
59
  const ALS_KEY = Symbol.for("emdash:request-context");
@@ -8,6 +8,7 @@ import type { Kysely } from "kysely";
8
8
 
9
9
  import type { Database } from "../database/types.js";
10
10
  import { getDb } from "../loader.js";
11
+ import { requestCached } from "../request-cache.js";
11
12
  import { SchemaRegistry } from "./registry.js";
12
13
  import type { Collection } from "./types.js";
13
14
 
@@ -25,8 +26,10 @@ import type { Collection } from "./types.js";
25
26
  * ```
26
27
  */
27
28
  export async function getCollectionInfo(slug: string): Promise<Collection | null> {
28
- const db = await getDb();
29
- return getCollectionInfoWithDb(db, slug);
29
+ return requestCached(`collection-info:${slug}`, async () => {
30
+ const db = await getDb();
31
+ return getCollectionInfoWithDb(db, slug);
32
+ });
30
33
  }
31
34
 
32
35
  /**