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/publisher.mjs CHANGED
@@ -1,14 +1,27 @@
1
- // publisher.mjs — builds/maintains the actor's public face on the remote pod
2
- // (webfinger, actor doc, collections, notes) and mirrors truth into the local
3
- // pod. The remote /ap/ tree is disposable: publishProfile() rebuilds it.
1
+ // publisher.mjs — builds/maintains the actor's public face on the remote pod:
2
+ // webfinger, actor doc, collections, notes. The /ap/ tree is disposable:
3
+ // publishProfile() rebuilds it.
4
4
 
5
5
  import crypto from 'node:crypto';
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import * as wire from './wire.mjs';
10
+ import * as polls from './polls.mjs';
10
11
  import { USER_AGENT } from './ua.mjs';
11
12
  import { HTTP_TIMEOUT_MS } from './safefetch.mjs';
13
+ import * as containers from './pod/containers.mjs';
14
+ import * as discovery from './pod/discovery.mjs';
15
+ import * as podActor from './pod/actor.mjs';
16
+ import * as podInbox from './pod/inbox.mjs';
17
+ import * as podOutbox from './pod/outbox.mjs';
18
+ import * as podFollowers from './pod/followers.mjs';
19
+ import * as podFollowing from './pod/following.mjs';
20
+ import * as podFeatured from './pod/featured.mjs';
21
+ import * as podPrivate from './pod/private.mjs';
22
+ import * as podPolicy from './pod/policy.mjs';
23
+ import * as podNotes from './pod/notes.mjs';
24
+ import * as podMedia from './pod/media.mjs';
12
25
 
13
26
  const ACCEPT_AP = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
14
27
  // The default for publishCollections: the whole public surface, ACLs included.
@@ -16,18 +29,22 @@ const ACCEPT_AP = 'application/activity+json, application/ld+json; profile="http
16
29
  // still gets everything, so a missed call site degrades to the old cost rather
17
30
  // than silently publishing nothing.
18
31
  const REBUILD_MAX_PER_RUN = 200;
32
+ // How long a poll gathers votes before its Question is rewritten. Every vote
33
+ // changes a number other servers re-read, and rewriting per vote would make a
34
+ // busy poll a steady write stream against the pod. A burst costs one rewrite
35
+ // and one Update instead.
36
+ const POLL_REWRITE_MS = 10_000;
19
37
  const ALL_COLLECTIONS = { followers: true, following: true, outbox: true, acls: true,
20
38
  pending: true, blocked: true };
