fedipod-server 0.10.0 → 0.12.1

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 (416) hide show
  1. package/README.md +15 -6
  2. package/dist/claims.js +1 -1
  3. package/dist/handler.d.ts +7 -1
  4. package/dist/handler.js +58 -12
  5. package/dist/handler.jsonld +5 -1
  6. package/dist/store-pod.js +18 -4
  7. package/lib/{c2s.mjs → client/c2s.mjs} +8 -3
  8. package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
  9. package/lib/client/masto/accounts.mjs +264 -0
  10. package/lib/client/masto/body.mjs +69 -0
  11. package/lib/client/masto/index.mjs +183 -0
  12. package/lib/client/masto/instance.mjs +104 -0
  13. package/lib/client/masto/media.mjs +133 -0
  14. package/lib/client/masto/oauth.mjs +599 -0
  15. package/lib/client/masto/render.mjs +459 -0
  16. package/lib/client/masto/statuses.mjs +331 -0
  17. package/lib/client/masto/timelines.mjs +316 -0
  18. package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
  19. package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
  20. package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
  21. package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
  22. package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
  23. package/lib/{import.mjs → connections/import.mjs} +2 -2
  24. package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +21 -4
  25. package/lib/connections/vault.mjs +114 -0
  26. package/lib/core/as2.mjs +124 -0
  27. package/lib/core/contexts/activitystreams.json +379 -0
  28. package/lib/core/contexts/did-v1.json +57 -0
  29. package/lib/core/contexts/fep-5711.json +36 -0
  30. package/lib/core/contexts/gotosocial.json +86 -0
  31. package/lib/core/contexts/identity-v1.json +152 -0
  32. package/lib/core/contexts/index.mjs +45 -0
  33. package/lib/core/contexts/join-lemmy.json +33 -0
  34. package/lib/core/contexts/joinmastodon.json +28 -0
  35. package/lib/core/contexts/map.json +16 -0
  36. package/lib/core/contexts/miscellany.json +19 -0
  37. package/lib/core/contexts/schemaorg.json +8845 -0
  38. package/lib/core/contexts/security-data-integrity-v1.json +78 -0
  39. package/lib/core/contexts/security-data-integrity-v2.json +81 -0
  40. package/lib/core/contexts/security-multikey-v1.json +35 -0
  41. package/lib/core/contexts/security-v1.json +74 -0
  42. package/lib/core/contexts/webfinger.json +10 -0
  43. package/lib/{deliver.mjs → core/deliver.mjs} +8 -2
  44. package/lib/core/intake/activities.mjs +437 -0
  45. package/lib/core/intake/activity.mjs +240 -0
  46. package/lib/core/intake/channel.mjs +144 -0
  47. package/lib/core/intake/group.mjs +222 -0
  48. package/lib/core/intake/index.mjs +629 -0
  49. package/lib/core/intake/notes.mjs +288 -0
  50. package/lib/core/intake/verify.mjs +141 -0
  51. package/lib/{keys.mjs → core/keys.mjs} +1 -1
  52. package/lib/core/publisher/collections.mjs +229 -0
  53. package/lib/core/publisher/index.mjs +421 -0
  54. package/lib/core/publisher/notes.mjs +188 -0
  55. package/lib/core/publisher/questions.mjs +233 -0
  56. package/lib/core/publisher/restore.mjs +196 -0
  57. package/lib/core/shapes/activitystreams.ttl +129 -0
  58. package/lib/core/shapes/index.mjs +107 -0
  59. package/lib/core/shapes/shapes-text.mjs +13 -0
  60. package/lib/{social.mjs → core/social.mjs} +19 -10
  61. package/lib/{storage.mjs → core/storage.mjs} +2 -2
  62. package/lib/{store.mjs → core/store.mjs} +12 -1
  63. package/lib/{wire.mjs → core/wire.mjs} +48 -67
  64. package/lib/device/admin/index.mjs +13 -0
  65. package/lib/device/admin/origins.mjs +35 -0
  66. package/lib/device/admin/routes/connections.mjs +144 -0
  67. package/lib/device/admin/routes/gateway.mjs +199 -0
  68. package/lib/device/admin/routes/lifecycle.mjs +191 -0
  69. package/lib/device/admin/routes/owner.mjs +322 -0
  70. package/lib/device/admin/routes/setup.mjs +393 -0
  71. package/lib/device/admin/routes/social.mjs +188 -0
  72. package/lib/device/admin/server.mjs +95 -0
  73. package/lib/device/admin/static.mjs +244 -0
  74. package/lib/device/admin/surface.mjs +274 -0
  75. package/lib/device/cli/commands/account.mjs +586 -0
  76. package/lib/device/cli/commands/run.mjs +278 -0
  77. package/lib/device/cli/commands/service.mjs +221 -0
  78. package/lib/device/cli/commands/setup.mjs +410 -0
  79. package/lib/device/cli/commands/state.mjs +559 -0
  80. package/lib/device/cli/context.mjs +288 -0
  81. package/lib/{export-collections.mjs → device/export-collections.mjs} +1 -1
  82. package/lib/{migrate.mjs → device/migrate.mjs} +5 -19
  83. package/lib/device/remote.mjs +54 -0
  84. package/lib/{setup.mjs → device/setup.mjs} +3 -3
  85. package/lib/{update.mjs → device/update.mjs} +1 -1
  86. package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
  87. package/lib/{front-core.mjs → gateway/front-core.mjs} +272 -49
  88. package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +3 -7
  89. package/lib/{httpsig.mjs → gateway/httpsig.mjs} +34 -3
  90. package/lib/pod/README.md +61 -0
  91. package/lib/pod/actor.mjs +96 -0
  92. package/lib/pod/collection.mjs +77 -0
  93. package/lib/pod/containers.mjs +136 -0
  94. package/lib/pod/discovery.mjs +46 -0
  95. package/lib/pod/featured.mjs +18 -0
  96. package/lib/pod/followers.mjs +24 -0
  97. package/lib/pod/following.mjs +10 -0
  98. package/lib/pod/http.mjs +74 -0
  99. package/lib/pod/inbox.mjs +144 -0
  100. package/lib/{links.mjs → pod/links.mjs} +1 -1
  101. package/lib/pod/media.mjs +27 -0
  102. package/lib/pod/notes.mjs +80 -0
  103. package/lib/pod/notifications.mjs +89 -0
  104. package/lib/pod/outbox.mjs +18 -0
  105. package/lib/pod/package.json +19 -0
  106. package/lib/pod/policy.mjs +58 -0
  107. package/lib/pod/private.mjs +19 -0
  108. package/lib/pod/root.mjs +63 -0
  109. package/lib/pod/state.mjs +39 -0
  110. package/lib/{remote.mjs → pod/transport.mjs} +217 -159
  111. package/lib/pod/urls.mjs +71 -0
  112. package/lib/{embed.mjs → server/embed.mjs} +149 -22
  113. package/lib/shared/links.mjs +9 -0
  114. package/lib/{ua.mjs → shared/ua.mjs} +1 -1
  115. package/package.json +1 -1
  116. package/run-agent.mjs +47 -64
  117. package/web/admin/actors.js +145 -0
  118. package/web/admin/bar.css +9 -13
  119. package/web/admin/bar.js +2 -2
  120. package/web/admin/client/client.js +1 -1
  121. package/web/admin/client/index.html +5 -6
  122. package/web/admin/common.js +23 -0
  123. package/web/admin/connections.js +112 -0
  124. package/web/admin/gateway.js +111 -0
  125. package/web/admin/group.js +258 -0
  126. package/web/admin/index.html +60 -66
  127. package/web/admin/record.js +378 -0
  128. package/web/admin/setup/index.html +3 -1
  129. package/web/admin/setup/setup.js +2 -13
  130. package/web/admin/tokens.css +56 -0
  131. package/web/admin/upkeep.js +170 -0
  132. package/web/app/README.md +77 -0
  133. package/web/app/admin-facade.mjs +549 -0
  134. package/web/app/agent.mjs +490 -0
  135. package/web/app/atproto-browser.mjs +86 -0
  136. package/web/app/boot.mjs +340 -0
  137. package/web/app/deliver-relay.mjs +72 -0
  138. package/web/app/dist/boot.js +34067 -0
  139. package/web/app/dist/boot.js.map +7 -0
  140. package/web/app/dist/sw.js +70014 -0
  141. package/web/app/dist/sw.js.map +7 -0
  142. package/web/app/fediacct-browser.mjs +146 -0
  143. package/web/app/idb-kv.mjs +35 -0
  144. package/web/app/index.html +203 -0
  145. package/web/app/index.html~ +152 -0
  146. package/web/app/keys-browser.mjs +46 -0
  147. package/web/app/keystore.mjs +99 -0
  148. package/web/app/oidc-session.mjs +189 -0
  149. package/web/app/pod-auth.mjs +248 -0
  150. package/web/app/pod-remote.mjs +84 -0
  151. package/web/app/shims/_globals.mjs +2 -0
  152. package/web/app/shims/fedify-sig.mjs +77 -0
  153. package/web/app/shims/node-crypto.mjs +113 -0
  154. package/web/app/shims/node-fs.mjs +17 -0
  155. package/web/app/shims/node-path.mjs +19 -0
  156. package/web/app/shims/node-url.mjs +7 -0
  157. package/web/app/shims/prelude.js +58 -0
  158. package/web/app/shims/safefetch.mjs +58 -0
  159. package/web/app/shims/shapes-text.mjs +8 -0
  160. package/web/app/shims/web-push.mjs +14 -0
  161. package/web/app/signup.mjs +248 -0
  162. package/web/app/site/_headers +9 -0
  163. package/web/app/site/_redirects +21 -0
  164. package/web/app/site/admin/actors.js +145 -0
  165. package/web/app/site/admin/bar.css +60 -0
  166. package/web/app/site/admin/bar.js +61 -0
  167. package/web/app/site/admin/client/client.js +66 -0
  168. package/web/app/site/admin/client/index.html +43 -0
  169. package/web/app/site/admin/common.js +23 -0
  170. package/web/app/site/admin/connections.js +112 -0
  171. package/web/app/site/admin/gateway.js +111 -0
  172. package/web/app/site/admin/group.js +258 -0
  173. package/web/app/site/admin/index.html +562 -0
  174. package/web/app/site/admin/record.js +378 -0
  175. package/web/app/site/admin/setup/index.html +192 -0
  176. package/web/app/site/admin/setup/setup.js +297 -0
  177. package/web/app/site/admin/tokens.css +56 -0
  178. package/web/app/site/admin/upkeep.js +170 -0
  179. package/web/app/site/admin/window.css +55 -0
  180. package/web/app/site/admin/window.js +136 -0
  181. package/web/app/site/app/404.html +40 -0
  182. package/web/app/site/app/_headers +2 -0
  183. package/web/app/site/app/apple-touch-icon.png +0 -0
  184. package/web/app/site/app/assets/ICONS-DIPJeU0C.js +2 -0
  185. package/web/app/site/app/assets/ICONS-DIPJeU0C.js.map +1 -0
  186. package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js +183 -0
  187. package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
  188. package/web/app/site/app/assets/Temml-CCjoacWt.woff2 +0 -0
  189. package/web/app/site/app/assets/__vite-browser-external-C7iut881.js +2 -0
  190. package/web/app/site/app/assets/__vite-browser-external-C7iut881.js.map +1 -0
  191. package/web/app/site/app/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
  192. package/web/app/site/app/assets/catch-up-O5q5C75Z.png +0 -0
  193. package/web/app/site/app/assets/chunk-aKtaBQYM.js +1 -0
  194. package/web/app/site/app/assets/compose-CFEx7wb4.js +2 -0
  195. package/web/app/site/app/assets/compose-CFEx7wb4.js.map +1 -0
  196. package/web/app/site/app/assets/compose-ZJN5xSb4.js +35 -0
  197. package/web/app/site/app/assets/compose-ZJN5xSb4.js.map +1 -0
  198. package/web/app/site/app/assets/debug.module-YFQgwnIr.js +52 -0
  199. package/web/app/site/app/assets/debug.module-YFQgwnIr.js.map +1 -0
  200. package/web/app/site/app/assets/dom-p1YcJ5Rw.js +2 -0
  201. package/web/app/site/app/assets/dom-p1YcJ5Rw.js.map +1 -0
  202. package/web/app/site/app/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
  203. package/web/app/site/app/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
  204. package/web/app/site/app/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
  205. package/web/app/site/app/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
  206. package/web/app/site/app/assets/icons/add-circle-line-WD0mwORD.js +1 -0
  207. package/web/app/site/app/assets/icons/alert-line-CpFUMf9s.js +1 -0
  208. package/web/app/site/app/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
  209. package/web/app/site/app/assets/icons/announcement-line-5nwaJFJm.js +1 -0
  210. package/web/app/site/app/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
  211. package/web/app/site/app/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
  212. package/web/app/site/app/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
  213. package/web/app/site/app/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
  214. package/web/app/site/app/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
  215. package/web/app/site/app/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
  216. package/web/app/site/app/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
  217. package/web/app/site/app/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
  218. package/web/app/site/app/assets/icons/at-line-CF66Bznk.js +1 -0
  219. package/web/app/site/app/assets/icons/attachment-line-BY8It-l9.js +1 -0
  220. package/web/app/site/app/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
  221. package/web/app/site/app/assets/icons/board-line-C0UWXyA6.js +1 -0
  222. package/web/app/site/app/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
  223. package/web/app/site/app/assets/icons/building-5-line-Dd59MzbO.js +1 -0
  224. package/web/app/site/app/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
  225. package/web/app/site/app/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
  226. package/web/app/site/app/assets/icons/calendar-month-line-vommts2C.js +1 -0
  227. package/web/app/site/app/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
  228. package/web/app/site/app/assets/icons/camera-line-Cv69MEei.js +1 -0
  229. package/web/app/site/app/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
  230. package/web/app/site/app/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
  231. package/web/app/site/app/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
  232. package/web/app/site/app/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
  233. package/web/app/site/app/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
  234. package/web/app/site/app/assets/icons/clipboard-line-BBp364gJ.js +1 -0
  235. package/web/app/site/app/assets/icons/close-circle-line-CICdiuGz.js +1 -0
  236. package/web/app/site/app/assets/icons/close-line-DYrFWcjv.js +1 -0
  237. package/web/app/site/app/assets/icons/cloud-line-tiQAbah8.js +1 -0
  238. package/web/app/site/app/assets/icons/code-line-fAl8iiV3.js +1 -0
  239. package/web/app/site/app/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
  240. package/web/app/site/app/assets/icons/copy-2-line-CLW69yF5.js +1 -0
  241. package/web/app/site/app/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
  242. package/web/app/site/app/assets/icons/document-3-line-DBGNhAg2.js +1 -0
  243. package/web/app/site/app/assets/icons/document-line-C7mb9sb7.js +1 -0
  244. package/web/app/site/app/assets/icons/down-line-BGqSSThd.js +1 -0
  245. package/web/app/site/app/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
  246. package/web/app/site/app/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
  247. package/web/app/site/app/assets/icons/exit-line-Dc07BS-x.js +1 -0
  248. package/web/app/site/app/assets/icons/external-link-line-BvpSkmjO.js +1 -0
  249. package/web/app/site/app/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
  250. package/web/app/site/app/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
  251. package/web/app/site/app/assets/icons/filter-2-line-B_mR2usy.js +1 -0
  252. package/web/app/site/app/assets/icons/filter-line-C_nm-3cU.js +1 -0
  253. package/web/app/site/app/assets/icons/flag-1-line-C2HB05I0.js +1 -0
  254. package/web/app/site/app/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
  255. package/web/app/site/app/assets/icons/formula-line-DLrt2d6j.js +1 -0
  256. package/web/app/site/app/assets/icons/grid-line-CgUmEikf.js +1 -0
  257. package/web/app/site/app/assets/icons/group-line-C5fnX0wU.js +1 -0
  258. package/web/app/site/app/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
  259. package/web/app/site/app/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
  260. package/web/app/site/app/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
  261. package/web/app/site/app/assets/icons/heart-line-CJ7K47J4.js +1 -0
  262. package/web/app/site/app/assets/icons/history-2-line-kWWvFzqv.js +1 -0
  263. package/web/app/site/app/assets/icons/history-line-bGQiwbTH.js +1 -0
  264. package/web/app/site/app/assets/icons/home-3-line-fRU1zeLG.js +1 -0
  265. package/web/app/site/app/assets/icons/information-line-iNk6-sOY.js +1 -0
  266. package/web/app/site/app/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
  267. package/web/app/site/app/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
  268. package/web/app/site/app/assets/icons/layout-5-line-B_znt5wO.js +1 -0
  269. package/web/app/site/app/assets/icons/left-line-eUw0tya4.js +1 -0
  270. package/web/app/site/app/assets/icons/lightning-line-DaKoDdix.js +1 -0
  271. package/web/app/site/app/assets/icons/link-2-line-DCqLmMA7.js +1 -0
  272. package/web/app/site/app/assets/icons/list-check-line-BAxosBh9.js +1 -0
  273. package/web/app/site/app/assets/icons/lock-line-CPoaXeUW.js +1 -0
  274. package/web/app/site/app/assets/icons/mail-line-B0P8aa1L.js +1 -0
  275. package/web/app/site/app/assets/icons/moon-line-CEqNAToP.js +1 -0
  276. package/web/app/site/app/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
  277. package/web/app/site/app/assets/icons/more-3-line-BIiVjYTz.js +1 -0
  278. package/web/app/site/app/assets/icons/notification-line-BZZq2Gtu.js +1 -0
  279. package/web/app/site/app/assets/icons/pencil-line-CKh8-A1v.js +1 -0
  280. package/web/app/site/app/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
  281. package/web/app/site/app/assets/icons/pin-line-DpTolyhs.js +1 -0
  282. package/web/app/site/app/assets/icons/play-fill-B0-G_8lX.js +1 -0
  283. package/web/app/site/app/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
  284. package/web/app/site/app/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
  285. package/web/app/site/app/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
  286. package/web/app/site/app/assets/icons/radar-line-CKyroTth.js +1 -0
  287. package/web/app/site/app/assets/icons/react-line-CoIKhhtK.js +1 -0
  288. package/web/app/site/app/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
  289. package/web/app/site/app/assets/icons/right-line-B3KWJaqc.js +1 -0
  290. package/web/app/site/app/assets/icons/rocket-line-4KZl537D.js +1 -0
  291. package/web/app/site/app/assets/icons/round-fill-C69NTrZ0.js +1 -0
  292. package/web/app/site/app/assets/icons/round-line-B9k_1uo-.js +1 -0
  293. package/web/app/site/app/assets/icons/route-line-B28ReHcE.js +1 -0
  294. package/web/app/site/app/assets/icons/rows-4-line-CrBniagM.js +1 -0
  295. package/web/app/site/app/assets/icons/scan-line-DSf74YdP.js +1 -0
  296. package/web/app/site/app/assets/icons/search-2-line-B6geIU1z.js +1 -0
  297. package/web/app/site/app/assets/icons/settings-3-line-azJwK47w.js +1 -0
  298. package/web/app/site/app/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
  299. package/web/app/site/app/assets/icons/share-2-line-D5MxWjrm.js +1 -0
  300. package/web/app/site/app/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
  301. package/web/app/site/app/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
  302. package/web/app/site/app/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
  303. package/web/app/site/app/assets/icons/time-line-BF3eet1v.js +1 -0
  304. package/web/app/site/app/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
  305. package/web/app/site/app/assets/icons/translate-line-BFWol2Ce.js +1 -0
  306. package/web/app/site/app/assets/icons/unlock-line-X_x5vwv3.js +1 -0
  307. package/web/app/site/app/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
  308. package/web/app/site/app/assets/icons/user-4-line-BVSynZCp.js +1 -0
  309. package/web/app/site/app/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
  310. package/web/app/site/app/assets/icons/user-add-line-BbXdHG4z.js +1 -0
  311. package/web/app/site/app/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
  312. package/web/app/site/app/assets/icons/user-follow-line-BXux0wSd.js +1 -0
  313. package/web/app/site/app/assets/icons/user-star-line-DdYlpHLU.js +1 -0
  314. package/web/app/site/app/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
  315. package/web/app/site/app/assets/icons/user-x-line-DIDgNlPH.js +1 -0
  316. package/web/app/site/app/assets/icons/volume-line-Bs390KQt.js +1 -0
  317. package/web/app/site/app/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
  318. package/web/app/site/app/assets/icons/walk-line-yNXfB-md.js +1 -0
  319. package/web/app/site/app/assets/icons/world-2-line-CenkGIwj.js +1 -0
  320. package/web/app/site/app/assets/icons/zoom-in-line-CaENUphf.js +1 -0
  321. package/web/app/site/app/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
  322. package/web/app/site/app/assets/instances-BNPptnn-.json +333 -0
  323. package/web/app/site/app/assets/locales/ar-SA-CfheRbCu.js +1 -0
  324. package/web/app/site/app/assets/locales/ca-ES-DHRW86Tz.js +1 -0
  325. package/web/app/site/app/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
  326. package/web/app/site/app/assets/locales/de-DE-ByRPMRFy.js +1 -0
  327. package/web/app/site/app/assets/locales/eo-UY-CPC7wLCS.js +1 -0
  328. package/web/app/site/app/assets/locales/es-ES-dJa0Fd9t.js +1 -0
  329. package/web/app/site/app/assets/locales/eu-ES-C3djlPR2.js +1 -0
  330. package/web/app/site/app/assets/locales/fa-IR-DTT490Ck.js +1 -0
  331. package/web/app/site/app/assets/locales/fi-FI-B23MCPuz.js +1 -0
  332. package/web/app/site/app/assets/locales/fr-FR-BcaQsO5C.js +1 -0
  333. package/web/app/site/app/assets/locales/gl-ES-BR-CVKBN.js +1 -0
  334. package/web/app/site/app/assets/locales/he-IL-CfheRbCu.js +1 -0
  335. package/web/app/site/app/assets/locales/hu-HU-CfheRbCu.js +1 -0
  336. package/web/app/site/app/assets/locales/it-IT-C1136UFV.js +1 -0
  337. package/web/app/site/app/assets/locales/ja-JP-CRZmRAhD.js +1 -0
  338. package/web/app/site/app/assets/locales/kab-Cz8xH_TT.js +1 -0
  339. package/web/app/site/app/assets/locales/ko-KR-CG9UiY4o.js +1 -0
  340. package/web/app/site/app/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
  341. package/web/app/site/app/assets/locales/nb-NO-CfheRbCu.js +1 -0
  342. package/web/app/site/app/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
  343. package/web/app/site/app/assets/locales/oc-FR-CfheRbCu.js +1 -0
  344. package/web/app/site/app/assets/locales/pl-PL-DkwGXf6P.js +1 -0
  345. package/web/app/site/app/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
  346. package/web/app/site/app/assets/locales/pt-BR-nGL0HdFS.js +1 -0
  347. package/web/app/site/app/assets/locales/pt-PT-Cp17aj2z.js +1 -0
  348. package/web/app/site/app/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
  349. package/web/app/site/app/assets/locales/th-TH-CfheRbCu.js +1 -0
  350. package/web/app/site/app/assets/locales/tok-CfheRbCu.js +1 -0
  351. package/web/app/site/app/assets/locales/tr-TR-CfheRbCu.js +1 -0
  352. package/web/app/site/app/assets/locales/uk-UA-C5E3dFBf.js +1 -0
  353. package/web/app/site/app/assets/locales/zh-CN-BHJi7_et.js +1 -0
  354. package/web/app/site/app/assets/locales/zh-TW-CfheRbCu.js +1 -0
  355. package/web/app/site/app/assets/main-BdqNbG-a.js +26 -0
  356. package/web/app/site/app/assets/main-BdqNbG-a.js.map +1 -0
  357. package/web/app/site/app/assets/mock-home-DGhR__He.js +2 -0
  358. package/web/app/site/app/assets/mock-home-DGhR__He.js.map +1 -0
  359. package/web/app/site/app/assets/multi-column-ROck0NVt.jpg +0 -0
  360. package/web/app/site/app/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
  361. package/web/app/site/app/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
  362. package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js +2 -0
  363. package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js.map +1 -0
  364. package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js +20 -0
  365. package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js.map +1 -0
  366. package/web/app/site/app/assets/style-B1iLPTPR.css +2 -0
  367. package/web/app/site/app/assets/temml-DuyIQMei.js +97 -0
  368. package/web/app/site/app/assets/temml-DuyIQMei.js.map +1 -0
  369. package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js +2 -0
  370. package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
  371. package/web/app/site/app/assets/year-in-posts-B2lywqdx.png +0 -0
  372. package/web/app/site/app/assets/year-in-posts-DJv_star.js +3 -0
  373. package/web/app/site/app/assets/year-in-posts-DJv_star.js.map +1 -0
  374. package/web/app/site/app/compose/index.html +20 -0
  375. package/web/app/site/app/favicon.ico +0 -0
  376. package/web/app/site/app/index.html +73 -0
  377. package/web/app/site/app/logo-192.png +0 -0
  378. package/web/app/site/app/logo-512.png +0 -0
  379. package/web/app/site/app/logo-badge-72.png +0 -0
  380. package/web/app/site/app/logo-maskable-512.png +0 -0
  381. package/web/app/site/app/logo-monochrome-512.png +0 -0
  382. package/web/app/site/app/logo-monochrome-maskable-512.png +0 -0
  383. package/web/app/site/app/manifest.webmanifest +1 -0
  384. package/web/app/site/app/og-image-2.jpg +0 -0
  385. package/web/app/site/app/og-image.png +0 -0
  386. package/web/app/site/app/robots.txt +2 -0
  387. package/web/app/site/app/version.json +1 -0
  388. package/web/app/site/boot.js +34067 -0
  389. package/web/app/site/index.html +203 -0
  390. package/web/app/site/sw.js +70014 -0
  391. package/web/app/sw-src.mjs +220 -0
  392. package/web/front/#new-account.html# +43 -0
  393. package/web/front/admin.html +1 -81
  394. package/web/front/admin.js +85 -0
  395. package/web/front/new-account.html +1 -9
  396. package/web/front/new-account.js +13 -0
  397. package/web/front/run.html +1 -92
  398. package/web/front/run.js +107 -0
  399. package/lib/admin.mjs +0 -1913
  400. package/lib/intake.mjs +0 -1764
  401. package/lib/mastoapi.mjs +0 -1947
  402. package/lib/podrdf.mjs +0 -132
  403. package/lib/publisher.mjs +0 -1263
  404. package/web/admin/admin.js +0 -1114
  405. /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
  406. /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
  407. /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
  408. /package/lib/{lease.mjs → core/lease.mjs} +0 -0
  409. /package/lib/{polls.mjs → core/polls.mjs} +0 -0
  410. /package/lib/{proof.mjs → core/proof.mjs} +0 -0
  411. /package/lib/{account.mjs → device/account.mjs} +0 -0
  412. /package/lib/{certs.mjs → device/certs.mjs} +0 -0
  413. /package/lib/{home.mjs → device/home.mjs} +0 -0
  414. /package/lib/{ports.mjs → device/ports.mjs} +0 -0
  415. /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
  416. /package/lib/{safefetch.mjs → shared/safefetch.mjs} +0 -0
