emdash 0.6.0 → 0.8.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-Bbq8TCrz.mjs +7 -0
  123. package/dist/{version-Uaf2ynPX.mjs.map → version-Bbq8TCrz.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 +18 -5
  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
@@ -29,6 +29,13 @@ const NAT64_HEX_PATTERN = /^64:ff9b::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i;
29
29
 
30
30
  const IPV6_BRACKET_PATTERN = /^\[|\]$/g;
31
31
 
32
+ /** Match fc00::/7 ULA — first byte 0xfc or 0xfd followed by any byte. */
33
+ const IPV6_ULA_FC_PATTERN = /^fc[0-9a-f]{2}:/;
34
+ const IPV6_ULA_FD_PATTERN = /^fd[0-9a-f]{2}:/;
35
+
36
+ /** Strip trailing dots from an FQDN-form hostname ("localhost." -> "localhost"). */
37
+ const TRAILING_DOT_PATTERN = /\.+$/;
38
+
32
39
  /**
33
40
  * Private and reserved IP ranges that should never be fetched.
34
41
  *
@@ -54,13 +61,35 @@ const BLOCKED_PATTERNS: Array<{ start: number; end: number }> = [
54
61
  { start: ip4ToNum(0, 0, 0, 0), end: ip4ToNum(0, 255, 255, 255) },
55
62
  ];
56
63
 
64
+ // Bracket-stripped form is used for lookups (validateExternalUrl strips
65
+ // brackets from parsed.hostname before checking), so "::1" appears here
66
+ // without brackets. The "::1" case is already covered by isPrivateIp, but
67
+ // keeping it here makes the intent explicit and gives a clearer error
68
+ // message for the common `http://[::1]/` form.
57
69
  const BLOCKED_HOSTNAMES = new Set([
58
70
  "localhost",
59
71
  "metadata.google.internal",
60
72
  "metadata.google",
61
- "[::1]",
73
+ "::1",
62
74
  ]);
63
75
 
76
+ /**
77
+ * Wildcard DNS services that publicly resolve arbitrary IPs embedded in the
78
+ * hostname. Commonly used in local dev and by SSRF exploit tooling to bypass
79
+ * hostname-only blocklists (e.g. 127.0.0.1.nip.io -> 127.0.0.1).
80
+ *
81
+ * Matched case-insensitively as a suffix, so both the apex and any subdomain
82
+ * are blocked.
83
+ */
84
+ const BLOCKED_HOSTNAME_SUFFIXES = [
85
+ "nip.io",
86
+ "sslip.io",
87
+ "xip.io",
88
+ "traefik.me",
89
+ "lvh.me",
90
+ "localtest.me",
91
+ ];
92
+
64
93
  /** Blocked URL schemes */
65
94
  const ALLOWED_SCHEMES = new Set(["http:", "https:"]);
66
95
 
@@ -115,22 +144,34 @@ export function normalizeIPv6MappedToIPv4(ip: string): string | null {
115
144
  }
116
145
 
117
146
  function isPrivateIp(ip: string): boolean {
147
+ // Normalize IPv6 strings to lowercase. `new URL().hostname` already
148
+ // lowercases, but resolver output (from DoH or an injected resolver) may
149
+ // not. Without this, "FE80::1" bypasses the link-local check.
150
+ const normalized = ip.toLowerCase();
151
+
118
152
  // Handle IPv6 loopback
119
- if (ip === "::1" || ip === "::ffff:127.0.0.1") return true;
153
+ if (normalized === "::1" || normalized === "::ffff:127.0.0.1") return true;
120
154
 
121
155
  // Handle IPv4-mapped IPv6 in hex form (WHATWG URL parser normalizes to this)
122
156
  // e.g. ::ffff:7f00:1 -> 127.0.0.1, ::ffff:a9fe:a9fe -> 169.254.169.254
123
- const hexIpv4 = normalizeIPv6MappedToIPv4(ip);
157
+ const hexIpv4 = normalizeIPv6MappedToIPv4(normalized);
124
158
  if (hexIpv4) return isPrivateIp(hexIpv4);
125
159
 
126
160
  // Handle IPv4-mapped IPv6 in dotted-decimal form
127
- const v4Match = ip.match(IPV4_MAPPED_IPV6_DOTTED_PATTERN);
128
- const ipv4 = v4Match ? v4Match[1] : ip;
161
+ const v4Match = normalized.match(IPV4_MAPPED_IPV6_DOTTED_PATTERN);
162
+ const ipv4 = v4Match ? v4Match[1] : normalized;
129
163
 
130
164
  const num = parseIpv4(ipv4);
131
165
  if (num === null) {
132
- // If we can't parse it, block IPv6 addresses that look internal
133
- return ip.startsWith("fe80:") || ip.startsWith("fc") || ip.startsWith("fd");
166
+ // If we can't parse it, block IPv6 addresses that look internal.
167
+ // fc00::/7 is Unique Local (first byte 0xfc or 0xfd), fe80::/10 is
168
+ // link-local. Only match when followed by hex digit + colon to avoid
169
+ // collisions with hypothetical non-address strings.
170
+ return (
171
+ normalized.startsWith("fe80:") ||
172
+ IPV6_ULA_FC_PATTERN.test(normalized) ||
173
+ IPV6_ULA_FD_PATTERN.test(normalized)
174
+ );
134
175
  }
135
176
 
136
177
  return BLOCKED_PATTERNS.some((range) => num >= range.start && num <= range.end);
@@ -182,19 +223,215 @@ export function validateExternalUrl(url: string): URL {
182
223
  // Strip brackets from IPv6 hostname
183
224
  const hostname = parsed.hostname.replace(IPV6_BRACKET_PATTERN, "");
184
225
 
226
+ // Normalize the hostname for blocklist matching: lowercase + strip any
227
+ // trailing dots. WHATWG preserves trailing dots on .hostname, so without
228
+ // this normalization "localhost." and "nip.io." bypass the checks.
229
+ const normalizedHost = hostname.toLowerCase().replace(TRAILING_DOT_PATTERN, "");
230
+
185
231
  // Check against known internal hostnames
186
- if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
232
+ if (BLOCKED_HOSTNAMES.has(normalizedHost)) {
187
233
  throw new SsrfError("URLs targeting internal hosts are not allowed");
188
234
  }
189
235
 
190
- // Check if hostname is an IP address in a private range
191
- if (isPrivateIp(hostname)) {
236
+ // Check against wildcard DNS services used by SSRF tooling to bypass
237
+ // hostname-only checks. Match the apex and any subdomain.
238
+ for (const suffix of BLOCKED_HOSTNAME_SUFFIXES) {
239
+ if (normalizedHost === suffix || normalizedHost.endsWith(`.${suffix}`)) {
240
+ throw new SsrfError("URLs targeting wildcard DNS services are not allowed");
241
+ }
242
+ }
243
+
244
+ // Check if hostname is an IP address in a private range. Use the
245
+ // normalized form so "127.0.0.1.." and friends don't bypass parseIpv4
246
+ // (which rejects extra trailing dots).
247
+ if (isPrivateIp(normalizedHost)) {
192
248
  throw new SsrfError("URLs targeting private IP addresses are not allowed");
193
249
  }
194
250
 
195
251
  return parsed;
196
252
  }
197
253
 
254
+ // ---------------------------------------------------------------------------
255
+ // DNS-aware validation
256
+ // ---------------------------------------------------------------------------
257
+
258
+ /**
259
+ * A resolver that maps a hostname to a list of IPv4/IPv6 addresses.
260
+ * Injectable so callers can swap in OS-level DNS on Node, stub it in tests,
261
+ * or point to a different DoH endpoint.
262
+ */
263
+ export type DnsResolver = (hostname: string) => Promise<string[]>;
264
+
265
+ /**
266
+ * Module-level default resolver. Tests can swap this with a stub so fetch
267
+ * mocks don't see unexpected DoH round-trips. Production code should leave
268
+ * it alone.
269
+ */
270
+ let defaultResolver: DnsResolver | null = null;
271
+
272
+ /** Override the default DNS resolver. Returns the previous value. */
273
+ export function setDefaultDnsResolver(resolver: DnsResolver | null): DnsResolver | null {
274
+ const previous = defaultResolver;
275
+ defaultResolver = resolver;
276
+ return previous;
277
+ }
278
+
279
+ /** Timeout for a single DoH request, in milliseconds. */
280
+ const DOH_TIMEOUT_MS = 3000;
281
+
282
+ /** Default DoH endpoint — Cloudflare's public resolver. */
283
+ const DEFAULT_DOH_URL = "https://cloudflare-dns.com/dns-query";
284
+
285
+ interface DohAnswer {
286
+ data: string;
287
+ }
288
+
289
+ interface DohResponse {
290
+ Status: number;
291
+ Answer: DohAnswer[];
292
+ }
293
+
294
+ function hasProperty<K extends string>(obj: unknown, key: K): obj is Record<K, unknown> {
295
+ return typeof obj === "object" && obj !== null && key in obj;
296
+ }
297
+
298
+ /**
299
+ * Narrow an unknown JSON body to a DohResponse shape we can read safely.
300
+ * Throws if the body doesn't look like a DoH response — a malformed body is
301
+ * indistinguishable from a failure and must not be silently treated as empty.
302
+ */
303
+ function parseDohResponse(raw: unknown): DohResponse {
304
+ if (!hasProperty(raw, "Status") || typeof raw.Status !== "number") {
305
+ throw new Error("DoH response missing Status field");
306
+ }
307
+ const answers: DohAnswer[] = [];
308
+ if (hasProperty(raw, "Answer") && Array.isArray(raw.Answer)) {
309
+ for (const entry of raw.Answer) {
310
+ if (hasProperty(entry, "data") && typeof entry.data === "string") {
311
+ answers.push({ data: entry.data });
312
+ }
313
+ }
314
+ }
315
+ return { Status: raw.Status, Answer: answers };
316
+ }
317
+
318
+ /**
319
+ * Resolve a hostname via DNS over HTTPS (Cloudflare). Returns all A and AAAA
320
+ * records. Works in both Workers and Node without requiring node:dns.
321
+ *
322
+ * Fails closed: any network error, non-2xx response, or DNS rcode != 0
323
+ * causes a rejected promise so the calling validator treats it as a block.
324
+ */
325
+ export const cloudflareDohResolver: DnsResolver = async (hostname) => {
326
+ async function query(type: "A" | "AAAA"): Promise<string[]> {
327
+ const params = new URLSearchParams({ name: hostname, type });
328
+ const controller = new AbortController();
329
+ const timeout = setTimeout(() => controller.abort(), DOH_TIMEOUT_MS);
330
+ try {
331
+ const response = await globalThis.fetch(`${DEFAULT_DOH_URL}?${params.toString()}`, {
332
+ headers: { Accept: "application/dns-json" },
333
+ signal: controller.signal,
334
+ });
335
+ if (!response.ok) {
336
+ throw new Error(`DoH lookup failed: ${response.status}`);
337
+ }
338
+ const raw = await response.json();
339
+ const body = parseDohResponse(raw);
340
+ // NXDOMAIN (3) is a legitimate "does not exist" — treat as empty.
341
+ // Any other non-zero status (SERVFAIL=2, REFUSED=5, etc.) is
342
+ // ambiguous and could be a split-view attacker hiding records
343
+ // from our resolver. Fail closed.
344
+ if (body.Status === 3) return [];
345
+ if (body.Status !== 0) {
346
+ throw new Error(`DoH ${type} lookup failed: rcode=${body.Status}`);
347
+ }
348
+ // DoH Answer arrays often include CNAME records alongside A/AAAA
349
+ // records. Their `data` is a hostname, not an IP. Filter to just
350
+ // IP literals so isPrivateIp sees real addresses.
351
+ return body.Answer.map((a) => a.data).filter(isIpLiteral);
352
+ } finally {
353
+ clearTimeout(timeout);
354
+ }
355
+ }
356
+
357
+ const [a, aaaa] = await Promise.all([query("A"), query("AAAA")]);
358
+ return [...a, ...aaaa];
359
+ };
360
+
361
+ /**
362
+ * Validate a URL and resolve its hostname to check the actual IPs against
363
+ * the private-range blocklist. This catches DNS rebinding attacks using
364
+ * attacker-controlled domains that publicly resolve to private addresses,
365
+ * and wildcard DNS services like nip.io used by exploit tooling.
366
+ *
367
+ * Runs `validateExternalUrl` first for cheap pre-flight checks (scheme,
368
+ * literal IP, known-bad hostnames). Then resolves the hostname and rejects
369
+ * if ANY returned address is private.
370
+ *
371
+ * Fails closed: if resolution fails or returns no records, throws SsrfError.
372
+ *
373
+ * **Caveats.** This does NOT fully close the TOCTOU between check and
374
+ * connect. Attacks that still work against this layer include:
375
+ *
376
+ * - TTL=0 rebind: authoritative server returns public IP to the check, then
377
+ * private IP to the subsequent fetch() a few milliseconds later.
378
+ * - Split-view via EDNS Client Subnet or source-IP inspection: the
379
+ * authoritative server returns public IP to Cloudflare's DoH resolver and
380
+ * private IP to the victim's own resolver (used by fetch()).
381
+ * - Host-file overrides or split-horizon corporate DNS on self-hosted Node.
382
+ * - Attacker-controlled rebinding services the caller has allowlisted.
383
+ *
384
+ * The only complete defense is a network-layer egress firewall. On
385
+ * Cloudflare Workers, the platform fetch pipeline provides most of that.
386
+ * On self-hosted Node, operators must restrict egress themselves.
387
+ */
388
+ export async function resolveAndValidateExternalUrl(
389
+ url: string,
390
+ options?: { resolver?: DnsResolver },
391
+ ): Promise<URL> {
392
+ const parsed = validateExternalUrl(url);
393
+
394
+ // Strip brackets from IPv6 hostnames
395
+ const hostname = parsed.hostname.replace(IPV6_BRACKET_PATTERN, "");
396
+
397
+ // If the hostname is already an IP literal, validateExternalUrl has
398
+ // already checked it against the private-range list. Skip DNS.
399
+ if (isIpLiteral(hostname)) {
400
+ return parsed;
401
+ }
402
+
403
+ const resolver = options?.resolver ?? defaultResolver ?? cloudflareDohResolver;
404
+
405
+ let addresses: string[];
406
+ try {
407
+ addresses = await resolver(hostname);
408
+ } catch (error) {
409
+ throw new SsrfError(
410
+ `Could not resolve hostname: ${error instanceof Error ? error.message : String(error)}`,
411
+ );
412
+ }
413
+
414
+ if (addresses.length === 0) {
415
+ throw new SsrfError("Hostname resolved to no addresses");
416
+ }
417
+
418
+ for (const ip of addresses) {
419
+ if (isPrivateIp(ip)) {
420
+ throw new SsrfError("Hostname resolves to a private IP address");
421
+ }
422
+ }
423
+
424
+ return parsed;
425
+ }
426
+
427
+ /** True when a string looks like an IPv4 or IPv6 literal. */
428
+ function isIpLiteral(host: string): boolean {
429
+ if (parseIpv4(host) !== null) return true;
430
+ // Very loose IPv6 heuristic — matches anything with a colon, which is
431
+ // never valid in DNS hostnames, so this is safe.
432
+ return host.includes(":");
433
+ }
434
+
198
435
  /**
199
436
  * Fetch a URL with SSRF protection on redirects.
200
437
  *
@@ -208,12 +445,16 @@ export function validateExternalUrl(url: string): URL {
208
445
  /** Headers that must be stripped when a redirect crosses origins */
209
446
  const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization"];
210
447
 
211
- export async function ssrfSafeFetch(url: string, init?: RequestInit): Promise<Response> {
448
+ export async function ssrfSafeFetch(
449
+ url: string,
450
+ init?: RequestInit,
451
+ options?: { resolver?: DnsResolver },
452
+ ): Promise<Response> {
212
453
  let currentUrl = url;
213
454
  let currentInit = init;
214
455
 
215
456
  for (let i = 0; i <= MAX_REDIRECTS; i++) {
216
- validateExternalUrl(currentUrl);
457
+ await resolveAndValidateExternalUrl(currentUrl, options);
217
458
 
218
459
  const response = await globalThis.fetch(currentUrl, {
219
460
  ...currentInit,
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export {
13
13
  ContentRepository,
14
14
  MediaRepository,
15
15
  EmDashValidationError,
16
+ InvalidCursorError,
16
17
  } from "./database/repositories/index.js";
17
18
  export type {
18
19
  ContentItem,
@@ -480,11 +481,14 @@ export type {
480
481
  SearchStats,
481
482
  } from "./search/index.js";
482
483
 
483
- // Auth types (for platform-specific auth providers)
484
+ // Auth types (for platform-specific auth providers and pluggable login methods)
484
485
  export type {
485
486
  AuthDescriptor,
487
+ AuthProviderDescriptor,
488
+ AuthProviderAdminExports,
486
489
  AuthProviderModule,
487
490
  AuthResult,
491
+ AuthRouteDescriptor,
488
492
  ExternalAuthConfig,
489
493
  } from "./auth/types.js";
490
494
 
package/src/loader.ts CHANGED
@@ -318,16 +318,17 @@ function buildOrderByClause(
318
318
  /**
319
319
  * Build a cursor WHERE condition for keyset pagination.
320
320
  * Uses the primary sort field + id as tiebreaker for stable ordering.
321
+ *
322
+ * Throws `InvalidCursorError` if the cursor is malformed; callers should
323
+ * let this propagate so users see a real error rather than silently
324
+ * falling back to the first page.
321
325
  */
322
326
  function buildCursorCondition(
323
327
  cursor: string,
324
328
  orderBy: OrderBySpec | undefined,
325
329
  tablePrefix?: string,
326
- ): ReturnType<typeof sql> | null {
327
- const decoded = decodeCursor(cursor);
328
- if (!decoded) return null;
329
-
330
- const { orderValue, id: cursorId } = decoded;
330
+ ): ReturnType<typeof sql> {
331
+ const { orderValue, id: cursorId } = decodeCursor(cursor);
331
332
  const primary = getPrimarySort(orderBy, tablePrefix);
332
333
  const idField = tablePrefix ? `${tablePrefix}.id` : "id";
333
334