21
39
  const AGENT_VERSION = JSON.parse(fs.readFileSync(
22
40
  path.join(path.dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8')).version;
23
41
 
24
42
  export class Publisher {
25
- constructor({ config, remote, local, store, deliverer, publicKeyPem, assertionKey = null, log = console.log,
26
- probeFetch = null, resolveMention = null, privateOnPod = true, clientOrigin = null,
43
+ constructor({ config, remote, store, deliverer, publicKeyPem, assertionKey = null, log = console.log,
44
+ probeFetch = null, resolveMention = null, clientOrigin = null,
27
45
  }) {
28
46
  this.config = config;
29
47
  this.remote = remote;
30
- this.local = local;
31
48
  this.store = store;
32
49
  this.deliverer = deliverer;
33
50
  this.publicKeyPem = publicKeyPem;
@@ -47,7 +64,8 @@ export class Publisher {
47
64
  // socket opened to that pod. Tests inject their own.
48
65
  this.probeFetch = probeFetch || ((u, i) => this.remote.probe(u, i));
49
66
  this.resolveMention = resolveMention;
50
- this.privateOnPod = privateOnPod;
67
+ // Per-poll rewrite windows, keyed by question id. See POLL_REWRITE_MS.
68
+ this.pollTimers = new Map();
51
69
  this.log = log;
52
70
  }
53
71
 
@@ -110,34 +128,25 @@ export class Publisher {
110
128
  return { unreachable: [], updated: 0, skipped: true };
111
129
  }
112
130
 
113
- await this.remote.putJson(urls.webfinger,
114
- wire.jrd({ handle: this.config.handle, host, actor: urls.actor }), 'application/jrd+json');
115
- await this.remote.setAcl(urls.webfinger, ['Read']);
131
+ await discovery.writeWebfinger(this.remote, urls,
132
+ wire.jrd({ handle: this.config.handle, host, actor: urls.actor }));
133
+ await discovery.writeHostMeta(this.remote, urls, wire.hostMeta(urls.base));
116
134
 
117
- const hostMetaUrl = urls.base + '.well-known/host-meta';
118
- await this.remote.put(hostMetaUrl, wire.hostMeta(urls.base), 'application/xrd+xml');
119
- await this.remote.setAcl(hostMetaUrl, ['Read']);
120
-
121
- // NodeInfo: pointer at the protocol-required root, document in our tree.
122
135
  const nodeinfoDocUrl = urls.home + 'ap/nodeinfo-2.0';
123
136
  const localPosts = this.store.getStatuses().filter(s => s.kind === 'post').length;
124
- await this.remote.putJson(urls.base + '.well-known/nodeinfo',
125
- wire.nodeinfoPointer(nodeinfoDocUrl), 'application/json');
126
- await this.remote.setAcl(urls.base + '.well-known/nodeinfo', ['Read']);
127
- await this.remote.putJson(nodeinfoDocUrl,
128
- wire.nodeinfoDoc({ version: AGENT_VERSION, localPosts }), 'application/json');
129
- await this.remote.setAcl(nodeinfoDocUrl, ['Read']);
137
+ await discovery.writeNodeinfo(this.remote, urls, {
138
+ pointer: wire.nodeinfoPointer(nodeinfoDocUrl),
139
+ doc: wire.nodeinfoDoc({ version: AGENT_VERSION, localPosts }),
140
+ });
130
141
 
131
142
  const actor = actorDoc; // built above, for the digest
132
- await this.remote.putJson(urls.actor, actor);
133
- await this.remote.setAcl(urls.actor, ['Read']);
143
+ await podActor.write(this.remote, urls, actor);
134
144
 
135
145
  // FEP-1b12: the moderator roster, public — it is what a recipient
136
146
  // validates a group's announced moderation against.
137
147
  if (moderators) {
138
- await this.remote.putJson(urls.moderators,
148
+ await podFeatured.writeModerators(this.remote, urls,
139
149
  wire.orderedCollection(urls.moderators, this.config.moderators));
140
- await this.remote.setAcl(urls.moderators, ['Read']);
141
150
  }
142
151
  // The gateway policy doc: the PUBLIC data a keyless gateway reads to decide
143
152
  // what concerns this identity. Written only while a gateway is advertised.
@@ -145,20 +154,19 @@ export class Publisher {
145
154
 
146
155
  // The human half: a page a browser can open and follow from. The actor
147
156
  // document is for servers; this is the address you hand to a person.
148
- await this.remote.put(urls.profileHtml, wire.profilePageHtml({
157
+ await podActor.writeProfilePage(this.remote, urls, wire.profilePageHtml({
149
158
  name: this.config.name || this.config.handle,
150
159
  address: wire.webfingerHost(urls.base) ? `@${this.config.handle}@${host}` : urls.actor,
151
160
  summary: this.config.summary ? wire.contentHtml(this.config.summary) : null,
152
161
  icon: this.config.icon || null,
153
162
  kind: this.config.kind,
154
- }), 'text/html');
155
- await this.remote.setAcl(urls.profileHtml, ['Read']);
163
+ }));
156
164
 
157
165
  // WebID → actor: the profile card lists the actor as a foaf:account.
158
166
  // Best-effort — a profile that cannot be read or edited does not stop the
159
167
  // publish, it is logged and the rest of the surface still goes up.
160
168
  try {
161
- const wrote = await this.remote.linkAccountInProfile({
169
+ const wrote = await podActor.linkInWebIdProfile(this.remote, {
162
170
  actorUrl: urls.actor,
163
171
  accountName: `@${this.config.handle}@${host}`,
164
172
  kind: this.config.kind,
@@ -171,21 +179,15 @@ export class Publisher {
171
179
  // inbox: public may only Append; owner (the agent) reads + drains. A
172
180
  // quiesced actor keeps its name resolving but takes no more mail, so a
173
181
  // republish must not re-open the door.
174
- await this.remote.putJson(urls.inbox + '.keep', { keep: true }, 'application/json');
175
- if (this.config.quiescedAt) {
176
- await this.remote.setAcl(urls.inbox, []);
177
- this.log('inbox left closed — this actor is quiesced');
178
- } else {
179
- await this.remote.setAcl(urls.inbox, ['Append']);
180
- }
182
+ await podInbox.writeKeep(this.remote, urls);
183
+ await podInbox.setPosture(this.remote, urls, this.config.quiescedAt ? 'closed' : 'open');
184
+ if (this.config.quiescedAt) this.log('inbox left closed — this actor is quiesced');
181
185
 
182
186
  // notes live under a public-Read container (acl:default covers new notes).
183
- await this.remote.putJson(urls.notes + '.keep', { keep: true }, 'application/json');
184
- await this.remote.setAcl(urls.notes, ['Read']);
187
+ await podNotes.provisionContainer(this.remote, urls);
185
188
 
186
189
  await this.publishCollections({ ...ALL_COLLECTIONS, force });
187
190
  const updated = await this.announceProfileChange(actor, { force });
188
- await this.local.writeSettings({ handle: this.config.handle, actorUrl: urls.actor });
189
191
  await this.ensurePrivateAcls();
190
192
  const unreachable = await this.verifyPublicSurface();
191
193
  // Last, and merged: announceProfileChange writes this document too.
@@ -272,23 +274,14 @@ export class Publisher {
272
274
  // this runs on every connect: probe UNauthenticated, rewrite whatever
273
275
  // answers, and say so loudly if the rewrite does not take.
274
276
  async ensurePrivateAcls() {
275
- // Only trees that are actually ON the pod. With the default local
276
- // privateRoot the fediverse tree lives on disk, so probing it was a request
277
- // that could only ever 404, on every start.
278
- const onPod = [this.urls.home, this.urls.state,
279
- ...(this.privateOnPod === false ? [] : [this.urls.fediverse])];
280
- for (const url of onPod) {
281
- if (!await this.publiclyReadable(url)) continue;
282
- this.log(`${url} was readable without credentials — rewriting its ACL`);
283
- try {
284
- await this.remote.setAcl(url, []);
285
- } catch (e) {
286
- this.log(`SECURITY: ${url} is public and its ACL could not be rewritten: ${e.message}`);
287
- continue;
288
- }
289
- if (await this.publiclyReadable(url)) {
290
- this.log(`SECURITY: ${url} is STILL readable without credentials — check the pod's ACLs`);
291
- }
277
+ const findings = await containers.repairPrivateAcls(this.remote,
278
+ [this.urls.home, this.urls.state],
279
+ { isPublic: (u) => this.publiclyReadable(u) });
280
+ // What a finding MEANS is ours to say; the library just reports.
281
+ for (const f of findings) {
282
+ this.log(`${f.url} was readable without credentials — rewriting its ACL`);
283
+ if (f.error) { this.log(`SECURITY: ${f.url} is public and its ACL could not be rewritten: ${f.error}`); continue; }
284
+ if (f.stillPublic) this.log(`SECURITY: ${f.url} is STILL readable without credentials — check the pod's ACLs`);
292
285
  }
293
286
  }
294
287
 
@@ -296,14 +289,9 @@ export class Publisher {
296
289
  // accept: */* matters — asking for turtle makes the server answer 501 on the
297
290
  // JSON documents (webfinger, actor), which reads as "unreachable" when the
298
291
  // world can in fact see them perfectly well.
299
- async publiclyReadable(url) {
300
- try {
301
- const res = await this.probeFetch(url, {
302
- headers: { accept: '*/*', 'user-agent': USER_AGENT },
303
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
304
- });
305
- return res.status < 400;
306
- } catch { return false; } // unreachable is not a finding here
292
+ publiclyReadable(url) {
293
+ return containers.probePublicReadability(this.probeFetch, url,
294
+ { headers: { 'user-agent': USER_AGENT }, timeoutMs: HTTP_TIMEOUT_MS });
307
295
  }
308
296
 
309
297
  // Retire this identity for good: tell everyone who follows us to drop the
@@ -318,8 +306,7 @@ export class Publisher {
318
306
  const inboxes = [...new Set(contacts.followers.map(f => f.sharedInbox || f.inbox).filter(Boolean))];
319
307
  const deletedAt = new Date().toISOString();
320
308
  await this.deliverer.deliverToAll(inboxes, wire.deleteActorActivity(urls, Date.parse(deletedAt)));
321
- await this.remote.putJson(urls.actor, wire.tombstoneDoc(urls, deletedAt, this.config.kind));
322
- await this.remote.setAcl(urls.actor, ['Read']); // a Tombstone must stay fetchable
309
+ await podActor.writeTombstone(this.remote, urls, wire.tombstoneDoc(urls, deletedAt, this.config.kind));
323
310
  this.store.setConfig({ ...this.store.getConfig(), retiredAt: deletedAt });
324
311
  await this.store.flush();
325
312
  this.log(`retired: Delete sent to ${inboxes.length} inbox(es), actor replaced with a Tombstone`);
@@ -330,7 +317,7 @@ export class Publisher {
330
317
  // 401 rather than a 201 into storage nobody will ever drain. WebFinger,
331
318
  // host-meta and the actor stay published, so the handle still resolves.
332
319
  async closeInbox() {
333
- await this.remote.setAcl(this.urls.inbox, []);
320
+ await podInbox.setPosture(this.remote, this.urls, 'closed');
334
321
  const at = new Date().toISOString();
335
322
  this.store.setConfig({ ...this.store.getConfig(), quiescedAt: at });
336
323
  await this.store.flush();
@@ -340,7 +327,7 @@ export class Publisher {
340
327
 
341
328
  // Undo closeInbox: mail flows again and the actor is no longer quiesced.
342
329
  async openInbox() {
343
- await this.remote.setAcl(this.urls.inbox, ['Append']);
330
+ await podInbox.setPosture(this.remote, this.urls, 'open');
344
331
  const { quiescedAt, ...rest } = this.store.getConfig() || {};
345
332
  this.store.setConfig(rest);
346
333
  this.config.quiescedAt = undefined;
@@ -352,7 +339,7 @@ export class Publisher {
352
339
  // the pod except through the gateway's verify-at-the-door. Reversible with
353
340
  // openInbox (public-Append) — the one-call rollback.
354
341
  async lockInboxToGateway(gatewayWebId) {
355
- await this.remote.setAcl(this.urls.inbox, [], { appendAgents: [gatewayWebId] });
342
+ await podInbox.setPosture(this.remote, this.urls, { gatewayWebId });
356
343
  this.log(`inbox locked to gateway ${gatewayWebId} — public delivery is refused`);
357
344
  }
358
345
 
@@ -366,7 +353,7 @@ export class Publisher {
366
353
  await this.deliverer.deliverToAll(inboxes, wire.moveActivity(urls, target, Date.parse(at)));
367
354
  this.config.movedTo = target; // so the republish below carries it
368
355
  this.store.setConfig({ ...this.store.getConfig(), movedTo: target, movedAt: at });
369
- await this.remote.putJson(urls.actor, wire.actorDoc({
356
+ await podActor.writeMoved(this.remote, urls, wire.actorDoc({
370
357
  urls, handle: wire.publicHandle(this.config), name: this.config.name,
371
358
  publicKeyPem: this.publicKeyPem, movedTo: target, kind: this.config.kind,
372
359
  approveJoins: wire.followsNeedApproval(this.config),
@@ -376,7 +363,6 @@ export class Publisher {
376
363
  webId: this.remote.webId || null,
377
364
  aliases: this.config.aliases || [],
378
365
  }));
379
- await this.remote.setAcl(urls.actor, ['Read']);
380
366
  await this.store.flush();
381
367
  this.log(`moved to ${target}: Move sent to ${inboxes.length} inbox(es), actor now advertises movedTo`);
382
368
  return { inboxes: inboxes.length, target, movedAt: at };
@@ -500,8 +486,7 @@ export class Publisher {
500
486
  // `-replies` — plus a `.keep`. Which is which is settled by reading the
501
487
  // document below, not by its name: a slug is a date and eight hex
502
488
  // characters and says nothing about what it holds.
503
- for (const child of await this.remote.listContainer(urls.notes).catch(() => [])) {
504
- if (/(-create|-replies|\/\.keep)$/.test(child.url)) continue;
489
+ for (const child of await podNotes.list(this.remote, urls).catch(() => [])) {
505
490
  ids.add(child.url);
506
491
  }
507
492
  indexed = true;
@@ -520,7 +505,7 @@ export class Publisher {
520
505
  if (budget <= 0) { this.log(`rebuild: stopping at ${REBUILD_MAX_PER_RUN} this run — run it again for the rest`); break; }
521
506
  if (have.has(id) || removed.has(id)) continue;
522
507
  budget--;
523
- const note = await this.remote.getJson(id).catch(() => null);
508
+ const note = await podNotes.read(this.remote, id).catch(() => null);
524
509
  if (note?.type !== 'Note' || note.id !== id || note.attributedTo !== urls.actor) continue;
525
510
  const attachments = wire.attachmentsOf(note);
526
511
  const mentions = (Array.isArray(note.tag) ? note.tag : [])
@@ -552,31 +537,18 @@ export class Publisher {
552
537
  }
553
538
  if (!recovered.length && !reblogs) return { indexed: ids.size, recovered: 0, reblogs: 0, landed: true };
554
539
 
555
- // Written whole and sorted, the way backfillStatuses does it: addStatus
556
- // unshifts and fires the streaming event, so a loop of it would arrive
557
- // backwards and push every recovered post at connected clients as new.
540
+ // Written whole and sorted: addStatus unshifts and fires the streaming
541
+ // event, so a loop of it would arrive backwards and push every recovered
542
+ // post at connected clients as new.
558
543
  merged.sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
559
544
  const kept = merged.slice(0, 1000);
560
545
  this.store.write('statuses.json', kept);
561
546
  const landed = await this.store.commit();
562
547
 
563
- // The RDF mirror is the other thing a lost machine took with it, and it is
564
- // what backfillStatuses reads on a fresh state. Best effort: a post in
565
- // statuses.json is already the recovery that matters.
566
- let rdf = 0;
567
- for (const s of recovered) {
568
- try {
569
- await this.local.writeNote('posts', s.slug, {
570
- noteId: s.noteId, actor: s.actor, published: s.published,
571
- content: s.content, inReplyTo: s.inReplyTo, attachments: s.attachments,
572
- });
573
- rdf++;
574
- } catch (e) { this.log(`rebuild: RDF for ${s.slug} not written (${e.message})`); }
575
- }
576
548
  this.log(`rebuilt ${recovered.length} post(s) and ${reblogs} boost(s) from the pod`
577
549
  + `${landed ? '' : ' — THE STATE WRITE DID NOT LAND'}`);
578
550
  return {
579
- indexed: ids.size, recovered: recovered.length, reblogs, rdf, landed,
551
+ indexed: ids.size, recovered: recovered.length, reblogs, landed,
580
552
  dropped: Math.max(0, merged.length - kept.length),
581
553
  };
582
554
  }
@@ -624,8 +596,8 @@ export class Publisher {
624
596
  const digest = crypto.createHash('sha256').update(JSON.stringify(doc)).digest('hex').slice(0, 16);
625
597
  after[n] = digest;
626
598
  if (before[n] === digest) continue; // sealed and unchanged
627
- await this.remote.putJson(wire.outboxPageId(urls.outbox, n), doc);
628
- if (!before[n] || acls) await this.remote.setAcl(wire.outboxPageId(urls.outbox, n), ['Read']);
599
+ await podOutbox.writePage(this.remote, wire.outboxPageId(urls.outbox, n), doc,
600
+ { publicRead: !before[n] || acls });
629
601
  wrote++;
630
602
  }
631
603
  // Pages above the new count are orphans: the outbox shrank past them, and
@@ -635,13 +607,12 @@ export class Publisher {
635
607
  const stale = Object.keys(seen.outboxPages || {}).map(Number)
636
608
  .filter(n => Number.isFinite(n) && n > pages.length);
637
609
  for (const n of stale) {
638
- await this.remote.delete(wire.outboxPageId(urls.outbox, n)).catch(() => {});
610
+ await podOutbox.dropPage(this.remote, wire.outboxPageId(urls.outbox, n));
639
611
  }
640
612
  // The head carries totalItems, so it moves whenever the outbox does. Four
641
613
  // lines, and constant however much you have posted.
642
- await this.remote.putJson(urls.outbox,
643
- wire.outboxHead(urls.outbox, outbox.length, pages.length));
644
- if (acls) await this.remote.setAcl(urls.outbox, ['Read']);
614
+ await podOutbox.writeHead(this.remote, urls,
615
+ wire.outboxHead(urls.outbox, outbox.length, pages.length), { publicRead: acls });
645
616
  this.store.write('published.json',
646
617
  { ...this.store.read('published.json', {}), outboxPages: after, outboxIndex: index });
647
618
  return wrote;
@@ -651,22 +622,7 @@ export class Publisher {
651
622
  // the flat collection this used to write, so an actor published before paging
652
623
  // is still readable — which matters because rebuild reads this to recover
653
624
  // posts a lost machine no longer has.
654
- async readPublishedOutbox() {
655
- const head = await this.remote.getJson(this.urls.outbox).catch(() => null);
656
- if (!head) return null;
657
- if (Array.isArray(head.orderedItems) && head.orderedItems.length) return head.orderedItems;
658
- const items = [];
659
- let next = head.first;
660
- const seen = new Set();
661
- while (next && !seen.has(next) && items.length < 10_000) {
662
- seen.add(next);
663
- const page = await this.remote.getJson(next).catch(() => null);
664
- if (!page) break;
665
- items.push(...(page.orderedItems || []));
666
- next = page.next;
667
- }
668
- return items;
669
- }
625
+ readPublishedOutbox() { return podOutbox.readPublished(this.remote, this.urls); }
670
626
 
671
627
  // The followers collection, paged like the outbox: a head that carries only
672
628
  // the count and the page bounds, and page documents holding the actor IRIs —
@@ -687,19 +643,18 @@ export class Publisher {
687
643
  const digest = crypto.createHash('sha256').update(JSON.stringify(doc)).digest('hex').slice(0, 16);
688
644
  after[n] = digest;
689
645
  if (before[n] === digest) continue; // unchanged page
690
- await this.remote.putJson(wire.followersPageId(urls.followers, n), doc);
691
- if (!before[n] || acls) await this.remote.setAcl(wire.followersPageId(urls.followers, n), ['Read']);
646
+ await podFollowers.writePage(this.remote, wire.followersPageId(urls.followers, n), doc,
647
+ { publicRead: !before[n] || acls });
692
648
  }
693
649
  // Pages the collection shrank past would keep serving names that no longer
694
650
  // follow; the head stops pointing at them but the URL is guessable.
695
651
  const stale = Object.keys(seen.followersPages || {}).map(Number)
696
652
  .filter(n => Number.isFinite(n) && n > pages.length);
697
653
  for (const n of stale) {
698
- await this.remote.delete(wire.followersPageId(urls.followers, n)).catch(() => {});
654
+ await podFollowers.dropPage(this.remote, wire.followersPageId(urls.followers, n));
699
655
  }
700
- await this.remote.putJson(urls.followers,
701
- wire.followersHead(urls.followers, actors.length, pages.length));
702
- if (acls) await this.remote.setAcl(urls.followers, ['Read']);
656
+ await podFollowers.writeHead(this.remote, urls,
657
+ wire.followersHead(urls.followers, actors.length, pages.length), { publicRead: acls });
703
658
  this.store.write('published.json',
704
659
  { ...this.store.read('published.json', {}), followersPages: after, followersIndex: index });
705
660
  }
@@ -707,23 +662,7 @@ export class Publisher {
707
662
  // Every actor in the published followers collection, walking pages. Also reads
708
663
  // the flat collection this used to write, so an actor published before paging
709
664
  // still reconciles.
710
- async readPublishedFollowers() {
711
- const head = await this.remote.getJson(this.urls.followers).catch(() => null);
712
- if (!head) return null;
713
- if (Array.isArray(head.orderedItems) && head.orderedItems.length) return head.orderedItems;
714
- if (Array.isArray(head.items) && head.items.length) return head.items;
715
- const items = [];
716
- let next = head.first;
717
- const seen = new Set();
718
- while (next && !seen.has(next) && items.length < 100_000) {
719
- seen.add(next);
720
- const page = await this.remote.getJson(next).catch(() => null);
721
- if (!page) break;
722
- items.push(...(page.orderedItems || []));
723
- next = page.next;
724
- }
725
- return items;
726
- }
665
+ readPublishedFollowers() { return podFollowers.readPublished(this.remote, this.urls); }
727
666
 
728
667
  async publishCollections(which = ALL_COLLECTIONS) {
729
668
  const { urls } = this;
@@ -740,9 +679,9 @@ export class Publisher {
740
679
  await this.publishFollowers(actors, { acls: which.acls, force: which.force });
741
680
  }
742
681
  if (which.following) {
743
- await this.remote.putJson(urls.following,
744
- wire.orderedCollection(urls.following, contacts.following.filter(f => f.accepted).map(f => f.actor)));
745
- if (which.acls) await this.remote.setAcl(urls.following, ['Read']);
682
+ await podFollowing.write(this.remote, urls,
683
+ wire.orderedCollection(urls.following, contacts.following.filter(f => f.accepted).map(f => f.actor)),
684
+ { publicRead: which.acls });
746
685
  }
747
686
  if (which.pending) await this.publishPending();
748
687
  if (which.blocked) await this.publishBlocked();
@@ -758,7 +697,6 @@ export class Publisher {
758
697
  if (which.force || !Array.isArray(known)) await this.reconcileOutbox(outbox);
759
698
  await this.publishOutbox(outbox, { acls: which.acls, force: which.force });
760
699
  }
761
- if (which.followers || which.following) await this.local.writeContacts(contacts);
762
700
  }
763
701
 
764
702
  // FEP-4ccd: the follows in limbo, as owner-only collections of the Follow
@@ -770,13 +708,13 @@ export class Publisher {
770
708
  if (await this.privateReady() !== true) return;
771
709
  const { urls } = this;
772
710
  const contacts = this.store.getContacts();
773
- await this.remote.putJson(urls.pendingFollowers, wire.orderedCollection(
774
- urls.pendingFollowers,
775
- this.store.getRequests().filter(r => r.activity && !r.bsky).map(r => r.activity)));
776
- await this.remote.putJson(urls.pendingFollowing, wire.orderedCollection(
777
- urls.pendingFollowing,
778
- contacts.following.filter(f => !f.accepted && f.followActivity)
779
- .map(f => f.followActivity).reverse()));
711
+ await podPrivate.writePending(this.remote, urls, {
712
+ followers: wire.orderedCollection(urls.pendingFollowers,
713
+ this.store.getRequests().filter(r => r.activity && !r.bsky).map(r => r.activity)),
714
+ following: wire.orderedCollection(urls.pendingFollowing,
715
+ contacts.following.filter(f => !f.accepted && f.followActivity)
716
+ .map(f => f.followActivity).reverse()),
717
+ });
780
718
  }
781
719
 
782
720
  // The gateway policy: a small PUBLIC document a keyless inbox gateway reads
@@ -789,7 +727,7 @@ export class Publisher {
789
727
  const { urls } = this;
790
728
  const contacts = this.store.getContacts();
791
729
  const bl = this.store.getBlocklist();
792
- await this.remote.putJson(urls.home + 'ap/gateway-policy.json', {
730
+ await podPolicy.write(this.remote, urls, {
793
731
  v: 1,
794
732
  actorUrl: urls.actor,
795
733
  followersUrl: urls.followers,
@@ -799,8 +737,7 @@ export class Publisher {
799
737
  kind: this.config.kind || 'person',
800
738
  following: contacts.following.filter(f => f.accepted && !f.bsky).map(f => f.actor),
801
739
  blocklist: { domains: bl.domains || [], actors: bl.actors || [] },
802
- }, 'application/json');
803
- await this.remote.setAcl(urls.home + 'ap/gateway-policy.json', ['Read']);
740
+ });
804
741
  }
805
742
 
806
743
  // FEP-c648: the blocked actors, as an owner-only collection. Actors only —
@@ -809,7 +746,7 @@ export class Publisher {
809
746
  if (await this.privateReady() !== true) return;
810
747
  const { urls } = this;
811
748
  const actors = [...(this.store.getBlocklist().actors || [])].reverse();
812
- await this.remote.putJson(urls.blocked, wire.orderedCollection(urls.blocked, actors));
749
+ await podPrivate.writeBlocked(this.remote, urls, wire.orderedCollection(urls.blocked, actors));
813
750
  }
814
751
 
815
752
  // The outbox is the public record of everything this actor has said, boosts
@@ -844,19 +781,32 @@ export class Publisher {
844
781
 
845
782
  // Media container on the remote pod — public-Read like notes, created lazily
846
783
  // at first upload (idempotent; the flag only saves round-trips).
784
+ // Ask before writing. `_mediaReady` is per PROCESS, and a service worker is
785
+ // restarted whenever the browser feels like it — so on the browser build this
786
+ // re-created a container that has existed since sign-up, and rewrote its ACL,
787
+ // on every restart. A HEAD is one request and usually the only one.
847
788
  async ensureMediaContainer() {
848
789
  if (this._mediaReady) return;
849
- await this.remote.putJson(this.urls.media + '.keep', { keep: true }, 'application/json');
850
- await this.remote.setAcl(this.urls.media, ['Read']);
790
+ if (await containers.exists(this.remote, this.urls.media)) { this._mediaReady = true; return; }
791
+ await containers.provisionPublic(this.remote, this.urls.media);
851
792
  this._mediaReady = true;
852
793
  }
853
794
 
795
+ // Whether the canary this class writes is already there. Only a definite
796
+ // "yes" counts: anything else falls through to the write, which is
797
+ // idempotent anyway, so a failed probe costs a request and never correctness.
798
+ _containerExists(base) { return containers.exists(this.remote, base); }
799
+
854
800
  // The owner-only container that followers-only and direct posts live in.
855
801
  // Its ACL is set once; every note under it inherits.
856
802
  async ensurePrivateContainer() {
857
803
  if (this._privateContainer) return;
858
- await this.remote.putJson(this.urls.privateNotes + '.keep', { keep: true }, 'application/json');
859
- await this.remote.setAcl(this.urls.privateNotes, []);
804
+ // Same reasoning as ensureMediaContainer: probe before provisioning, so a
805
+ // worker restart is not a re-provision. The ACL is NOT skipped on the
806
+ // strength of the container existing alone — ensurePrivateAcls re-checks
807
+ // that separately on every connect, which is the control that matters here.
808
+ if (await containers.exists(this.remote, this.urls.privateNotes)) { this._privateContainer = true; return; }
809
+ await containers.provisionOwnerOnly(this.remote, this.urls.privateNotes);
860
810
  this._privateContainer = true;
861
811
  }
862
812
 
@@ -873,9 +823,8 @@ export class Publisher {
873
823
  // fronted identity must map the advertised private container back to the
874
824
  // pod itself — the ACL we are testing is the pod's.
875
825
  const pn = this.urls.toPod ? this.urls.toPod(this.urls.privateNotes) : this.urls.privateNotes;
876
- const r = await (this.probeFetch || fetch)(pn + '.keep', { redirect: 'manual' });
877
- this._privateVerdict = (r.status === 401 || r.status === 403) ? true
878
- : `this pod serves private documents to strangers (HTTP ${r.status}) — private posts stay off it`;
826
+ const verdict = await containers.probePrivateEnforcement(this.probeFetch || fetch, pn + '.keep');
827
+ this._privateVerdict = verdict === true ? true : `${verdict} private posts stay off it`;
879
828
  return this._privateVerdict;
880
829
  } catch (e) {
881
830
  return `could not verify that the pod protects private posts (${e.message})`;
@@ -883,20 +832,13 @@ export class Publisher {
883
832
  }
884
833
 
885
834
  // Compose → wire note on remote pod + RDF truth locally + deliver Create.
886
- async publishNote(content, { inReplyTo, attachments, visibility = 'public', spoilerText = null } = {}) {
887
- const { urls } = this;
888
- const priv = visibility === 'private' || visibility === 'direct';
889
- if (priv) {
890
- const ready = await this.privateReady();
891
- if (ready !== true) throw new Error(ready);
892
- }
893
- const published = new Date().toISOString();
894
- const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
895
- // A mention nobody can resolve stays plain text rather than failing the post.
896
- // The reply's own text decides who is mentioned: trim a handle out and that
897
- // person is not notified, which is what every fediverse client leads people
898
- // to expect. A Group named in the parent is the one thing carried forward
899
- // regardless — drop it and the group stops carrying the thread.
835
+ // Who this text mentions, resolved. A mention nobody can resolve stays plain
836
+ // text rather than failing the post. The text itself decides: trim a handle
837
+ // out and that person is not notified, which is what every fediverse client
838
+ // leads people to expect. A Group named in the parent is the one thing
839
+ // carried forward regardless — drop it and the group stops carrying the
840
+ // thread.
841
+ async _mentionsFor(content, inReplyTo) {
900
842
  const inText = new Set(wire.mentionsIn(content));
901
843
  const carried = inReplyTo
902
844
  ? (this.store.getStatuses().find(s => s.noteId === inReplyTo)?.mentions || [])
@@ -910,18 +852,29 @@ export class Publisher {
910
852
  if (!inText.has(handle) && doc.type !== 'Group') continue; // author trimmed them out
911
853
  mentions.push({ handle, actor: doc.id, page: doc.url || null, inbox: doc.endpoints?.sharedInbox || doc.inbox });
912
854
  }
855
+ return mentions;
856
+ }
857
+
858
+ async publishNote(content, { inReplyTo, attachments, visibility = 'public', spoilerText = null } = {}) {
859
+ const { urls } = this;
860
+ const priv = visibility === 'private' || visibility === 'direct';
861
+ if (priv) {
862
+ const ready = await this.privateReady();
863
+ if (ready !== true) throw new Error(ready);
864
+ }
865
+ const published = new Date().toISOString();
866
+ const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
867
+ const mentions = await this._mentionsFor(content, inReplyTo);
913
868
  const note = wire.noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions,
914
869
  visibility, summary: spoilerText, container: priv ? urls.privateNotes : urls.notes });
915
870
 
916
- await this.remote.putJson(note.id, note);
871
+ await podNotes.write(this.remote, note.id, note);
917
872
  // Empty, but present: a dangling `replies` that 404s is worse than none.
918
- await this.remote.putJson(wire.repliesId(note.id), wire.collection(wire.repliesId(note.id), []));
873
+ await podNotes.writeEmptyReplies(this.remote, wire.repliesId(note.id),
874
+ wire.collection(wire.repliesId(note.id), []));
919
875
  // The outbox is the PUBLIC index; a private or direct post is not in it.
920
876
  if (!priv) await this.recordOutbox(note.id);
921
877
 
922
- await this.local.writeNote('posts', slug, {
923
- noteId: note.id, actor: urls.actor, published, content: note.content, inReplyTo, attachments,
924
- });
925
878
  this.store.addStatus({
926
879
  noteId: note.id, actor: urls.actor, content: note.content, published, inReplyTo,
927
880
  kind: 'post', slug, text: content, visibility,
@@ -934,7 +887,7 @@ export class Publisher {
934
887
  // Published as its own document: a group that carries this post wraps the
935
888
  // whole activity, and the receiving server resolves it by fetching this id.
936
889
  // It inherits the notes container's public-Read acl:default.
937
- await this.remote.putJson(create.id, create);
890
+ await podNotes.writeCreate(this.remote, create.id, create);
938
891
  const contacts = this.store.getContacts();
939
892
  // A direct post goes to the people it names and to nobody else.
940
893
  const inboxes = [...new Set([
@@ -962,12 +915,234 @@ export class Publisher {
962
915
  return note;
963
916
  }
964
917
 
918
+ // --- polls ---------------------------------------------------------------
919
+ //
920
+ // A poll is published as a Question and answered by ordinary replies naming
921
+ // an option, so the count is ours to keep and ours to republish. The roster
922
+ // of who chose what lives in agent state (polls.VOTES_DOC); the tallies on
923
+ // the status row and in the pod document are always derived from it.
924
+
925
+ /**
926
+ * Publish a poll. `options` is a list of choice titles, `multiple` lets a
927
+ * voter pick more than one, and `expiresAt` is when voting stops.
928
+ */
929
+ async publishQuestion(content, { options = [], multiple = false, expiresAt = null,
930
+ inReplyTo = undefined, visibility = 'public', spoilerText = null } = {}) {
931
+ const { urls } = this;
932
+ const priv = visibility === 'private' || visibility === 'direct';
933
+ if (priv) {
934
+ const ready = await this.privateReady();
935
+ if (ready !== true) throw new Error(ready);
936
+ }
937
+ const titles = [].concat(options).map(o => String(o ?? '').trim()).filter(Boolean);
938
+ if (titles.length < 2) throw new Error('a poll needs at least two options');
939
+ // Options are matched BY NAME when a vote arrives — that is the whole of
940
+ // the convention — so two options reading the same are one option that
941
+ // cannot be told apart.
942
+ if (new Set(titles).size !== titles.length) throw new Error('a poll’s options must differ from one another');
943
+ // Bounded here as well as at the client API, because the outbox is a
944
+ // second way in and an unbounded poll is a document a stranger sizes.
945
+ if (titles.length > polls.MAX_OPTIONS) throw new Error(`a poll takes at most ${polls.MAX_OPTIONS} options`);
946
+ if (titles.some(t => t.length > polls.MAX_OPTION_CHARS)) {
947
+ throw new Error(`a poll option is at most ${polls.MAX_OPTION_CHARS} characters`);
948
+ }
949
+ if (expiresAt) {
950
+ const ends = Date.parse(expiresAt);
951
+ if (!Number.isFinite(ends)) throw new Error('the closing time is not a date');
952
+ const seconds = (ends - Date.now()) / 1000;
953
+ if (seconds < polls.MIN_SECONDS || seconds > polls.MAX_SECONDS) {
954
+ throw new Error(`a poll runs between ${polls.MIN_SECONDS} and ${polls.MAX_SECONDS} seconds`);
955
+ }
956
+ }
957
+
958
+ const published = new Date().toISOString();
959
+ const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
960
+ const mentions = await this._mentionsFor(content, inReplyTo);
961
+ const poll = {
962
+ multiple: !!multiple,
963
+ expiresAt: expiresAt || null,
964
+ closed: null,
965
+ options: titles.map(title => ({ title, votes: 0 })),
966
+ votersCount: 0,
967
+ // Resolved once, here: a tally rewrite must not cost a webfinger lookup
968
+ // per vote for people the poll named.
969
+ mentionInboxes: [...new Set(mentions.map(m => m.inbox).filter(Boolean))],
970
+ };
971
+ const question = wire.questionDoc({
972
+ urls, slug, content, published, inReplyTo, attachments: [], mentions,
973
+ visibility, summary: spoilerText,
974
+ container: priv ? urls.privateNotes : urls.notes,
975
+ options: poll.options, multiple: poll.multiple, endTime: poll.expiresAt, votersCount: 0,
976
+ });
977
+
978
+ await podNotes.write(this.remote, question.id, question);
979
+ // Empty, but present: a dangling `replies` that 404s is worse than none.
980
+ await podNotes.writeEmptyReplies(this.remote, wire.repliesId(question.id),
981
+ wire.collection(wire.repliesId(question.id), []));
982
+ if (!priv) await this.recordOutbox(question.id);
983
+ this.store.addStatus({
984
+ noteId: question.id, actor: urls.actor, content: question.content, published,
985
+ kind: 'post', slug, text: content, visibility, poll, inReplyTo,
986
+ ...(spoilerText ? { spoiler: spoilerText } : {}),
987
+ ...(question.tag?.length ? { mentions: question.tag.map(t => ({ href: t.href, name: t.name })) } : {}),
988
+ });
989
+
990
+ const create = this._pollActivity('Create', question, wire.createActivityId(question.id));
991
+ await podNotes.writeCreate(this.remote, create.id, create);
992
+ const contacts = this.store.getContacts();
993
+ const inboxes = [...new Set([
994
+ ...(visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
995
+ ...poll.mentionInboxes,
996
+ ].filter(Boolean))];
997
+ await this.deliverer.deliverToAll(inboxes, create);
998
+ this.log(`poll published: ${question.id} (${titles.length} options) → ${inboxes.length} inbox(es)`);
999
+ return question;
1000
+ }
1001
+
1002
+ // The Create or Update carrying a Question. The context is hoisted onto the
1003
+ // activity and the embedded object keeps none: a nested @context is legal
1004
+ // JSON-LD but not every server reads one, and votersCount is declared there.
1005
+ _pollActivity(type, question, id) {
1006
+ const { '@context': ctx, ...object } = question;
1007
+ return {
1008
+ '@context': ctx, id, type,
1009
+ actor: this.urls.actor,
1010
+ published: question.published,
1011
+ to: question.to, cc: question.cc,
1012
+ object,
1013
+ };
1014
+ }
1015
+
1016
+ /**
1017
+ * One vote on one of OUR polls, named by the option's title. Returns true
1018
+ * when it counted — a second answer to a single-choice poll, an option we do
1019
+ * not offer, or a poll that has closed all count for nothing.
1020
+ */
1021
+ async recordVote(questionId, actor, optionName) {
1022
+ const s = this.store.getStatuses().find(x => x.noteId === questionId);
1023
+ if (!s?.poll || s.kind !== 'post' || s.actor !== this.urls.actor) return false;
1024
+ if (polls.pollClosed(s.poll)) return false;
1025
+ const index = polls.optionIndex(s.poll, optionName);
1026
+ if (index < 0) return false;
1027
+ const all = this.store.read(polls.VOTES_DOC, {});
1028
+ const { roster, changed } = polls.addVote(all[questionId] || {}, polls.voterKey(actor), index,
1029
+ { multiple: !!s.poll.multiple });
1030
+ if (!changed) return false;
1031
+ this.store.write(polls.VOTES_DOC, { ...all, [questionId]: roster });
1032
+ this.store.updateStatus(questionId, { poll: polls.withTally(s.poll, roster) });
1033
+ this._pollDirty(questionId);
1034
+ return true;
1035
+ }
1036
+
1037
+ // Open the rewrite window for a poll whose count moved. Already open is
1038
+ // already enough: the whole point is that a burst costs one rewrite.
1039
+ _pollDirty(questionId) {
1040
+ if (this.pollTimers.has(questionId)) return;
1041
+ const t = setTimeout(() => {
1042
+ this.pollTimers.delete(questionId);
1043
+ this.republishPoll(questionId).catch(e => this.log(`poll rewrite: ${e.message}`));
1044
+ }, POLL_REWRITE_MS);
1045
+ t.unref?.();
1046
+ this.pollTimers.set(questionId, t);
1047
+ }
1048
+
1049
+ /**
1050
+ * Write the poll's current count back to the pod and tell everyone who has
1051
+ * it. `closing` stamps it shut, which is a one-way door.
1052
+ */
1053
+ async republishPoll(questionId, { closing = null } = {}) {
1054
+ const s = this.store.getStatuses().find(x => x.noteId === questionId);
1055
+ if (!s?.poll) return null;
1056
+ const { urls } = this;
1057
+ const roster = this.store.read(polls.VOTES_DOC, {})[questionId] || {};
1058
+ // A shut poll whose roster has been retired keeps the counts on its row:
1059
+ // deriving them from an empty roster would publish a poll nobody voted in.
1060
+ const shut = closing || s.poll.closed;
1061
+ const counted = shut && !Object.keys(roster).length ? s.poll : polls.withTally(s.poll, roster);
1062
+ const poll = { ...counted, ...(closing ? { closed: closing } : {}) };
1063
+ const container = String(s.noteId).startsWith(urls.privateNotes) ? urls.privateNotes : urls.notes;
1064
+ const slug = s.slug || String(s.noteId).slice(container.length);
1065
+ const mentions = (s.mentions || []).map(m => ({
1066
+ handle: String(m.name || '').replace(/^@/, ''), actor: m.href, page: null, inbox: null,
1067
+ }));
1068
+ const question = wire.questionDoc({
1069
+ urls, slug, content: s.text ?? '', published: s.published, inReplyTo: s.inReplyTo,
1070
+ attachments: [], mentions,
1071
+ visibility: s.visibility || 'public', summary: s.spoiler || null, container,
1072
+ options: poll.options, multiple: !!poll.multiple, endTime: poll.expiresAt,
1073
+ closed: poll.closed, votersCount: poll.votersCount || 0,
1074
+ });
1075
+ await podNotes.write(this.remote, question.id, question);
1076
+ // The Create is overwritten too, so a group's Announce resolves to the
1077
+ // current count rather than to the one the poll opened with.
1078
+ await podNotes.writeCreate(this.remote, wire.createActivityId(question.id),
1079
+ this._pollActivity('Create', question, wire.createActivityId(question.id)));
1080
+ this.store.updateStatus(questionId, { poll });
1081
+
1082
+ // Not `updated`: a changed count is not an edit, and stamping one would
1083
+ // have every client show the poll as edited each time somebody voted.
1084
+ //
1085
+ // The Update is named after the STATE it carries rather than the moment it
1086
+ // was sent. A clock only tells two rewrites apart when they fall in
1087
+ // different milliseconds, and a receiving server that has seen an activity
1088
+ // id drops the next one wearing it — which would quietly freeze the count.
1089
+ // Naming the state means an id changes exactly when there is something new
1090
+ // to say, and two sends of the same numbers are the duplicate they look
1091
+ // like.
1092
+ const stamp = crypto.createHash('sha256').update(JSON.stringify([
1093
+ poll.options.map(o => o.votes || 0), poll.votersCount || 0, poll.closed || '',
1094
+ ])).digest('hex').slice(0, 12);
1095
+ const update = this._pollActivity('Update', question, `${question.id}#poll-${stamp}`);
1096
+ const contacts = this.store.getContacts();
1097
+ const inboxes = [...new Set([
1098
+ ...(s.visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
1099
+ ...(s.poll.mentionInboxes || []),
1100
+ ].filter(Boolean))];
1101
+ await this.deliverer.deliverToAll(inboxes, update);
1102
+ this.log(`poll ${closing ? 'closed' : 'count published'}: ${question.id} → ${inboxes.length} inbox(es)`);
1103
+ return poll;
1104
+ }
1105
+
1106
+ /**
1107
+ * Shut any poll whose time is up. Called from the agent's sweep. Closing is
1108
+ * recorded BEFORE the republish, so a failed republish cannot leave a poll
1109
+ * open and collecting votes it has already refused.
1110
+ */
1111
+ async closeDuePolls(now = Date.now()) {
1112
+ const due = this.store.getStatuses().filter(s => s.kind === 'post' && s.poll
1113
+ && !s.poll.closed && s.poll.expiresAt && Date.parse(s.poll.expiresAt) <= now);
1114
+ for (const s of due) {
1115
+ const closed = new Date(Math.min(now, Date.parse(s.poll.expiresAt) || now)).toISOString();
1116
+ clearTimeout(this.pollTimers.get(s.noteId));
1117
+ this.pollTimers.delete(s.noteId);
1118
+ this.store.updateStatus(s.noteId, { poll: { ...s.poll, closed } });
1119
+ const done = await this.republishPoll(s.noteId, { closing: closed })
1120
+ .catch(e => { this.log(`poll close ${s.noteId}: ${e.message}`); return null; });
1121
+ // The roster only ever answered one question — has this person already
1122
+ // voted — and a shut poll has stopped asking it. Dropping it keeps a
1123
+ // document we serialize whole from carrying every poll's voters forever.
1124
+ if (done) {
1125
+ const all = this.store.read(polls.VOTES_DOC, {});
1126
+ if (all[s.noteId]) {
1127
+ delete all[s.noteId];
1128
+ this.store.write(polls.VOTES_DOC, all);
1129
+ }
1130
+ }
1131
+ }
1132
+ return due.length;
1133
+ }
1134
+
1135
+ /** Stop the pending rewrite windows. Called at shutdown. */
1136
+ stopPolls() {
1137
+ for (const t of this.pollTimers.values()) clearTimeout(t);
1138
+ this.pollTimers.clear();
1139
+ }
1140
+
965
1141
  // The pinned posts, as the actor's featured collection — the one document a
966
1142
  // remote server reads when it shows this profile's pins.
967
1143
  async publishFeatured() {
968
1144
  const ids = this.store.getStatuses().filter(s => s.kind === 'post' && s.pinned).map(s => s.noteId);
969
- await this.remote.putJson(this.urls.featured, wire.orderedCollection(this.urls.featured, ids));
970
- await this.remote.setAcl(this.urls.featured, ['Read']);
1145
+ await podFeatured.write(this.remote, this.urls, wire.orderedCollection(this.urls.featured, ids));
971
1146
  return ids.length;
972
1147
  }
973
1148
 
@@ -996,14 +1171,8 @@ export class Publisher {
996
1171
  attachments: atts, mentions, visibility: s.visibility || 'public',
997
1172
  summary: spoilerText, updated, container,
998
1173
  });
999
- await this.remote.putJson(note.id, note);
1000
- await this.remote.putJson(wire.createActivityId(note.id), wire.createActivity(note, urls));
1001
- if (s.slug) {
1002
- await this.local.writeNote('posts', s.slug, {
1003
- noteId: note.id, actor: urls.actor, published: s.published, content: note.content,
1004
- inReplyTo: s.inReplyTo, attachments: atts,
1005
- });
1006
- }
1174
+ await podNotes.write(this.remote, note.id, note);
1175
+ await podNotes.writeCreate(this.remote, wire.createActivityId(note.id), wire.createActivity(note, urls));
1007
1176
  const patched = this.store.updateStatus(s.noteId, {
1008
1177
  content: note.content, text: content, editedAt: updated,
1009
1178
  spoiler: spoilerText || undefined,