package/lib/publisher.mjs DELETED
@@ -1,1263 +0,0 @@
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.
4
-
5
- import crypto from 'node:crypto';
6
- import fs from 'node:fs';
7
- import path from 'node:path';
8
- import { fileURLToPath } from 'node:url';
9
- import * as wire from './wire.mjs';
10
- import * as polls from './polls.mjs';
11
- import { USER_AGENT } from './ua.mjs';
12
- import { HTTP_TIMEOUT_MS } from './safefetch.mjs';
13
-
14
- const ACCEPT_AP = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
15
- // The default for publishCollections: the whole public surface, ACLs included.
16
- // A caller that knows what it changed narrows it; a caller that says nothing
17
- // still gets everything, so a missed call site degrades to the old cost rather
18
- // than silently publishing nothing.
19
- const REBUILD_MAX_PER_RUN = 200;
20
- // How long a poll gathers votes before its Question is rewritten. Every vote
21
- // changes a number other servers re-read, and rewriting per vote would make a
22
- // busy poll a steady write stream against the pod. A burst costs one rewrite
23
- // and one Update instead.
24
- const POLL_REWRITE_MS = 10_000;
25
- const ALL_COLLECTIONS = { followers: true, following: true, outbox: true, acls: true,
26
- pending: true, blocked: true };
27
- const AGENT_VERSION = JSON.parse(fs.readFileSync(
28
- path.join(path.dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8')).version;
29
-
30
- export class Publisher {
31
- constructor({ config, remote, local, store, deliverer, publicKeyPem, assertionKey = null, log = console.log,
32
- probeFetch = null, resolveMention = null, privateOnPod = true, clientOrigin = null,
33
- }) {
34
- this.config = config;
35
- this.remote = remote;
36
- this.local = local;
37
- this.store = store;
38
- this.deliverer = deliverer;
39
- this.publicKeyPem = publicKeyPem;
40
- this.assertionKey = assertionKey; // Ed25519 public half, multibase; null = no proofs
41
- // Where this identity's client surface answers, when that is an address a
42
- // stranger can reach. Null on a laptop, where the surface is on loopback
43
- // and advertising it to the world would name somewhere nobody can go.
44
- this.clientOrigin = clientOrigin;
45
- // A fronted identity (config.gateway.frontActor) advertises its ids on a
46
- // shared domain; the map tells RemotePod where each writes on the pod.
47
- const publicBase = config.gateway?.frontActor
48
- ? config.gateway.frontActor.replace(/ap\/actor\/?$/, '') : null;
49
- this.urls = wire.apUrls(config.remotePod, config.root, { publicBase });
50
- if (this.urls.toPod && this.remote?.setUrlMap) this.remote.setUrlMap(this.urls.toPod);
51
- // Credential-free by design — it asks what a stranger sees — but routed
52
- // through the pod's own cooldown and accounting, because it is still a
53
- // socket opened to that pod. Tests inject their own.
54
- this.probeFetch = probeFetch || ((u, i) => this.remote.probe(u, i));
55
- this.resolveMention = resolveMention;
56
- this.privateOnPod = privateOnPod;
57
- // Per-poll rewrite windows, keyed by question id. See POLL_REWRITE_MS.
58
- this.pollTimers = new Map();
59
- this.log = log;
60
- }
61
-
62
- // Idempotent: (re)write webfinger + actor + collections + container ACLs.
63
- //
64
- // ~34 pod requests, so it does not run when it would rewrite the same bytes.
65
- // Every document below is derived from the actor doc, the handle, the host,
66
- // whether the actor is quiesced (which decides the inbox ACL) and the agent
67
- // version (which rides in nodeinfo) — so if none of those moved, there is
68
- // nothing to say. Phanpy's editor submits the whole form on every save, so
69
- // "saved without changing anything" is the common case, not a rare one.
70
- //
71
- // `force` is for the callers that publish precisely BECAUSE the pod does not
72
- // have what the digest says it has: the repair path, and the explicit
73
- // republish button. Without it, an actor lost from the pod would match the
74
- // digest, be skipped, and leave the agent reporting success while nobody can
75
- // resolve it.
76
- async publishProfile({ force = false } = {}) {
77
- const { urls } = this;
78
- const host = new URL(urls.base).host;
79
-
80
- // The owner-only collections (FEP-4ccd, FEP-c648) are advertised only
81
- // where "owner-only" is real — the pod must provably enforce the private
82
- // container's ACL, the same bar private posts clear.
83
- const priv = await this.privateReady() === true;
84
- const moderators = (this.config.moderators || []).length ? urls.moderators : null;
85
- // An advertised inbox gateway (config.gateway.mode past 'off') becomes the
86
- // actor's inbox; deliveries reach the pod inbox through it. Absent → today.
87
- const gw = this.config.gateway;
88
- const gwActive = gw && gw.url && gw.mode && gw.mode !== 'off';
89
- const actorDoc = wire.actorDoc({
90
- urls, handle: wire.publicHandle(this.config), name: this.config.name, publicKeyPem: this.publicKeyPem,
91
- movedTo: this.config.movedTo || null, kind: this.config.kind,
92
- approveJoins: wire.followsNeedApproval(this.config),
93
- assertionKey: this.assertionKey,
94
- summary: this.config.summary || null, icon: this.config.icon || null,
95
- image: this.config.image || null, fields: this.config.fields || [],
96
- webId: this.remote.webId || null,
97
- aliases: this.config.aliases || [],
98
- moderators,
99
- pendingFollowers: priv ? urls.pendingFollowers : null,
100
- pendingFollowing: priv ? urls.pendingFollowing : null,
101
- blocked: priv ? urls.blocked : null,
102
- inbox: gwActive ? gw.url : null,
103
- // The agent's own outbox endpoint, where it is reachable: a client
104
- // following the actor must arrive somewhere that will take a write.
105
- outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : null,
106
- // How a client-to-server client finds the way in with nothing configured
107
- // by hand. Advertised only where the surface is publicly reachable.
108
- oauthAuthorize: this.clientOrigin ? `${this.clientOrigin}oauth/authorize` : null,
109
- oauthToken: this.clientOrigin ? `${this.clientOrigin}oauth/token` : null,
110
- });
111
- const surface = crypto.createHash('sha256').update(JSON.stringify({
112
- actor: actorDoc, handle: this.config.handle, host,
113
- quiesced: !!this.config.quiescedAt, version: AGENT_VERSION,
114
- moderators: this.config.moderators || [],
115
- })).digest('hex').slice(0, 32);
116
- if (!force && this.store.read('published.json', {}).surfaceDigest === surface) {
117
- this.log('profile unchanged — nothing republished');
118
- return { unreachable: [], updated: 0, skipped: true };
119
- }
120
-
121
- await this.remote.putJson(urls.webfinger,
122
- wire.jrd({ handle: this.config.handle, host, actor: urls.actor }), 'application/jrd+json');
123
- await this.remote.setAcl(urls.webfinger, ['Read']);
124
-
125
- const hostMetaUrl = urls.base + '.well-known/host-meta';
126
- await this.remote.put(hostMetaUrl, wire.hostMeta(urls.base), 'application/xrd+xml');
127
- await this.remote.setAcl(hostMetaUrl, ['Read']);
128
-
129
- // NodeInfo: pointer at the protocol-required root, document in our tree.
130
- const nodeinfoDocUrl = urls.home + 'ap/nodeinfo-2.0';
131
- const localPosts = this.store.getStatuses().filter(s => s.kind === 'post').length;
132
- await this.remote.putJson(urls.base + '.well-known/nodeinfo',
133
- wire.nodeinfoPointer(nodeinfoDocUrl), 'application/json');
134
- await this.remote.setAcl(urls.base + '.well-known/nodeinfo', ['Read']);
135
- await this.remote.putJson(nodeinfoDocUrl,
136
- wire.nodeinfoDoc({ version: AGENT_VERSION, localPosts }), 'application/json');
137
- await this.remote.setAcl(nodeinfoDocUrl, ['Read']);
138
-
139
- const actor = actorDoc; // built above, for the digest
140
- await this.remote.putJson(urls.actor, actor);
141
- await this.remote.setAcl(urls.actor, ['Read']);
142
-
143
- // FEP-1b12: the moderator roster, public — it is what a recipient
144
- // validates a group's announced moderation against.
145
- if (moderators) {
146
- await this.remote.putJson(urls.moderators,
147
- wire.orderedCollection(urls.moderators, this.config.moderators));
148
- await this.remote.setAcl(urls.moderators, ['Read']);
149
- }
150
- // The gateway policy doc: the PUBLIC data a keyless gateway reads to decide
151
- // what concerns this identity. Written only while a gateway is advertised.
152
- if (gwActive) await this.publishGatewayPolicy();
153
-
154
- // The human half: a page a browser can open and follow from. The actor
155
- // document is for servers; this is the address you hand to a person.
156
- await this.remote.put(urls.profileHtml, wire.profilePageHtml({
157
- name: this.config.name || this.config.handle,
158
- address: wire.webfingerHost(urls.base) ? `@${this.config.handle}@${host}` : urls.actor,
159
- summary: this.config.summary ? wire.contentHtml(this.config.summary) : null,
160
- icon: this.config.icon || null,
161
- kind: this.config.kind,
162
- }), 'text/html');
163
- await this.remote.setAcl(urls.profileHtml, ['Read']);
164
-
165
- // WebID → actor: the profile card lists the actor as a foaf:account.
166
- // Best-effort — a profile that cannot be read or edited does not stop the
167
- // publish, it is logged and the rest of the surface still goes up.
168
- try {
169
- const wrote = await this.remote.linkAccountInProfile({
170
- actorUrl: urls.actor,
171
- accountName: `@${this.config.handle}@${host}`,
172
- kind: this.config.kind,
173
- });
174
- if (wrote) this.log('WebID profile now lists the actor as a foaf:account');
175
- } catch (e) {
176
- this.log(`WebID profile not updated with the actor link: ${e.message}`);
177
- }
178
-
179
- // inbox: public may only Append; owner (the agent) reads + drains. A
180
- // quiesced actor keeps its name resolving but takes no more mail, so a
181
- // republish must not re-open the door.
182
- await this.remote.putJson(urls.inbox + '.keep', { keep: true }, 'application/json');
183
- if (this.config.quiescedAt) {
184
- await this.remote.setAcl(urls.inbox, []);
185
- this.log('inbox left closed — this actor is quiesced');
186
- } else {
187
- await this.remote.setAcl(urls.inbox, ['Append']);
188
- }
189
-
190
- // notes live under a public-Read container (acl:default covers new notes).
191
- await this.remote.putJson(urls.notes + '.keep', { keep: true }, 'application/json');
192
- await this.remote.setAcl(urls.notes, ['Read']);
193
-
194
- await this.publishCollections({ ...ALL_COLLECTIONS, force });
195
- const updated = await this.announceProfileChange(actor, { force });
196
- await this.local.writeSettings({ handle: this.config.handle, actorUrl: urls.actor });
197
- await this.ensurePrivateAcls();
198
- const unreachable = await this.verifyPublicSurface();
199
- // Last, and merged: announceProfileChange writes this document too.
200
- //
201
- // Only when the surface came back readable. Recording it regardless meant a
202
- // publish that half-landed still matched the digest, so the NEXT save — the
203
- // one the operator makes because the first did not work — was skipped as a
204
- // no-op and reported success. The digest is a record of what is up there,
205
- // and an unreachable document is not up there.
206
- if (!unreachable.length) {
207
- this.store.write('published.json',
208
- { ...this.store.read('published.json', {}), surfaceDigest: surface });
209
- }
210
- // Named as the fediverse sees it: a fronted actor's name and host are the
211
- // front's, not the pod's.
212
- const pubName = wire.publicHandle(this.config);
213
- const pubHost = this.config.gateway?.frontActor ? new URL(this.config.gateway.frontActor).host : host;
214
- this.log(this.config.gateway?.frontActor || wire.webfingerHost(urls.base)
215
- ? `profile published: @${pubName}@${pubHost} → ${urls.actor}`
216
- : `profile published → ${urls.actor} — NOT discoverable as @${pubName}@${pubHost}: `
217
- + 'this pod is a path on a shared host, and WebFinger is only answered at a host root');
218
- return { unreachable, updated };
219
- }
220
-
221
- // Fires when the document differs from the one last published — including
222
- // the first time, when there is nothing to differ from.
223
- //
224
- // Every call to publishProfile is already a DELIBERATE republish: setup, a
225
- // rename, an edit through the client or /config, `describe`, a key rotation,
226
- // or an actor document found missing from the pod. Starting the agent does not
227
- // call it. So the digest is not there to survive restarts — it is there for
228
- // the republish that changes nothing, which /config does whenever you save a
229
- // field the actor document does not carry.
230
- //
231
- // A silent first publish was considered and is WRONG here: it would spend a
232
- // real edit doing nothing but recording a digest, and that edit is exactly the
233
- // one whose invisibility this fixes.
234
- async announceProfileChange(actor, { force = false } = {}) {
235
- const digest = crypto.createHash('sha256').update(JSON.stringify(actor)).digest('hex').slice(0, 32);
236
- const seen = this.store.read('published.json', {});
237
- // A forced republish is the operator saying TELL THE WORLD — the digest
238
- // gate is for silent no-op saves, not for that.
239
- if (seen.actorDigest === digest && !force) return 0;
240
- this.store.write('published.json', { ...seen, actorDigest: digest, at: new Date().toISOString() });
241
- const inboxes = [...new Set(this.store.getContacts().followers
242
- .map(f => f.sharedInbox || f.inbox).filter(Boolean))];
243
- if (!inboxes.length) return 0;
244
- await this.deliverer.deliverToAll(inboxes,
245
- wire.updateActorActivity({ urls: this.urls, actor, serial: Date.now() }));
246
- this.log(`profile changed — Update delivered to ${inboxes.length} inbox(es)`);
247
- return inboxes.length;
248
- }
249
-
250
- // The mirror of ensurePrivateAcls, and the check this project lacked: these
251
- // documents MUST be readable by strangers or no server can see the actor.
252
- // A publish that dies half-way, or an ACL write that is accepted without
253
- // taking effect, is otherwise indistinguishable from success — the agent
254
- // reports itself configured and federating while nobody can find it.
255
- async verifyPublicSurface() {
256
- const { urls } = this;
257
- const targets = [
258
- ['webfinger', urls.webfinger],
259
- ['host-meta', urls.base + '.well-known/host-meta'],
260
- ['actor', urls.actor],
261
- ['notes', urls.notes],
262
- ['followers', urls.followers],
263
- ['following', urls.following],
264
- ['outbox', urls.outbox],
265
- ];
266
- const unreachable = [];
267
- for (const [name, url] of targets) {
268
- if (!await this.publiclyReadable(url)) unreachable.push(name);
269
- }
270
- if (unreachable.length) {
271
- this.log(`FEDERATION: ${unreachable.join(', ')} not readable without credentials — `
272
- + 'other servers cannot resolve or fetch this actor');
273
- }
274
- return unreachable;
275
- }
276
-
277
- // An ACL write that silently failed — or was changed afterwards by anything
278
- // else touching the pod — leaves the private trees, signing keys included,
279
- // world-readable. bootstrap writes them once at setup and never returns, so
280
- // this runs on every connect: probe UNauthenticated, rewrite whatever
281
- // answers, and say so loudly if the rewrite does not take.
282
- async ensurePrivateAcls() {
283
- // Only trees that are actually ON the pod. With the default local
284
- // privateRoot the fediverse tree lives on disk, so probing it was a request
285
- // that could only ever 404, on every start.
286
- const onPod = [this.urls.home, this.urls.state,
287
- ...(this.privateOnPod === false ? [] : [this.urls.fediverse])];
288
- for (const url of onPod) {
289
- if (!await this.publiclyReadable(url)) continue;
290
- this.log(`${url} was readable without credentials — rewriting its ACL`);
291
- try {
292
- await this.remote.setAcl(url, []);
293
- } catch (e) {
294
- this.log(`SECURITY: ${url} is public and its ACL could not be rewritten: ${e.message}`);
295
- continue;
296
- }
297
- if (await this.publiclyReadable(url)) {
298
- this.log(`SECURITY: ${url} is STILL readable without credentials — check the pod's ACLs`);
299
- }
300
- }
301
- }
302
-
303
- // Deliberately credential-free: this asks what a stranger would see.
304
- // accept: */* matters — asking for turtle makes the server answer 501 on the
305
- // JSON documents (webfinger, actor), which reads as "unreachable" when the
306
- // world can in fact see them perfectly well.
307
- async publiclyReadable(url) {
308
- try {
309
- const res = await this.probeFetch(url, {
310
- headers: { accept: '*/*', 'user-agent': USER_AGENT },
311
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
312
- });
313
- return res.status < 400;
314
- } catch { return false; } // unreachable is not a finding here
315
- }
316
-
317
- // Retire this identity for good: tell everyone who follows us to drop the
318
- // account, then leave a Tombstone where the actor was. The inbox stays
319
- // publicly Append-able on purpose — closing it would make deliveries 401,
320
- // which Mastodon treats as failure and retries, the opposite of the point.
321
- // A Delete stops well-behaved servers; anything that keeps delivering gets a
322
- // cheap 201 into a container we no longer read.
323
- async retireActor() {
324
- const { urls } = this;
325
- const contacts = this.store.getContacts();
326
- const inboxes = [...new Set(contacts.followers.map(f => f.sharedInbox || f.inbox).filter(Boolean))];
327
- const deletedAt = new Date().toISOString();
328
- await this.deliverer.deliverToAll(inboxes, wire.deleteActorActivity(urls, Date.parse(deletedAt)));
329
- await this.remote.putJson(urls.actor, wire.tombstoneDoc(urls, deletedAt, this.config.kind));
330
- await this.remote.setAcl(urls.actor, ['Read']); // a Tombstone must stay fetchable
331
- this.store.setConfig({ ...this.store.getConfig(), retiredAt: deletedAt });
332
- await this.store.flush();
333
- this.log(`retired: Delete sent to ${inboxes.length} inbox(es), actor replaced with a Tombstone`);
334
- return { inboxes: inboxes.length, deletedAt };
335
- }
336
-
337
- // Stop accepting mail without giving up the name: deliveries get an immediate
338
- // 401 rather than a 201 into storage nobody will ever drain. WebFinger,
339
- // host-meta and the actor stay published, so the handle still resolves.
340
- async closeInbox() {
341
- await this.remote.setAcl(this.urls.inbox, []);
342
- const at = new Date().toISOString();
343
- this.store.setConfig({ ...this.store.getConfig(), quiescedAt: at });
344
- await this.store.flush();
345
- this.log(`inbox closed — @${this.config.handle} still resolves but accepts nothing`);
346
- return at;
347
- }
348
-
349
- // Undo closeInbox: mail flows again and the actor is no longer quiesced.
350
- async openInbox() {
351
- await this.remote.setAcl(this.urls.inbox, ['Append']);
352
- const { quiescedAt, ...rest } = this.store.getConfig() || {};
353
- this.store.setConfig(rest);
354
- this.config.quiescedAt = undefined;
355
- await this.store.flush();
356
- this.log(`inbox re-opened — @${this.config.handle} is taking mail again`);
357
- }
358
-
359
- // Lock the inbox to a gateway: the public loses Append, so nothing reaches
360
- // the pod except through the gateway's verify-at-the-door. Reversible with
361
- // openInbox (public-Append) — the one-call rollback.
362
- async lockInboxToGateway(gatewayWebId) {
363
- await this.remote.setAcl(this.urls.inbox, [], { appendAgents: [gatewayWebId] });
364
- this.log(`inbox locked to gateway ${gatewayWebId} — public delivery is refused`);
365
- }
366
-
367
- // Tell the fediverse the account lives somewhere else now. Well-behaved
368
- // servers migrate their followers to the target and stop delivering here.
369
- async publishMove(target) {
370
- const { urls } = this;
371
- const contacts = this.store.getContacts();
372
- const inboxes = [...new Set(contacts.followers.map(f => f.sharedInbox || f.inbox).filter(Boolean))];
373
- const at = new Date().toISOString();
374
- await this.deliverer.deliverToAll(inboxes, wire.moveActivity(urls, target, Date.parse(at)));
375
- this.config.movedTo = target; // so the republish below carries it
376
- this.store.setConfig({ ...this.store.getConfig(), movedTo: target, movedAt: at });
377
- await this.remote.putJson(urls.actor, wire.actorDoc({
378
- urls, handle: wire.publicHandle(this.config), name: this.config.name,
379
- publicKeyPem: this.publicKeyPem, movedTo: target, kind: this.config.kind,
380
- approveJoins: wire.followsNeedApproval(this.config),
381
- assertionKey: this.assertionKey,
382
- summary: this.config.summary || null, icon: this.config.icon || null,
383
- image: this.config.image || null, fields: this.config.fields || [],
384
- webId: this.remote.webId || null,
385
- aliases: this.config.aliases || [],
386
- }));
387
- await this.remote.setAcl(urls.actor, ['Read']);
388
- await this.store.flush();
389
- this.log(`moved to ${target}: Move sent to ${inboxes.length} inbox(es), actor now advertises movedTo`);
390
- return { inboxes: inboxes.length, target, movedAt: at };
391
- }
392
-
393
- // Anyone the pod says follows us that we have no record of, and never
394
- // deliberately removed. In the steady state there is nobody: the pod's list is
395
- // written from this one. They appear when the local half is BEHIND the pod —
396
- // a restored backup, a home copied off a dead machine — and republishing
397
- // blindly would delete them from the wire and, worse, stop delivering to them.
398
- //
399
- // Removals are what makes this safe to do unconditionally: `dropFollower`
400
- // records every unfollow, ejection and account deletion, so a returning name
401
- // is either genuinely still a follower or genuinely a mistake we made.
402
- async reconcileFollowers(contacts) {
403
- let published = [];
404
- try {
405
- published = (await this.readPublishedFollowers()) || []; // walks pages
406
- } catch { return 0; } // no list to reconcile against
407
-
408
- const known = new Set(contacts.followers.map(f => f.actor));
409
- const removed = new Set((contacts.removedFollowers || []).map(r => r.actor));
410
- const missing = published.filter(a => typeof a === 'string' && !known.has(a) && !removed.has(a));
411
- if (!missing.length) return 0;
412
-
413
- // An inbox is what delivery needs, and only the actor document has it — so
414
- // recovering a follower costs one fetch each. Capped: a list this wrong is
415
- // a restore, and the rest catch up on the next publish.
416
- let recovered = 0;
417
- for (const actor of missing.slice(0, 200)) {
418
- try {
419
- const res = await this.deliverer.signedFetch(actor, { headers: { accept: ACCEPT_AP } });
420
- if (!res.ok) continue;
421
- const doc = await res.json();
422
- if (!doc?.inbox) continue;
423
- contacts.followers.push({
424
- actor, inbox: doc.inbox, sharedInbox: doc.endpoints?.sharedInbox || null, recovered: true,
425
- // Said explicitly, because onUndo reads it: the pod publishes WHO
426
- // follows, never the id of the Follow that did it, so a recovered
427
- // record has nothing an Undo can be matched against and must not be
428
- // evictable by one naming anything at all.
429
- followId: null,
430
- });
431
- recovered++;
432
- } catch { /* unreachable now; it will be there next time */ }
433
- }
434
- if (recovered) {
435
- this.store.setContacts(contacts);
436
- this.log(`reconciled ${recovered} follower(s) the pod knew about and this machine did not `
437
- + '— a restored or copied state was behind');
438
- }
439
- return recovered;
440
- }
441
-
442
- // The same argument as reconcileFollowers, for the other published list. It
443
- // matters more: the outbox is the INDEX a statuses rebuild reads, so a
444
- // republish from a restored-and-behind machine would destroy the record of
445
- // everything this actor ever posted — and destroy it before anyone noticed
446
- // there was anything to recover.
447
- //
448
- // Safe for the same reason: `unrecordOutbox` leaves a tombstone, so an entry
449
- // the pod still carries is one this machine has not heard of, never one it
450
- // deliberately took back.
451
- async reconcileOutbox(outbox) {
452
- let published = [];
453
- try {
454
- published = await this.readPublishedOutbox() || [];
455
- } catch { return 0; }
456
- if (!Array.isArray(published) || !published.length) return 0;
457
-
458
- const idOf = (i) => (typeof i === 'string' ? i : i?.id || null);
459
- const known = new Set(outbox.map(idOf).filter(Boolean));
460
- const removed = new Set((this.store.read('outbox-removed.json', [])).map(r => r.id));
461
- const missing = published.filter((i) => {
462
- const id = idOf(i);
463
- return id && !known.has(id) && !removed.has(id);
464
- });
465
- if (!missing.length) return 0;
466
- // Newest first, like recordOutbox leaves it; the pod's copy is already in
467
- // that order, so appending the tail is enough to keep both sorted.
468
- outbox.push(...missing);
469
- this.store.write('outbox.json', outbox);
470
- this.log(`reconciled ${missing.length} outbox entr(ies) the pod carried and this machine did not`);
471
- return missing.length;
472
- }
473
-
474
- // Recover this actor's own posts from the pod's public face. The private half
475
- // lives on this machine now, so a restored backup or a replaced machine loses
476
- // statuses.json while the pod still serves every note. Followers already come
477
- // back; this is the other half of the same gap.
478
- //
479
- // The INDEX is ap/outbox, not the ap/notes/ listing, and that difference is
480
- // the safety argument. Deleting a post rewrites the outbox in one PUT, so an
481
- // entry still there is a post that still stands. The note DOCUMENT can outlive
482
- // its own deletion — deleteNote's `remote.delete` is a request that can fail —
483
- // so walking the container can bring back something its author took down.
484
- // `fromNotes` is for when you would rather have that than lose the post; it is
485
- // not the default, and it says so where it is offered.
486
- //
487
- // MERGE ONLY. A status this machine already holds is left exactly as it is:
488
- // it carries local facts — favourited, reblogged, the activities an Undo has
489
- // to name — that the pod knows nothing about. That is also what makes the
490
- // failure modes harmless: a listing that fails returns nothing, and nothing
491
- // is what an empty listing recovers.
492
- async rebuildStatuses({ fromNotes = false } = {}) {
493
- const { urls } = this;
494
- const ids = new Set();
495
- const boosts = [];
496
- let indexed = false;
497
-
498
- const published = await this.readPublishedOutbox().catch(() => null);
499
- if (published) {
500
- indexed = true;
501
- for (const item of published) {
502
- if (typeof item === 'string') { if (item.startsWith(urls.notes)) ids.add(item); }
503
- else if (item?.type === 'Announce') boosts.push(item);
504
- }
505
- }
506
- if (fromNotes) {
507
- // Three documents are published per post — the note, `-create` and
508
- // `-replies` — plus a `.keep`. Which is which is settled by reading the
509
- // document below, not by its name: a slug is a date and eight hex
510
- // characters and says nothing about what it holds.
511
- for (const child of await this.remote.listContainer(urls.notes).catch(() => [])) {
512
- if (/(-create|-replies|\/\.keep)$/.test(child.url)) continue;
513
- ids.add(child.url);
514
- }
515
- indexed = true;
516
- }
517
- if (!indexed) return { indexed: 0, recovered: 0, reblogs: 0, landed: false, why: 'the pod would not answer for its outbox' };
518
-
519
- const statuses = this.store.getStatuses();
520
- const have = new Set(statuses.map(s => s.noteId));
521
- const removed = new Set(this.store.read('outbox-removed.json', []).map(r => r.id));
522
- const recovered = [];
523
- // Capped per run: a long-lived actor's rebuild is otherwise one pod request
524
- // per post it has ever made, in one burst. What is left is picked up by
525
- // running it again — the merge is idempotent, so that is safe to repeat.
526
- let budget = REBUILD_MAX_PER_RUN;
527
- for (const id of ids) {
528
- if (budget <= 0) { this.log(`rebuild: stopping at ${REBUILD_MAX_PER_RUN} this run — run it again for the rest`); break; }
529
- if (have.has(id) || removed.has(id)) continue;
530
- budget--;
531
- const note = await this.remote.getJson(id).catch(() => null);
532
- if (note?.type !== 'Note' || note.id !== id || note.attributedTo !== urls.actor) continue;
533
- const attachments = wire.attachmentsOf(note);
534
- const mentions = (Array.isArray(note.tag) ? note.tag : [])
535
- .filter(t => t?.type === 'Mention' && t.href)
536
- .map(t => ({ href: t.href, name: t.name }));
537
- recovered.push({
538
- noteId: note.id, actor: urls.actor, content: note.content || '',
539
- published: note.published || null,
540
- ...(note.inReplyTo ? { inReplyTo: note.inReplyTo } : {}),
541
- kind: 'post', slug: note.id.slice(urls.notes.length),
542
- ...(attachments.length ? { attachments } : {}),
543
- ...(mentions.length ? { mentions } : {}),
544
- recovered: true,
545
- });
546
- }
547
-
548
- // A boost is only recoverable for a post we can name — the Announce carries
549
- // the activity a later Undo needs, but not the boosted post's text, which
550
- // belongs to whoever wrote it.
551
- const merged = [...statuses, ...recovered];
552
- let reblogs = 0;
553
- for (const act of boosts) {
554
- const object = typeof act.object === 'string' ? act.object : act.object?.id;
555
- const s = object && merged.find(x => x.noteId === object);
556
- if (!s || s.reblogged) continue;
557
- s.reblogged = true;
558
- s.announceActivity = act;
559
- reblogs++;
560
- }
561
- if (!recovered.length && !reblogs) return { indexed: ids.size, recovered: 0, reblogs: 0, landed: true };
562
-
563
- // Written whole and sorted, the way backfillStatuses does it: addStatus
564
- // unshifts and fires the streaming event, so a loop of it would arrive
565
- // backwards and push every recovered post at connected clients as new.
566
- merged.sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
567
- const kept = merged.slice(0, 1000);
568
- this.store.write('statuses.json', kept);
569
- const landed = await this.store.commit();
570
-
571
- // The RDF mirror is the other thing a lost machine took with it, and it is
572
- // what backfillStatuses reads on a fresh state. Best effort: a post in
573
- // statuses.json is already the recovery that matters.
574
- let rdf = 0;
575
- for (const s of recovered) {
576
- try {
577
- await this.local.writeNote('posts', s.slug, {
578
- noteId: s.noteId, actor: s.actor, published: s.published,
579
- content: s.content, inReplyTo: s.inReplyTo, attachments: s.attachments,
580
- });
581
- rdf++;
582
- } catch (e) { this.log(`rebuild: RDF for ${s.slug} not written (${e.message})`); }
583
- }
584
- this.log(`rebuilt ${recovered.length} post(s) and ${reblogs} boost(s) from the pod`
585
- + `${landed ? '' : ' — THE STATE WRITE DID NOT LAND'}`);
586
- return {
587
- indexed: ids.size, recovered: recovered.length, reblogs, rdf, landed,
588
- dropped: Math.max(0, merged.length - kept.length),
589
- };
590
- }
591
-
592
- // Publish the collections a change actually TOUCHED.
593
- //
594
- // Publishing all three on every follower event cost nine pod requests where
595
- // two do: two reconcile reads, three collection PUTs, and three ACL PUTs
596
- // whose bodies are a pure function of the WebID and the target URL and so
597
- // are byte-identical to the ones written at setup. A new follower does not
598
- // change what this actor follows, and it does not change the outbox.
599
- //
600
- // `acls` is true only on the default path, which is publishProfile: that is
601
- // where the public surface is built, and where verifyPublicSurface already
602
- // checks the world can read it.
603
- //
604
- // Reconciliation stays welded to the collection it guards. It is what stops a
605
- // restored-and-behind machine publishing a short list over the pod's longer
606
- // one — erasing followers it would then stop delivering to, and erasing the
607
- // outbox that `rebuild` reads as its index — so a narrowed publish still runs
608
- // the one belonging to whatever it is about to overwrite.
609
- // Publish only the pages that actually changed.
610
- //
611
- // `known` is what we last wrote, so a post rewrites the newest page and the
612
- // head and nothing else — where the flat collection rewrote the actor's whole
613
- // history on every post. A page gets its ACL when it is first created; the
614
- // container above it is owner-only, so it cannot be inherited.
615
- // `force` is for the caller that publishes BECAUSE the pod does not have what
616
- // the digests say it has. Without it a repair republish rewrote the head and
617
- // skipped every page — the digests still matched the local record — so the
618
- // head advertised a `first:` that 404s, readPublishedOutbox came back empty,
619
- // rebuildStatuses recovered nothing, and the whole thing logged success. That
620
- // happens to an actor with one post as surely as one with five thousand.
621
- async publishOutbox(outbox, { acls = false, force = false } = {}) {
622
- const { urls } = this;
623
- const seen = this.store.read('published.json', {});
624
- const { pages, index } = wire.outboxPaging(outbox, seen.outboxIndex || []);
625
- const before = force ? {} : (seen.outboxPages || {});
626
- const after = {};
627
- let wrote = 0;
628
-
629
- for (let i = 0; i < pages.length; i++) {
630
- const n = i + 1; // 1 = oldest
631
- const doc = wire.outboxPage(urls.outbox, n, pages[i]);
632
- const digest = crypto.createHash('sha256').update(JSON.stringify(doc)).digest('hex').slice(0, 16);
633
- after[n] = digest;
634
- if (before[n] === digest) continue; // sealed and unchanged
635
- await this.remote.putJson(wire.outboxPageId(urls.outbox, n), doc);
636
- if (!before[n] || acls) await this.remote.setAcl(wire.outboxPageId(urls.outbox, n), ['Read']);
637
- wrote++;
638
- }
639
- // Pages above the new count are orphans: the outbox shrank past them, and
640
- // left where they were they keep serving activities that have been taken
641
- // back. The head no longer points at them, so nothing walks to them — but
642
- // the URL is guessable and public.
643
- const stale = Object.keys(seen.outboxPages || {}).map(Number)
644
- .filter(n => Number.isFinite(n) && n > pages.length);
645
- for (const n of stale) {
646
- await this.remote.delete(wire.outboxPageId(urls.outbox, n)).catch(() => {});
647
- }
648
- // The head carries totalItems, so it moves whenever the outbox does. Four
649
- // lines, and constant however much you have posted.
650
- await this.remote.putJson(urls.outbox,
651
- wire.outboxHead(urls.outbox, outbox.length, pages.length));
652
- if (acls) await this.remote.setAcl(urls.outbox, ['Read']);
653
- this.store.write('published.json',
654
- { ...this.store.read('published.json', {}), outboxPages: after, outboxIndex: index });
655
- return wrote;
656
- }
657
-
658
- // Every activity in the published outbox, walking the pages. Also understands
659
- // the flat collection this used to write, so an actor published before paging
660
- // is still readable — which matters because rebuild reads this to recover
661
- // posts a lost machine no longer has.
662
- async readPublishedOutbox() {
663
- const head = await this.remote.getJson(this.urls.outbox).catch(() => null);
664
- if (!head) return null;
665
- if (Array.isArray(head.orderedItems) && head.orderedItems.length) return head.orderedItems;
666
- const items = [];
667
- let next = head.first;
668
- const seen = new Set();
669
- while (next && !seen.has(next) && items.length < 10_000) {
670
- seen.add(next);
671
- const page = await this.remote.getJson(next).catch(() => null);
672
- if (!page) break;
673
- items.push(...(page.orderedItems || []));
674
- next = page.next;
675
- }
676
- return items;
677
- }
678
-
679
- // The followers collection, paged like the outbox: a head that carries only
680
- // the count and the page bounds, and page documents holding the actor IRIs —
681
- // so a remote server reads a small head and walks pages instead of pulling one
682
- // document that grows without limit. Regenerated from the in-memory follow
683
- // graph: a follow extends the newest page and an unfollow leaves its page one
684
- // short (wire.pageItems), so only the pages that changed are rewritten.
685
- async publishFollowers(actors, { acls = false, force = false } = {}) {
686
- const { urls } = this;
687
- const seen = this.store.read('published.json', {});
688
- const { pages, index } = wire.followersPaging(actors, seen.followersIndex || []);
689
- const before = force ? {} : (seen.followersPages || {});
690
- const after = {};
691
-
692
- for (let i = 0; i < pages.length; i++) {
693
- const n = i + 1;
694
- const doc = wire.followersPage(urls.followers, n, pages[i], pages.length);
695
- const digest = crypto.createHash('sha256').update(JSON.stringify(doc)).digest('hex').slice(0, 16);
696
- after[n] = digest;
697
- if (before[n] === digest) continue; // unchanged page
698
- await this.remote.putJson(wire.followersPageId(urls.followers, n), doc);
699
- if (!before[n] || acls) await this.remote.setAcl(wire.followersPageId(urls.followers, n), ['Read']);
700
- }
701
- // Pages the collection shrank past would keep serving names that no longer
702
- // follow; the head stops pointing at them but the URL is guessable.
703
- const stale = Object.keys(seen.followersPages || {}).map(Number)
704
- .filter(n => Number.isFinite(n) && n > pages.length);
705
- for (const n of stale) {
706
- await this.remote.delete(wire.followersPageId(urls.followers, n)).catch(() => {});
707
- }
708
- await this.remote.putJson(urls.followers,
709
- wire.followersHead(urls.followers, actors.length, pages.length));
710
- if (acls) await this.remote.setAcl(urls.followers, ['Read']);
711
- this.store.write('published.json',
712
- { ...this.store.read('published.json', {}), followersPages: after, followersIndex: index });
713
- }
714
-
715
- // Every actor in the published followers collection, walking pages. Also reads
716
- // the flat collection this used to write, so an actor published before paging
717
- // still reconciles.
718
- async readPublishedFollowers() {
719
- const head = await this.remote.getJson(this.urls.followers).catch(() => null);
720
- if (!head) return null;
721
- if (Array.isArray(head.orderedItems) && head.orderedItems.length) return head.orderedItems;
722
- if (Array.isArray(head.items) && head.items.length) return head.items;
723
- const items = [];
724
- let next = head.first;
725
- const seen = new Set();
726
- while (next && !seen.has(next) && items.length < 100_000) {
727
- seen.add(next);
728
- const page = await this.remote.getJson(next).catch(() => null);
729
- if (!page) break;
730
- items.push(...(page.orderedItems || []));
731
- next = page.next;
732
- }
733
- return items;
734
- }
735
-
736
- async publishCollections(which = ALL_COLLECTIONS) {
737
- const { urls } = this;
738
- const contacts = this.store.getContacts();
739
- if (which.followers) {
740
- // Reconcile walks the published pages, so — like the outbox — pay for it
741
- // only when the local record of what is up there is missing (a restore or
742
- // a copied machine), not on every ordinary save.
743
- const knownF = this.store.read('published.json', {}).followersIndex;
744
- if (which.force || !Array.isArray(knownF)) await this.reconcileFollowers(contacts);
745
- // Bluesky-only members are not AP actors; the published collection
746
- // lists only what a remote server could dereference.
747
- const actors = contacts.followers.filter(f => !f.bsky).map(f => f.actor);
748
- await this.publishFollowers(actors, { acls: which.acls, force: which.force });
749
- }
750
- if (which.following) {
751
- await this.remote.putJson(urls.following,
752
- wire.orderedCollection(urls.following, contacts.following.filter(f => f.accepted).map(f => f.actor)));
753
- if (which.acls) await this.remote.setAcl(urls.following, ['Read']);
754
- }
755
- if (which.pending) await this.publishPending();
756
- if (which.blocked) await this.publishBlocked();
757
- if (which.outbox) {
758
- const outbox = this.store.read('outbox.json', []);
759
- // Reconcile reads every published page, which is the expensive part of a
760
- // profile save. It is worth paying only when we are about to write pages
761
- // we did not write: on a repair, or on a machine whose state no longer
762
- // records what it put up there — which is exactly the restored backup the
763
- // reconcile exists for. With an intact record our copy IS what the pod
764
- // has, and re-reading it to confirm that is a page walk for nothing.
765
- const known = this.store.read('published.json', {}).outboxIndex;
766
- if (which.force || !Array.isArray(known)) await this.reconcileOutbox(outbox);
767
- await this.publishOutbox(outbox, { acls: which.acls, force: which.force });
768
- }
769
- if (which.followers || which.following) await this.local.writeContacts(contacts);
770
- }
771
-
772
- // FEP-4ccd: the follows in limbo, as owner-only collections of the Follow
773
- // activities themselves. Inside the private container so its ACL is
774
- // inherited — and published only where that ACL provably holds, the same
775
- // bar private posts clear. A Bluesky-only request has no Follow activity a
776
- // remote server could ever act on, so it is not listed.
777
- async publishPending() {
778
- if (await this.privateReady() !== true) return;
779
- const { urls } = this;
780
- const contacts = this.store.getContacts();
781
- await this.remote.putJson(urls.pendingFollowers, wire.orderedCollection(
782
- urls.pendingFollowers,
783
- this.store.getRequests().filter(r => r.activity && !r.bsky).map(r => r.activity)));
784
- await this.remote.putJson(urls.pendingFollowing, wire.orderedCollection(
785
- urls.pendingFollowing,
786
- contacts.following.filter(f => !f.accepted && f.followActivity)
787
- .map(f => f.followActivity).reverse()));
788
- }
789
-
790
- // The gateway policy: a small PUBLIC document a keyless inbox gateway reads
791
- // to decide, at the edge, what to forward and what to drop. It carries only
792
- // public facts — the actor/followers URLs, the REAL pod inbox to forward to,
793
- // the accepted-following list (already public) and a mirror of the blocklist.
794
- // Publishing the blocklist here makes it public; that is a deliberate part of
795
- // running a gateway, surfaced to the operator in the admin UI.
796
- async publishGatewayPolicy() {
797
- const { urls } = this;
798
- const contacts = this.store.getContacts();
799
- const bl = this.store.getBlocklist();
800
- await this.remote.putJson(urls.home + 'ap/gateway-policy.json', {
801
- v: 1,
802
- actorUrl: urls.actor,
803
- followersUrl: urls.followers,
804
- followingUrl: urls.following,
805
- inboxUrl: urls.inbox, // the pod inbox the gateway forwards to
806
- notesPrefix: urls.notes,
807
- kind: this.config.kind || 'person',
808
- following: contacts.following.filter(f => f.accepted && !f.bsky).map(f => f.actor),
809
- blocklist: { domains: bl.domains || [], actors: bl.actors || [] },
810
- }, 'application/json');
811
- await this.remote.setAcl(urls.home + 'ap/gateway-policy.json', ['Read']);
812
- }
813
-
814
- // FEP-c648: the blocked actors, as an owner-only collection. Actors only —
815
- // domain blocks are ours, and the FEP does not carry them.
816
- async publishBlocked() {
817
- if (await this.privateReady() !== true) return;
818
- const { urls } = this;
819
- const actors = [...(this.store.getBlocklist().actors || [])].reverse();
820
- await this.remote.putJson(urls.blocked, wire.orderedCollection(urls.blocked, actors));
821
- }
822
-
823
- // The outbox is the public record of everything this actor has said, boosts
824
- // included. A Create goes in as its note id, which dereferences; an Announce
825
- // has only a fragment id, so the activity itself goes in the collection —
826
- // legal AS2, and what Mastodon serves.
827
- async recordOutbox(item) {
828
- const outbox = this.store.read('outbox.json', []);
829
- outbox.unshift(item);
830
- this.store.write('outbox.json', outbox);
831
- await this.publishOutbox(outbox);
832
- }
833
-
834
- // Taking something out of the outbox is a DECISION — a post deleted, a boost
835
- // undone. It leaves a mark for the same reason dropFollower does: the pod's
836
- // copy is rewritten right after, but a rewrite that fails would otherwise let
837
- // the next reconcile put the entry back. Bounded, like the follower one.
838
- async unrecordOutbox(matches) {
839
- const before = this.store.read('outbox.json', []);
840
- const outbox = before.filter(i => !matches(i));
841
- const gone = before.filter(i => matches(i))
842
- .map(i => (typeof i === 'string' ? i : i?.id)).filter(Boolean);
843
- if (gone.length) {
844
- const marks = this.store.read('outbox-removed.json', []).filter(r => !gone.includes(r.id));
845
- const at = new Date().toISOString();
846
- this.store.write('outbox-removed.json',
847
- [...marks, ...gone.map(id => ({ id, at }))].slice(-500));
848
- }
849
- this.store.write('outbox.json', outbox);
850
- await this.publishOutbox(outbox);
851
- }
852
-
853
- // Media container on the remote pod — public-Read like notes, created lazily
854
- // at first upload (idempotent; the flag only saves round-trips).
855
- async ensureMediaContainer() {
856
- if (this._mediaReady) return;
857
- await this.remote.putJson(this.urls.media + '.keep', { keep: true }, 'application/json');
858
- await this.remote.setAcl(this.urls.media, ['Read']);
859
- this._mediaReady = true;
860
- }
861
-
862
- // The owner-only container that followers-only and direct posts live in.
863
- // Its ACL is set once; every note under it inherits.
864
- async ensurePrivateContainer() {
865
- if (this._privateContainer) return;
866
- await this.remote.putJson(this.urls.privateNotes + '.keep', { keep: true }, 'application/json');
867
- await this.remote.setAcl(this.urls.privateNotes, []);
868
- this._privateContainer = true;
869
- }
870
-
871
- // Whether the pod actually enforces that ACL: a bare, unauthenticated read
872
- // of the private container's canary must be refused. Returns true, or the
873
- // reason private posts stay off. A definite answer is cached for the run; a
874
- // network failure is not, so a pod that was briefly unreachable is asked
875
- // again rather than refused forever.
876
- async privateReady() {
877
- if (this._privateVerdict !== undefined) return this._privateVerdict;
878
- try {
879
- await this.ensurePrivateContainer();
880
- // The probe is credential-free and bypasses RemotePod's url map, so a
881
- // fronted identity must map the advertised private container back to the
882
- // pod itself — the ACL we are testing is the pod's.
883
- const pn = this.urls.toPod ? this.urls.toPod(this.urls.privateNotes) : this.urls.privateNotes;
884
- const r = await (this.probeFetch || fetch)(pn + '.keep', { redirect: 'manual' });
885
- this._privateVerdict = (r.status === 401 || r.status === 403) ? true
886
- : `this pod serves private documents to strangers (HTTP ${r.status}) — private posts stay off it`;
887
- return this._privateVerdict;
888
- } catch (e) {
889
- return `could not verify that the pod protects private posts (${e.message})`;
890
- }
891
- }
892
-
893
- // Compose → wire note on remote pod + RDF truth locally + deliver Create.
894
- // Who this text mentions, resolved. A mention nobody can resolve stays plain
895
- // text rather than failing the post. The text itself decides: trim a handle
896
- // out and that person is not notified, which is what every fediverse client
897
- // leads people to expect. A Group named in the parent is the one thing
898
- // carried forward regardless — drop it and the group stops carrying the
899
- // thread.
900
- async _mentionsFor(content, inReplyTo) {
901
- const inText = new Set(wire.mentionsIn(content));
902
- const carried = inReplyTo
903
- ? (this.store.getStatuses().find(s => s.noteId === inReplyTo)?.mentions || [])
904
- .map(m => String(m.name || '').replace(/^@/, '')).filter(Boolean)
905
- : [];
906
- const mentions = [];
907
- for (const handle of [...new Set([...inText, ...carried])]) {
908
- if (!this.resolveMention) break;
909
- const doc = await this.resolveMention(handle).catch(() => null);
910
- if (!doc?.id) { this.log(`mention @${handle} did not resolve — left as text`); continue; }
911
- if (!inText.has(handle) && doc.type !== 'Group') continue; // author trimmed them out
912
- mentions.push({ handle, actor: doc.id, page: doc.url || null, inbox: doc.endpoints?.sharedInbox || doc.inbox });
913
- }
914
- return mentions;
915
- }
916
-
917
- async publishNote(content, { inReplyTo, attachments, visibility = 'public', spoilerText = null } = {}) {
918
- const { urls } = this;
919
- const priv = visibility === 'private' || visibility === 'direct';
920
- if (priv) {
921
- const ready = await this.privateReady();
922
- if (ready !== true) throw new Error(ready);
923
- }
924
- const published = new Date().toISOString();
925
- const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
926
- const mentions = await this._mentionsFor(content, inReplyTo);
927
- const note = wire.noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions,
928
- visibility, summary: spoilerText, container: priv ? urls.privateNotes : urls.notes });
929
-
930
- await this.remote.putJson(note.id, note);
931
- // Empty, but present: a dangling `replies` that 404s is worse than none.
932
- await this.remote.putJson(wire.repliesId(note.id), wire.collection(wire.repliesId(note.id), []));
933
- // The outbox is the PUBLIC index; a private or direct post is not in it.
934
- if (!priv) await this.recordOutbox(note.id);
935
-
936
- await this.local.writeNote('posts', slug, {
937
- noteId: note.id, actor: urls.actor, published, content: note.content, inReplyTo, attachments,
938
- });
939
- this.store.addStatus({
940
- noteId: note.id, actor: urls.actor, content: note.content, published, inReplyTo,
941
- kind: 'post', slug, text: content, visibility,
942
- ...(spoilerText ? { spoiler: spoilerText } : {}),
943
- ...(attachments?.length ? { attachments } : {}),
944
- ...(note.tag?.length ? { mentions: note.tag.map(t => ({ href: t.href, name: t.name })) } : {}),
945
- });
946
-
947
- const create = wire.createActivity(note, urls);
948
- // Published as its own document: a group that carries this post wraps the
949
- // whole activity, and the receiving server resolves it by fetching this id.
950
- // It inherits the notes container's public-Read acl:default.
951
- await this.remote.putJson(create.id, create);
952
- const contacts = this.store.getContacts();
953
- // A direct post goes to the people it names and to nobody else.
954
- const inboxes = [...new Set([
955
- ...(visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
956
- ...mentions.map(m => m.inbox),
957
- ].filter(Boolean))];
958
- await this.deliverer.deliverToAll(inboxes, create);
959
- this.log(`note published: ${note.id} → ${inboxes.length} inbox(es)`);
960
-
961
- // Bluesky mirror: PUBLIC posts only — unlisted, followers-only and direct
962
- // are never carried off the fediverse. A mirror failure never fails the
963
- // post; it is logged and recorded on the status for the admin page.
964
- if (visibility === 'public' && this.atproto?.connected()
965
- && this.store.getConfig()?.atproto?.crossPost) {
966
- try {
967
- const mirror = await this.atproto.crossPost(
968
- { text: content, published, attachments }, { noteUrl: note.id });
969
- this.store.updateStatus(note.id, { atproto: mirror });
970
- this.log(`cross-posted to bluesky: ${mirror.uri}${mirror.truncated ? ' (truncated, links back)' : ''}`);
971
- } catch (e) {
972
- this.store.updateStatus(note.id, { atproto: { error: e.message } });
973
- this.log(`bluesky cross-post failed: ${e.message}`);
974
- }
975
- }
976
- return note;
977
- }
978
-
979
- // --- polls ---------------------------------------------------------------
980
- //
981
- // A poll is published as a Question and answered by ordinary replies naming
982
- // an option, so the count is ours to keep and ours to republish. The roster
983
- // of who chose what lives in agent state (polls.VOTES_DOC); the tallies on
984
- // the status row and in the pod document are always derived from it.
985
-
986
- /**
987
- * Publish a poll. `options` is a list of choice titles, `multiple` lets a
988
- * voter pick more than one, and `expiresAt` is when voting stops.
989
- */
990
- async publishQuestion(content, { options = [], multiple = false, expiresAt = null,
991
- inReplyTo = undefined, visibility = 'public', spoilerText = null } = {}) {
992
- const { urls } = this;
993
- const priv = visibility === 'private' || visibility === 'direct';
994
- if (priv) {
995
- const ready = await this.privateReady();
996
- if (ready !== true) throw new Error(ready);
997
- }
998
- const titles = [].concat(options).map(o => String(o ?? '').trim()).filter(Boolean);
999
- if (titles.length < 2) throw new Error('a poll needs at least two options');
1000
- // Options are matched BY NAME when a vote arrives — that is the whole of
1001
- // the convention — so two options reading the same are one option that
1002
- // cannot be told apart.
1003
- if (new Set(titles).size !== titles.length) throw new Error('a poll’s options must differ from one another');
1004
- // Bounded here as well as at the client API, because the outbox is a
1005
- // second way in and an unbounded poll is a document a stranger sizes.
1006
- if (titles.length > polls.MAX_OPTIONS) throw new Error(`a poll takes at most ${polls.MAX_OPTIONS} options`);
1007
- if (titles.some(t => t.length > polls.MAX_OPTION_CHARS)) {
1008
- throw new Error(`a poll option is at most ${polls.MAX_OPTION_CHARS} characters`);
1009
- }
1010
- if (expiresAt) {
1011
- const ends = Date.parse(expiresAt);
1012
- if (!Number.isFinite(ends)) throw new Error('the closing time is not a date');
1013
- const seconds = (ends - Date.now()) / 1000;
1014
- if (seconds < polls.MIN_SECONDS || seconds > polls.MAX_SECONDS) {
1015
- throw new Error(`a poll runs between ${polls.MIN_SECONDS} and ${polls.MAX_SECONDS} seconds`);
1016
- }
1017
- }
1018
-
1019
- const published = new Date().toISOString();
1020
- const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
1021
- const mentions = await this._mentionsFor(content, inReplyTo);
1022
- const poll = {
1023
- multiple: !!multiple,
1024
- expiresAt: expiresAt || null,
1025
- closed: null,
1026
- options: titles.map(title => ({ title, votes: 0 })),
1027
- votersCount: 0,
1028
- // Resolved once, here: a tally rewrite must not cost a webfinger lookup
1029
- // per vote for people the poll named.
1030
- mentionInboxes: [...new Set(mentions.map(m => m.inbox).filter(Boolean))],
1031
- };
1032
- const question = wire.questionDoc({
1033
- urls, slug, content, published, inReplyTo, attachments: [], mentions,
1034
- visibility, summary: spoilerText,
1035
- container: priv ? urls.privateNotes : urls.notes,
1036
- options: poll.options, multiple: poll.multiple, endTime: poll.expiresAt, votersCount: 0,
1037
- });
1038
-
1039
- await this.remote.putJson(question.id, question);
1040
- // Empty, but present: a dangling `replies` that 404s is worse than none.
1041
- await this.remote.putJson(wire.repliesId(question.id), wire.collection(wire.repliesId(question.id), []));
1042
- if (!priv) await this.recordOutbox(question.id);
1043
- await this.local.writeNote('posts', slug, {
1044
- noteId: question.id, actor: urls.actor, published, content: question.content,
1045
- inReplyTo, attachments: [],
1046
- });
1047
- this.store.addStatus({
1048
- noteId: question.id, actor: urls.actor, content: question.content, published,
1049
- kind: 'post', slug, text: content, visibility, poll, inReplyTo,
1050
- ...(spoilerText ? { spoiler: spoilerText } : {}),
1051
- ...(question.tag?.length ? { mentions: question.tag.map(t => ({ href: t.href, name: t.name })) } : {}),
1052
- });
1053
-
1054
- const create = this._pollActivity('Create', question, wire.createActivityId(question.id));
1055
- await this.remote.putJson(create.id, create);
1056
- const contacts = this.store.getContacts();
1057
- const inboxes = [...new Set([
1058
- ...(visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
1059
- ...poll.mentionInboxes,
1060
- ].filter(Boolean))];
1061
- await this.deliverer.deliverToAll(inboxes, create);
1062
- this.log(`poll published: ${question.id} (${titles.length} options) → ${inboxes.length} inbox(es)`);
1063
- return question;
1064
- }
1065
-
1066
- // The Create or Update carrying a Question. The context is hoisted onto the
1067
- // activity and the embedded object keeps none: a nested @context is legal
1068
- // JSON-LD but not every server reads one, and votersCount is declared there.
1069
- _pollActivity(type, question, id) {
1070
- const { '@context': ctx, ...object } = question;
1071
- return {
1072
- '@context': ctx, id, type,
1073
- actor: this.urls.actor,
1074
- published: question.published,
1075
- to: question.to, cc: question.cc,
1076
- object,
1077
- };
1078
- }
1079
-
1080
- /**
1081
- * One vote on one of OUR polls, named by the option's title. Returns true
1082
- * when it counted — a second answer to a single-choice poll, an option we do
1083
- * not offer, or a poll that has closed all count for nothing.
1084
- */
1085
- async recordVote(questionId, actor, optionName) {
1086
- const s = this.store.getStatuses().find(x => x.noteId === questionId);
1087
- if (!s?.poll || s.kind !== 'post' || s.actor !== this.urls.actor) return false;
1088
- if (polls.pollClosed(s.poll)) return false;
1089
- const index = polls.optionIndex(s.poll, optionName);
1090
- if (index < 0) return false;
1091
- const all = this.store.read(polls.VOTES_DOC, {});
1092
- const { roster, changed } = polls.addVote(all[questionId] || {}, polls.voterKey(actor), index,
1093
- { multiple: !!s.poll.multiple });
1094
- if (!changed) return false;
1095
- this.store.write(polls.VOTES_DOC, { ...all, [questionId]: roster });
1096
- this.store.updateStatus(questionId, { poll: polls.withTally(s.poll, roster) });
1097
- this._pollDirty(questionId);
1098
- return true;
1099
- }
1100
-
1101
- // Open the rewrite window for a poll whose count moved. Already open is
1102
- // already enough: the whole point is that a burst costs one rewrite.
1103
- _pollDirty(questionId) {
1104
- if (this.pollTimers.has(questionId)) return;
1105
- const t = setTimeout(() => {
1106
- this.pollTimers.delete(questionId);
1107
- this.republishPoll(questionId).catch(e => this.log(`poll rewrite: ${e.message}`));
1108
- }, POLL_REWRITE_MS);
1109
- t.unref?.();
1110
- this.pollTimers.set(questionId, t);
1111
- }
1112
-
1113
- /**
1114
- * Write the poll's current count back to the pod and tell everyone who has
1115
- * it. `closing` stamps it shut, which is a one-way door.
1116
- */
1117
- async republishPoll(questionId, { closing = null } = {}) {
1118
- const s = this.store.getStatuses().find(x => x.noteId === questionId);
1119
- if (!s?.poll) return null;
1120
- const { urls } = this;
1121
- const roster = this.store.read(polls.VOTES_DOC, {})[questionId] || {};
1122
- // A shut poll whose roster has been retired keeps the counts on its row:
1123
- // deriving them from an empty roster would publish a poll nobody voted in.
1124
- const shut = closing || s.poll.closed;
1125
- const counted = shut && !Object.keys(roster).length ? s.poll : polls.withTally(s.poll, roster);
1126
- const poll = { ...counted, ...(closing ? { closed: closing } : {}) };
1127
- const container = String(s.noteId).startsWith(urls.privateNotes) ? urls.privateNotes : urls.notes;
1128
- const slug = s.slug || String(s.noteId).slice(container.length);
1129
- const mentions = (s.mentions || []).map(m => ({
1130
- handle: String(m.name || '').replace(/^@/, ''), actor: m.href, page: null, inbox: null,
1131
- }));
1132
- const question = wire.questionDoc({
1133
- urls, slug, content: s.text ?? '', published: s.published, inReplyTo: s.inReplyTo,
1134
- attachments: [], mentions,
1135
- visibility: s.visibility || 'public', summary: s.spoiler || null, container,
1136
- options: poll.options, multiple: !!poll.multiple, endTime: poll.expiresAt,
1137
- closed: poll.closed, votersCount: poll.votersCount || 0,
1138
- });
1139
- await this.remote.putJson(question.id, question);
1140
- // The Create is overwritten too, so a group's Announce resolves to the
1141
- // current count rather than to the one the poll opened with.
1142
- await this.remote.putJson(wire.createActivityId(question.id),
1143
- this._pollActivity('Create', question, wire.createActivityId(question.id)));
1144
- this.store.updateStatus(questionId, { poll });
1145
-
1146
- // Not `updated`: a changed count is not an edit, and stamping one would
1147
- // have every client show the poll as edited each time somebody voted.
1148
- //
1149
- // The Update is named after the STATE it carries rather than the moment it
1150
- // was sent. A clock only tells two rewrites apart when they fall in
1151
- // different milliseconds, and a receiving server that has seen an activity
1152
- // id drops the next one wearing it — which would quietly freeze the count.
1153
- // Naming the state means an id changes exactly when there is something new
1154
- // to say, and two sends of the same numbers are the duplicate they look
1155
- // like.
1156
- const stamp = crypto.createHash('sha256').update(JSON.stringify([
1157
- poll.options.map(o => o.votes || 0), poll.votersCount || 0, poll.closed || '',
1158
- ])).digest('hex').slice(0, 12);
1159
- const update = this._pollActivity('Update', question, `${question.id}#poll-${stamp}`);
1160
- const contacts = this.store.getContacts();
1161
- const inboxes = [...new Set([
1162
- ...(s.visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
1163
- ...(s.poll.mentionInboxes || []),
1164
- ].filter(Boolean))];
1165
- await this.deliverer.deliverToAll(inboxes, update);
1166
- this.log(`poll ${closing ? 'closed' : 'count published'}: ${question.id} → ${inboxes.length} inbox(es)`);
1167
- return poll;
1168
- }
1169
-
1170
- /**
1171
- * Shut any poll whose time is up. Called from the agent's sweep. Closing is
1172
- * recorded BEFORE the republish, so a failed republish cannot leave a poll
1173
- * open and collecting votes it has already refused.
1174
- */
1175
- async closeDuePolls(now = Date.now()) {
1176
- const due = this.store.getStatuses().filter(s => s.kind === 'post' && s.poll
1177
- && !s.poll.closed && s.poll.expiresAt && Date.parse(s.poll.expiresAt) <= now);
1178
- for (const s of due) {
1179
- const closed = new Date(Math.min(now, Date.parse(s.poll.expiresAt) || now)).toISOString();
1180
- clearTimeout(this.pollTimers.get(s.noteId));
1181
- this.pollTimers.delete(s.noteId);
1182
- this.store.updateStatus(s.noteId, { poll: { ...s.poll, closed } });
1183
- const done = await this.republishPoll(s.noteId, { closing: closed })
1184
- .catch(e => { this.log(`poll close ${s.noteId}: ${e.message}`); return null; });
1185
- // The roster only ever answered one question — has this person already
1186
- // voted — and a shut poll has stopped asking it. Dropping it keeps a
1187
- // document we serialize whole from carrying every poll's voters forever.
1188
- if (done) {
1189
- const all = this.store.read(polls.VOTES_DOC, {});
1190
- if (all[s.noteId]) {
1191
- delete all[s.noteId];
1192
- this.store.write(polls.VOTES_DOC, all);
1193
- }
1194
- }
1195
- }
1196
- return due.length;
1197
- }
1198
-
1199
- /** Stop the pending rewrite windows. Called at shutdown. */
1200
- stopPolls() {
1201
- for (const t of this.pollTimers.values()) clearTimeout(t);
1202
- this.pollTimers.clear();
1203
- }
1204
-
1205
- // The pinned posts, as the actor's featured collection — the one document a
1206
- // remote server reads when it shows this profile's pins.
1207
- async publishFeatured() {
1208
- const ids = this.store.getStatuses().filter(s => s.kind === 'post' && s.pinned).map(s => s.noteId);
1209
- await this.remote.putJson(this.urls.featured, wire.orderedCollection(this.urls.featured, ids));
1210
- await this.remote.setAcl(this.urls.featured, ['Read']);
1211
- return ids.length;
1212
- }
1213
-
1214
- // An edit keeps the note's id, slug and published time; `updated` is the
1215
- // edit's own stamp. The pod documents are overwritten in place — the Create
1216
- // too, so a group's Announce resolves to the edited text — and an Update
1217
- // goes everywhere the Create went.
1218
- async updateNote(s, { content, spoilerText = null, attachments = null } = {}) {
1219
- const { urls } = this;
1220
- const updated = new Date().toISOString();
1221
- const inText = new Set(wire.mentionsIn(content));
1222
- const mentions = [];
1223
- for (const handle of inText) {
1224
- if (!this.resolveMention) break;
1225
- const doc = await this.resolveMention(handle).catch(() => null);
1226
- if (!doc?.id) { this.log(`mention @${handle} did not resolve — left as text`); continue; }
1227
- mentions.push({ handle, actor: doc.id, page: doc.url || null, inbox: doc.endpoints?.sharedInbox || doc.inbox });
1228
- }
1229
- const atts = attachments ?? s.attachments ?? [];
1230
- // The note stays in the container its visibility put it in; a recovered
1231
- // post may carry no slug, but the note id already contains it.
1232
- const container = String(s.noteId).startsWith(urls.privateNotes) ? urls.privateNotes : urls.notes;
1233
- const slug = s.slug || String(s.noteId).slice(container.length);
1234
- const note = wire.noteDoc({
1235
- urls, slug, content, published: s.published, inReplyTo: s.inReplyTo,
1236
- attachments: atts, mentions, visibility: s.visibility || 'public',
1237
- summary: spoilerText, updated, container,
1238
- });
1239
- await this.remote.putJson(note.id, note);
1240
- await this.remote.putJson(wire.createActivityId(note.id), wire.createActivity(note, urls));
1241
- if (s.slug) {
1242
- await this.local.writeNote('posts', s.slug, {
1243
- noteId: note.id, actor: urls.actor, published: s.published, content: note.content,
1244
- inReplyTo: s.inReplyTo, attachments: atts,
1245
- });
1246
- }
1247
- const patched = this.store.updateStatus(s.noteId, {
1248
- content: note.content, text: content, editedAt: updated,
1249
- spoiler: spoilerText || undefined,
1250
- attachments: atts.length ? atts : undefined,
1251
- mentions: note.tag?.length ? note.tag.map(t => ({ href: t.href, name: t.name })) : undefined,
1252
- });
1253
- const update = wire.updateActivity(note, urls);
1254
- const contacts = this.store.getContacts();
1255
- const inboxes = [...new Set([
1256
- ...(s.visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
1257
- ...mentions.map(m => m.inbox),
1258
- ].filter(Boolean))];
1259
- await this.deliverer.deliverToAll(inboxes, update);
1260
- this.log(`note edited: ${note.id} → ${inboxes.length} inbox(es)`);
1261
- return patched;
1262
- }
1263
- }