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
package/lib/wire.mjs CHANGED
@@ -6,75 +6,40 @@ export const AS_CTX = 'https://www.w3.org/ns/activitystreams';
6
6
  export const SEC_CTX = 'https://w3id.org/security/v1';
7
7
  export const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
8
8
 
9
- // URLs of the actor's documents on the remote pod. Everything the agent owns
10
- // nests under ONE top-level container (`root`) so the pod stays tidy and one
11
- // pod could host several actors under different roots. Webfinger + host-meta
12
- // stay at the pod root fediverse discovery requires the host root.
13
- // The AP URLs for an identity. Normally everything hangs off the pod home.
14
- //
15
- // When `publicBase` is given (a fronted identity, e.g.
16
- // "https://fedipod.net/u/me/"), the ADVERTISED ids — the actor and everything
17
- // under `ap/` a remote server sees — build on the fronted home instead, while
18
- // the pod-side trees (`fediverse/`, `ap-state/`) and the write targets stay on
19
- // the pod. `toPod`/`toPublic` map between the two spaces; `RemotePod` applies
20
- // `toPod` at its one request choke point, so publisher/social/intake keep
21
- // passing advertised ids and the writes still land on the pod. With no
22
- // `publicBase` the returned object is byte-identical to before — the invariant
23
- // the smoke suite pins.
9
+ // The pod LAYOUT which container each document lives in — moved into the pod
10
+ // library (lib/pod/urls.mjs), which knows nothing about FediPod and requires
11
+ // the container root to be stated rather than defaulted. What stays here is
12
+ // FediPod's own answer to "which root", and the handle rule, both of which read
13
+ // this application's config shape.
14
+ import { apUrls as podUrls } from './pod/urls.mjs';
15
+
16
+ export { webfingerHost } from './pod/urls.mjs';
17
+
18
+ // The container the Node agent has always used. Older than the rename to
19
+ // FediPod and kept because it is what every existing Node-side pod actually
20
+ // contains; the browser build states `fedipod/` on its own configs. A config
21
+ // that names a root is always believed this is only the answer for one that
22
+ // does not.
23
+ export const DEFAULT_ROOT = 'activitypods-js/';
24
+
24
25
  // The handle the fediverse sees: a fronted identity's name is the front's.
25
26
  export function publicHandle(config) {
26
27
  return config?.gateway?.frontActor?.match(/\/u\/([^/]+)\/ap\/actor\/?$/)?.[1]
27
28
  || config?.handle || null;
28
29
  }
29
30
 
