fedipod-server 0.9.0 → 0.11.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 (319) hide show
  1. package/dist/claims.js +1 -1
  2. package/dist/handler.d.ts +1 -1
  3. package/dist/handler.js +35 -2
  4. package/dist/handler.jsonld +1 -1
  5. package/lib/admin.mjs +3 -3
  6. package/lib/c2s.mjs +27 -2
  7. package/lib/deliver.mjs +6 -0
  8. package/lib/embed.mjs +1 -0
  9. package/lib/export-collections.mjs +1 -1
  10. package/lib/front-core.mjs +272 -49
  11. package/lib/gateway-core.mjs +3 -7
  12. package/lib/httpsig.mjs +33 -2
  13. package/lib/import.mjs +1 -1
  14. package/lib/intake.mjs +357 -118
  15. package/lib/links.mjs +6 -32
  16. package/lib/mastoapi.mjs +449 -38
  17. package/lib/migrate.mjs +4 -18
  18. package/lib/pod/README.md +61 -0
  19. package/lib/pod/actor.mjs +96 -0
  20. package/lib/pod/collection.mjs +77 -0
  21. package/lib/pod/containers.mjs +136 -0
  22. package/lib/pod/discovery.mjs +46 -0
  23. package/lib/pod/featured.mjs +18 -0
  24. package/lib/pod/followers.mjs +24 -0
  25. package/lib/pod/following.mjs +10 -0
  26. package/lib/pod/http.mjs +74 -0
  27. package/lib/pod/inbox.mjs +144 -0
  28. package/lib/pod/links.mjs +35 -0
  29. package/lib/pod/media.mjs +27 -0
  30. package/lib/pod/notes.mjs +80 -0
  31. package/lib/pod/notifications.mjs +89 -0
  32. package/lib/pod/outbox.mjs +18 -0
  33. package/lib/pod/package.json +19 -0
  34. package/lib/pod/policy.mjs +58 -0
  35. package/lib/pod/private.mjs +19 -0
  36. package/lib/pod/root.mjs +63 -0
  37. package/lib/pod/state.mjs +39 -0
  38. package/lib/pod/transport.mjs +477 -0
  39. package/lib/pod/urls.mjs +71 -0
  40. package/lib/polls.mjs +105 -0
  41. package/lib/publisher.mjs +357 -188
  42. package/lib/remote.mjs +30 -395
  43. package/lib/social.mjs +20 -9
  44. package/lib/storage.mjs +2 -2
  45. package/lib/store.mjs +8 -1
  46. package/lib/tagfeed.mjs +19 -2
  47. package/lib/wire.mjs +87 -67
  48. package/package.json +1 -1
  49. package/run-agent.mjs +20 -39
  50. package/web/admin/admin.js +141 -74
  51. package/web/admin/bar.css +9 -13
  52. package/web/admin/bar.js +2 -2
  53. package/web/admin/client/client.js +1 -1
  54. package/web/admin/client/index.html +5 -6
  55. package/web/admin/index.html +53 -65
  56. package/web/admin/setup/index.html +2 -1
  57. package/web/admin/setup/setup.js +1 -1
  58. package/web/admin/tokens.css +56 -0
  59. package/web/app/README.md +77 -0
  60. package/web/app/admin-facade.mjs +549 -0
  61. package/web/app/agent.mjs +490 -0
  62. package/web/app/atproto-browser.mjs +86 -0
  63. package/web/app/boot.mjs +340 -0
  64. package/web/app/deliver-relay.mjs +72 -0
  65. package/web/app/dist/boot.js +34067 -0
  66. package/web/app/dist/boot.js.map +7 -0
  67. package/web/app/dist/sw.js +53745 -0
  68. package/web/app/dist/sw.js.map +7 -0
  69. package/web/app/fediacct-browser.mjs +146 -0
  70. package/web/app/idb-kv.mjs +35 -0
  71. package/web/app/index.html +203 -0
  72. package/web/app/index.html~ +152 -0
  73. package/web/app/keys-browser.mjs +46 -0
  74. package/web/app/keystore.mjs +99 -0
  75. package/web/app/oidc-session.mjs +189 -0
  76. package/web/app/pod-auth.mjs +248 -0
  77. package/web/app/pod-remote.mjs +84 -0
  78. package/web/app/shims/_globals.mjs +2 -0
  79. package/web/app/shims/fedify-sig.mjs +77 -0
  80. package/web/app/shims/node-crypto.mjs +113 -0
  81. package/web/app/shims/node-fs.mjs +17 -0
  82. package/web/app/shims/node-path.mjs +19 -0
  83. package/web/app/shims/node-url.mjs +7 -0
  84. package/web/app/shims/prelude.js +58 -0
  85. package/web/app/shims/safefetch.mjs +58 -0
  86. package/web/app/shims/web-push.mjs +14 -0
  87. package/web/app/signup.mjs +248 -0
  88. package/web/app/site/_headers +9 -0
  89. package/web/app/site/_redirects +21 -0
  90. package/web/app/site/admin/admin.js +1181 -0
  91. package/web/app/site/admin/bar.css +60 -0
  92. package/web/app/site/admin/bar.js +61 -0
  93. package/web/app/site/admin/client/client.js +66 -0
  94. package/web/app/site/admin/client/index.html +43 -0
  95. package/web/app/site/admin/index.html +556 -0
  96. package/web/app/site/admin/setup/index.html +191 -0
  97. package/web/app/site/admin/setup/setup.js +308 -0
  98. package/web/app/site/admin/tokens.css +56 -0
  99. package/web/app/site/admin/window.css +55 -0
  100. package/web/app/site/admin/window.js +136 -0
  101. package/web/app/site/app/404.html +40 -0
  102. package/web/app/site/app/_headers +2 -0
  103. package/web/app/site/app/apple-touch-icon.png +0 -0
  104. package/web/app/site/app/assets/ICONS-DIPJeU0C.js +2 -0
  105. package/web/app/site/app/assets/ICONS-DIPJeU0C.js.map +1 -0
  106. package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js +183 -0
  107. package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
  108. package/web/app/site/app/assets/Temml-CCjoacWt.woff2 +0 -0
  109. package/web/app/site/app/assets/__vite-browser-external-C7iut881.js +2 -0
  110. package/web/app/site/app/assets/__vite-browser-external-C7iut881.js.map +1 -0
  111. package/web/app/site/app/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
  112. package/web/app/site/app/assets/catch-up-O5q5C75Z.png +0 -0
  113. package/web/app/site/app/assets/chunk-aKtaBQYM.js +1 -0
  114. package/web/app/site/app/assets/compose-CFEx7wb4.js +2 -0
  115. package/web/app/site/app/assets/compose-CFEx7wb4.js.map +1 -0
  116. package/web/app/site/app/assets/compose-ZJN5xSb4.js +35 -0
  117. package/web/app/site/app/assets/compose-ZJN5xSb4.js.map +1 -0
  118. package/web/app/site/app/assets/debug.module-YFQgwnIr.js +52 -0
  119. package/web/app/site/app/assets/debug.module-YFQgwnIr.js.map +1 -0
  120. package/web/app/site/app/assets/dom-p1YcJ5Rw.js +2 -0
  121. package/web/app/site/app/assets/dom-p1YcJ5Rw.js.map +1 -0
  122. package/web/app/site/app/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
  123. package/web/app/site/app/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
  124. package/web/app/site/app/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
  125. package/web/app/site/app/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
  126. package/web/app/site/app/assets/icons/add-circle-line-WD0mwORD.js +1 -0
  127. package/web/app/site/app/assets/icons/alert-line-CpFUMf9s.js +1 -0
  128. package/web/app/site/app/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
  129. package/web/app/site/app/assets/icons/announcement-line-5nwaJFJm.js +1 -0
  130. package/web/app/site/app/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
  131. package/web/app/site/app/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
  132. package/web/app/site/app/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
  133. package/web/app/site/app/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
  134. package/web/app/site/app/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
  135. package/web/app/site/app/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
  136. package/web/app/site/app/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
  137. package/web/app/site/app/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
  138. package/web/app/site/app/assets/icons/at-line-CF66Bznk.js +1 -0
  139. package/web/app/site/app/assets/icons/attachment-line-BY8It-l9.js +1 -0
  140. package/web/app/site/app/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
  141. package/web/app/site/app/assets/icons/board-line-C0UWXyA6.js +1 -0
  142. package/web/app/site/app/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
  143. package/web/app/site/app/assets/icons/building-5-line-Dd59MzbO.js +1 -0
  144. package/web/app/site/app/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
  145. package/web/app/site/app/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
  146. package/web/app/site/app/assets/icons/calendar-month-line-vommts2C.js +1 -0
  147. package/web/app/site/app/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
  148. package/web/app/site/app/assets/icons/camera-line-Cv69MEei.js +1 -0
  149. package/web/app/site/app/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
  150. package/web/app/site/app/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
  151. package/web/app/site/app/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
  152. package/web/app/site/app/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
  153. package/web/app/site/app/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
  154. package/web/app/site/app/assets/icons/clipboard-line-BBp364gJ.js +1 -0
  155. package/web/app/site/app/assets/icons/close-circle-line-CICdiuGz.js +1 -0
  156. package/web/app/site/app/assets/icons/close-line-DYrFWcjv.js +1 -0
  157. package/web/app/site/app/assets/icons/cloud-line-tiQAbah8.js +1 -0
  158. package/web/app/site/app/assets/icons/code-line-fAl8iiV3.js +1 -0
  159. package/web/app/site/app/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
  160. package/web/app/site/app/assets/icons/copy-2-line-CLW69yF5.js +1 -0
  161. package/web/app/site/app/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
  162. package/web/app/site/app/assets/icons/document-3-line-DBGNhAg2.js +1 -0
  163. package/web/app/site/app/assets/icons/document-line-C7mb9sb7.js +1 -0
  164. package/web/app/site/app/assets/icons/down-line-BGqSSThd.js +1 -0
  165. package/web/app/site/app/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
  166. package/web/app/site/app/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
  167. package/web/app/site/app/assets/icons/exit-line-Dc07BS-x.js +1 -0
  168. package/web/app/site/app/assets/icons/external-link-line-BvpSkmjO.js +1 -0
  169. package/web/app/site/app/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
  170. package/web/app/site/app/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
  171. package/web/app/site/app/assets/icons/filter-2-line-B_mR2usy.js +1 -0
  172. package/web/app/site/app/assets/icons/filter-line-C_nm-3cU.js +1 -0
  173. package/web/app/site/app/assets/icons/flag-1-line-C2HB05I0.js +1 -0
  174. package/web/app/site/app/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
  175. package/web/app/site/app/assets/icons/formula-line-DLrt2d6j.js +1 -0
  176. package/web/app/site/app/assets/icons/grid-line-CgUmEikf.js +1 -0
  177. package/web/app/site/app/assets/icons/group-line-C5fnX0wU.js +1 -0
  178. package/web/app/site/app/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
  179. package/web/app/site/app/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
  180. package/web/app/site/app/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
  181. package/web/app/site/app/assets/icons/heart-line-CJ7K47J4.js +1 -0
  182. package/web/app/site/app/assets/icons/history-2-line-kWWvFzqv.js +1 -0
  183. package/web/app/site/app/assets/icons/history-line-bGQiwbTH.js +1 -0
  184. package/web/app/site/app/assets/icons/home-3-line-fRU1zeLG.js +1 -0
  185. package/web/app/site/app/assets/icons/information-line-iNk6-sOY.js +1 -0
  186. package/web/app/site/app/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
  187. package/web/app/site/app/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
  188. package/web/app/site/app/assets/icons/layout-5-line-B_znt5wO.js +1 -0
  189. package/web/app/site/app/assets/icons/left-line-eUw0tya4.js +1 -0
  190. package/web/app/site/app/assets/icons/lightning-line-DaKoDdix.js +1 -0
  191. package/web/app/site/app/assets/icons/link-2-line-DCqLmMA7.js +1 -0
  192. package/web/app/site/app/assets/icons/list-check-line-BAxosBh9.js +1 -0
  193. package/web/app/site/app/assets/icons/lock-line-CPoaXeUW.js +1 -0
  194. package/web/app/site/app/assets/icons/mail-line-B0P8aa1L.js +1 -0
  195. package/web/app/site/app/assets/icons/moon-line-CEqNAToP.js +1 -0
  196. package/web/app/site/app/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
  197. package/web/app/site/app/assets/icons/more-3-line-BIiVjYTz.js +1 -0
  198. package/web/app/site/app/assets/icons/notification-line-BZZq2Gtu.js +1 -0
  199. package/web/app/site/app/assets/icons/pencil-line-CKh8-A1v.js +1 -0
  200. package/web/app/site/app/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
  201. package/web/app/site/app/assets/icons/pin-line-DpTolyhs.js +1 -0
  202. package/web/app/site/app/assets/icons/play-fill-B0-G_8lX.js +1 -0
  203. package/web/app/site/app/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
  204. package/web/app/site/app/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
  205. package/web/app/site/app/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
  206. package/web/app/site/app/assets/icons/radar-line-CKyroTth.js +1 -0
  207. package/web/app/site/app/assets/icons/react-line-CoIKhhtK.js +1 -0
  208. package/web/app/site/app/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
  209. package/web/app/site/app/assets/icons/right-line-B3KWJaqc.js +1 -0
  210. package/web/app/site/app/assets/icons/rocket-line-4KZl537D.js +1 -0
  211. package/web/app/site/app/assets/icons/round-fill-C69NTrZ0.js +1 -0
  212. package/web/app/site/app/assets/icons/round-line-B9k_1uo-.js +1 -0
  213. package/web/app/site/app/assets/icons/route-line-B28ReHcE.js +1 -0
  214. package/web/app/site/app/assets/icons/rows-4-line-CrBniagM.js +1 -0
  215. package/web/app/site/app/assets/icons/scan-line-DSf74YdP.js +1 -0
  216. package/web/app/site/app/assets/icons/search-2-line-B6geIU1z.js +1 -0
  217. package/web/app/site/app/assets/icons/settings-3-line-azJwK47w.js +1 -0
  218. package/web/app/site/app/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
  219. package/web/app/site/app/assets/icons/share-2-line-D5MxWjrm.js +1 -0
  220. package/web/app/site/app/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
  221. package/web/app/site/app/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
  222. package/web/app/site/app/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
  223. package/web/app/site/app/assets/icons/time-line-BF3eet1v.js +1 -0
  224. package/web/app/site/app/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
  225. package/web/app/site/app/assets/icons/translate-line-BFWol2Ce.js +1 -0
  226. package/web/app/site/app/assets/icons/unlock-line-X_x5vwv3.js +1 -0
  227. package/web/app/site/app/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
  228. package/web/app/site/app/assets/icons/user-4-line-BVSynZCp.js +1 -0
  229. package/web/app/site/app/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
  230. package/web/app/site/app/assets/icons/user-add-line-BbXdHG4z.js +1 -0
  231. package/web/app/site/app/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
  232. package/web/app/site/app/assets/icons/user-follow-line-BXux0wSd.js +1 -0
  233. package/web/app/site/app/assets/icons/user-star-line-DdYlpHLU.js +1 -0
  234. package/web/app/site/app/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
  235. package/web/app/site/app/assets/icons/user-x-line-DIDgNlPH.js +1 -0
  236. package/web/app/site/app/assets/icons/volume-line-Bs390KQt.js +1 -0
  237. package/web/app/site/app/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
  238. package/web/app/site/app/assets/icons/walk-line-yNXfB-md.js +1 -0
  239. package/web/app/site/app/assets/icons/world-2-line-CenkGIwj.js +1 -0
  240. package/web/app/site/app/assets/icons/zoom-in-line-CaENUphf.js +1 -0
  241. package/web/app/site/app/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
  242. package/web/app/site/app/assets/instances-BNPptnn-.json +333 -0
  243. package/web/app/site/app/assets/locales/ar-SA-CfheRbCu.js +1 -0
  244. package/web/app/site/app/assets/locales/ca-ES-DHRW86Tz.js +1 -0
  245. package/web/app/site/app/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
  246. package/web/app/site/app/assets/locales/de-DE-ByRPMRFy.js +1 -0
  247. package/web/app/site/app/assets/locales/eo-UY-CPC7wLCS.js +1 -0
  248. package/web/app/site/app/assets/locales/es-ES-dJa0Fd9t.js +1 -0
  249. package/web/app/site/app/assets/locales/eu-ES-C3djlPR2.js +1 -0
  250. package/web/app/site/app/assets/locales/fa-IR-DTT490Ck.js +1 -0
  251. package/web/app/site/app/assets/locales/fi-FI-B23MCPuz.js +1 -0
  252. package/web/app/site/app/assets/locales/fr-FR-BcaQsO5C.js +1 -0
  253. package/web/app/site/app/assets/locales/gl-ES-BR-CVKBN.js +1 -0
  254. package/web/app/site/app/assets/locales/he-IL-CfheRbCu.js +1 -0
  255. package/web/app/site/app/assets/locales/hu-HU-CfheRbCu.js +1 -0
  256. package/web/app/site/app/assets/locales/it-IT-C1136UFV.js +1 -0
  257. package/web/app/site/app/assets/locales/ja-JP-CRZmRAhD.js +1 -0
  258. package/web/app/site/app/assets/locales/kab-Cz8xH_TT.js +1 -0
  259. package/web/app/site/app/assets/locales/ko-KR-CG9UiY4o.js +1 -0
  260. package/web/app/site/app/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
  261. package/web/app/site/app/assets/locales/nb-NO-CfheRbCu.js +1 -0
  262. package/web/app/site/app/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
  263. package/web/app/site/app/assets/locales/oc-FR-CfheRbCu.js +1 -0
  264. package/web/app/site/app/assets/locales/pl-PL-DkwGXf6P.js +1 -0
  265. package/web/app/site/app/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
  266. package/web/app/site/app/assets/locales/pt-BR-nGL0HdFS.js +1 -0
  267. package/web/app/site/app/assets/locales/pt-PT-Cp17aj2z.js +1 -0
  268. package/web/app/site/app/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
  269. package/web/app/site/app/assets/locales/th-TH-CfheRbCu.js +1 -0
  270. package/web/app/site/app/assets/locales/tok-CfheRbCu.js +1 -0
  271. package/web/app/site/app/assets/locales/tr-TR-CfheRbCu.js +1 -0
  272. package/web/app/site/app/assets/locales/uk-UA-C5E3dFBf.js +1 -0
  273. package/web/app/site/app/assets/locales/zh-CN-BHJi7_et.js +1 -0
  274. package/web/app/site/app/assets/locales/zh-TW-CfheRbCu.js +1 -0
  275. package/web/app/site/app/assets/main-BdqNbG-a.js +26 -0
  276. package/web/app/site/app/assets/main-BdqNbG-a.js.map +1 -0
  277. package/web/app/site/app/assets/mock-home-DGhR__He.js +2 -0
  278. package/web/app/site/app/assets/mock-home-DGhR__He.js.map +1 -0
  279. package/web/app/site/app/assets/multi-column-ROck0NVt.jpg +0 -0
  280. package/web/app/site/app/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
  281. package/web/app/site/app/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
  282. package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js +2 -0
  283. package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js.map +1 -0
  284. package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js +20 -0
  285. package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js.map +1 -0
  286. package/web/app/site/app/assets/style-B1iLPTPR.css +2 -0
  287. package/web/app/site/app/assets/temml-DuyIQMei.js +97 -0
  288. package/web/app/site/app/assets/temml-DuyIQMei.js.map +1 -0
  289. package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js +2 -0
  290. package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
  291. package/web/app/site/app/assets/year-in-posts-B2lywqdx.png +0 -0
  292. package/web/app/site/app/assets/year-in-posts-DJv_star.js +3 -0
  293. package/web/app/site/app/assets/year-in-posts-DJv_star.js.map +1 -0
  294. package/web/app/site/app/compose/index.html +20 -0
  295. package/web/app/site/app/favicon.ico +0 -0
  296. package/web/app/site/app/index.html +73 -0
  297. package/web/app/site/app/logo-192.png +0 -0
  298. package/web/app/site/app/logo-512.png +0 -0
  299. package/web/app/site/app/logo-badge-72.png +0 -0
  300. package/web/app/site/app/logo-maskable-512.png +0 -0
  301. package/web/app/site/app/logo-monochrome-512.png +0 -0
  302. package/web/app/site/app/logo-monochrome-maskable-512.png +0 -0
  303. package/web/app/site/app/manifest.webmanifest +1 -0
  304. package/web/app/site/app/og-image-2.jpg +0 -0
  305. package/web/app/site/app/og-image.png +0 -0
  306. package/web/app/site/app/robots.txt +2 -0
  307. package/web/app/site/app/version.json +1 -0
  308. package/web/app/site/boot.js +34067 -0
  309. package/web/app/site/index.html +203 -0
  310. package/web/app/site/sw.js +53745 -0
  311. package/web/app/sw-src.mjs +205 -0
  312. package/web/front/#new-account.html# +43 -0
  313. package/web/front/admin.html +1 -81
  314. package/web/front/admin.js +85 -0
  315. package/web/front/new-account.html +1 -9
  316. package/web/front/new-account.js +13 -0
  317. package/web/front/run.html +1 -92
  318. package/web/front/run.js +107 -0
  319. package/lib/podrdf.mjs +0 -132
