fedipod 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 (276) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/architecture.svg +89 -0
  4. package/bin/fedipod.mjs +2303 -0
  5. package/cli.md +243 -0
  6. package/groups.md +85 -0
  7. package/gui.md +99 -0
  8. package/lib/account.mjs +87 -0
  9. package/lib/admin.mjs +1606 -0
  10. package/lib/atproto.mjs +310 -0
  11. package/lib/bskyfeed.mjs +166 -0
  12. package/lib/bskygroup.mjs +162 -0
  13. package/lib/c2s.mjs +305 -0
  14. package/lib/certs.mjs +265 -0
  15. package/lib/deliver.mjs +249 -0
  16. package/lib/directory.mjs +90 -0
  17. package/lib/embed.mjs +214 -0
  18. package/lib/export-collections.mjs +161 -0
  19. package/lib/front-core.mjs +322 -0
  20. package/lib/gateway-core.mjs +115 -0
  21. package/lib/guard.mjs +213 -0
  22. package/lib/home.mjs +161 -0
  23. package/lib/httpsig.mjs +109 -0
  24. package/lib/import.mjs +357 -0
  25. package/lib/intake.mjs +1710 -0
  26. package/lib/keys.mjs +139 -0
  27. package/lib/lease.mjs +190 -0
  28. package/lib/localapi.mjs +87 -0
  29. package/lib/mastoapi.mjs +1588 -0
  30. package/lib/migrate.mjs +127 -0
  31. package/lib/oidc-auth.mjs +53 -0
  32. package/lib/podrdf.mjs +132 -0
  33. package/lib/ports.mjs +27 -0
  34. package/lib/publisher.mjs +1003 -0
  35. package/lib/remote.mjs +303 -0
  36. package/lib/safefetch.mjs +235 -0
  37. package/lib/setup.mjs +262 -0
  38. package/lib/social.mjs +505 -0
  39. package/lib/storage.mjs +170 -0
  40. package/lib/store.mjs +470 -0
  41. package/lib/streaming.mjs +154 -0
  42. package/lib/tagfeed.mjs +158 -0
  43. package/lib/ua.mjs +29 -0
  44. package/lib/update.mjs +91 -0
  45. package/lib/webpush.mjs +98 -0
  46. package/lib/wire.mjs +762 -0
  47. package/package.json +60 -0
  48. package/phanpy/dist/404.html +40 -0
  49. package/phanpy/dist/_headers +2 -0
  50. package/phanpy/dist/apple-touch-icon.png +0 -0
  51. package/phanpy/dist/assets/ICONS-DIPJeU0C.js +2 -0
  52. package/phanpy/dist/assets/ICONS-DIPJeU0C.js.map +1 -0
  53. package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js +183 -0
  54. package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
  55. package/phanpy/dist/assets/Temml-CCjoacWt.woff2 +0 -0
  56. package/phanpy/dist/assets/__vite-browser-external-C7iut881.js +2 -0
  57. package/phanpy/dist/assets/__vite-browser-external-C7iut881.js.map +1 -0
  58. package/phanpy/dist/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
  59. package/phanpy/dist/assets/catch-up-O5q5C75Z.png +0 -0
  60. package/phanpy/dist/assets/chunk-aKtaBQYM.js +1 -0
  61. package/phanpy/dist/assets/compose-CFEx7wb4.js +2 -0
  62. package/phanpy/dist/assets/compose-CFEx7wb4.js.map +1 -0
  63. package/phanpy/dist/assets/compose-ZJN5xSb4.js +35 -0
  64. package/phanpy/dist/assets/compose-ZJN5xSb4.js.map +1 -0
  65. package/phanpy/dist/assets/debug.module-YFQgwnIr.js +52 -0
  66. package/phanpy/dist/assets/debug.module-YFQgwnIr.js.map +1 -0
  67. package/phanpy/dist/assets/dom-p1YcJ5Rw.js +2 -0
  68. package/phanpy/dist/assets/dom-p1YcJ5Rw.js.map +1 -0
  69. package/phanpy/dist/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
  70. package/phanpy/dist/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
  71. package/phanpy/dist/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
  72. package/phanpy/dist/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
  73. package/phanpy/dist/assets/icons/add-circle-line-WD0mwORD.js +1 -0
  74. package/phanpy/dist/assets/icons/alert-line-CpFUMf9s.js +1 -0
  75. package/phanpy/dist/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
  76. package/phanpy/dist/assets/icons/announcement-line-5nwaJFJm.js +1 -0
  77. package/phanpy/dist/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
  78. package/phanpy/dist/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
  79. package/phanpy/dist/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
  80. package/phanpy/dist/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
  81. package/phanpy/dist/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
  82. package/phanpy/dist/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
  83. package/phanpy/dist/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
  84. package/phanpy/dist/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
  85. package/phanpy/dist/assets/icons/at-line-CF66Bznk.js +1 -0
  86. package/phanpy/dist/assets/icons/attachment-line-BY8It-l9.js +1 -0
  87. package/phanpy/dist/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
  88. package/phanpy/dist/assets/icons/board-line-C0UWXyA6.js +1 -0
  89. package/phanpy/dist/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
  90. package/phanpy/dist/assets/icons/building-5-line-Dd59MzbO.js +1 -0
  91. package/phanpy/dist/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
  92. package/phanpy/dist/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
  93. package/phanpy/dist/assets/icons/calendar-month-line-vommts2C.js +1 -0
  94. package/phanpy/dist/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
  95. package/phanpy/dist/assets/icons/camera-line-Cv69MEei.js +1 -0
  96. package/phanpy/dist/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
  97. package/phanpy/dist/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
  98. package/phanpy/dist/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
  99. package/phanpy/dist/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
  100. package/phanpy/dist/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
  101. package/phanpy/dist/assets/icons/clipboard-line-BBp364gJ.js +1 -0
  102. package/phanpy/dist/assets/icons/close-circle-line-CICdiuGz.js +1 -0
  103. package/phanpy/dist/assets/icons/close-line-DYrFWcjv.js +1 -0
  104. package/phanpy/dist/assets/icons/cloud-line-tiQAbah8.js +1 -0
  105. package/phanpy/dist/assets/icons/code-line-fAl8iiV3.js +1 -0
  106. package/phanpy/dist/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
  107. package/phanpy/dist/assets/icons/copy-2-line-CLW69yF5.js +1 -0
  108. package/phanpy/dist/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
  109. package/phanpy/dist/assets/icons/document-3-line-DBGNhAg2.js +1 -0
  110. package/phanpy/dist/assets/icons/document-line-C7mb9sb7.js +1 -0
  111. package/phanpy/dist/assets/icons/down-line-BGqSSThd.js +1 -0
  112. package/phanpy/dist/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
  113. package/phanpy/dist/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
  114. package/phanpy/dist/assets/icons/exit-line-Dc07BS-x.js +1 -0
  115. package/phanpy/dist/assets/icons/external-link-line-BvpSkmjO.js +1 -0
  116. package/phanpy/dist/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
  117. package/phanpy/dist/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
  118. package/phanpy/dist/assets/icons/filter-2-line-B_mR2usy.js +1 -0
  119. package/phanpy/dist/assets/icons/filter-line-C_nm-3cU.js +1 -0
  120. package/phanpy/dist/assets/icons/flag-1-line-C2HB05I0.js +1 -0
  121. package/phanpy/dist/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
  122. package/phanpy/dist/assets/icons/formula-line-DLrt2d6j.js +1 -0
  123. package/phanpy/dist/assets/icons/grid-line-CgUmEikf.js +1 -0
  124. package/phanpy/dist/assets/icons/group-line-C5fnX0wU.js +1 -0
  125. package/phanpy/dist/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
  126. package/phanpy/dist/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
  127. package/phanpy/dist/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
  128. package/phanpy/dist/assets/icons/heart-line-CJ7K47J4.js +1 -0
  129. package/phanpy/dist/assets/icons/history-2-line-kWWvFzqv.js +1 -0
  130. package/phanpy/dist/assets/icons/history-line-bGQiwbTH.js +1 -0
  131. package/phanpy/dist/assets/icons/home-3-line-fRU1zeLG.js +1 -0
  132. package/phanpy/dist/assets/icons/information-line-iNk6-sOY.js +1 -0
  133. package/phanpy/dist/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
  134. package/phanpy/dist/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
  135. package/phanpy/dist/assets/icons/layout-5-line-B_znt5wO.js +1 -0
  136. package/phanpy/dist/assets/icons/left-line-eUw0tya4.js +1 -0
  137. package/phanpy/dist/assets/icons/lightning-line-DaKoDdix.js +1 -0
  138. package/phanpy/dist/assets/icons/link-2-line-DCqLmMA7.js +1 -0
  139. package/phanpy/dist/assets/icons/list-check-line-BAxosBh9.js +1 -0
  140. package/phanpy/dist/assets/icons/lock-line-CPoaXeUW.js +1 -0
  141. package/phanpy/dist/assets/icons/mail-line-B0P8aa1L.js +1 -0
  142. package/phanpy/dist/assets/icons/moon-line-CEqNAToP.js +1 -0
  143. package/phanpy/dist/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
  144. package/phanpy/dist/assets/icons/more-3-line-BIiVjYTz.js +1 -0
  145. package/phanpy/dist/assets/icons/notification-line-BZZq2Gtu.js +1 -0
  146. package/phanpy/dist/assets/icons/pencil-line-CKh8-A1v.js +1 -0
  147. package/phanpy/dist/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
  148. package/phanpy/dist/assets/icons/pin-line-DpTolyhs.js +1 -0
  149. package/phanpy/dist/assets/icons/play-fill-B0-G_8lX.js +1 -0
  150. package/phanpy/dist/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
  151. package/phanpy/dist/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
  152. package/phanpy/dist/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
  153. package/phanpy/dist/assets/icons/radar-line-CKyroTth.js +1 -0
  154. package/phanpy/dist/assets/icons/react-line-CoIKhhtK.js +1 -0
  155. package/phanpy/dist/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
  156. package/phanpy/dist/assets/icons/right-line-B3KWJaqc.js +1 -0
  157. package/phanpy/dist/assets/icons/rocket-line-4KZl537D.js +1 -0
  158. package/phanpy/dist/assets/icons/round-fill-C69NTrZ0.js +1 -0
  159. package/phanpy/dist/assets/icons/round-line-B9k_1uo-.js +1 -0
  160. package/phanpy/dist/assets/icons/route-line-B28ReHcE.js +1 -0
  161. package/phanpy/dist/assets/icons/rows-4-line-CrBniagM.js +1 -0
  162. package/phanpy/dist/assets/icons/scan-line-DSf74YdP.js +1 -0
  163. package/phanpy/dist/assets/icons/search-2-line-B6geIU1z.js +1 -0
  164. package/phanpy/dist/assets/icons/settings-3-line-azJwK47w.js +1 -0
  165. package/phanpy/dist/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
  166. package/phanpy/dist/assets/icons/share-2-line-D5MxWjrm.js +1 -0
  167. package/phanpy/dist/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
  168. package/phanpy/dist/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
  169. package/phanpy/dist/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
  170. package/phanpy/dist/assets/icons/time-line-BF3eet1v.js +1 -0
  171. package/phanpy/dist/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
  172. package/phanpy/dist/assets/icons/translate-line-BFWol2Ce.js +1 -0
  173. package/phanpy/dist/assets/icons/unlock-line-X_x5vwv3.js +1 -0
  174. package/phanpy/dist/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
  175. package/phanpy/dist/assets/icons/user-4-line-BVSynZCp.js +1 -0
  176. package/phanpy/dist/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
  177. package/phanpy/dist/assets/icons/user-add-line-BbXdHG4z.js +1 -0
  178. package/phanpy/dist/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
  179. package/phanpy/dist/assets/icons/user-follow-line-BXux0wSd.js +1 -0
  180. package/phanpy/dist/assets/icons/user-star-line-DdYlpHLU.js +1 -0
  181. package/phanpy/dist/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
  182. package/phanpy/dist/assets/icons/user-x-line-DIDgNlPH.js +1 -0
  183. package/phanpy/dist/assets/icons/volume-line-Bs390KQt.js +1 -0
  184. package/phanpy/dist/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
  185. package/phanpy/dist/assets/icons/walk-line-yNXfB-md.js +1 -0
  186. package/phanpy/dist/assets/icons/world-2-line-CenkGIwj.js +1 -0
  187. package/phanpy/dist/assets/icons/zoom-in-line-CaENUphf.js +1 -0
  188. package/phanpy/dist/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
  189. package/phanpy/dist/assets/instances-BNPptnn-.json +333 -0
  190. package/phanpy/dist/assets/locales/ar-SA-CfheRbCu.js +1 -0
  191. package/phanpy/dist/assets/locales/ca-ES-DHRW86Tz.js +1 -0
  192. package/phanpy/dist/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
  193. package/phanpy/dist/assets/locales/de-DE-ByRPMRFy.js +1 -0
  194. package/phanpy/dist/assets/locales/eo-UY-CPC7wLCS.js +1 -0
  195. package/phanpy/dist/assets/locales/es-ES-dJa0Fd9t.js +1 -0
  196. package/phanpy/dist/assets/locales/eu-ES-C3djlPR2.js +1 -0
  197. package/phanpy/dist/assets/locales/fa-IR-DTT490Ck.js +1 -0
  198. package/phanpy/dist/assets/locales/fi-FI-B23MCPuz.js +1 -0
  199. package/phanpy/dist/assets/locales/fr-FR-BcaQsO5C.js +1 -0
  200. package/phanpy/dist/assets/locales/gl-ES-BR-CVKBN.js +1 -0
  201. package/phanpy/dist/assets/locales/he-IL-CfheRbCu.js +1 -0
  202. package/phanpy/dist/assets/locales/hu-HU-CfheRbCu.js +1 -0
  203. package/phanpy/dist/assets/locales/it-IT-C1136UFV.js +1 -0
  204. package/phanpy/dist/assets/locales/ja-JP-CRZmRAhD.js +1 -0
  205. package/phanpy/dist/assets/locales/kab-Cz8xH_TT.js +1 -0
  206. package/phanpy/dist/assets/locales/ko-KR-CG9UiY4o.js +1 -0
  207. package/phanpy/dist/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
  208. package/phanpy/dist/assets/locales/nb-NO-CfheRbCu.js +1 -0
  209. package/phanpy/dist/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
  210. package/phanpy/dist/assets/locales/oc-FR-CfheRbCu.js +1 -0
  211. package/phanpy/dist/assets/locales/pl-PL-DkwGXf6P.js +1 -0
  212. package/phanpy/dist/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
  213. package/phanpy/dist/assets/locales/pt-BR-nGL0HdFS.js +1 -0
  214. package/phanpy/dist/assets/locales/pt-PT-Cp17aj2z.js +1 -0
  215. package/phanpy/dist/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
  216. package/phanpy/dist/assets/locales/th-TH-CfheRbCu.js +1 -0
  217. package/phanpy/dist/assets/locales/tok-CfheRbCu.js +1 -0
  218. package/phanpy/dist/assets/locales/tr-TR-CfheRbCu.js +1 -0
  219. package/phanpy/dist/assets/locales/uk-UA-C5E3dFBf.js +1 -0
  220. package/phanpy/dist/assets/locales/zh-CN-BHJi7_et.js +1 -0
  221. package/phanpy/dist/assets/locales/zh-TW-CfheRbCu.js +1 -0
  222. package/phanpy/dist/assets/main-BdqNbG-a.js +26 -0
  223. package/phanpy/dist/assets/main-BdqNbG-a.js.map +1 -0
  224. package/phanpy/dist/assets/mock-home-DGhR__He.js +2 -0
  225. package/phanpy/dist/assets/mock-home-DGhR__He.js.map +1 -0
  226. package/phanpy/dist/assets/multi-column-ROck0NVt.jpg +0 -0
  227. package/phanpy/dist/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
  228. package/phanpy/dist/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
  229. package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js +2 -0
  230. package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js.map +1 -0
  231. package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js +20 -0
  232. package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js.map +1 -0
  233. package/phanpy/dist/assets/style-B1iLPTPR.css +2 -0
  234. package/phanpy/dist/assets/temml-DuyIQMei.js +97 -0
  235. package/phanpy/dist/assets/temml-DuyIQMei.js.map +1 -0
  236. package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js +2 -0
  237. package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
  238. package/phanpy/dist/assets/year-in-posts-B2lywqdx.png +0 -0
  239. package/phanpy/dist/assets/year-in-posts-DJv_star.js +3 -0
  240. package/phanpy/dist/assets/year-in-posts-DJv_star.js.map +1 -0
  241. package/phanpy/dist/compose/index.html +20 -0
  242. package/phanpy/dist/favicon.ico +0 -0
  243. package/phanpy/dist/index.html +73 -0
  244. package/phanpy/dist/logo-192.png +0 -0
  245. package/phanpy/dist/logo-512.png +0 -0
  246. package/phanpy/dist/logo-badge-72.png +0 -0
  247. package/phanpy/dist/logo-maskable-512.png +0 -0
  248. package/phanpy/dist/logo-monochrome-512.png +0 -0
  249. package/phanpy/dist/logo-monochrome-maskable-512.png +0 -0
  250. package/phanpy/dist/manifest.webmanifest +1 -0
  251. package/phanpy/dist/og-image-2.jpg +0 -0
  252. package/phanpy/dist/og-image.png +0 -0
  253. package/phanpy/dist/robots.txt +2 -0
  254. package/phanpy/dist/sw.js +25 -0
  255. package/phanpy/dist/version.json +1 -0
  256. package/run-agent.mjs +805 -0
  257. package/scripts/build-dist.mjs +37 -0
  258. package/scripts/verify-ui.mjs +60 -0
  259. package/ui/README.md +23 -0
  260. package/vendor/gate.cjs +132 -0
  261. package/vendor/idp-grant.cjs +394 -0
  262. package/vendor/jfetch.cjs +16 -0
  263. package/web/admin/admin.js +958 -0
  264. package/web/admin/bar.css +64 -0
  265. package/web/admin/bar.js +61 -0
  266. package/web/admin/client/client.js +66 -0
  267. package/web/admin/client/index.html +42 -0
  268. package/web/admin/index.html +493 -0
  269. package/web/admin/setup/index.html +172 -0
  270. package/web/admin/setup/setup.js +268 -0
  271. package/web/admin/window.css +55 -0
  272. package/web/admin/window.js +136 -0
  273. package/web/front/install.sh +69 -0
  274. package/web/front/new-account.html +424 -0
  275. package/web/front/run.html +138 -0
  276. package/web/front/solid-client-authn.bundle.js +2 -0