30
- export function apUrls(remotePod, root = 'activitypods-js/', { publicBase = null } = {}) {
31
- const base = remotePod.endsWith('/') ? remotePod : remotePod + '/';
32
- const home = base + (!root || root.endsWith('/') ? root : root + '/');
33
- // The face a remote sees: the fronted home, or the pod home when unfronted.
34
- const face = publicBase ? (publicBase.endsWith('/') ? publicBase : publicBase + '/') : home;
35
- const urls = {
36
- base, home,
37
- webfinger: base + '.well-known/webfinger',
38
- actor: face + 'ap/actor',
39
- inbox: face + 'ap/inbox/',
40
- outbox: face + 'ap/outbox',
41
- followers: face + 'ap/followers',
42
- following: face + 'ap/following',
43
- notes: face + 'ap/notes/',
44
- privateNotes: face + 'ap/private/',
45
- featured: face + 'ap/featured',
46
- // FEP-1b12: the moderator roster a recipient validates announced
47
- // moderation against. Published only when moderators are configured.
48
- moderators: face + 'ap/moderators',
49
- // FEP-4ccd and FEP-c648: follows in limbo and the block list, as
50
- // collections. They live in the private container so the owner-only ACL
51
- // is inherited, not re-stated per document.
52
- pendingFollowers: face + 'ap/private/pending-followers',
53
- pendingFollowing: face + 'ap/private/pending-following',
54
- blocked: face + 'ap/private/blocked',
55
- profileHtml: face + 'ap/profile.html',
56
- // Media stays on the pod even when fronted: attachment urls are not
57
- // identity-checked by remotes, and proxying blobs would be pure cost.
58
- media: home + 'ap/media/',
59
- fediverse: home + 'fediverse/',
60
- state: home + 'ap-state/',
61
- };
62
- if (publicBase) {
63
- urls.podHome = home;
64
- urls.publicHome = face;
65
- urls.toPod = (u) => (typeof u === 'string' && u.startsWith(face) ? home + u.slice(face.length) : u);
66
- urls.toPublic = (u) => (typeof u === 'string' && u.startsWith(home) ? face + u.slice(home.length) : u);
67
- }
68
- return urls;
69
- }
70
-
71
- // The host a handle would resolve through, or null when no handle can point
72
- // here. @name@host is looked up at https://host/.well-known/webfinger, so only
73
- // a pod that owns the root of its host can answer for one; a pod living at
74
- // https://server/name/ may publish the document but nothing will ever ask.
75
- export function webfingerHost(podUrl) {
76
- const u = new URL(podUrl);
77
- return u.pathname === '/' ? u.host : null;
31
+ /**
32
+ * The AP URLs for an identity, with FediPod's default root applied.
33
+ *
34
+ * The library underneath refuses to guess a root. Every caller here goes
35
+ * through this wrapper so there is exactly ONE answer to "which container",
36
+ * which is the thing that was wrong: the browser agent decided `fedipod/` for
37
+ * its own urls while the publisher, handed a config with no `root`, decided
38
+ * `activitypods-js/` — putting an identity's state in one container and its
39
+ * published documents in another.
40
+ */
41
+ export function apUrls(remotePod, root, opts = {}) {
42
+ return podUrls(remotePod, root || DEFAULT_ROOT, opts);
78
43
  }
79
44
 
80
45
  // RFC 6415 host-meta: several fediverse implementations discover WebFinger
@@ -585,15 +550,31 @@ export function titledContent(note) {
585
550
  }
586
551
 
587
552
  // Normalize a wire Note's attachment list to { url, mediaType, description }.
553
+ // Capped: this list comes from somebody else's document and lands in a
554
+ // statuses.json row, which is rewritten whole on every change. Twenty is more
555
+ // than any client shows and more than any real post carries.
556
+ const MAX_ATTACHMENTS = 20;
557
+ const MAX_ATTACHMENT_URL = 2048;
558
+ // http(s) only: an attachment URL becomes a client's <img src> or <video src>,
559
+ // and the store has guarded avatars this way all along (safeUrl).
560
+ const attachmentUrl = (u) => {
561
+ if (!u) return null;
562
+ const s = String(u).slice(0, MAX_ATTACHMENT_URL);
563
+ try {
564
+ const p = new URL(s);
565
+ return (p.protocol === 'https:' || p.protocol === 'http:') ? s : null;
566
+ } catch { return null; }
567
+ };
568
+
588
569
  export function attachmentsOf(note) {
589
570
  const list = Array.isArray(note?.attachment) ? note.attachment : note?.attachment ? [note.attachment] : [];
590
571
  // A bare Link carries `href` and no `url` — Lemmy's link posts arrive that
591
572
  // way, and reading only `url` dropped the link the post was about.
592
- return list.map(a => ({
573
+ return list.slice(0, MAX_ATTACHMENTS).map(a => ({
593
574
  url: typeof a?.url === 'string' ? a.url : a?.url?.href || (typeof a?.href === 'string' ? a.href : undefined),
594
- mediaType: a?.mediaType || '',
595
- ...(a?.name ? { description: a.name } : {}),
596
- })).filter(a => a.url);
575
+ mediaType: String(a?.mediaType || '').slice(0, 128),
576
+ ...(a?.name ? { description: String(a.name).slice(0, 1500) } : {}),
577
+ })).map(a => ({ ...a, url: attachmentUrl(a.url) })).filter(a => a.url);
597
578
  }
598
579
 
599
580
  const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;' };
@@ -660,6 +641,45 @@ export function noteDoc({ urls, slug, content, published, inReplyTo, attachments
660
641
  return note;
661
642
  }
662
643
 
644
+ // A poll is a note that asks something: the same addressing, the same
645
+ // mentions, the same replies collection, with the choices in `oneOf` (pick
646
+ // one) or `anyOf` (pick several). Each choice carries the count as the
647
+ // totalItems of its own replies collection, which is where every server that
648
+ // shows a poll reads it from.
649
+ //
650
+ // `endTime` is when voting stops and `closed` is the stamp saying it has —
651
+ // both plain AS2. `votersCount` is Mastodon's: PEOPLE rather than answers,
652
+ // which differ only once a poll takes several answers each. It is not in the
653
+ // base context, so it is declared inline exactly as Mastodon declares it, and
654
+ // only when we actually carry it.
655
+ export function questionDoc({ options = [], multiple = false, endTime = null,
656
+ closed = null, votersCount = null, ...rest }) {
657
+ const note = noteDoc(rest);
658
+ const choices = options.map(o => ({
659
+ type: 'Note',
660
+ name: String(o.title),
661
+ replies: { type: 'Collection', totalItems: Number(o.votes) || 0 },
662
+ }));
663
+ const question = {
664
+ ...note,
665
+ type: 'Question',
666
+ [multiple ? 'anyOf' : 'oneOf']: choices,
667
+ };
668
+ if (endTime) question.endTime = endTime;
669
+ if (closed) question.closed = closed;
670
+ if (votersCount !== null && votersCount !== undefined) {
671
+ question['@context'] = [AS_CTX, {
672
+ toot: 'http://joinmastodon.org/ns#',
673
+ votersCount: {
674
+ '@id': 'toot:votersCount',
675
+ '@type': 'http://www.w3.org/2001/XMLSchema#nonNegativeInteger',
676
+ },
677
+ }];
678
+ question.votersCount = votersCount;
679
+ }
680
+ return question;
681
+ }
682
+
663
683
  // The id is a document of its own, not `note.id + '#create'`. A group wraps this
664
684
  // whole activity in its Announce (FEP-1b12), and the receiver resolves it by
665
685
  // dereferencing this id — a fragment would just serve the Note back under a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod-server",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "The FediPod Server: a full ActivityPub server as a Community Solid Server component.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
package/run-agent.mjs CHANGED
@@ -34,7 +34,6 @@ import { ensureTrustedTls } from './lib/certs.mjs';
34
34
  import { storageFor } from './lib/storage.mjs';
35
35
  import { resolveKeys } from './lib/keys.mjs';
36
36
  import { RemotePod } from './lib/remote.mjs';
37
- import { PodRdf } from './lib/podrdf.mjs';
38
37
  import { Deliverer } from './lib/deliver.mjs';
39
38
  import { Publisher } from './lib/publisher.mjs';
40
39
  import { Intake } from './lib/intake.mjs';
@@ -69,15 +68,14 @@ export class Agent {
69
68
 
70
69
  // Where the private half lives. `privateRoot` in the credential file names a
71
70
  // container — by default a plain directory beside the credential, and a pod
72
- // on this machine if you move it there under which the same two trees are
73
- // laid out as on the pod. Absent means on the pod, exactly as before, so an
71
+ // on this machine if you move it there. Absent means on the pod, so an
74
72
  // existing install is untouched.
75
73
  privateUrls(cred, urls = this.urls) {
76
74
  if (!cred.privateRoot) {
77
- return { state: urls.state, fediverse: urls.fediverse, archive: urls.home + 'inbox-archive/', elsewhere: false };
75
+ return { state: urls.state, archive: urls.home + 'inbox-archive/', elsewhere: false };
78
76
  }
79
77
  const base = cred.privateRoot.endsWith('/') ? cred.privateRoot : cred.privateRoot + '/';
80
- return { state: base + 'ap-state/', fediverse: base + 'fediverse/', archive: base + 'inbox-archive/', elsewhere: true };
78
+ return { state: base + 'ap-state/', archive: base + 'inbox-archive/', elsewhere: true };
81
79
  }
82
80
 
83
81
  // A container to keep the private half in. On the pod it is reached with the
@@ -146,14 +144,9 @@ export class Agent {
146
144
  await this.remote.setAcl(this.urls.home, []);
147
145
  await this.remote.setAcl(this.urls.state, []);
148
146
  if (priv.elsewhere) {
149
- for (const s of [stateStore, this.privateStorage(cred, 'fediverse')]) {
150
- const r = await s.write('.keep', '{"keep":true}\n', 'application/json');
151
- if (!r.ok) throw new Error(`private store ${s.base}.keep → ${r.why}`);
152
- }
147
+ const r = await stateStore.write('.keep', '{"keep":true}\n', 'application/json');
148
+ if (!r.ok) throw new Error(`private store ${stateStore.base}.keep → ${r.why}`);
153
149
  this.log(`private state lives at ${cred.privateRoot} (${stateStore.kind})`);
154
- } else {
155
- await this.remote.putJson(this.urls.fediverse + '.keep', { keep: true }, 'application/json');
156
- await this.remote.setAcl(this.urls.fediverse, []);
157
150
  }
158
151
  this.store.attach(stateStore);
159
152
  // Re-running setup must not destroy state set afterwards (the UI
@@ -314,7 +307,6 @@ export class Agent {
314
307
  const { rotateKeyOnce, ...rest } = cred;
315
308
  writeJsonAtomic(path.join(this.home, 'credential.json'), rest);
316
309
  }
317
- this.local = new PodRdf({ storage: this.privateStorage(cred, 'fediverse') });
318
310
  // connect() can run more than once — connectWithRetry retries after a throw,
319
311
  // and the CLI connects for real after its confirmation. Deliverer arms a
320
312
  // 60s queue timer in its constructor and Intake owns a poll timer and a
@@ -324,6 +316,7 @@ export class Agent {
324
316
  this.deliverer?.stop();
325
317
  this.tagfeed?.stop();
326
318
  clearInterval(this.schedTimer);
319
+ this.publisher?.stopPolls();
327
320
  this.deliverer = new Deliverer({
328
321
  store: this.store, rsaPrivate: keys.rsaPrivate, keyId: this.urls.actor + '#main-key',
329
322
  actorId: this.urls.actor, edPrivate: keys.edPrivate,
@@ -331,13 +324,10 @@ export class Agent {
331
324
  log: this.log, passive: this.viewer,
332
325
  });
333
326
  this.publisher = new Publisher({
334
- config, remote: this.remote, local: this.local, store: this.store,
327
+ config, remote: this.remote, store: this.store,
335
328
  deliverer: this.deliverer, publicKeyPem: keys.rsaPublicPem,
336
329
  assertionKey: keys.edPublicMultibase, log: this.log,
337
330
  resolveMention: (h) => resolveHandle(this, h),
338
- // Whether the fediverse tree is on the pod at all, so the ACL check does
339
- // not probe for something the default layout keeps on local disk.
340
- privateOnPod: !cred.privateRoot,
341
331
  // Inside a pod server the client surface answers on the pod's own
342
332
  // origin, so it can be advertised. Standalone it is on loopback, and
343
333
  // naming it in a world-readable actor would send clients nowhere.
@@ -346,7 +336,7 @@ export class Agent {
346
336
  // Intake is constructed even for viewers — its signed fetchAP powers
347
337
  // search/deref; start() (draining) is active-only.
348
338
  this.intake = new Intake({
349
- config, urls: this.urls, remote: this.remote, local: this.local, store: this.store,
339
+ config, urls: this.urls, remote: this.remote, store: this.store,
350
340
  deliverer: this.deliverer, publisher: this.publisher, log: this.log, lease: this.lease,
351
341
  archive: this.privateStorage(cred, 'archive'),
352
342
  push: !this.embedded, pollSeconds: this.pollSeconds || null,
@@ -547,6 +537,10 @@ export class Agent {
547
537
  }).then(() => this.log(`scheduled post published (${e.id})`))
548
538
  .catch(err => this.log(`scheduled post ${e.id} failed: ${err.message} — dropped`));
549
539
  }
540
+ // A poll whose time is up is shut on the same sweep: it stops taking
541
+ // answers here, and everyone holding it is told once.
542
+ this.publisher.closeDuePolls()
543
+ .catch(err => this.log(`closing polls: ${err.message}`));
550
544
  }, 30_000);
551
545
  this.schedTimer.unref();
552
546
  // A CSV import interrupted by a restart or a handoff picks back up here.
@@ -576,7 +570,7 @@ export class Agent {
576
570
  this.ensureActorPublished()
577
571
  .catch(e => this.log(`actor check failed: ${e.message}`));
578
572
  }
579
- this.backfillStatuses().catch(e => this.log(`statuses backfill failed: ${e.message}`));
573
+ this.seedFollowNotifications().catch(e => this.log(`notification seeding failed: ${e.message}`));
580
574
  }
581
575
 
582
576
  // Re-read state before acting on what we hold. A viewer's cache is kept
@@ -651,6 +645,7 @@ export class Agent {
651
645
  this.deliverer?.stop();
652
646
  this.importer?.stop();
653
647
  clearInterval(this.schedTimer);
648
+ this.publisher?.stopPolls();
654
649
  // The lease too: standing down means standing down. Left renewing, a viewer
655
650
  // keeps writing to the pod on the active agent's behalf and can win the
656
651
  // lease back on a conditional PUT it had no business making.
@@ -689,27 +684,13 @@ export class Agent {
689
684
  return true;
690
685
  }
691
686
 
692
- // The statuses index is an operational mirror of the pod's RDF — rebuild it
693
- // from /fediverse/ when absent (fresh state, or state loss).
694
- async backfillStatuses() {
695
- if (!this.store.has('notifications.json')) {
696
- for (const f of this.store.getContacts().followers) {
697
- this.store.addNotification({ type: 'follow', actor: f.actor });
698
- }
699
- }
700
- if (this.store.has('statuses.json')) return;
701
- const entries = [];
702
- for (const [container, kind] of [['timeline', 'timeline'], ['posts', 'post']]) {
703
- for (const url of await this.local.listNotes(container)) {
704
- try {
705
- const n = await this.local.readNote(url);
706
- if (n.noteId) entries.push({ ...n, kind, slug: url.split('/').pop() });
707
- } catch (e) { this.log(`backfill: skipped ${url}: ${e.message}`); }
708
- }
687
+ // A first run inherits followers without ever having seen them arrive, so
688
+ // the notifications list starts empty where it should start full.
689
+ async seedFollowNotifications() {
690
+ if (this.store.has('notifications.json')) return;
691
+ for (const f of this.store.getContacts().followers) {
692
+ this.store.addNotification({ type: 'follow', actor: f.actor });
709
693
  }
710
- entries.sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
711
- this.store.write('statuses.json', entries.slice(0, 1000));
712
- this.log(`backfilled ${entries.length} statuses from pod RDF`);
713
694
  }
714
695
  }
715
696
 
@@ -13,8 +13,15 @@ const $ = (id) => document.getElementById(id);
13
13
  // machine, `/app` when the identity is hosted by its own pod server. Read from
14
14
  // the page's own address, so one build serves both.
15
15
  const BASE = location.pathname.replace(/\/admin\/.*$/u, '');
16
+ // Every call carries `x-fedipod-page`. On the Node agent it is ignored — the
17
+ // Origin check there already refuses a page on somebody else's site. In the
18
+ // browser build the agent IS this origin's service worker, which cannot see
19
+ // Sec-Fetch-* and answers these routes with no password, so this header is the
20
+ // whole door: a cross-site form cannot set one, and a cross-origin fetch that
21
+ // sets one is stopped at a preflight the worker never grants. See sw-src.mjs.
22
+ const PAGE_HEADER = { 'x-fedipod-page': '1' };
16
23
  const api = async (path, init) => {
17
- const res = await fetch(BASE + path, init);
24
+ const res = await fetch(BASE + path, { ...init, headers: { ...(init?.headers || {}), ...PAGE_HEADER } });
18
25
  return { status: res.status, json: await res.json().catch(() => null) };
19
26
  };
20
27
  const postJson = (path, body) => api(path, {
@@ -27,14 +34,12 @@ let config = null;
27
34
  const MODERATION = $('moderation');
28
35
  const STATUS = $('status-ctl');
29
36
  const STATUS_PICK = $('status-pick'); // held: getElementById can't see it mid-render
30
- const BSKY_CTL = $('bsky-ctl');
31
37
  const IDENT_CTL = $('ident-ctl');
32
38
  const FOLLOWS_CTL = $('follows-ctl');
33
39
  const FOLLOWS_PICK = $('follows-pick');
34
40
  const UPDATE_CTL = $('update-ctl');
35
41
  const UPDATE_WORD = $('update-word');
36
42
  const UPDATE_GO = $('update-go');
37
- const BSKY_CROSSPOST = $('bsky-crosspost'); // held: it rides BSKY_CTL into a generated row
38
43
 
39
44
  // #say and #fatal live in the accessibility tree from load and hide by being
40
45
  // empty (see the stylesheet). Unhiding a live region and filling it in the same
@@ -95,10 +100,7 @@ function resetConfirm() {
95
100
  $('confirm-handle').value = '';
96
101
  $('confirm-handle-move').value = '';
97
102
  $('move-target').value = '';
98
- $('state-path').value = '';
99
- $('state-url').value = '';
100
- document.querySelector('input[name=stateWhere][value=path]').checked = true;
101
- stateRows();
103
+ $('rotate-password').value = '';
102
104
  }
103
105
  function closePanels(keep = null) {
104
106
  if (!keep) solWindow.close();
@@ -126,34 +128,129 @@ function identityRows() {
126
128
  dd.append(...nodes);
127
129
  out.push([dt, dd]);
128
130
  };
129
- if (config.atproto?.connected) {
130
- const a = document.createElement('a');
131
- a.href = `https://bsky.app/profile/${config.atproto.handle}`;
132
- a.textContent = `@${config.atproto.handle}`;
133
- a.target = '_blank';
134
- a.rel = 'noopener';
135
- a.title = ` Open this account's Bluesky page in a new tab`;
136
- BSKY_CTL.hidden = false;
137
- BSKY_CROSSPOST.value = config.atproto.crossPost ? 'on' : 'off';
138
- row('Bluesky', a, ' ', BSKY_CTL);
139
- } else {
140
- BSKY_CTL.hidden = true;
131
+ // One descriptor shape for every connected account — Bluesky and each
132
+ // fediverse account — so the row and its Options menu build the same way.
133
+ const accounts = [];
134
+ if (config.atproto?.connected) accounts.push({
135
+ label: 'Bluesky', handle: `@${config.atproto.handle}`,
136
+ href: `https://bsky.app/profile/${config.atproto.handle}`,
137
+ storage: config.atproto.storage || null,
138
+ feedPaused: 'feedPaused' in config.atproto ? config.atproto.feedPaused : null,
139
+ crossPost: 'crossPost' in config.atproto ? config.atproto.crossPost : null,
140
+ feed: (paused) => ['/atproto', { feedPaused: paused }],
141
+ cross: (on) => ['/atproto', { crossPost: on }],
142
+ store: (w) => ['/atproto', { storage: w }],
143
+ off: () => ['/atproto/disconnect', {}],
144
+ });
145
+ for (const acct of config.fediAccounts || []) accounts.push({
146
+ label: acct.host,
147
+ handle: acct.needsReconnect ? `${acct.handle} — sign in again` : acct.handle,
148
+ warn: acct.needsReconnect,
149
+ storage: acct.storage || null,
150
+ feedPaused: !acct.enabled,
151
+ crossPost: null,
152
+ feed: (paused) => ['/fediacct', { id: acct.id, enabled: !paused }],
153
+ store: (w) => ['/fediacct', { id: acct.id, storage: w }],
154
+ off: () => ['/fediacct/disconnect', { id: acct.id }],
155
+ });
156
+ for (const acct of accounts) {
157
+ const name = document.createElement(acct.href ? 'a' : 'span');
158
+ name.textContent = acct.handle;
159
+ if (acct.href) { name.href = acct.href; name.target = '_blank'; name.rel = 'noopener'; name.title = ' Open this account elsewhere in a new tab'; }
160
+ if (acct.warn) name.className = 'warn';
161
+ // Where storage is a real choice (every browser-build account today), two
162
+ // dropdowns sit in the row itself. Where it isn't (the Node agent's
163
+ // Bluesky, which shows a cross-post toggle instead), the Options
164
+ // disclosure it always has is unchanged.
165
+ row(acct.label, name, ' ', ...(acct.storage ? connectionControls(acct) : [optionsMenu(acct)]));
166
+ }
167
+ return out;
168
+ }
169
+
170
+ // Connected, Paused or Disconnected, and where the key lives — one dropdown
171
+ // each, in the row. Replaces the separate feed and connect dropdowns that used
172
+ // to sit behind an Options disclosure; only reached where acct.storage is set.
173
+ function connectionControls(acct) {
174
+ const send = async ([path, payload], msg) => { if (await write(path, payload, msg)) await load(); };
175
+ const pick = (opts, current, onChange, title) => {
176
+ const el = document.createElement('select');
177
+ if (title) el.title = title;
178
+ for (const [v, t] of opts) { const o = document.createElement('option'); o.value = v; o.textContent = t; o.selected = v === current; el.appendChild(o); }
179
+ el.addEventListener('change', () => onChange(el.value));
180
+ return el;
181
+ };
182
+ const conn = pick(
183
+ [['connected', 'Connected'], ['paused', 'Paused'], ['disconnected', 'Disconnected']],
184
+ acct.feedPaused ? 'paused' : 'connected',
185
+ (v) => (v === 'disconnected'
186
+ ? send(acct.off(), 'disconnected')
187
+ : send(acct.feed(v === 'paused'), v === 'paused' ? 'paused adding to your feed' : 'added to your home feed')),
188
+ ' Connected feeds this account into your home feed here; Paused keeps the account but stops adding its posts; Disconnected removes it.');
189
+ const storage = pick(
190
+ [['pod', 'Store key on Pod'], ['browser', 'Store key in Browser']],
191
+ acct.storage,
192
+ (v) => {
193
+ if (v === 'pod' && !confirm('Store this account on your pod?\n\nIt will follow you to any browser you sign in from — but a full-access token to that account then lives on your pod.')) { load(); return; }
194
+ send(acct.store(v), v === 'pod' ? 'stored on your pod' : 'stored in this browser');
195
+ },
196
+ ' On your pod the key follows you to any browser you sign in from, and a full-access token then lives there; in this browser the token never leaves this device.');
197
+ const wrap = document.createElement('span');
198
+ wrap.className = 'conn-controls';
199
+ wrap.append(conn, ' ', storage);
200
+ return [wrap];
201
+ }
202
+
203
+ // Every connected account without a storage choice carries an Options menu
204
+ // instead: pause its feed, its cross-post toggle (the Node agent's Bluesky),
205
+ // disconnect.
206
+ function optionsMenu(acct) {
207
+ const d = document.createElement('details');
208
+ d.className = 'opts';
209
+ const sum = document.createElement('summary');
210
+ sum.textContent = 'Options';
211
+ d.appendChild(sum);
212
+ const body = document.createElement('div');
213
+ d.appendChild(body);
214
+
215
+ // Each control is one dropdown whose options ARE the states, so it needs no
216
+ // separate label — the chosen option reads as the current state.
217
+ const pick = (opts, current, onChange) => {
218
+ const el = document.createElement('select');
219
+ for (const [v, t] of opts) { const o = document.createElement('option'); o.value = v; o.textContent = t; o.selected = v === current; el.appendChild(o); }
220
+ el.addEventListener('change', () => onChange(el.value));
221
+ body.appendChild(el);
222
+ };
223
+ const send = async ([path, payload], msg) => { if (await write(path, payload, msg)) await load(); };
224
+
225
+ // Feed: shown in / hidden from the feed you read here.
226
+ if (acct.feedPaused !== null) {
227
+ pick([['show', 'Add to home feed'], ['hide', 'Pause adding to home feed']], acct.feedPaused ? 'hide' : 'show',
228
+ (v) => send(acct.feed(v === 'hide'), v === 'hide' ? 'paused adding to your feed' : 'added to your home feed'));
141
229
  }
142
- for (const acct of config.fediAccounts || []) {
143
- const name = document.createElement('span');
144
- name.textContent = acct.needsReconnect ? `${acct.handle} — sign in again` : acct.handle;
145
- if (acct.needsReconnect) name.className = 'warn';
146
- const off = document.createElement('button');
147
- off.type = 'button';
148
- off.className = 'inline danger';
149
- off.textContent = 'Disconnect';
150
- off.title = ` Stop reading ${acct.handle} and remove its stored token`;
151
- off.addEventListener('click', async () => {
152
- if (await write('/fediacct/disconnect', { id: acct.id }, `${acct.handle} disconnected`)) await load();
230
+ // Storage where the browser offers the choice; the Node agent's Bluesky shows
231
+ // its cross-post toggle in the same slot instead.
232
+ if (acct.storage) {
233
+ pick([['browser', 'Store key in Browser'], ['pod', 'Store key on Pod']], acct.storage, (v) => {
234
+ if (v === 'pod' && !confirm('Store this account on your pod?\n\nIt will follow you to any browser you sign in from — but a full-access token to that account then lives on your pod.')) { load(); return; }
235
+ send(acct.store(v), v === 'pod' ? 'stored on your pod' : 'stored in this browser');
153
236
  });
154
- row(acct.host, name, ' ', off);
237
+ } else if (acct.crossPost !== null) {
238
+ pick([['on', 'Cross-post On'], ['off', 'Cross-post Off']], acct.crossPost ? 'on' : 'off',
239
+ (v) => send(acct.cross(v === 'on'), ''));
155
240
  }
156
- return out;
241
+ // Connection: staying connected, or disconnecting.
242
+ pick([['connected', 'Connected'], ['disconnect', 'Disconnect']], 'connected',
243
+ (v) => { if (v === 'disconnect') send(acct.off(), 'disconnected'); else load(); });
244
+
245
+ // The storage consequence, at the bottom of the box under every dropdown.
246
+ if (acct.storage) {
247
+ const hint = document.createElement('p'); hint.className = 'opt-hint';
248
+ hint.textContent = acct.storage === 'pod'
249
+ ? 'Follows you to every browser you sign in from; a full-access token lives on your pod.'
250
+ : 'Stays in this browser; the token never leaves this device.';
251
+ body.appendChild(hint);
252
+ }
253
+ return d;
157
254
  }
158
255
 
159
256
  function render() {
@@ -180,9 +277,10 @@ function render() {
180
277
  ['Other identities', 'ctl'],
181
278
  ['local store', config.home],
182
279
  // The address you actually open, not the bare number — the named origin when
183
- // there is one, since that is what the client and the OAuth redirect use.
184
- ['local host', (origins.named || origins.loopback || `http://localhost:${config.port}`)
185
- .replace(/\/$/, '')],
280
+ // there is one, since that is what the client and the OAuth redirect use. An
281
+ // agent with no local host of its own (embedded in a pod server, or in a
282
+ // browser) sends none, and then there is no row rather than a bare number.
283
+ ['local host', (origins.named || origins.loopback || '').replace(/\/$/, '') || null],
186
284
  ];
187
285
  if (config.version || config.update || config.pendingUpgrade?.length) rows.push(['software', 'ctl']);
188
286
  rows.push(['gateway', 'ctl']);
@@ -502,13 +600,8 @@ $('fediacct-form').addEventListener('submit', async (ev) => {
502
600
  } finally { fediBusy = false; }
503
601
  });
504
602
 
505
- $('bsky-disconnect').addEventListener('click', async () => {
506
- if (bskyBusy) return;
507
- bskyBusy = true;
508
- try {
509
- if (await write('/atproto/disconnect', {}, 'bluesky account disconnected')) await load();
510
- } finally { bskyBusy = false; }
511
- });
603
+ // Disconnecting Bluesky, pausing its feed and choosing its storage all live in
604
+ // the account's Options menu now (see optionsMenu). Connecting is still here.
512
605
  // Adding resolves the old account on the agent side, so what lands in
513
606
  // alsoKnownAs is its canonical id, not the string typed here.
514
607
  // The accounts elsewhere this one may receive a Move from — the chips in the
@@ -553,15 +646,6 @@ $('alias-add').addEventListener('click', async () => {
553
646
  } finally { aliasBusy = false; }
554
647
  });
555
648
 
556
- // Silent on success — the select already shows the new state.
557
- const setCrossPost = async (on) => {
558
- if (bskyBusy || on === !!config.atproto?.crossPost) return;
559
- bskyBusy = true;
560
- try {
561
- if (await write('/atproto', { crossPost: on }, '')) config.atproto.crossPost = on;
562
- BSKY_CROSSPOST.value = config.atproto?.crossPost ? 'on' : 'off';
563
- } finally { bskyBusy = false; }
564
- };
565
649
 
566
650
  // ---- group ----
567
651
 
@@ -655,8 +739,6 @@ function onPick(el, apply, repaint = renderGroupToggles) {
655
739
  onPick($('joins-mod'), (v) => setJoins(v === MOD.on));
656
740
  onPick($('review-mod'), (v) => setReview(v === MOD.on));
657
741
  onPick(STATUS_PICK, (v) => setStatus(v === 'parked'), renderStatus);
658
- onPick(BSKY_CROSSPOST, (v) => setCrossPost(v === 'on'),
659
- () => { BSKY_CROSSPOST.value = config?.atproto?.crossPost ? 'on' : 'off'; });
660
742
 
661
743
  // A person's follower gate. Automatic is what a migration wave needs: every
662
744
  // follower's server re-follows at once, and the waiting queue caps at 500.
@@ -897,21 +979,7 @@ const LIFECYCLE = {
897
979
  retire: { path: '/retire', title: 'Retire this identity', go: 'Retire it', danger: true, done: (r) => `retired ${r.deletedAt}: Delete delivered to ${r.inboxes} inbox(es)` },
898
980
  move: { path: '/move', title: 'Transfer this account away', go: 'Transfer it', focus: 'move-target',
899
981
  done: (r) => `transferred to ${r.target}: Move delivered to ${r.inboxes} inbox(es), unfollowed ${r.unfollowed}/${r.following}` },
900
- 'move-state': { path: '/state-move', title: 'Move private data', go: 'Move it', focus: 'state-path',
901
- done: (r) => (r.unchanged ? 'already there — nothing moved'
902
- : `moved ${r.docs} document(s) and ${r.notes} post(s) — private data is now ${r.now}`) },
903
982
  };
904
-
905
- // The destination choice reveals the field it needs: a directory for this
906
- // device, an address for another one, nothing for the pod.
907
- function stateRows() {
908
- const w = picked('stateWhere');
909
- $('state-row-path').hidden = w !== 'path';
910
- $('state-row-url').hidden = w !== 'url';
911
- }
912
- for (const el of document.querySelectorAll('input[name=stateWhere]')) {
913
- el.addEventListener('change', stateRows);
914
- }
915
983
  let pending = null;
916
984
 
917
985
  const closeConfirm = () => closePanels();
@@ -954,16 +1022,15 @@ $('confirm-form').addEventListener('submit', async (ev) => {
954
1022
  const what = pending;
955
1023
  const body = what === 'retire' ? { confirm: $('confirm-handle').value.trim() }
956
1024
  : what === 'move' ? { target: $('move-target').value.trim(), confirm: $('confirm-handle-move').value.trim() }
957
- : what === 'move-state' ? {
958
- to: picked('stateWhere') === 'pod' ? 'pod'
959
- : picked('stateWhere') === 'url' ? $('state-url').value.trim() : $('state-path').value.trim(),
960
- }
1025
+ // Only where the field is shown (the browser build — see index.html).
1026
+ // On the Node agent the row stays hidden and nothing is sent, which is
1027
+ // what that agent expects.
1028
+ : what === 'rotate-key' && !$('rotate-pw-row').hidden
1029
+ ? { password: $('rotate-password').value }
961
1030
  : {};
962
1031
  if (what === 'move' && !body.target) { say('name the account to transfer to', 'err'); return; }
963
- if (what === 'move-state' && !body.to) { say('name the destination', 'err'); return; }
964
1032
  $('confirm-go').disabled = true;
965
- say(what === 'move-state' ? 'moving your private data copying, checking, then switching over'
966
- : `${what} — this talks to the pod and to other servers, so it takes a moment`);
1033
+ say(`${what} this talks to the pod and to other servers, so it takes a moment`);
967
1034
  const r = await write(LIFECYCLE[what].path, body, what);
968
1035
  $('confirm-go').disabled = false;
969
1036
  if (!r) return; // write() already said why