@@ -0,0 +1,490 @@
1
+ // agent.mjs — FediPod's agent, running in the browser.
2
+ //
3
+ // The Node agent (run-agent.mjs) wires the same pieces; this is that wiring with
4
+ // the browser's edges: the pod is reached over the DPoP session (pod-remote),
5
+ // the signing key comes from WebCrypto (keys-browser), and delivery goes through
6
+ // the relay (deliver-relay). Everything between — wire, the store, the
7
+ // publisher, intake, the Mastodon facade — is lib/, unchanged.
8
+ import { apUrls } from '../../lib/wire.mjs';
9
+ import * as containers from '../../lib/pod/containers.mjs';
10
+ import * as podState from '../../lib/pod/state.mjs';
11
+ import { PodStore } from '../../lib/store.mjs';
12
+ import { HttpStorage } from '../../lib/storage.mjs';
13
+ import { Publisher } from '../../lib/publisher.mjs';
14
+ import { Intake } from '../../lib/intake.mjs';
15
+ import { Lease } from '../../lib/lease.mjs';
16
+ import { MastoApi } from '../../lib/mastoapi.mjs';
17
+ import { TagFeed } from '../../lib/tagfeed.mjs';
18
+ import { makeDpopSession } from './pod-auth.mjs';
19
+ import { BrowserRemotePod } from './pod-remote.mjs';
20
+ import { importSigningKey, loadKeysFromPod, keyCacheKey } from './keys-browser.mjs';
21
+ import { generateKeys, wrapKeys } from './keystore.mjs';
22
+ import { kvPut } from './idb-kv.mjs';
23
+ import { RelayDeliverer } from './deliver-relay.mjs';
24
+ import { AdminFacade } from './admin-facade.mjs';
25
+ import { BrowserAtproto } from './atproto-browser.mjs';
26
+ import { BskyFeed } from '../../lib/bskyfeed.mjs';
27
+ import { BrowserFediAccounts } from './fediacct-browser.mjs';
28
+ import { AcctFeed } from '../../lib/acctfeed.mjs';
29
+ import { followActor, unfollowActor } from '../../lib/social.mjs';
30
+ import { ImportWorker } from '../../lib/import.mjs';
31
+
32
+ // The authorities this identity answers on: exactly one, this origin. The Node
33
+ // agent gets this from lib/guard.mjs, which is not in the browser bundle and
34
+ // would not fit if it were — its whole subject is loopback and named local
35
+ // origins, neither of which means anything here.
36
+ //
37
+ // Two questions are asked of it. `has()` decides whether an OAuth redirect_uri
38
+ // is somewhere this agent actually answers. `isLocalRequest()` decides whether
39
+ // a caller is the owner rather than a stranger, and in a browser that question
40
+ // has exactly one honest answer: whether the request came from this origin —
41
+ // which the service worker has already established before anything reaches the
42
+ // facade, and stamps on the request it builds (see sw-src.mjs).
43
+ const originAuthorities = (host) => ({
44
+ set: new Set([String(host || '').toLowerCase()]),
45
+ has(authority) { return this.set.has(String(authority || '').toLowerCase()); },
46
+ isLocalRequest(req) { return req?.sameOrigin === true; },
47
+ isLocal(h) { return this.has(h); },
48
+ wsAuthorities() { return []; }, // fetch-only: this build serves no socket
49
+ });
50
+
51
+ export class BrowserAgent {
52
+ constructor({ log = console.log } = {}) {
53
+ this.log = log;
54
+ this.viewer = false;
55
+ this.fediaccts = null;
56
+ this.atproto = null;
57
+ this.intake = null;
58
+ }
59
+
60
+ configured() { return !!this.store?.getConfig(); }
61
+
62
+ // Asked by every write / destructive path before it acts. If we already hold
63
+ // the lease, proceed. If we are a viewer, the owner acting HERE outranks the
64
+ // idle active device: claim the lease outright and become active.
65
+ async requestTakeover() {
66
+ if (!this.viewer) return true;
67
+ if (!(await this.lease.takeover())) return false;
68
+ clearTimeout(this._viewerTimer); this._viewerTimer = null;
69
+ await this.goActive();
70
+ this.log('took over from the other device (action here)');
71
+ return true;
72
+ }
73
+
74
+ // Become the active agent: renew the lease, then start what a viewer skips —
75
+ // publish the face, drain the inbox, run the mirrors.
76
+ async goActive() {
77
+ this.viewer = false;
78
+ this.lease.onLost = () => this.demote();
79
+ this.lease.startRenewal();
80
+ try {
81
+ // Forced, not revalidated. While this device watched, the ACTIVE one was
82
+ // writing; our cache is however stale the last viewer poll left it, and
83
+ // the store is write-through — so the first write from here would push a
84
+ // whole document back over newer state. Read what is actually there
85
+ // before acting on it.
86
+ await this.store.load({ force: true }).catch((e) => this.log(`re-reading state: ${e.message}`));
87
+ // And start delivering again, since demote() stopped it. startQueue() is
88
+ // idempotent, so a goActive() that was already active costs nothing.
89
+ this.deliverer?.startQueue?.();
90
+ await this.publisher.publishProfile();
91
+ await this.store.flush?.();
92
+ await this.intake.start();
93
+ this.startBsky();
94
+ this.startAccts();
95
+ this.tagfeed?.start();
96
+ // Resumes a run that was staged on another device, or here before a
97
+ // restart — the state lives on the pod, not in this process.
98
+ this.importer?.start();
99
+ } catch (e) { this.log(`going active: ${e.message}`); }
100
+ }
101
+
102
+ // Another device took the lease: stop acting (the drain also self-checks the
103
+ // lease), keep serving the feed read-only, and poll to take it back if freed.
104
+ demote() {
105
+ if (this.viewer) return;
106
+ this.viewer = true;
107
+ this.log('another device took over — read-only here');
108
+ this.lease.stopRenewal();
109
+ this.intake?.stop?.();
110
+ // The delivery queue as well. Its timer starts in the Deliverer's
111
+ // constructor and nothing here ever switched it off, so a demoted device
112
+ // went on sending from a queue the active device is also sending from —
113
+ // the same activity delivered twice, from two browsers, over one key.
114
+ this.deliverer?.stop?.();
115
+ this.importer?.stop?.();
116
+ this.stopBsky(); this.stopAccts(); this.tagfeed?.stop?.();
117
+ this.startViewerPoll();
118
+ }
119
+
120
+ // A viewer refreshes the feed from the pod (the active device updates it), and
121
+ // promotes the moment the lease is free.
122
+ // Five minutes with jitter, matching the Node agent (run-agent.mjs). Two
123
+ // minutes flat was ~60 pod requests an hour from a device that is not acting,
124
+ // and unjittered meant several idle devices on one pod knocking in lockstep.
125
+ static VIEWER_POLL_MS = 5 * 60_000;
126
+
127
+ startViewerPoll() {
128
+ clearTimeout(this._viewerTimer);
129
+ const every = () => BrowserAgent.VIEWER_POLL_MS * (0.85 + Math.random() * 0.3);
130
+ const tick = () => {
131
+ this._viewerTimer = setTimeout(async () => {
132
+ try {
133
+ if (!this.viewer) return;
134
+ await this.store.load().catch(() => {}); // revalidating is enough while watching
135
+ if (await this.lease.acquire()) { await this.goActive(); this.log('lease freed — now active'); return; }
136
+ } catch (e) { this.log(`viewer poll: ${e.message}`); }
137
+ if (this.viewer) tick();
138
+ }, every());
139
+ };
140
+ tick();
141
+ }
142
+
143
+ /**
144
+ * Boot from what sign-up (or sign-in) produced.
145
+ * @param credential the pod credential { clientId, secret, tokenEndpoint, webId, remotePod, root }
146
+ * @param keysRecord the plaintext signing keys { rsa:{privatePem,publicPem} }
147
+ * @param config { remotePod, root, handle, name, issuer, gateway? }
148
+ * @param opts.frontOrigin where the relay lives (for delivery)
149
+ */
150
+ /**
151
+ * Boot the agent. Two ways in:
152
+ * - { oidc }: a Solid-OIDC session from oidc-session.getSession() (the real,
153
+ * redirect-login path). Config and keys are read from the pod, which the
154
+ * session is authenticated to; a fresh sign-up writes them first.
155
+ * - { credential, keysRecord, config }: a client-credential session with the
156
+ * material in hand (the offline test path).
157
+ */
158
+ async boot({ oidc, credential, keysRecord, config, frontOrigin }) {
159
+ let session; let webId; let remotePod;
160
+ if (oidc) {
161
+ session = { fetch: (u, i) => oidc.fetch(u, i) };
162
+ webId = oidc.webId;
163
+ remotePod = new URL(webId).origin + '/';
164
+ } else {
165
+ const dpop = await makeDpopSession(credential);
166
+ session = { fetch: (u, i) => dpop.fetch(u, i) };
167
+ webId = credential.webId;
168
+ remotePod = credential.remotePod;
169
+ }
170
+ this.webId = webId;
171
+ const root = (config && config.root) || 'fedipod/';
172
+ this.remote = new BrowserRemotePod(session, { webId, log: this.log });
173
+ this.urls = apUrls(remotePod, root);
174
+
175
+ // State store, on the pod.
176
+ // Through the transport, not the raw session: state writes are pod writes,
177
+ // and going round it skipped the Retry-After cooldown, the retry ladder and
178
+ // the deletion deny-list that every other write on this agent observes.
179
+ // The Node agent has always passed remote.fetch here.
180
+ const podFetch = (u, i) => this.remote.fetch(u, i);
181
+ this.store = new PodStore({ storage: new HttpStorage(this.urls.state, podFetch), log: this.log });
182
+ await this.store.load().catch(() => { /* first boot: nothing there yet */ });
183
+ // Config: handed in on sign-up, or read from the pod on a returning sign-in.
184
+ const cfg = config || this.store.getConfig();
185
+ if (!cfg) throw new Error('no account config on this pod — sign up first');
186
+ // `root` is written INTO the config, not just used above. The Publisher
187
+ // builds its own urls from `config.root` (publisher.mjs), and a config
188
+ // without one falls to the Node default — so an account set up elsewhere
189
+ // and signed into here would keep its state under `fedipod/` while every
190
+ // document it published landed under `activitypods-js/`. One root, decided
191
+ // once, carried by the config everything downstream reads.
192
+ this.store.setConfig({ ...(this.store.getConfig() || {}), ...cfg, root });
193
+ // Keys: handed in (offline), or the owner-only keys.json read from the pod.
194
+ const keys = keysRecord ? await importSigningKey(keysRecord) : await loadKeysFromPod(this.remote, this.urls);
195
+ config = this.store.getConfig();
196
+
197
+
198
+ // `passive`: no queue-drain timer until this device is the active one.
199
+ // Whether it IS the active one is not known here — the lease is acquired
200
+ // further down, after everything is constructed — and the timer starts in
201
+ // the Deliverer's constructor, so a viewer used to boot delivering. Start
202
+ // it in goActive() instead, which is the one place that means "act".
203
+ this.deliverer = new RelayDeliverer({
204
+ passive: true,
205
+ store: this.store, rsaPrivate: keys.rsaPrivate, keyId: this.urls.actor + '#main-key',
206
+ actorId: this.urls.actor, log: this.log,
207
+ relayUrl: `${frontOrigin.replace(/\/$/, '')}/api/relay`, handle: config.handle, sessionFetch: session.fetch,
208
+ });
209
+
210
+ this.publisher = new Publisher({
211
+ config: this.store.getConfig(), remote: this.remote, store: this.store,
212
+ deliverer: this.deliverer, publicKeyPem: keys.rsaPublicPem, assertionKey: null, log: this.log,
213
+ });
214
+
215
+ // The Bluesky connection, stamped to this actor. The same client the Node
216
+ // agent uses; here the credential lives in the owner-only pod state (see
217
+ // atproto-browser.mjs). The publisher cross-posts public notes through it
218
+ // when one is connected and crossPost is on; BskyFeed mirrors Bluesky back.
219
+ this.atproto = new BrowserAtproto({ store: this.store, actorId: this.urls.actor, log: this.log });
220
+ this.publisher.atproto = this.atproto;
221
+
222
+ // Fediverse accounts the owner holds on OTHER servers, mirrored into the
223
+ // feed read here. Their full-access tokens live in this browser (IndexedDB),
224
+ // never on the pod — see fediacct-browser.mjs. Records are loaded, and the
225
+ // poll started, in the background provisioning below.
226
+ this.fediaccts = new BrowserFediAccounts({ store: this.store, actorId: this.urls.actor, log: this.log });
227
+
228
+ // The facade and the intake object exist synchronously, so the client can be
229
+ // served the instant boot returns. The heavy pod I/O — provisioning the
230
+ // owner-only containers, publishing the public face, and starting the inbox
231
+ // drain — is a burst of ~20 writes that a pod behind an edge (Cloudflare on
232
+ // solidcommunity.net) throttles. Reaching the feed must NOT wait on it, or a
233
+ // throttled request hangs the whole sign-in. So it runs in the background,
234
+ // where per-request retry rides out the throttling; `this.provisioning`
235
+ // resolves when it is done (a test, or a later call, can await it).
236
+ // Single-active-agent lease (lib/lease.mjs): the inbox drain is a
237
+ // destructive read and the state store is write-through-cached, so exactly
238
+ // one browser/device may ACT on a pod at a time — a later arrival runs
239
+ // read-only until the owner acts on it and it takes over. Written with fresh
240
+ // fetches, never the cached store. Passed into Intake so the drain checks it.
241
+ this.lease = new Lease({ url: this.urls.state + 'lease.json', fetchImpl: podFetch, log: this.log });
242
+ this.intake = new Intake({
243
+ config: this.store.getConfig(), urls: this.urls, remote: this.remote,
244
+ store: this.store, deliverer: this.deliverer, publisher: this.publisher, log: this.log, push: true, lease: this.lease,
245
+ });
246
+ // The Mastodon facade the service worker serves.
247
+ //
248
+ // It used to be handed `allowed: null`, which reads as "no policy" — and
249
+ // `redirectAllowed()` waves through EVERY redirect_uri when there is no
250
+ // policy (lib/mastoapi.mjs:315). So any page could have a freshly minted
251
+ // 90-day bearer delivered to an address of its own. One origin, ours, is
252
+ // the whole of the policy here.
253
+ // Three capabilities this build does not have, declared rather than
254
+ // pretended: no streaming socket (a worker is fetch-only), no web push
255
+ // (shims/web-push.mjs is a no-op), and no scheduling (nothing here runs
256
+ // between now and the scheduled time to publish). Each is omitted from the
257
+ // instance document, so the client hides the control instead of offering
258
+ // one that quietly does nothing.
259
+ this.masto = new MastoApi({
260
+ agent: this, log: this.log, scheme: 'https',
261
+ streaming: false, webPush: false, scheduling: false,
262
+ allowed: originAuthorities(self.location.host),
263
+ });
264
+
265
+ // The owner's record/manage surface (the same web/admin page the Node agent
266
+ // serves), answered by this agent over the worker. Personal only — the
267
+ // group and multi-actor endpoints do not exist here. See admin-facade.mjs.
268
+ this.admin = new AdminFacade({ agent: this, log: this.log });
269
+
270
+ // The CSV follow/block/mute importer — the same worker the Node agent runs.
271
+ // It needs only `agent.store` and social.mjs, both of which are in this
272
+ // bundle, so the browser answering 501 was a gap rather than a limitation.
273
+ // Started only by goActive(): it follows people over minutes, which is
274
+ // exactly the kind of writing a viewer must not do.
275
+ this.importer = new ImportWorker({ agent: this, log: this.log });
276
+
277
+ // The topical (hashtag) feed, so a fresh account is not a blank wall. It
278
+ // polls public tag timelines and mirrors NEW notes as view-only statuses —
279
+ // nothing is written to the pod. A browser cannot fetch arbitrary fediverse
280
+ // servers cross-origin, so every fetch it makes (the tag timeline and each
281
+ // note's verification via intake.fetchAP) goes through the same relay the
282
+ // rest of the agent's remote reads use. The user controls it from the
283
+ // client's Followed Hashtags surface (see MastoApi's tag endpoints).
284
+ const relayGet = (u, i = {}) => this.deliverer.signedFetch(u, { ...i, method: 'GET' });
285
+ this.tagfeed = new TagFeed({ store: this.store, intake: this.intake, log: this.log, fetcher: relayGet });
286
+ // Started only when this browser is the ACTIVE agent (goActive), so two
287
+ // devices do not double-mirror the same hashtags into the pod state.
288
+
289
+ this.provisioning = (async () => {
290
+ // Owner-only containers first; idempotent, so a second device provisioning
291
+ // them too is harmless. Each public document sets its own Read ACL inside
292
+ // publishProfile. Mirrors run-agent bootstrap.
293
+ await containers.provisionPrivate(this.remote, this.urls);
294
+ // Connected-account records are reads — safe whether we act or view.
295
+ await this.atproto.load(); // the Bluesky credential, if it is browser-stored
296
+ await this.fediaccts.load(); // connected fediverse accounts, from both backends
297
+ // The lease decides: this device ACTS on the pod, or reads it read-only.
298
+ this.viewer = !(await this.lease.acquire());
299
+ if (this.viewer) {
300
+ this.log(`read-only viewer: another device is active on @${config.handle}`);
301
+ this.startViewerPoll(); // reload the feed, and promote if the lease frees
302
+ return;
303
+ }
304
+ await this.goActive();
305
+ this.log(`browser agent fully provisioned (active): @${config.handle}`);
306
+ })().catch((e) => { this.log(`background provisioning: ${e.message}`); throw e; });
307
+ // Never let an unhandled rejection escape when nobody awaits it.
308
+ this.provisioning.catch(() => {});
309
+
310
+ this.log(`browser agent up: @${config.handle} on ${remotePod}`);
311
+ return this;
312
+ }
313
+
314
+ // What the record page and the bar read (GET /status). The same shape the
315
+ // Node agent returns (run-agent.mjs), minus what a browser cannot have — no
316
+ // process, no local checkout, no atproto yet, so those are null.
317
+ status() {
318
+ const cfg = this.store?.getConfig();
319
+ const contacts = this.store?.getContacts() || { followers: [], following: [] };
320
+ return {
321
+ configured: this.configured(),
322
+ mode: !this.configured() ? 'unconfigured' : this.viewer ? 'viewer' : 'active',
323
+ kind: cfg?.kind || 'person',
324
+ handle: cfg?.handle || null,
325
+ actor: this.urls?.actor || null,
326
+ followers: contacts.followers.length,
327
+ following: contacts.following.length,
328
+ queue: this.store?.getQueue().length || 0,
329
+ deadLetters: this.store?.getDeadLetters().length || 0,
330
+ blockedDomains: this.store?.getBlocklist().domains.length || 0,
331
+ push: this.intake?.wsState || 'n/a',
332
+ inbox: this.intake?.inboxStats || null,
333
+ lastDrain: this.intake?.lastDrain || null,
334
+ tagfeed: this.tagfeed
335
+ ? { ...this.tagfeed.config(), lastSweep: this.tagfeed.lastSweep, lastAdded: this.tagfeed.lastAdded }
336
+ : null,
337
+ atproto: this.atproto?.status() || null,
338
+ podRequests: this.remote?.stats?.() || null,
339
+ update: null,
340
+ inboxCooldownFor: 0,
341
+ };
342
+ }
343
+
344
+ // Rotate the signing key (POST /rotate-key). Mint a fresh keypair, replace the
345
+ // pod's copy, swap it into the live publisher/deliverer, and republish the
346
+ // actor so the new public key is on the wire. The old key stops signing the
347
+ // moment this returns — same one-way change as the Node agent's rotateKey
348
+ // (run-agent.mjs).
349
+ //
350
+ // The pod's copy is wrapped, so this needs the account password. There is
351
+ // nowhere to get it from without asking: the worker boots from a stored
352
+ // session and holds no password, and caching one to save a prompt on a
353
+ // once-in-a-while action would put the account password in storage to avoid
354
+ // typing it. So the caller supplies it, and rotating without one is refused
355
+ // rather than quietly writing a bare key back where a wrapped one was.
356
+ async rotateKey({ password } = {}) {
357
+ if (!password) {
358
+ const e = new Error('rotating the signing key needs your account password — '
359
+ + 'it is what the new key is locked under on the pod');
360
+ e.code = 'key-password-needed';
361
+ throw e;
362
+ }
363
+ const before = this.publisher.publicKeyPem;
364
+ const rec = await generateKeys();
365
+ rec.mintedFor = this.urls.actor; // one key, one actor (lib/keys.mjs)
366
+ await podState.writeWrappedKeys(this.remote, this.urls, await wrapKeys(rec, password));
367
+ await kvPut(keyCacheKey(this.urls.actor), rec).catch(() => {});
368
+ const keys = await importSigningKey(rec);
369
+ this.publisher.publicKeyPem = keys.rsaPublicPem;
370
+ this.deliverer.rsaPrivate = keys.rsaPrivate;
371
+ await this.publisher.publishProfile();
372
+ return { changed: before !== keys.rsaPublicPem, publicKeyPem: keys.rsaPublicPem };
373
+ }
374
+
375
+ // The Bluesky mirror poll — Bluesky notifications and replies into the pod —
376
+ // running only while an account is connected. Reused across calls, not
377
+ // replaced, so a reconnect never leaves an orphaned timer (as in run-agent).
378
+ // A person's mirror only; a group's Bluesky presence is not built here.
379
+ startBsky() {
380
+ if (!this.atproto?.feedActive()) return; // connected AND not paused
381
+ if (this.store.getConfig()?.quiescedAt) return;
382
+ this.bskyfeed ||= new BskyFeed({ store: this.store, atproto: this.atproto, log: this.log });
383
+ this.bskyfeed.start();
384
+ }
385
+
386
+ stopBsky() { this.bskyfeed?.stop(); }
387
+
388
+ // Pausing/resuming or moving the Bluesky connection changes whether the mirror
389
+ // should run — re-evaluate it.
390
+ restartBsky() { this.stopBsky(); this.bskyfeed = null; this.startBsky(); }
391
+
392
+ // The connected-fediverse-accounts mirror — their home timelines and
393
+ // notifications polled into the feed read here — running only while at least
394
+ // one is connected. Reused, not replaced, for the same orphaned-timer reason
395
+ // as the others (run-agent.mjs).
396
+ startAccts() {
397
+ if (!this.fediaccts?.connected()) return;
398
+ if (this.store.getConfig()?.quiescedAt) return;
399
+ this.acctfeed ||= new AcctFeed({ store: this.store, accounts: this.fediaccts, log: this.log });
400
+ this.acctfeed.start();
401
+ }
402
+
403
+ stopAccts() { this.acctfeed?.stop(); }
404
+
405
+ // Connecting or disconnecting one changes what there is to poll.
406
+ restartAccts() { this.stopAccts(); this.acctfeed = null; this.startAccts(); }
407
+
408
+ // The follow graph, written down before it is torn down.
409
+ //
410
+ // Both parking and moving away unfollow everyone, and the record page offers
411
+ // "active" again after either — so both have to leave something to come back
412
+ // from. moveTo used to report `snapshot: following.length` without writing
413
+ // one, so a browser account set back to active re-followed nobody and said so
414
+ // only in a count of zero. Same file, same name, same shape as the Node
415
+ // agent's `_snapshotFollowing` (run-agent.mjs).
416
+ async _snapshotFollowing() {
417
+ const following = this.store.getContacts().following;
418
+ this.store.write('parked.json', {
419
+ parkedAt: new Date().toISOString(),
420
+ following: following.map(f => ({ actor: f.actor, handle: f.handle || null })),
421
+ });
422
+ await this.store.flush();
423
+ return following;
424
+ }
425
+
426
+ // Keep the handle, take no more mail.
427
+ //
428
+ // Unfollowing is what actually stops the volume — every account you follow
429
+ // pushes its posts into your inbox — and closing the inbox handles what no
430
+ // follow graph can gate: stranger mentions, new follow requests, spam.
431
+ //
432
+ // This matters more here than it does on Node. A Node agent runs as a service
433
+ // and keeps draining; close the tab on a browser agent and nothing drains,
434
+ // while the gateway goes on delivering into the pod inbox regardless (it
435
+ // writes straight to the pod — see lib/gateway-core.mjs). An unattended
436
+ // browser account is a pod quietly filling up with nobody collecting, and
437
+ // this is the only control that stops it at the source.
438
+ async quiesce() {
439
+ const following = this.store.getContacts().following.map(f => f.actor).filter(Boolean);
440
+ let unfollowed = 0;
441
+ for (const actor of following) {
442
+ try { await unfollowActor(this, actor); unfollowed++; }
443
+ catch (e) { this.log(`unfollow ${actor} failed: ${e.message}`); }
444
+ }
445
+ const quiescedAt = await this.publisher.closeInbox();
446
+ this.log(`quiesced: unfollowed ${unfollowed}/${following.length}, inbox closed`);
447
+ return { unfollowed, following: following.length, quiescedAt };
448
+ }
449
+
450
+ // Park (POST /park). The snapshot is taken FIRST: unfollowing is what stops
451
+ // the traffic, but it also destroys the only record of who was being
452
+ // followed, and "until I want this back" needs that record.
453
+ async park() {
454
+ const following = await this._snapshotFollowing();
455
+ const r = await this.quiesce();
456
+ this.log(`parked: ${r.unfollowed} unfollow(s) recorded for revival, inbox closed`);
457
+ return { ...r, snapshot: following.length };
458
+ }
459
+
460
+ // Undo a park (POST /revive). Re-open the inbox, then re-follow everyone in
461
+ // the snapshot. Each Follow needs the far end to Accept, so this is a request
462
+ // rather than a restoration — some will not come back, which is the nature of
463
+ // the thing, and why the result counts both numbers.
464
+ async revive() {
465
+ const parked = this.store.read('parked.json', null);
466
+ await this.publisher.openInbox();
467
+ let refollowed = 0;
468
+ for (const f of parked?.following || []) {
469
+ try { await followActor(this, f.actor); refollowed++; }
470
+ catch (e) { this.log(`re-follow ${f.actor} failed: ${e.message}`); }
471
+ }
472
+ if (parked) this.store.remove('parked.json').catch(() => {});
473
+ await this.store.flush();
474
+ this.log(`revived: inbox open, ${refollowed}/${parked?.following?.length || 0} follow(s) re-sent`);
475
+ return { refollowed, of: parked?.following?.length || 0, parkedAt: parked?.parkedAt || null };
476
+ }
477
+
478
+ // Hand the identity to another account (POST /move). The federated act is the
479
+ // Move to every follower's server; then this account quiesces — unfollows
480
+ // everyone and closes its inbox — since it is being left behind. Same as the
481
+ // Node agent's moveTo + quiesce (run-agent.mjs). `target` is an actor URL,
482
+ // already resolved by the caller.
483
+ async moveTo(target) {
484
+ const moved = await this.publisher.publishMove(target);
485
+ const following = await this._snapshotFollowing();
486
+ const r = await this.quiesce();
487
+ this.log(`moved to ${target}: unfollowed ${r.unfollowed}/${r.following}, inbox closed`);
488
+ return { ...moved, ...r, snapshot: following.length };
489
+ }
490
+ }
@@ -0,0 +1,86 @@
1
+ // atproto-browser.mjs — the Bluesky connection, in the browser.
2
+ //
3
+ // Same Atproto client the Node agent uses (lib/atproto.mjs). Two browser edges:
4
+ //
5
+ // 1. Storage is the owner's choice, per connection. "browser" keeps the
6
+ // credential in IndexedDB on this device (default — a full-access token
7
+ // stays here); "pod" keeps it in the owner-only pod state so it follows the
8
+ // owner to any browser they sign in from. The record carries which, and
9
+ // read/write route to that backend; moving between them is just a re-write.
10
+ // 2. The mirror can be paused (feedPaused) without disconnecting.
11
+ //
12
+ // Bluesky's API answers CORS, so the fetches go direct, not through the relay.
13
+ import { Atproto } from '../../lib/atproto.mjs';
14
+ import { kvGet, kvPut, kvDel } from './idb-kv.mjs';
15
+
16
+ const IDB_KEY = 'atproto';
17
+ const POD_DOC = 'atproto.json';
18
+
19
+ export class BrowserAtproto extends Atproto {
20
+ constructor({ store, actorId = null, log = console.log } = {}) {
21
+ super({ localDir: '/atproto', actorId, log }); // localDir unused; storage below
22
+ this.store = store;
23
+ this._idbRec = null; // the browser-stored record, cached at boot
24
+ }
25
+
26
+ // The IndexedDB copy into memory, so read() stays synchronous as the base
27
+ // class expects. The pod copy is already in the loaded store.
28
+ async load() { this._idbRec = await kvGet(IDB_KEY).catch(() => null); return this; }
29
+
30
+ // Whichever backend holds it, tagged with where it lives. The stamped-record
31
+ // guard is the base class's.
32
+ read() {
33
+ let rec = null; let where = null;
34
+ if (this._idbRec) { rec = this._idbRec; where = 'browser'; }
35
+ else { const p = this.store.read(POD_DOC, null); if (p) { rec = p; where = 'pod'; } }
36
+ if (!rec) return null;
37
+ if (rec.mintedFor && this.actorId && rec.mintedFor !== this.actorId) {
38
+ this.log(`atproto belongs to ${rec.mintedFor} — not reusing it for ${this.actorId}`);
39
+ return null;
40
+ }
41
+ return { ...rec, storage: where };
42
+ }
43
+
44
+ // Write to the record's chosen backend (default browser), and clear the other
45
+ // so a moved credential leaves no copy behind.
46
+ write(rec) {
47
+ const where = rec.storage || 'browser';
48
+ const clean = { ...rec, storage: where };
49
+ if (where === 'pod') {
50
+ this.store.write(POD_DOC, clean); this.store.flush?.().catch(() => {});
51
+ this._idbRec = null; kvDel(IDB_KEY).catch(() => {});
52
+ } else {
53
+ this._idbRec = clean; kvPut(IDB_KEY, clean).catch((e) => this.log(`atproto persist: ${e.message}`));
54
+ this.store.write(POD_DOC, null); this.store.flush?.().catch(() => {});
55
+ }
56
+ }
57
+
58
+ status() {
59
+ const rec = this.read();
60
+ return {
61
+ connected: !!rec?.did, service: rec?.service || null, handle: rec?.handle || null,
62
+ did: rec?.did || null, lastError: this.lastError,
63
+ cooldownFor: Math.max(0, Math.round((this.pausedUntil - Date.now()) / 1000)),
64
+ storage: rec?.storage || null, feedPaused: !!rec?.feedPaused,
65
+ };
66
+ }
67
+
68
+ // The mirror runs unless paused; the account stays connected either way.
69
+ feedActive() { return this.connected() && !this.read()?.feedPaused; }
70
+ setFeedPaused(on) { const rec = this.read(); if (rec) this.write({ ...rec, feedPaused: !!on }); }
71
+
72
+ // Move the credential between backends (the Options menu's Storage control).
73
+ setStorage(where) { const rec = this.read(); if (rec && (where === 'pod' || where === 'browser')) this.write({ ...rec, storage: where }); }
74
+
75
+ async disconnect() {
76
+ const rec = this.read();
77
+ if (rec?.refreshJwt) {
78
+ await this._fetch(`${rec.service}/xrpc/com.atproto.server.deleteSession`, {
79
+ method: 'POST', headers: { authorization: `Bearer ${rec.refreshJwt}` },
80
+ }).catch(() => {});
81
+ }
82
+ this._idbRec = null; await kvDel(IDB_KEY).catch(() => {});
83
+ this.store.write(POD_DOC, null); await this.store.flush?.().catch(() => {});
84
+ this.log('bluesky disconnected');
85
+ }
86
+ }