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
@@ -11,7 +11,7 @@
11
11
 
12
12
  import type { MediaProvider, MediaProviderItem, MediaValue } from "./types.js";
13
13
 
14
- const INTERNAL_MEDIA_PREFIX = "/_emdash/api/media/file/";
14
+ export const INTERNAL_MEDIA_PREFIX = "/_emdash/api/media/file/";
15
15
  const URL_PATTERN = /^https?:\/\//;
16
16
 
17
17
  /**
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Public media URL resolution.
3
+ *
4
+ * Used at render time by the Image components to decide whether a storage
5
+ * key should be served from the configured `publicUrl` (R2 custom domain,
6
+ * S3 CDN) or through the internal `/_emdash/api/media/file/{key}` route.
7
+ */
8
+ import type { Storage } from "../storage/types.js";
9
+ import { INTERNAL_MEDIA_PREFIX } from "./normalize.js";
10
+
11
+ // Keys accepted by the public-URL rewrite: the `{ulid}{ext}` shape produced by
12
+ // the upload pipeline, with letters, digits, dots, dashes, and underscores.
13
+ // Slashes, `?`, `#`, and `%` are rejected so attacker-controlled content in a
14
+ // portable-text `asset.url` cannot traverse or reroute on the CDN origin.
15
+ const SAFE_STORAGE_KEY = /^[A-Za-z0-9._-]+$/;
16
+
17
+ /**
18
+ * Resolve the public URL for a locally stored media key. Returns an empty
19
+ * string when no key is given. When a storage adapter is supplied, defers to
20
+ * `storage.getPublicUrl()`; otherwise returns the internal proxy route.
21
+ */
22
+ export function resolvePublicMediaUrl(
23
+ storage: Storage | null | undefined,
24
+ storageKey: string,
25
+ ): string {
26
+ if (!storageKey) return "";
27
+ if (storage) return storage.getPublicUrl(storageKey);
28
+ return `/_emdash/api/media/file/${storageKey}`;
29
+ }
30
+
31
+ /**
32
+ * Build the `getPublicMediaUrl` closure attached to `Astro.locals.emdash`.
33
+ * Shared by the anonymous fast path and the full-runtime path in middleware.
34
+ *
35
+ * @internal
36
+ */
37
+ export function createPublicMediaUrlResolver(
38
+ storage: Storage | null | undefined,
39
+ ): (key: string) => string {
40
+ return (key) => resolvePublicMediaUrl(storage, key);
41
+ }
42
+
43
+ /** Input shape for {@link buildRenderMediaUrl}. */
44
+ export interface RenderMediaRef {
45
+ /** Storage key with extension (the canonical shape from the upload pipeline). */
46
+ storageKey?: string;
47
+ /** Pre-baked URL (either an internal proxy URL or an external URL). */
48
+ url?: string;
49
+ /** Bare media id (ULID without extension); only the internal proxy can look this up. */
50
+ id?: string;
51
+ }
52
+
53
+ /**
54
+ * Build a render-time media URL. Prefers `storageKey`, then rewrites an
55
+ * internal `url` via `resolve`, then falls back to the internal proxy for a
56
+ * bare `id`. External URLs and non-matching internal-looking URLs pass
57
+ * through untouched. Returns `""` when nothing usable is present.
58
+ *
59
+ * @internal
60
+ */
61
+ export function buildRenderMediaUrl(
62
+ resolve: ((key: string) => string) | undefined,
63
+ ref: RenderMediaRef,
64
+ ): string {
65
+ const { storageKey, url, id } = ref;
66
+ if (storageKey) {
67
+ return resolve ? resolve(storageKey) : `${INTERNAL_MEDIA_PREFIX}${storageKey}`;
68
+ }
69
+ if (url) {
70
+ if (resolve && url.startsWith(INTERNAL_MEDIA_PREFIX)) {
71
+ const key = url.slice(INTERNAL_MEDIA_PREFIX.length);
72
+ if (SAFE_STORAGE_KEY.test(key)) return resolve(key);
73
+ }
74
+ return url;
75
+ }
76
+ if (id) return `${INTERNAL_MEDIA_PREFIX}${id}`;
77
+ return "";
78
+ }
@@ -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
  /**
@@ -30,9 +30,16 @@ export interface StoredEmail {
30
30
  * instances (the runtime and the route handler may load separate copies
31
31
  * of this module, but globalThis is always the same object).
32
32
  */