package/lib/remote.mjs ADDED
@@ -0,0 +1,303 @@
1
+ // remote.mjs — authenticated I/O against the remote pod, via CSS
2
+ // client-credentials + DPoP (vendor/idp-grant.cjs, extracted from
3
+ // data-kitchen's "remember this IdP" machinery — plain Node, no Electron).
4
+
5
+ import { createRequire } from 'node:module';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import * as $rdf from 'rdflib';
9
+
10
+ const LDP = $rdf.Namespace('http://www.w3.org/ns/ldp#');
11
+ const DC = $rdf.Namespace('http://purl.org/dc/terms/');
12
+ const POSIX = $rdf.Namespace('http://www.w3.org/ns/posix/stat#');
13
+ const RDF = $rdf.Namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#');
14
+ const ACL = $rdf.Namespace('http://www.w3.org/ns/auth/acl#');
15
+ const FOAF = $rdf.Namespace('http://xmlns.com/foaf/0.1/');
16
+ const AS = $rdf.Namespace('https://www.w3.org/ns/activitystreams#');
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const vendorDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../vendor');
20
+ const { mintCredential, discoverTokenEndpoint, createGrantSession, revokeCredentialViaAccount } =
21
+ require(path.join(vendorDir, 'idp-grant.cjs'));
22
+
23
+ export { mintCredential, discoverTokenEndpoint, revokeCredentialViaAccount };
24
+
25
+ // A pod that answers 429 or 503 is asking to be left alone; Retry-After says
26
+ // for how long. Every request to this pod goes through one cooldown, so a
27
+ // server under strain gets silence rather than each timer discovering the
28
+ // refusal separately. Requests during the window fail fast — no socket is
29
+ // opened — and the callers' existing retry paths take it from there.
30
+ // The parser is shared with the outbound path; see lib/safefetch.mjs.
31
+ import { retryAfterMs, readCapped } from './safefetch.mjs';
32
+
33
+ // The inbox is public-Append, so the listing's size is in other people's
34
+ // hands; reading it whole must still have a ceiling.
35
+ const LISTING_MAX_BYTES = 10 * 1024 * 1024;
36
+
37
+ // A pod's ability to BE a pod. Not data, and never ours to remove: delete one
38
+ // of these and the pod does not degrade, it stops — and it cannot be repaired
39
+ // by the tool that broke it, because that tool can no longer authenticate. A
40
+ // pod was crippled exactly this way.
41
+ //
42
+ // Here rather than in any one script, because every DELETE this project sends
43
+ // goes through RemotePod.delete — so this is the one place that cannot be
44
+ // routed around. A DENY-list, not an allow-prefix: the next script will have a
45
+ // different prefix and the same list of things it must never touch.
46
+ const PROTECTED = [
47
+ [/\/profile(\/|$)/, 'the WebID document — nothing could authenticate as this pod again'],
48
+ [/\/settings(\/|$)/, "the pod's own settings"],
49
+ [/\/\.well-known(\/|$)/, 'discovery — the handle would stop resolving'],
50
+ [/\.acl$/, 'an access-control document — what it governs becomes unreachable'],
51
+ [/\.meta$/, 'a resource description the server itself reads'],
52
+ // The single-active-agent lock. It is on the pod precisely because the
53
+ // private half need not be — a lease only one machine can reach coordinates
54
+ // nothing — so it survives moving the state off, and nothing ever deletes it:
55
+ // release() writes an expiry, it does not remove the document. Two agents
56
+ // both believing they hold it is silent, destructive, duplicated inbox
57
+ // draining.
58
+ [/\/lease\.json$/, 'the lease — two agents would drain the same inbox'],
59
+ ];
60
+
61
+ export function protectedFromDeletion(url) {
62
+ let p;
63
+ try { p = new URL(url).pathname; } catch { throw new Error(`refusing to DELETE an unparsable URL: ${url}`); }
64
+ if (p === '/' || p === '') throw new Error(`refusing to DELETE the pod root: ${url}`);
65
+ for (const [re, why] of PROTECTED) {
66
+ if (re.test(p)) {
67
+ throw new Error(`refusing to DELETE ${url} — ${why}. Deny-list: lib/remote.mjs.`);
68
+ }
69
+ }
70
+ }
71
+
72
+ export class RemotePod {
73
+ constructor(credential, { log = () => {}, home = null, session = null } = {}) {
74
+ // An injected session replaces the credential-backed one, and everything
75
+ // below it — the cooldown, the deny-list, the url map — is unchanged. That
76
+ // is how the agent runs inside a pod server: same class, no token to mint.
77
+ this.session = session || createGrantSession(credential, home ? {
78
+ backoffFile: path.join(home, 'backoff.json'),
79
+ tokenFile: path.join(home, 'token.json'),
80
+ } : {});
81
+ this.webId = credential.webId;
82
+ this.pausedUntil = 0;
83
+ this.probeCount = 0;
84
+ this.log = log;
85
+ // A fronted identity advertises ids on a shared domain but writes to the
86
+ // pod. run-agent installs the fronted→pod mapping here, so every request
87
+ // built from an advertised id lands on the pod — one choke point, and
88
+ // publisher/social/intake stay unaware. Unset → identity, byte-for-byte
89
+ // the unfronted behaviour.
90
+ this.toPod = null;
91
+ }
92
+
93
+ // Install the advertised→pod url map for a fronted identity (urls.toPod).
94
+ setUrlMap(fn) { this.toPod = typeof fn === 'function' ? fn : null; }
95
+
96
+ async warmup() { return this.session.warmup(); }
97
+
98
+ // What we have asked of this pod, for /status and for answering an operator.
99
+ stats() {
100
+ return {
101
+ ...this.session.stats(),
102
+ probes: this.probeCount,
103
+ pausedFor: Math.max(0, Math.round((this.pausedUntil - Date.now()) / 1000)),
104
+ };
105
+ }
106
+
107
+ // A deliberately CREDENTIAL-FREE request: it asks what a stranger would see,
108
+ // so it cannot go through session.fetch without answering a different
109
+ // question. It still belongs to this pod, though — it opens a socket to it
110
+ // and takes one of its workers — so it observes the same Retry-After window
111
+ // and is counted. Ten of these ride on every profile save, and none of them
112
+ // used to appear in /status, which made podRequests under-report by design.
113
+ async probe(url, init = {}) {
114
+ const left = this.pausedUntil - Date.now();
115
+ if (left > 0) {
116
+ throw new Error(`pod asked us to back off — ${Math.ceil(left / 1000)}s left of its Retry-After`);
117
+ }
118
+ this.probeCount++;
119
+ const res = await fetch(url, init);
120
+ if (res.status === 429 || res.status === 503) {
121
+ const ms = retryAfterMs(res) ?? 60_000;
122
+ this.pausedUntil = Date.now() + ms;
123
+ this.log(`pod returned ${res.status} to an unauthenticated probe — pausing for ${Math.round(ms / 1000)}s`);
124
+ }
125
+ return res;
126
+ }
127
+
128
+ async fetch(url, init) {
129
+ const left = this.pausedUntil - Date.now();
130
+ if (left > 0) {
131
+ throw new Error(`pod asked us to back off — ${Math.ceil(left / 1000)}s left of its Retry-After`);
132
+ }
133
+ // Fronted identity: an advertised id maps to its pod location here, so the
134
+ // request lands on the pod. A no-op for a pod-native id or an unfronted
135
+ // identity (toPod unset).
136
+ if (this.toPod) url = this.toPod(url);
137
+ const res = await this.session.fetch(url, init);
138
+ if (res.status === 429 || res.status === 503) {
139
+ const ms = retryAfterMs(res) ?? 60_000; // no header: our own cooldown
140
+ this.pausedUntil = Date.now() + ms;
141
+ this.log(`pod returned ${res.status} — pausing all requests for ${Math.round(ms / 1000)}s`);
142
+ }
143
+ return res;
144
+ }
145
+
146
+ // Everything below goes through this.fetch, never session.fetch directly:
147
+ // that is the only place the Retry-After cooldown is both OBSERVED and ARMED.
148
+ // Writing straight to the session meant a pod could answer 429 to every PUT
149
+ // and DELETE we sent without any of them ever being recorded as a refusal,
150
+ // and meant a cooldown armed by a read did not stop a publish burst.
151
+ async put(url, body, contentType) {
152
+ const res = await this.fetch(url, {
153
+ method: 'PUT', headers: { 'content-type': contentType }, body,
154
+ });
155
+ if (res.status >= 400) throw new Error(`PUT ${url} → ${res.status}`);
156
+ return res;
157
+ }
158
+
159
+ async putJson(url, obj, contentType = 'application/activity+json') {
160
+ return this.put(url, JSON.stringify(obj), contentType);
161
+ }
162
+
163
+ // A read that FAILED is not a document that is ABSENT. Returning null for
164
+ // both let a 429 read as "no replies yet", and the caller then rewrote the
165
+ // collection from empty — erasing every reply already recorded. Only a real
166
+ // 404/410 is absence; anything else throws and the caller retries later.
167
+ async getJson(url) {
168
+ const res = await this.fetch(url, { headers: { accept: '*/*' } });
169
+ if (res.status === 404 || res.status === 410) return null;
170
+ if (res.status >= 400) throw new Error(`GET ${url} → ${res.status}`);
171
+ return res.json().catch(() => null);
172
+ }
173
+
174
+ async delete(url) {
175
+ protectedFromDeletion(url);
176
+ const res = await this.fetch(url, { method: 'DELETE' });
177
+ return res.status < 400 || res.status === 404;
178
+ }
179
+
180
+ // Child documents of an LDP container (URLs under it, excluding aux docs).
181
+ // Revalidated: the inbox is polled every two minutes and is usually
182
+ // unchanged, so ask conditionally and let the server answer 304.
183
+ async listContainer(url) {
184
+ this._listCache ||= new Map();
185
+ const known = this._listCache.get(url);
186
+ const res = await this.fetch(url, {
187
+ headers: { accept: 'text/turtle', ...(known?.etag ? { 'if-none-match': known.etag } : {}) },
188
+ });
189
+ if (res.status === 304 && known) return known.children;
190
+ if (res.status >= 400) return [];
191
+ // rdflib, not a regex: a container listing is RDF, and the gap between
192
+ // "any angle-bracketed thing in the document" and "what this container
193
+ // actually contains" is where quiet bugs live. claude/plans/no-regex-rdf.md
194
+ //
195
+ // CSS puts dc:modified and posix:size on every child of the same listing,
196
+ // so age and weight cost nothing extra — which is what lets the drain work
197
+ // oldest-first and lets a backlog be measured without reading any of it.
198
+ let body;
199
+ try {
200
+ body = await readCapped(res, LISTING_MAX_BYTES);
201
+ } catch (e) {
202
+ this.log(`listing at ${url}: ${e.message} — using the last known listing`);
203
+ return known?.children ?? [];
204
+ }
205
+ const g = $rdf.graph();
206
+ $rdf.parse(body, g, url, 'text/turtle');
207
+ const here = $rdf.sym(url);
208
+ const seen = new Set();
209
+ const list = [];
210
+ for (const child of g.each(here, LDP('contains'), null, here)) {
211
+ const u = child.value;
212
+ // `.receipt.json` is a verification receipt a gateway wrote beside an
213
+ // inbox item — read with the item, never enumerated as an item itself.
214
+ if (!u.startsWith(url) || u === url || /\.(acl|meta|receipt\.json)$/.test(u) || seen.has(u)) continue;
215
+ seen.add(u);
216
+ list.push({
217
+ url: u,
218
+ size: Number(g.any(child, POSIX('size'), null, here)?.value || 0),
219
+ modified: g.any(child, DC('modified'), null, here)?.value || null,
220
+ });
221
+ }
222
+ // Oldest first. An LDP listing is a set, so without this the drain works
223
+ // in whatever order the graph happened to parse — a mention from last week
224
+ // after one from today, and no way to make progress predictable.
225
+ list.sort((a, b) => String(a.modified || '').localeCompare(String(b.modified || '')));
226
+ this._listCache.set(url, { etag: res.headers.get('etag'), children: list });
227
+ return list;
228
+ }
229
+
230
+ // WAC doc granting the public `publicModes` on target, owner full control.
231
+ // An empty publicModes list yields an owner-only document.
232
+ //
233
+ // Built and serialised by rdflib, like every other document this project
234
+ // writes — see claude/plans/no-regex-rdf.md. This was the last site still
235
+ // assembling Turtle from template literals, and it is the highest-consequence
236
+ // RDF here: an ACL that comes out malformed, or naming the wrong subject,
237
+ // either locks the owner out or leaves the private trees world-readable.
238
+ // $rdf.sym() also throws on an illegal IRI, so a pod URL with something odd
239
+ // in it fails here rather than silently producing a document that means
240
+ // something else.
241
+ aclDoc(targetUrl, publicModes, { appendAgents = [] } = {}) {
242
+ const url = targetUrl + '.acl';
243
+ const doc = $rdf.sym(url);
244
+ const target = $rdf.sym(targetUrl);
245
+ const g = $rdf.graph();
246
+ const authorize = (subject, agentPred, agent, modes) => {
247
+ g.add(subject, RDF('type'), ACL('Authorization'), doc);
248
+ g.add(subject, agentPred, agent, doc);
249
+ g.add(subject, ACL('accessTo'), target, doc);
250
+ g.add(subject, ACL('default'), target, doc);
251
+ for (const m of modes) g.add(subject, ACL('mode'), ACL(m), doc);
252
+ };
253
+ if (publicModes.length) {
254
+ authorize($rdf.sym(url + '#public'), ACL('agentClass'), FOAF('Agent'), publicModes);
255
+ }
256
+ // Named agents granted Append but not public: this is how the inbox is
257
+ // locked to a gateway — public loses Append, the gateway's WebID keeps it.
258
+ appendAgents.forEach((webId, i) =>
259
+ authorize($rdf.sym(url + `#gw${i}`), ACL('agent'), $rdf.sym(webId), ['Append']));
260
+ authorize($rdf.sym(url + '#owner'), ACL('agent'), $rdf.sym(this.webId),
261
+ ['Read', 'Write', 'Control']);
262
+ return $rdf.serialize(doc, g, url, 'text/turtle');
263
+ }
264
+
265
+ async setAcl(targetUrl, publicModes, opts = {}) {
266
+ return this.put(targetUrl + '.acl', this.aclDoc(targetUrl, publicModes, opts), 'text/turtle');
267
+ }
268
+
269
+ // The WebID profile advertises the actor as an account:
270
+ // <webId> foaf:account <actor> .
271
+ // <actor> a foaf:OnlineAccount, as:Person|as:Group ; foaf:accountName "@handle@host" .
272
+ // Read–check–write through rdflib. Returns false when the profile already
273
+ // says all of it. The parsed graph must mention the WebID before anything is
274
+ // written back — an empty or foreign body must never become the new profile.
275
+ async linkAccountInProfile({ actorUrl, accountName, kind = 'person' }) {
276
+ const docUrl = this.webId.split('#')[0];
277
+ const res = await this.fetch(docUrl, { headers: { accept: 'text/turtle' } });
278
+ if (res.status >= 400) throw new Error(`GET ${docUrl} → ${res.status}`);
279
+ const g = $rdf.graph();
280
+ $rdf.parse(await res.text(), g, docUrl, 'text/turtle');
281
+ const doc = $rdf.sym(docUrl);
282
+ const me = $rdf.sym(this.webId);
283
+ if (!g.statementsMatching(me, null, null, doc).length) {
284
+ throw new Error(`profile at ${docUrl} does not mention ${this.webId} — not rewriting it`);
285
+ }
286
+ const actor = $rdf.sym(actorUrl);
287
+ const wanted = [
288
+ [me, FOAF('account'), actor],
289
+ [actor, RDF('type'), FOAF('OnlineAccount')],
290
+ [actor, RDF('type'), kind === 'group' ? AS('Group') : AS('Person')],
291
+ [actor, FOAF('accountName'), $rdf.literal(accountName)],
292
+ ];
293
+ const missing = wanted.filter(([s, p, o]) => !g.holds(s, p, o, doc));
294
+ // A handle change leaves the old accountName behind; ours is replaced.
295
+ const stale = g.statementsMatching(actor, FOAF('accountName'), null, doc)
296
+ .filter(st => st.object.value !== accountName);
297
+ if (!missing.length && !stale.length) return false;
298
+ for (const st of stale) g.remove(st);
299
+ for (const [s, p, o] of missing) g.add(s, p, o, doc);
300
+ await this.put(docUrl, $rdf.serialize(doc, g, docUrl, 'text/turtle'), 'text/turtle');
301
+ return true;
302
+ }
303
+ }
@@ -0,0 +1,235 @@
1
+ // safefetch.mjs — SSRF guard for every outbound request. The pod inbox is
2
+ // public-Append, so ANY fediverse user can hand this agent URLs that it will
3
+ // dereference (verify-by-deref) or deliver to. Without a filter those URLs
4
+ // can name the machine's own services, the LAN, or cloud metadata.
5
+ //
6
+ // Policy: http/https only; the host must resolve exclusively to public
7
+ // addresses; redirects are followed by hand so every hop is re-checked;
8
+ // responses are size-capped. AP_ALLOW_PRIVATE_TARGETS=1 disables the address
9
+ // check for local federation testing.
10
+
11
+ import { USER_AGENT } from './ua.mjs';
12
+ import dns from 'node:dns/promises';
13
+ import net from 'node:net';
14
+
15
+ const MAX_REDIRECTS = 3;
16
+ const MAX_BYTES = 5 * 1024 * 1024;
17
+
18
+ // ONE default, read from ONE place. It was 15s in publisher, 20s in intake and
19
+ // other values again in the vendor grant, so "the timeout" meant four things
20
+ // and AP_HTTP_TIMEOUT_MS moved some of them and not others.
21
+ export const HTTP_TIMEOUT_MS = Number(process.env.AP_HTTP_TIMEOUT_MS) || 20_000;
22
+
23
+ // Said once, loudly: with the flag on there is NO address check and NO pinning.
24
+ if (process.env.AP_ALLOW_PRIVATE_TARGETS === '1') {
25
+ console.warn('safefetch: AP_ALLOW_PRIVATE_TARGETS=1 — SSRF protection is OFF. '
26
+ + 'Local federation testing only; never set this on a networked deployment.');
27
+ }
28
+
29
+ // A server answering 429 or 503 is asking to be left alone, and Retry-After
30
+ // says for how long. Here rather than in remote.mjs because BOTH directions
31
+ // need it: the pod has honoured it since the incident, and the outbound path
32
+ // — the one that talks to other people's servers — did not.
33
+ const COOLDOWN_DEFAULT_MS = 60_000;
34
+ const COOLDOWN_MAX_MS = 30 * 60_000;
35
+
36
+ // null when the server sent NO Retry-After. The caller decides what absence
37
+ // means: the pod path falls back to its own 60s cooldown, and the delivery
38
+ // queue keeps its exponential ladder — which is why this cannot just return a
39
+ // default. Doing so collapsed the ladder to a flat 60s for every 503 without
40
+ // the header, which is most of them.
41
+ export function retryAfterMs(res, max = COOLDOWN_MAX_MS) {
42
+ const raw = res?.headers?.get?.('retry-after');
43
+ if (!raw) return null;
44
+ const secs = Number(raw);
45
+ if (Number.isFinite(secs)) return Math.min(Math.max(secs, 1) * 1000, max);
46
+ const when = Date.parse(raw); // HTTP-date form
47
+ if (Number.isFinite(when)) return Math.min(Math.max(when - Date.now(), 1000), max);
48
+ return null;
49
+ }
50
+
51
+ function isPrivateV4(ip) {
52
+ const [a, b] = ip.split('.').map(Number);
53
+ return a === 0 || a === 10 || a === 127
54
+ || (a === 169 && b === 254) // link-local + cloud metadata
55
+ || (a === 172 && b >= 16 && b <= 31)
56
+ || (a === 192 && b === 168)
57
+ || (a === 100 && b >= 64 && b <= 127) // CGNAT
58
+ || (a === 192 && b === 0) // 192.0.0.0/24 special-use
59
+ || a >= 224; // multicast + reserved
60
+ }
61
+
62
+ function isPrivateV6(ip) {
63
+ const a = ip.toLowerCase();
64
+ if (a === '::' || a === '::1') return true;
65
+ if (/^fe[89ab]/.test(a)) return true; // fe80::/10 link-local, all four nibbles
66
+ if (a.startsWith('fc') || a.startsWith('fd')) return true; // ULA
67
+ if (a.startsWith('fec0')) return true; // deprecated site-local, still routed by some
68
+ if (a.startsWith('ff')) return true; // multicast
69
+ // A v4-mapped address, in EITHER spelling. The dotted form was the only one
70
+ // matched, and it is the one that never arrives: the WHATWG URL parser
71
+ // normalises an IPv6 literal to compressed hex, so `[::ffff:127.0.0.1]` is
72
+ // handed to us as `::ffff:7f00:1` and walked straight past this filter — and
73
+ // with it every one of 127/8, 10/8, 172.16/12, 192.168/16 and 169.254/16.
74
+ // Canonicalise to the quad first, then classify once.
75
+ const dotted = a.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
76
+ if (dotted) return isPrivateV4(dotted[1]);
77
+ const hex = a.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
78
+ if (hex) {
79
+ const [hi, lo] = [parseInt(hex[1], 16), parseInt(hex[2], 16)];
80
+ return isPrivateV4(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
81
+ }
82
+ return false;
83
+ }
84
+
85
+ export function isPrivateAddress(ip) {
86
+ return net.isIPv4(ip) ? isPrivateV4(ip) : net.isIPv6(ip) ? isPrivateV6(ip) : true;
87
+ }
88
+
89
+ // Is this host the machine we are running on? Not a security check — the
90
+ // operator typed this URL — but the one distinction that decides whether
91
+ // plaintext is harmless. Loopback traffic never reaches a network interface,
92
+ // so `http://localhost:8000/` crosses nothing and TLS would add nothing to it.
93
+ // A LAN address is a different matter entirely: 192.168.1.50 is a real wire,
94
+ // shared with every other device on it.
95
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
96
+ export function isLoopbackHost(host) {
97
+ const h = String(host || '').toLowerCase().replace(/^\[|\]$/g, '');
98
+ if (LOOPBACK_HOSTS.has(h) || h === '[::1]') return true;
99
+ if (h.endsWith('.localhost')) return true; // RFC 6761 reserves the whole label
100
+ return /^127\./.test(h);
101
+ }
102
+
103
+ /**
104
+ * Refuse a pod, issuer or private-root address that would carry secrets in
105
+ * clear over a real network. Returns a reason string, or null when acceptable.
106
+ *
107
+ * The pod URL is where the account password is exchanged for a credential and
108
+ * where that credential is used from then on; the issuer is where the password
109
+ * itself is posted. Nothing checked the scheme, so a typed `http://` sent both
110
+ * in plaintext with no warning. Loopback is exempt because it cannot leak, and
111
+ * because a pod on this machine is a documented, ordinary way to run this.
112
+ */
113
+ export function insecureUrlReason(url, what = 'address') {
114
+ if (!url) return null;
115
+ let u;
116
+ try { u = new URL(url); } catch { return `"${url}" is not a ${what}`; }
117
+ if (u.protocol === 'https:') return null;
118
+ if (u.protocol !== 'http:') return `the ${what} must be http(s), not ${u.protocol}`;
119
+ if (isLoopbackHost(u.hostname)) return null; // never crosses a wire
120
+ return `refusing an unencrypted ${what}: ${u.origin} would send your password, `
121
+ + 'your credential and everything after it in clear over the network. '
122
+ + 'Use https:// — or a loopback address if the pod is on this machine.';
123
+ }
124
+
125
+ // Throws when the URL must not be requested; otherwise returns the
126
+ // validated address to pin the connection to ({address, family}), or null
127
+ // when the URL already names a literal IP.
128
+ export async function assertPublicUrl(url) {
129
+ if (process.env.AP_ALLOW_PRIVATE_TARGETS === '1') return null;
130
+ let u;
131
+ try { u = new URL(url); } catch { throw new Error(`unparsable URL: ${url}`); }
132
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error(`blocked scheme: ${u.protocol}`);
133
+ const host = u.hostname.replace(/^\[|\]$/g, '');
134
+ if (net.isIP(host)) {
135
+ if (isPrivateAddress(host)) throw new Error(`blocked private address: ${host}`);
136
+ return null;
137
+ }
138
+ let addrs;
139
+ try { addrs = await dns.lookup(host, { all: true }); }
140
+ catch (e) { throw new Error(`DNS lookup failed for ${host}: ${e.message}`); }
141
+ if (!addrs.length) throw new Error(`no addresses for ${host}`);
142
+ // ALL addresses must be public — a name resolving to both public and
143
+ // private answers is a rebinding attempt.
144
+ for (const a of addrs) {
145
+ if (isPrivateAddress(a.address)) throw new Error(`blocked private address: ${host} → ${a.address}`);
146
+ }
147
+ return { address: addrs[0].address, family: addrs[0].family };
148
+ }
149
+
150
+ // Validating a hostname and then handing that NAME to fetch leaves a
151
+ // rebinding window: the resolver can answer publicly for our check and
152
+ // privately for the connection. Pinning the dispatcher's lookup to the
153
+ // address we validated closes it, while TLS/SNI still see the real
154
+ // hostname (so certificates verify normally).
155
+ // Declared in package.json since 2026-08-03. It had been arriving as a
156
+ // four-level transitive dependency of rdflib's, which meant the rebinding
157
+ // protection below rested on a package nothing had asked for — and the catch
158
+ // swallowed its absence, so losing it would have been silent. Say so instead:
159
+ // the address check still runs, but the connection is no longer pinned to the
160
+ // address that passed it.
161
+ let Agent = null;
162
+ try { ({ Agent } = await import('undici')); }
163
+ catch {
164
+ console.warn('safefetch: undici is not available — addresses are still checked, '
165
+ + 'but connections are NOT pinned to the checked address, so a name that '
166
+ + 'resolves differently on the second lookup is not caught');
167
+ }
168
+
169
+ function pinnedDispatcher(address, family) {
170
+ if (!Agent) return undefined;
171
+ // The connect layer calls lookup either callback-style (err, address,
172
+ // family) or, with options.all, expecting an array of records.
173
+ const lookup = (_host, opts, cb) => (opts && opts.all
174
+ ? cb(null, [{ address, family }])
175
+ : cb(null, address, family));
176
+ return new Agent({ connect: { lookup } });
177
+ }
178
+
179
+ /** Resolve + validate, returning a dispatcher pinned to the checked address. */
180
+ export async function pinnedFor(url) {
181
+ if (process.env.AP_ALLOW_PRIVATE_TARGETS === '1') return undefined;
182
+ const addr = await assertPublicUrl(url);
183
+ return addr ? pinnedDispatcher(addr.address, addr.family) : undefined;
184
+ }
185
+
186
+ /** Read a response body with a hard byte budget (streams are unbounded). */
187
+ export async function readCapped(res, max = MAX_BYTES) {
188
+ const len = Number(res.headers?.get?.('content-length') || 0);
189
+ if (len > max) throw new Error(`response too large (${len} bytes)`);
190
+ if (!res.body) return await res.text();
191
+ const reader = res.body.getReader();
192
+ const chunks = [];
193
+ let total = 0;
194
+ for (;;) {
195
+ const { done, value } = await reader.read();
196
+ if (done) break;
197
+ total += value.length;
198
+ if (total > max) { await reader.cancel(); throw new Error(`response exceeded ${max} bytes`); }
199
+ chunks.push(value);
200
+ }
201
+ return Buffer.concat(chunks.map(Buffer.from)).toString('utf8');
202
+ }
203
+
204
+ /**
205
+ * fetch() with the address policy applied to the initial URL and to every
206
+ * redirect hop, the connection pinned to the validated address, and a
207
+ * response size cap.
208
+ * @param {string} url
209
+ * @param {object} [init] passed through to fetch
210
+ * @param {function} [fetchImpl] the actual fetch (e.g. a signing fetch);
211
+ * receives (url, init) with the dispatcher set
212
+ */
213
+ export async function safeFetch(url, init = {}, fetchImpl = fetch) {
214
+ let current = url;
215
+ const withUa = { ...init, headers: { 'user-agent': USER_AGENT, ...(init.headers || {}) } };
216
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
217
+ const dispatcher = await pinnedFor(current);
218
+ const res = await fetchImpl(current, {
219
+ ...withUa,
220
+ signal: init.signal || AbortSignal.timeout(HTTP_TIMEOUT_MS),
221
+ ...(dispatcher ? { dispatcher } : {}),
222
+ redirect: 'manual',
223
+ });
224
+ if (res.status >= 300 && res.status < 400) {
225
+ const loc = res.headers.get('location');
226
+ if (!loc) return res;
227
+ current = new URL(loc, current).href;
228
+ continue;
229
+ }
230
+ const len = Number(res.headers?.get?.('content-length') || 0);
231
+ if (len > MAX_BYTES) throw new Error(`response too large (${len} bytes) from ${current}`);
232
+ return res;
233
+ }
234
+ throw new Error(`too many redirects from ${url}`);
235
+ }