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
@@ -0,0 +1,249 @@
1
+ // deliver.mjs — signed outbound delivery + retry queue. Fedify's signRequest
2
+ // does draft-cavage HTTP Signatures (what Mastodon verifies). Failures go to
3
+ // a JSON-file queue with exponential backoff; peers that stay down are
4
+ // dropped after MAX_ATTEMPTS (~3 days).
5
+
6
+ import { signRequest } from '@fedify/fedify/sig';
7
+ import { pinnedFor, retryAfterMs, HTTP_TIMEOUT_MS } from './safefetch.mjs';
8
+ import { USER_AGENT } from './ua.mjs';
9
+
10
+ const MAX_REDIRECTS = 3;
11
+
12
+ const MAX_ATTEMPTS = 12; // 2^12 min ≈ 68h of backoff
13
+ const TICK_MS = 60_000;
14
+ const MAX_QUEUE = 2000; // beyond it, overflow dead-letters
15
+
16
+ // Is this failure about the SERVER, or about this one recipient? A 410 Gone,
17
+ // a 403, a 401 from an authorized-fetch instance are answers about one inbox;
18
+ // cooling the whole host for them starves every other recipient on it — which
19
+ // on a big instance is most of your followers. Only a refusal that is about
20
+ // the server, or no answer at all, is grounds to leave it alone.
21
+ const hostOf = (inbox) => { try { return new URL(inbox).host; } catch { return inbox; } };
22
+ const aboutTheHost = (e) => !e.status || e.status >= 500 || e.status === 429;
23
+
24
+ export class Deliverer {
25
+ // passive: signing-only (viewer-mode agents) — no queue drain timer, so a
26
+ // read-only agent never mutates shared delivery state. startQueue() flips
27
+ // it live when a viewer is promoted to active.
28
+ constructor({ store, keyId, rsaPrivate, log = console.log, passive = false }) {
29
+ this.store = store;
30
+ this.keyId = keyId;
31
+ this.rsaPrivate = rsaPrivate;
32
+ this.log = log;
33
+ if (!passive) this.startQueue();
34
+ }
35
+
36
+ startQueue() {
37
+ if (this.timer) return;
38
+ this.timer = setInterval(() => this.drainQueue().catch(e => this.log(`queue: ${e.message}`)), TICK_MS);
39
+ this.timer.unref?.();
40
+ }
41
+
42
+ // Signed fetch — also used for GETs so authorized-fetch instances answer
43
+ // us. Targets come from attacker-supplied documents, so every hop is
44
+ // address-checked and the connection is pinned to the checked address.
45
+ // Redirects are followed by hand because each hop needs its own signature
46
+ // (the signature covers the request target).
47
+ async signedFetch(url, init = {}) {
48
+ let current = url;
49
+ // Named on every hop: a remote operator seeing our deliveries or our
50
+ // verify-by-dereference GETs should know what we are.
51
+ //
52
+ // And bounded. This was the one remote-facing path in the project without a
53
+ // deadline — publisher, intake and the token grant all set one — so a peer
54
+ // that accepted the connection and then stalled held us for undici's 300s
55
+ // default. Against a 60s queue tick that is five overlapping drains
56
+ // re-POSTing the same activity at a server already in trouble. One signal,
57
+ // built once, so it covers all four redirect hops together rather than
58
+ // giving each hop a fresh deadline.
59
+ const withUa = {
60
+ ...init,
61
+ signal: init.signal || AbortSignal.timeout(HTTP_TIMEOUT_MS),
62
+ headers: { 'user-agent': USER_AGENT, ...(init.headers || {}) },
63
+ };
64
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
65
+ const dispatcher = await pinnedFor(current);
66
+ const req = new Request(current, withUa);
67
+ const signed = await signRequest(req, this.rsaPrivate, new URL(this.keyId));
68
+ const res = await fetch(signed, { ...(dispatcher ? { dispatcher } : {}), redirect: 'manual' });
69
+ if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
70
+ current = new URL(res.headers.get('location'), current).href;
71
+ continue;
72
+ }
73
+ return res;
74
+ }
75
+ throw new Error(`too many redirects from ${url}`);
76
+ }
77
+
78
+ async deliverNow(inbox, activity) {
79
+ const res = await this.signedFetch(inbox, {
80
+ method: 'POST',
81
+ headers: { 'content-type': 'application/activity+json' },
82
+ body: JSON.stringify(activity),
83
+ });
84
+ if (res.status >= 400) {
85
+ // Carry what the server told us instead of flattening it to a string.
86
+ // The Response used to be discarded here, headers and all, which is why
87
+ // a `Retry-After: 3600` was answered with four more POSTs inside the
88
+ // hour it asked for.
89
+ const err = new Error(`POST ${inbox} → ${res.status}`);
90
+ err.status = res.status;
91
+ // Only when the header is ACTUALLY there. Defaulting made every 503
92
+ // without one — which is most of them — collapse the exponential ladder
93
+ // to a flat 60s, the opposite of the intent. A remote's own instruction
94
+ // is honoured up to a day rather than the pod path's 30-minute ceiling:
95
+ // MAX_ATTEMPTS bounds the wait anyway.
96
+ const ra = retryAfterMs(res, 24 * 60 * 60_000);
97
+ if (ra != null) err.retryAfterMs = ra;
98
+ throw err;
99
+ }
100
+ return res;
101
+ }
102
+
103
+ // Deliver, queueing on failure.
104
+ async deliver(inbox, activity) {
105
+ // A host we already know is refusing: queue without asking again. This is
106
+ // the path a FRESH activity takes, so without it a fan-out to a struggling
107
+ // server opened one socket per follower before any of this applied.
108
+ const host = hostOf(inbox);
109
+ const until = this._cooling?.get(host);
110
+ if (until && until > Date.now()) {
111
+ this.log(`${host} is cooling — queueing ${activity.type} rather than asking again`);
112
+ this._enqueue({ inbox, activity, attempts: 1, nextAt: until });
113
+ return;
114
+ }
115
+ try {
116
+ await this.deliverNow(inbox, activity);
117
+ this.log(`delivered ${activity.type} → ${inbox}`);
118
+ } catch (e) {
119
+ this.log(`delivery failed (${e.message}) — queued`);
120
+ const wait = e.retryAfterMs || 60_000;
121
+ if (aboutTheHost(e)) {
122
+ this._cooling ||= new Map();
123
+ this._cooling.set(host, Date.now() + wait);
124
+ }
125
+ this._enqueue({ inbox, activity, attempts: 1, nextAt: Date.now() + wait });
126
+ }
127
+ }
128
+
129
+ // The queue has a ceiling: a fan-out into a sea of down servers must not grow
130
+ // queue.json without bound. Overflow keeps the evidence as a dead letter.
131
+ _enqueue(item) {
132
+ const q = this.store.getQueue();
133
+ if (q.length >= MAX_QUEUE) {
134
+ this.log(`delivery queue is full (${MAX_QUEUE}) — dead-lettering ${item.activity?.type} → ${item.inbox}`);
135
+ this.store.addDeadLetter({ inboxUrl: item.inbox, reason: 'delivery queue full', activity: item.activity });
136
+ return;
137
+ }
138
+ q.push(item);
139
+ this.store.setQueue(q);
140
+ }
141
+
142
+ async deliverToAll(inboxes, activity) {
143
+ // Shared inboxes deduplicate fan-out to the same server.
144
+ for (const inbox of [...new Set(inboxes)]) await this.deliver(inbox, activity);
145
+ }
146
+
147
+ // Serialized, for the same reason Intake.drain is: the tick is 60s and a
148
+ // drain over slow peers outlasts it, so a second run started on top of the
149
+ // first. Both read their own clone of queue.json, so both re-POSTed every due
150
+ // activity — duplicate deliveries at a server that is already failing — and
151
+ // both then wrote `keep` back from a stale snapshot, losing the attempt
152
+ // counts that are supposed to make the backoff converge.
153
+ async drainQueue() {
154
+ if (this._draining) { this._drainAgain = true; return this._draining; }
155
+ this._draining = this._drainQueueOnce().finally(() => {
156
+ this._draining = null;
157
+ if (this._drainAgain) { this._drainAgain = false; this.drainQueue().catch(e => this.log(`queue: ${e.message}`)); }
158
+ });
159
+ return this._draining;
160
+ }
161
+
162
+ // The queue is walked per HOST, not per item.
163
+ //
164
+ // It used to be a flat sequential loop, which had two costs against a server
165
+ // that was down. Every due item for that host was POSTed — the first failure
166
+ // told us nothing the code could act on — and because each request carries a
167
+ // 15s deadline, K items for one dead host held the drain for up to 15K
168
+ // seconds while every healthy peer's activity waited behind them. Compounding
169
+ // requests at a struggling server, and head-of-line blocking of everyone
170
+ // else: the shape of the incident this project has a postmortem for.
171
+ //
172
+ // One failure now cools the whole host for the rest of this drain, and a
173
+ // Retry-After it sent decides for how long. The breaker is in memory; what
174
+ // persists is nextAt, which the queue already carried.
175
+ async _drainQueueOnce() {
176
+ const q = this.store.getQueue();
177
+ if (!q.length) return;
178
+ const now = Date.now();
179
+ const keep = [];
180
+ // Whether anything actually moved. The only early return above is on an
181
+ // EMPTY queue, so one item with a far-future nextAt used to rewrite
182
+ // queue.json on all 1440 ticks of a day with byte-identical content —
183
+ // PodStore.write has no equality check, and the ordinary ladder reaches
184
+ // hours-long waits by attempt 6, so any peer that fails a few times got
185
+ // there. A length compare is not enough: getQueue() hands back a clone, so
186
+ // attempts/nextAt are mutated in place and the count stays the same.
187
+ let changed = false;
188
+ // Outlives the drain: a Retry-After only reached items that happened to be
189
+ // due in the same pass, so a sibling due a minute later ignored it entirely.
190
+ this._cooling ||= new Map(); // host → until
191
+ for (const [h, until] of this._cooling) if (until <= now) this._cooling.delete(h);
192
+
193
+ const ladderFor = (item) => {
194
+ const base = Math.min(2 ** item.attempts, 2 ** MAX_ATTEMPTS) * 60_000;
195
+ return Math.round(base * (0.85 + Math.random() * 0.3)); // jittered, not lockstep
196
+ };
197
+
198
+ for (const item of q) {
199
+ // Due first, THEN cooling: an item that is not yet due must not have its
200
+ // ladder advanced on every 60s tick of a 15-minute cooldown. Once
201
+ // deferred its nextAt IS the cooldown, so it advances exactly once per
202
+ // cooldown — the same rate as the item that opened the socket.
203
+ if (item.nextAt > now) { keep.push(item); continue; }
204
+ const host = hostOf(item.inbox);
205
+ const until = this._cooling.get(host);
206
+ if (until && until > now) {
207
+ // A sibling already failed against this host. Defer without opening a
208
+ // socket — but ADVANCE THE LADDER, or only the one item that actually
209
+ // made a request ever climbs it and a queue of K items takes K times
210
+ // as long to give up as the module promises.
211
+ item.attempts += 1;
212
+ changed = true;
213
+ if (item.attempts > MAX_ATTEMPTS) {
214
+ this.log(`giving up on ${item.inbox} after ${MAX_ATTEMPTS} attempts`);
215
+ continue;
216
+ }
217
+ item.nextAt = Math.max(item.nextAt, until);
218
+ keep.push(item);
219
+ continue;
220
+ }
221
+ try {
222
+ await this.deliverNow(item.inbox, item.activity);
223
+ changed = true;
224
+ this.log(`retry ok: ${item.activity.type} → ${item.inbox}`);
225
+ } catch (e) {
226
+ item.attempts += 1;
227
+ changed = true;
228
+ const wait = e.retryAfterMs || ladderFor(item);
229
+ // Cool the host BEFORE the give-up test: a failure is evidence about
230
+ // the server whether or not the item that produced it survives, and
231
+ // skipping this left the flat behaviour intact for exactly the backlog
232
+ // that has just aged out.
233
+ if (aboutTheHost(e)) {
234
+ this._cooling.set(host, now + wait);
235
+ if (e.retryAfterMs) this.log(`${host} asked for ${Math.round(wait / 1000)}s — honouring it`);
236
+ }
237
+ if (item.attempts > MAX_ATTEMPTS) {
238
+ this.log(`giving up on ${item.inbox} after ${MAX_ATTEMPTS} attempts`);
239
+ continue;
240
+ }
241
+ item.nextAt = now + wait;
242
+ keep.push(item);
243
+ }
244
+ }
245
+ if (changed) this.store.setQueue(keep);
246
+ }
247
+
248
+ stop() { clearInterval(this.timer); this.timer = null; }
249
+ }
@@ -0,0 +1,90 @@
1
+ // directory.mjs — the well-known door. Whichever running agent holds port
2
+ // 8030 answers there with a redirect to its own record page, so opening
3
+ // https://localhost:8030/ always reaches a page that lists every identity on
4
+ // this machine, whatever ports they actually run on. Loopback only, https
5
+ // only — like every listener this project starts. Held only by a CONFIGURED agent: an
6
+ // unconfigured one's record page is a setup form with no identity list,
7
+ // which strands whoever the door sends there.
8
+ //
9
+ // The door is held politely: it identifies itself at GET /__directory, and a
10
+ // real owner of the port — an identity that recorded 8030 as its own — asks it
11
+ // to step aside with POST /__directory/yield before binding. The holder frees
12
+ // the port at once and stays off it for a minute. AP_DIRECTORY=0 disables the
13
+ // whole mechanism (the test suite sets it, so throwaway agents do not fight
14
+ // over a real machine's door).
15
+ import https from 'node:https';
16
+ import { localFetch } from './localapi.mjs';
17
+
18
+ export const DIRECTORY_PORT = 8030;
19
+
20
+ // Answer for a running agent. No-op when this agent already IS the door, or
21
+ // when the mechanism is switched off. `eligible` is asked before every claim
22
+ // attempt — the 15 s retry means an agent that configures later takes the
23
+ // door soon after, and one that never does never holds it.
24
+ export function claimDirectory({ port, origin, log = () => {}, eligible = () => true, tls = null }) {
25
+ if (process.env.AP_DIRECTORY === '0' || port === DIRECTORY_PORT) {
26
+ return { stop: () => {} };
27
+ }
28
+ const servers = [];
29
+ let held = false;
30
+ let pausedUntil = 0;
31
+ const handler = (req, res) => {
32
+ const p = req.url || '/';
33
+ if (p === '/__directory') {
34
+ res.writeHead(200, { 'content-type': 'application/json' });
35
+ res.end(JSON.stringify({ directory: true, agent: origin() }) + '\n');
36
+ return;
37
+ }
38
+ if (req.method === 'POST' && p === '/__directory/yield') {
39
+ pausedUntil = Date.now() + 60_000;
40
+ res.writeHead(200, { 'content-type': 'application/json' });
41
+ res.end('{"ok":true}\n');
42
+ release();
43
+ log(`directory: yielded port ${DIRECTORY_PORT}`);
44
+ return;
45
+ }
46
+ res.writeHead(302, { location: origin().replace(/\/$/, '') + p });
47
+ res.end();
48
+ };
49
+ const release = () => {
50
+ for (const s of servers.splice(0)) { try { s.close(); } catch { /* already down */ } }
51
+ held = false;
52
+ };
53
+ const tryClaim = () => {
54
+ if (held || !eligible() || Date.now() < pausedUntil) return;
55
+ // Loopback both ways, like the agent's own listener: "localhost" resolves
56
+ // to ::1 first on many systems. IPv4 is the one that decides `held`.
57
+ if (!tls) return; // no certificate, no door: this project serves https only
58
+ const tlsOpts = { key: tls.key, cert: tls.cert };
59
+ const s4 = https.createServer(tlsOpts, handler);
60
+ s4.on('error', () => { /* someone else holds the door — retry later */ });
61
+ s4.listen(DIRECTORY_PORT, '127.0.0.1', () => {
62
+ held = true;
63
+ servers.push(s4);
64
+ const s6 = https.createServer(tlsOpts, handler);
65
+ s6.on('error', () => { /* no IPv6 loopback here — IPv4 covers it */ });
66
+ s6.listen(DIRECTORY_PORT, '::1', () => servers.push(s6));
67
+ log(`directory: https://localhost:${DIRECTORY_PORT}/ now reaches ${origin()}`);
68
+ });
69
+ };
70
+ tryClaim();
71
+ const timer = setInterval(tryClaim, 15_000);
72
+ timer.unref();
73
+ return { stop: () => { clearInterval(timer); release(); } };
74
+ }
75
+
76
+ // Free `port` if what holds it is the directory door. Returns true when the
77
+ // port is free afterwards. Quick and quiet on every other kind of holder.
78
+ export async function yieldDirectory(port, { portFree, home = null } = {}) {
79
+ const probe = await localFetch(home, port, '/__directory', { timeout: 1000 })
80
+ .then(r => (r.ok ? r.json() : null)).catch(() => null);
81
+ if (!probe?.directory) return false;
82
+ await localFetch(home, port, '/__directory/yield', { method: 'POST', timeout: 1000 })
83
+ .catch(() => {});
84
+ if (!portFree) return true;
85
+ for (let i = 0; i < 10; i++) {
86
+ if (await portFree(port)) return true;
87
+ await new Promise(r => setTimeout(r, 100));
88
+ }
89
+ return false;
90
+ }
package/lib/embed.mjs ADDED
@@ -0,0 +1,214 @@
1
+ // embed.mjs — the agent, running inside the pod server that holds its pod.
2
+ //
3
+ // Nothing here reimplements the agent: it is the same Agent class, the same
4
+ // intake, deliverer, publisher and lease. What changes is the transport. The
5
+ // caller hands in a session that reaches the pod through the server's own
6
+ // store, and RemotePod takes it instead of minting a credential — so an
7
+ // identity acts on its pod with no token, no socket, and no second process.
8
+ //
9
+ // The one thing a pod server can do that a laptop cannot is notice a write the
10
+ // moment it happens, so the notification socket is replaced by the store's own
11
+ // change events.
12
+
13
+ import crypto from 'node:crypto';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ import { createRequire } from 'node:module';
18
+
19
+ import { Agent } from '../run-agent.mjs';
20
+ import { RemotePod } from './remote.mjs';
21
+ import { apUrls } from './wire.mjs';
22
+ import { writeJsonAtomic } from './home.mjs';
23
+ import { buildAdminSurface } from './admin.mjs';
24
+ import { FixedAuthorities } from './guard.mjs';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const { makeGate } = require('../vendor/gate.cjs');
28
+
29
+ // How long to gather store events before draining, so a delivery of several
30
+ // items costs one sweep rather than one each.
31
+ const DRAIN_COALESCE_MS = 250;
32
+
33
+ // How every secret in this project is minted (the attach flow's recipe).
34
+ const mintSecret = () => crypto.randomBytes(32).toString('base64');
35
+
36
+ /**
37
+ * The secret guarding one identity's owner door, kept beside its signing key.
38
+ * Minted when absent; `rotate` re-mints over an existing one — which is how a
39
+ * lost secret is recovered, by proving pod control again. Never logged.
40
+ */
41
+ export function ensureDoorSecret(dataDir, handle, { rotate = false } = {}) {
42
+ const home = path.join(dataDir, handle);
43
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
44
+ const file = path.join(home, 'door-secret.json');
45
+ if (!rotate) {
46
+ try {
47
+ const rec = JSON.parse(fs.readFileSync(file, 'utf8'));
48
+ if (rec?.secret) return { secret: rec.secret, path: file, rotated: false };
49
+ } catch { /* absent or unreadable: mint below */ }
50
+ }
51
+ const rec = { secret: mintSecret(), mintedAt: new Date().toISOString() };
52
+ writeJsonAtomic(file, rec, { mode: 0o600 });
53
+ return { secret: rec.secret, path: file, rotated: rotate };
54
+ }
55
+
56
+ /** The identity's name: a subdomain pod is its label, a path pod its last segment. */
57
+ export function handleFor(podBase) {
58
+ const u = new URL(podBase);
59
+ const segments = u.pathname.split('/').filter(Boolean);
60
+ return segments.length ? segments[segments.length - 1] : u.hostname.split('.')[0];
61
+ }
62
+
63
+ /**
64
+ * The credential file an embedded identity runs on. It names the pod and
65
+ * nothing else: there is no client id and no secret, because there is nobody
66
+ * to authenticate to. Absent `privateRoot`, so the state tree stays on the pod.
67
+ */
68
+ function ensureCredential(home, { podBase, webId }) {
69
+ const file = path.join(home, 'credential.json');
70
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
71
+ catch { /* first run for this identity */ }
72
+ const rec = {
73
+ webId,
74
+ remotePod: podBase.endsWith('/') ? podBase : podBase + '/',
75
+ root: 'activitypods-js/',
76
+ };
77
+ writeJsonAtomic(file, rec, { mode: 0o600 });
78
+ return rec;
79
+ }
80
+
81
+ /**
82
+ * Bring one identity up inside the server.
83
+ *
84
+ * `session` is the store-backed transport ({ fetch, warmup, stats }).
85
+ * `resourceStore` is optional and used only to watch for inbox writes.
86
+ * Returns the running agent and a stop() that leaves the pod tidy.
87
+ */
88
+ export async function startEmbeddedAgent({
89
+ podBase,
90
+ dataDir,
91
+ session,
92
+ resourceStore = null,
93
+ webIdSuffix = 'profile/card#me',
94
+ log = () => {},
95
+ pollSeconds = null,
96
+ autoAcceptFollows = true,
97
+ gateToken = null,
98
+ uiPath = '/fedipod/',
99
+ }) {
100
+ const base = podBase.endsWith('/') ? podBase : podBase + '/';
101
+ const handle = handleFor(base);
102
+ const home = path.join(dataDir, handle);
103
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
104
+
105
+ const webId = base + webIdSuffix.replace(/^\//u, '');
106
+ const cred = ensureCredential(home, { podBase: base, webId });
107
+
108
+ const agent = new Agent({ home, log });
109
+ agent.log = log;
110
+ agent.store.log = log;
111
+ // Read by connect() when it builds Intake: in-process, the pod's own change
112
+ // events are the push channel.
113
+ agent.embedded = true;
114
+ if (pollSeconds) agent.pollSeconds = pollSeconds;
115
+ agent.remote = new RemotePod(cred, { log, session });
116
+ await agent.remote.warmup();
117
+
118
+ // Whether this pod already carries an identity is the question connect()
119
+ // assumes an answer to, so settle it first.
120
+ const urls = apUrls(cred.remotePod, cred.root);
121
+ agent.store.attach(agent.privateStorage(cred, 'state', urls));
122
+
123
+ // A state container that is NOT THERE means a pod nobody has set up yet. A
124
+ // state container we could not READ means a pod we could not ask, which is a
125
+ // different answer: treating it as empty would provision a second identity
126
+ // over the top of a working one.
127
+ const state = await agent.remote.fetch(urls.state, { headers: { accept: 'text/turtle' } });
128
+ const unprovisioned = state.status === 404 || state.status === 410;
129
+ if (!unprovisioned && state.status >= 400) {
130
+ throw new Error(`state at ${urls.state} → ${state.status} — cannot tell whether this pod is set up`);
131
+ }
132
+ if (!unprovisioned) {
133
+ await agent.store.load();
134
+ agent.stateLoaded = true;
135
+ }
136
+
137
+ if (!agent.store.getConfig()) {
138
+ // The pod itself must exist first. A server creates its seeded pods after
139
+ // it runs its initializers, so an agent that provisioned eagerly would
140
+ // leave a container tree exactly where a pod is about to be created.
141
+ const profile = await agent.remote.fetch(webId.split('#')[0], { headers: { accept: 'text/turtle' } });
142
+ if (profile.status === 404 || profile.status === 410) {
143
+ throw new Error(`no pod at ${base} yet — its owner profile is not there`);
144
+ }
145
+ log(`no identity on ${base} yet — provisioning @${handle}`);
146
+ await agent.bootstrap({ handle, name: handle, kind: 'person' });
147
+ if (autoAcceptFollows) {
148
+ agent.store.setConfig({ ...agent.store.getConfig(), autoAcceptFollows: true });
149
+ await agent.store.flush();
150
+ }
151
+ agent.stateLoaded = true;
152
+ }
153
+
154
+ await agent.connect();
155
+
156
+ // The client surfaces, on the pod's own origin: the Mastodon API a phone app
157
+ // speaks, the write API, nodeinfo, and behind the door the admin routes and
158
+ // the web client. Same code the standalone agent serves, minus the routes
159
+ // that only mean something to a process of one's own.
160
+ const authorities = new FixedAuthorities(base);
161
+ agent.authorities = authorities;
162
+ const surface = buildAdminSurface({
163
+ agent,
164
+ log,
165
+ gate: makeGate(gateToken, { secureCookie: authorities.secure }),
166
+ allowed: authorities,
167
+ embedded: true,
168
+ basePath: uiPath,
169
+ publicOrigin: base,
170
+ scheme: new URL(base).protocol,
171
+ });
172
+
173
+ // A delivery landing in the inbox — through the door, or from any other
174
+ // writer — wakes the drain at once. Only additions, and only in the inbox:
175
+ // the drain's own DELETEs must not call it back.
176
+ let coalesce = null;
177
+ let onChanged = null;
178
+ if (typeof resourceStore?.on === 'function') {
179
+ const inbox = urls.inbox;
180
+ onChanged = (identifier, activity) => {
181
+ const kind = String(activity?.value ?? activity ?? '');
182
+ if (!identifier?.path?.startsWith(inbox)) return;
183
+ if (!kind.endsWith('Create') && !kind.endsWith('Add')) return;
184
+ if (coalesce) return;
185
+ coalesce = setTimeout(() => {
186
+ coalesce = null;
187
+ if (!agent.viewer) agent.intake?.drain().catch((e) => log(`drain: ${e.message}`));
188
+ }, DRAIN_COALESCE_MS);
189
+ coalesce.unref?.();
190
+ };
191
+ resourceStore.on('changed', onChanged);
192
+ }
193
+
194
+ const stop = async () => {
195
+ if (onChanged) resourceStore.off('changed', onChanged);
196
+ surface.streaming?.stop?.();
197
+ clearTimeout(coalesce);
198
+ agent.intake?.stop();
199
+ agent.tagfeed?.stop();
200
+ agent.bskyfeed?.stop();
201
+ agent.deliverer?.stop();
202
+ agent.importer?.stop();
203
+ clearInterval(agent.schedTimer);
204
+ clearInterval(agent.refreshTimer);
205
+ // Same order the standalone agent's shutdown uses: write what is pending,
206
+ // then let go of the lease so the next agent need not wait out the TTL.
207
+ await Promise.allSettled([
208
+ agent.store.flush(),
209
+ agent.viewer ? Promise.resolve() : agent.lease?.release(),
210
+ ]);
211
+ };
212
+
213
+ return { agent, handle, home, surface, host: authorities.host, stop };
214
+ }