33
- const GLOBAL_KEY = "__emdash_dev_emails__" as const;
34
- const storedEmails: StoredEmail[] = ((globalThis as Record<string, unknown>)[GLOBAL_KEY] ??=
35
- []) as StoredEmail[];
33
+ const GLOBAL_KEY = Symbol.for("emdash:dev-emails");
34
+ const g = globalThis as Record<symbol, unknown>;
35
+ const storedEmails: StoredEmail[] = (() => {
36
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- globalThis singleton pattern (see request-context.ts)
37
+ const existing = g[GLOBAL_KEY] as StoredEmail[] | undefined;
38
+ if (existing) return existing;
39
+ const fresh: StoredEmail[] = [];
40
+ g[GLOBAL_KEY] = fresh;
41
+ return fresh;
42
+ })();
36
43
 
37
44
  /**
38
45
  * Get all stored dev emails (most recent first).
@@ -1218,6 +1218,17 @@ export class HookPipeline {
1218
1218
  return hooks.filter((h) => h.exclusive).map((h) => ({ pluginId: h.pluginId }));
1219
1219
  }
1220
1220
 
1221
+ /**
1222
+ * Get all plugins that registered a non-exclusive handler for a given
1223
+ * hook (e.g. `email:beforeSend`, `email:afterSend`), preserving priority
1224
+ * order. Partitions with `getExclusiveHookProviders()`, which returns
1225
+ * plugins whose registration is marked `exclusive: true`.
1226
+ */
1227
+ getHookProviders(hookName: string): Array<{ pluginId: string }> {
1228
+ const hooks = this.hooks.get(hookName as HookNameV2) ?? [];
1229
+ return hooks.filter((h) => !h.exclusive).map((h) => ({ pluginId: h.pluginId }));
1230
+ }
1231
+
1221
1232
  /**
1222
1233
  * Invoke an exclusive hook — dispatch only to the selected provider.
1223
1234
  * Returns null if no provider is selected or if the selected hook
@@ -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
 
@@ -131,6 +131,18 @@ const settingFieldSchema = z.discriminatedUnion("type", [
131
131
  default: z.string().optional(),
132
132
  }),
133
133
  z.object({ ...baseSettingFields, type: z.literal("secret") }),
134
+ z.object({
135
+ ...baseSettingFields,
136
+ type: z.literal("url"),
137
+ default: z.string().optional(),
138
+ placeholder: z.string().optional(),
139
+ }),
140
+ z.object({
141
+ ...baseSettingFields,
142
+ type: z.literal("email"),
143
+ default: z.string().optional(),
144
+ placeholder: z.string().optional(),
145
+ }),
134
146
  ]);
135
147
 
136
148
  const adminPageSchema = z.object({
@@ -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
@@ -1114,7 +1114,14 @@ export interface PluginDashboardWidget {
1114
1114
  /**
1115
1115
  * Settings field types (for admin UI generation)
1116
1116
  */
1117
- export type SettingFieldType = "string" | "number" | "boolean" | "select" | "secret";
1117
+ export type SettingFieldType =
1118
+ | "string"
1119
+ | "number"
1120
+ | "boolean"
1121
+ | "select"
1122
+ | "secret"
1123
+ | "url"
1124
+ | "email";
1118
1125
 
1119
1126
  export interface BaseSettingField {
1120
1127
  type: SettingFieldType;
@@ -1150,12 +1157,26 @@ export interface SecretSettingField extends BaseSettingField {
1150
1157
  type: "secret";
1151
1158
  }
1152
1159
 
1160
+ export interface UrlSettingField extends BaseSettingField {
1161
+ type: "url";
1162
+ default?: string;
1163
+ placeholder?: string;
1164
+ }
1165
+
1166
+ export interface EmailSettingField extends BaseSettingField {
1167
+ type: "email";
1168
+ default?: string;
1169
+ placeholder?: string;
1170
+ }
1171
+
1153
1172
  export type SettingField =
1154
1173
  | StringSettingField
1155
1174
  | NumberSettingField
1156
1175
  | BooleanSettingField
1157
1176
  | SelectSettingField
1158
- | SecretSettingField;
1177
+ | SecretSettingField
1178
+ | UrlSettingField
1179
+ | EmailSettingField;
1159
1180
 
1160
1181
  /**
1161
1182
  * Block Kit element for block editing fields.
package/src/query.ts CHANGED
@@ -478,7 +478,7 @@ export async function getEmDashEntry<T extends string, D = InferCollectionData<T
478
478
  // Edit mode (authenticated editors) has collection-wide draft access.
479
479
  if (isPreviewMode && !isEditMode) {
480
480
  const dbId = entryDatabaseId(baseEntry);
481
- if (preview!.id !== dbId && preview!.id !== id) {
481
+ if (preview.id !== dbId && preview.id !== id) {
482
482
  // Token doesn't match — serve only if publicly visible, without draft access
483
483
  if (isVisible(baseEntry)) {
484
484
  return successResult(wrapEntry(baseEntry), {
@@ -22,6 +22,7 @@ type CacheStore = WeakMap<EmDashRequestContext, Map<string, Promise<unknown>>>;
22
22
  const STORE_KEY = Symbol.for("emdash:request-cache");
23
23
  const g = globalThis as Record<symbol, unknown>;
24
24
  const store: CacheStore =
25
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- globalThis singleton pattern (see request-context.ts)
25
26
  (g[STORE_KEY] as CacheStore | undefined) ??
26
27
  (() => {
27
28
  const wm: CacheStore = new WeakMap();
@@ -47,6 +48,7 @@ export function requestCached<T>(key: string, fn: () => Promise<T>): Promise<T>
47
48
  }
48
49
 
49
50
  const existing = cache.get(key);
51
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- heterogeneous cache; key namespacing guarantees the stored promise resolves to T
50
52
  if (existing) return existing as Promise<T>;
51
53
 
52
54
  const promise = Promise.resolve()
@@ -74,6 +76,7 @@ export function peekRequestCache<T>(key: string): Promise<T> | undefined {
74
76
  const ctx = getRequestContext();
75
77
  if (!ctx) return undefined;
76
78
  const cache = store.get(ctx);
79
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- heterogeneous cache; caller is responsible for using a T-compatible key
77
80
  return cache?.get(key) as Promise<T> | undefined;
78
81
  }
79
82
 
@@ -11,6 +11,7 @@ import { FTSManager } from "../search/fts-manager.js";
11
11
  import {
12
12
  type Collection,
13
13
  type CollectionSource,
14
+ type CollectionSupport,
14
15
  type ColumnType,
15
16
  type Field,
16
17
  type CreateCollectionInput,
@@ -49,6 +50,34 @@ function isColumnType(value: string): value is ColumnType {
49
50
  return COLUMN_TYPES.has(value);
50
51
  }
51
52
 
53
+ const VALID_COLLECTION_SUPPORTS: ReadonlySet<string> = new Set<CollectionSupport>([
54
+ "drafts",
55
+ "revisions",
56
+ "preview",
57
+ "scheduling",
58
+ "search",
59
+ "seo",
60
+ ]);
61
+
62
+ function isCollectionSupport(value: unknown): value is CollectionSupport {
63
+ return typeof value === "string" && VALID_COLLECTION_SUPPORTS.has(value);
64
+ }
65
+
66
+ /**
67
+ * Parse a collection's `supports` column (stored as a JSON array of
68
+ * CollectionSupport keys). Unknown/invalid entries are filtered out so the
69
+ * runtime value matches the declared `CollectionSupport[]` type.
70
+ *
71
+ * Throws on malformed JSON so corruption surfaces loudly; returns an empty
72
+ * array only for explicitly null/empty values or non-array JSON.
73
+ */
74
+ function parseSupports(raw: string | null | undefined): CollectionSupport[] {
75
+ if (!raw) return [];
76
+ const parsed: unknown = JSON.parse(raw);
77
+ if (!Array.isArray(parsed)) return [];
78
+ return parsed.filter(isCollectionSupport);
79
+ }
80
+
52
81
  /**
53
82
  * Error thrown when a schema operation fails
54
83
  */
@@ -132,11 +161,18 @@ export class SchemaRegistry {
132
161
 
133
162
  const id = ulid();
134
163
 
164
+ // Default `supports` to drafts + revisions when the caller didn't
165
+ // specify it. Explicit empty array (`[]`) is preserved as an opt-out
166
+ // — only `undefined` triggers the default. This is the canonical
167
+ // default for new collections; the MCP and admin UI layers used to
168
+ // duplicate this default but now defer to the registry.
169
+ const supports = input.supports ?? ["drafts", "revisions"];
170
+
135
171
  // Insert collection record and create content table in a transaction
136
172
  // so a failure in table creation doesn't leave an orphaned row.
137
173
  // Uses withTransaction for D1 compatibility (no transaction support).
138
174
  // Derive hasSeo from supports array if not explicitly set
139
- const hasSeo = input.hasSeo ?? input.supports?.includes("seo") ?? false;
175
+ const hasSeo = input.hasSeo ?? supports.includes("seo") ?? false;
140
176
 
141
177
  await withTransaction(this.db, async (trx) => {
142
178
  await trx
@@ -148,7 +184,7 @@ export class SchemaRegistry {
148
184
  label_singular: input.labelSingular ?? null,
149
185
  description: input.description ?? null,
150
186
  icon: input.icon ?? null,
151
- supports: input.supports ? JSON.stringify(input.supports) : null,
187
+ supports: JSON.stringify(supports),
152
188
  source: input.source ?? "manual",
153
189
  has_seo: hasSeo ? 1 : 0,
154
190
  comments_enabled: input.commentsEnabled ? 1 : 0,
@@ -243,7 +279,7 @@ export class SchemaRegistry {
243
279
  // Sync FTS state when the supports array changes (e.g. search toggled on/off)
244
280
  if (input.supports !== undefined) {
245
281
  const hadSearch = existing.supports.includes("search");
246
- const hasSearch = (JSON.parse(row.supports ?? "[]") as string[]).includes("search");
282
+ const hasSearch = parseSupports(row.supports).includes("search");
247
283
  if (hadSearch !== hasSearch) {
248
284
  await this.syncSearchState(slug, trx);
249
285
  }
@@ -525,7 +561,7 @@ export class SchemaRegistry {
525
561
  .executeTakeFirst();
526
562
  if (!row) return;
527
563
 
528
- const wantsSearch = (JSON.parse(row.supports ?? "[]") as string[]).includes("search");
564
+ const wantsSearch = parseSupports(row.supports).includes("search");
529
565
  const searchableFields = await ftsManager.getSearchableFields(collectionSlug);
530
566
  const config = await ftsManager.getSearchConfig(collectionSlug);
531
567
  const ftsActive = config?.enabled === true;
@@ -881,7 +917,7 @@ export class SchemaRegistry {
881
917
  labelSingular: row.label_singular ?? undefined,
882
918
  description: row.description ?? undefined,
883
919
  icon: row.icon ?? undefined,
884
- supports: row.supports ? JSON.parse(row.supports) : [],
920
+ supports: parseSupports(row.supports),
885
921
  source: row.source && isCollectionSource(row.source) ? row.source : undefined,
886
922
  hasSeo: row.has_seo === 1,
887
923
  urlPattern: row.url_pattern ?? undefined,
@@ -418,8 +418,6 @@ export class FTSManager {
418
418
  console.warn(
419
419
  `FTS index for "${collectionSlug}" has ${ftsRows} rows but content table has ${contentRows}. Rebuilding.`,
420
420
  );
421
- const fields = await this.getSearchableFields(collectionSlug);
422
- const config = await this.getSearchConfig(collectionSlug);
423
421
  if (fields.length > 0) {
424
422
  await this.rebuildIndex(collectionSlug, fields, config?.weights);
425
423
  }