fedipod-server 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. package/dist/claims.js +1 -1
  2. package/dist/handler.d.ts +1 -1
  3. package/dist/handler.js +35 -2
  4. package/dist/handler.jsonld +1 -1
  5. package/lib/admin.mjs +3 -3
  6. package/lib/c2s.mjs +27 -2
  7. package/lib/deliver.mjs +6 -0
  8. package/lib/embed.mjs +1 -0
  9. package/lib/export-collections.mjs +1 -1
  10. package/lib/front-core.mjs +272 -49
  11. package/lib/gateway-core.mjs +3 -7
  12. package/lib/httpsig.mjs +33 -2
  13. package/lib/import.mjs +1 -1
  14. package/lib/intake.mjs +357 -118
  15. package/lib/links.mjs +6 -32
  16. package/lib/mastoapi.mjs +449 -38
  17. package/lib/migrate.mjs +4 -18
  18. package/lib/pod/README.md +61 -0
  19. package/lib/pod/actor.mjs +96 -0
  20. package/lib/pod/collection.mjs +77 -0
  21. package/lib/pod/containers.mjs +136 -0
  22. package/lib/pod/discovery.mjs +46 -0
  23. package/lib/pod/featured.mjs +18 -0
  24. package/lib/pod/followers.mjs +24 -0
  25. package/lib/pod/following.mjs +10 -0
  26. package/lib/pod/http.mjs +74 -0
  27. package/lib/pod/inbox.mjs +144 -0
  28. package/lib/pod/links.mjs +35 -0
  29. package/lib/pod/media.mjs +27 -0
  30. package/lib/pod/notes.mjs +80 -0
  31. package/lib/pod/notifications.mjs +89 -0
  32. package/lib/pod/outbox.mjs +18 -0
  33. package/lib/pod/package.json +19 -0
  34. package/lib/pod/policy.mjs +58 -0
  35. package/lib/pod/private.mjs +19 -0
  36. package/lib/pod/root.mjs +63 -0
  37. package/lib/pod/state.mjs +39 -0
  38. package/lib/pod/transport.mjs +477 -0
  39. package/lib/pod/urls.mjs +71 -0
  40. package/lib/polls.mjs +105 -0
  41. package/lib/publisher.mjs +357 -188
  42. package/lib/remote.mjs +30 -395
  43. package/lib/social.mjs +20 -9
  44. package/lib/storage.mjs +2 -2
  45. package/lib/store.mjs +8 -1
  46. package/lib/tagfeed.mjs +19 -2
  47. package/lib/wire.mjs +87 -67
  48. package/package.json +1 -1
  49. package/run-agent.mjs +20 -39
  50. package/web/admin/admin.js +141 -74
  51. package/web/admin/bar.css +9 -13
  52. package/web/admin/bar.js +2 -2
  53. package/web/admin/client/client.js +1 -1
  54. package/web/admin/client/index.html +5 -6
  55. package/web/admin/index.html +53 -65
  56. package/web/admin/setup/index.html +2 -1
  57. package/web/admin/setup/setup.js +1 -1
  58. package/web/admin/tokens.css +56 -0
  59. package/web/app/README.md +77 -0
  60. package/web/app/admin-facade.mjs +549 -0
  61. package/web/app/agent.mjs +490 -0
  62. package/web/app/atproto-browser.mjs +86 -0
  63. package/web/app/boot.mjs +340 -0
  64. package/web/app/deliver-relay.mjs +72 -0
  65. package/web/app/dist/boot.js +34067 -0
  66. package/web/app/dist/boot.js.map +7 -0
  67. package/web/app/dist/sw.js +53745 -0
  68. package/web/app/dist/sw.js.map +7 -0
  69. package/web/app/fediacct-browser.mjs +146 -0
  70. package/web/app/idb-kv.mjs +35 -0
  71. package/web/app/index.html +203 -0
  72. package/web/app/index.html~ +152 -0
  73. package/web/app/keys-browser.mjs +46 -0
  74. package/web/app/keystore.mjs +99 -0
  75. package/web/app/oidc-session.mjs +189 -0
  76. package/web/app/pod-auth.mjs +248 -0
  77. package/web/app/pod-remote.mjs +84 -0
  78. package/web/app/shims/_globals.mjs +2 -0
  79. package/web/app/shims/fedify-sig.mjs +77 -0
  80. package/web/app/shims/node-crypto.mjs +113 -0
  81. package/web/app/shims/node-fs.mjs +17 -0
  82. package/web/app/shims/node-path.mjs +19 -0
  83. package/web/app/shims/node-url.mjs +7 -0
  84. package/web/app/shims/prelude.js +58 -0
  85. package/web/app/shims/safefetch.mjs +58 -0
  86. package/web/app/shims/web-push.mjs +14 -0
  87. package/web/app/signup.mjs +248 -0
  88. package/web/app/site/_headers +9 -0
  89. package/web/app/site/_redirects +21 -0
  90. package/web/app/site/admin/admin.js +1181 -0
  91. package/web/app/site/admin/bar.css +60 -0
  92. package/web/app/site/admin/bar.js +61 -0
  93. package/web/app/site/admin/client/client.js +66 -0
  94. package/web/app/site/admin/client/index.html +43 -0
  95. package/web/app/site/admin/index.html +556 -0
  96. package/web/app/site/admin/setup/index.html +191 -0
  97. package/web/app/site/admin/setup/setup.js +308 -0
  98. package/web/app/site/admin/tokens.css +56 -0
  99. package/web/app/site/admin/window.css +55 -0
  100. package/web/app/site/admin/window.js +136 -0
  101. package/web/app/site/app/404.html +40 -0
  102. package/web/app/site/app/_headers +2 -0
  103. package/web/app/site/app/apple-touch-icon.png +0 -0
  104. package/web/app/site/app/assets/ICONS-DIPJeU0C.js +2 -0
  105. package/web/app/site/app/assets/ICONS-DIPJeU0C.js.map +1 -0
  106. package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js +183 -0
  107. package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
  108. package/web/app/site/app/assets/Temml-CCjoacWt.woff2 +0 -0
  109. package/web/app/site/app/assets/__vite-browser-external-C7iut881.js +2 -0
  110. package/web/app/site/app/assets/__vite-browser-external-C7iut881.js.map +1 -0
  111. package/web/app/site/app/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
  112. package/web/app/site/app/assets/catch-up-O5q5C75Z.png +0 -0
  113. package/web/app/site/app/assets/chunk-aKtaBQYM.js +1 -0
  114. package/web/app/site/app/assets/compose-CFEx7wb4.js +2 -0
  115. package/web/app/site/app/assets/compose-CFEx7wb4.js.map +1 -0
  116. package/web/app/site/app/assets/compose-ZJN5xSb4.js +35 -0
  117. package/web/app/site/app/assets/compose-ZJN5xSb4.js.map +1 -0
  118. package/web/app/site/app/assets/debug.module-YFQgwnIr.js +52 -0
  119. package/web/app/site/app/assets/debug.module-YFQgwnIr.js.map +1 -0
  120. package/web/app/site/app/assets/dom-p1YcJ5Rw.js +2 -0
  121. package/web/app/site/app/assets/dom-p1YcJ5Rw.js.map +1 -0
  122. package/web/app/site/app/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
  123. package/web/app/site/app/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
  124. package/web/app/site/app/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
  125. package/web/app/site/app/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
  126. package/web/app/site/app/assets/icons/add-circle-line-WD0mwORD.js +1 -0
  127. package/web/app/site/app/assets/icons/alert-line-CpFUMf9s.js +1 -0
  128. package/web/app/site/app/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
  129. package/web/app/site/app/assets/icons/announcement-line-5nwaJFJm.js +1 -0
  130. package/web/app/site/app/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
  131. package/web/app/site/app/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
  132. package/web/app/site/app/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
  133. package/web/app/site/app/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
  134. package/web/app/site/app/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
  135. package/web/app/site/app/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
  136. package/web/app/site/app/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
  137. package/web/app/site/app/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
  138. package/web/app/site/app/assets/icons/at-line-CF66Bznk.js +1 -0
  139. package/web/app/site/app/assets/icons/attachment-line-BY8It-l9.js +1 -0
  140. package/web/app/site/app/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
  141. package/web/app/site/app/assets/icons/board-line-C0UWXyA6.js +1 -0
  142. package/web/app/site/app/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
  143. package/web/app/site/app/assets/icons/building-5-line-Dd59MzbO.js +1 -0
  144. package/web/app/site/app/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
  145. package/web/app/site/app/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
  146. package/web/app/site/app/assets/icons/calendar-month-line-vommts2C.js +1 -0
  147. package/web/app/site/app/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
  148. package/web/app/site/app/assets/icons/camera-line-Cv69MEei.js +1 -0
  149. package/web/app/site/app/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
  150. package/web/app/site/app/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
  151. package/web/app/site/app/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
  152. package/web/app/site/app/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
  153. package/web/app/site/app/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
  154. package/web/app/site/app/assets/icons/clipboard-line-BBp364gJ.js +1 -0
  155. package/web/app/site/app/assets/icons/close-circle-line-CICdiuGz.js +1 -0
  156. package/web/app/site/app/assets/icons/close-line-DYrFWcjv.js +1 -0
  157. package/web/app/site/app/assets/icons/cloud-line-tiQAbah8.js +1 -0
  158. package/web/app/site/app/assets/icons/code-line-fAl8iiV3.js +1 -0
  159. package/web/app/site/app/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
  160. package/web/app/site/app/assets/icons/copy-2-line-CLW69yF5.js +1 -0
  161. package/web/app/site/app/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
  162. package/web/app/site/app/assets/icons/document-3-line-DBGNhAg2.js +1 -0
  163. package/web/app/site/app/assets/icons/document-line-C7mb9sb7.js +1 -0
  164. package/web/app/site/app/assets/icons/down-line-BGqSSThd.js +1 -0
  165. package/web/app/site/app/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
  166. package/web/app/site/app/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
  167. package/web/app/site/app/assets/icons/exit-line-Dc07BS-x.js +1 -0
  168. package/web/app/site/app/assets/icons/external-link-line-BvpSkmjO.js +1 -0
  169. package/web/app/site/app/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
  170. package/web/app/site/app/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
  171. package/web/app/site/app/assets/icons/filter-2-line-B_mR2usy.js +1 -0
  172. package/web/app/site/app/assets/icons/filter-line-C_nm-3cU.js +1 -0
  173. package/web/app/site/app/assets/icons/flag-1-line-C2HB05I0.js +1 -0
  174. package/web/app/site/app/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
  175. package/web/app/site/app/assets/icons/formula-line-DLrt2d6j.js +1 -0
  176. package/web/app/site/app/assets/icons/grid-line-CgUmEikf.js +1 -0
  177. package/web/app/site/app/assets/icons/group-line-C5fnX0wU.js +1 -0
  178. package/web/app/site/app/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
  179. package/web/app/site/app/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
  180. package/web/app/site/app/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
  181. package/web/app/site/app/assets/icons/heart-line-CJ7K47J4.js +1 -0
  182. package/web/app/site/app/assets/icons/history-2-line-kWWvFzqv.js +1 -0
  183. package/web/app/site/app/assets/icons/history-line-bGQiwbTH.js +1 -0
  184. package/web/app/site/app/assets/icons/home-3-line-fRU1zeLG.js +1 -0
  185. package/web/app/site/app/assets/icons/information-line-iNk6-sOY.js +1 -0
  186. package/web/app/site/app/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
  187. package/web/app/site/app/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
  188. package/web/app/site/app/assets/icons/layout-5-line-B_znt5wO.js +1 -0
  189. package/web/app/site/app/assets/icons/left-line-eUw0tya4.js +1 -0
  190. package/web/app/site/app/assets/icons/lightning-line-DaKoDdix.js +1 -0
  191. package/web/app/site/app/assets/icons/link-2-line-DCqLmMA7.js +1 -0
  192. package/web/app/site/app/assets/icons/list-check-line-BAxosBh9.js +1 -0
  193. package/web/app/site/app/assets/icons/lock-line-CPoaXeUW.js +1 -0
  194. package/web/app/site/app/assets/icons/mail-line-B0P8aa1L.js +1 -0
  195. package/web/app/site/app/assets/icons/moon-line-CEqNAToP.js +1 -0
  196. package/web/app/site/app/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
  197. package/web/app/site/app/assets/icons/more-3-line-BIiVjYTz.js +1 -0
  198. package/web/app/site/app/assets/icons/notification-line-BZZq2Gtu.js +1 -0
  199. package/web/app/site/app/assets/icons/pencil-line-CKh8-A1v.js +1 -0
  200. package/web/app/site/app/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
  201. package/web/app/site/app/assets/icons/pin-line-DpTolyhs.js +1 -0
  202. package/web/app/site/app/assets/icons/play-fill-B0-G_8lX.js +1 -0
  203. package/web/app/site/app/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
  204. package/web/app/site/app/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
  205. package/web/app/site/app/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
  206. package/web/app/site/app/assets/icons/radar-line-CKyroTth.js +1 -0
  207. package/web/app/site/app/assets/icons/react-line-CoIKhhtK.js +1 -0
  208. package/web/app/site/app/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
  209. package/web/app/site/app/assets/icons/right-line-B3KWJaqc.js +1 -0
  210. package/web/app/site/app/assets/icons/rocket-line-4KZl537D.js +1 -0
  211. package/web/app/site/app/assets/icons/round-fill-C69NTrZ0.js +1 -0
  212. package/web/app/site/app/assets/icons/round-line-B9k_1uo-.js +1 -0
  213. package/web/app/site/app/assets/icons/route-line-B28ReHcE.js +1 -0
  214. package/web/app/site/app/assets/icons/rows-4-line-CrBniagM.js +1 -0
  215. package/web/app/site/app/assets/icons/scan-line-DSf74YdP.js +1 -0
  216. package/web/app/site/app/assets/icons/search-2-line-B6geIU1z.js +1 -0
  217. package/web/app/site/app/assets/icons/settings-3-line-azJwK47w.js +1 -0
  218. package/web/app/site/app/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
  219. package/web/app/site/app/assets/icons/share-2-line-D5MxWjrm.js +1 -0
  220. package/web/app/site/app/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
  221. package/web/app/site/app/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
  222. package/web/app/site/app/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
  223. package/web/app/site/app/assets/icons/time-line-BF3eet1v.js +1 -0
  224. package/web/app/site/app/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
  225. package/web/app/site/app/assets/icons/translate-line-BFWol2Ce.js +1 -0
  226. package/web/app/site/app/assets/icons/unlock-line-X_x5vwv3.js +1 -0
  227. package/web/app/site/app/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
  228. package/web/app/site/app/assets/icons/user-4-line-BVSynZCp.js +1 -0
  229. package/web/app/site/app/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
  230. package/web/app/site/app/assets/icons/user-add-line-BbXdHG4z.js +1 -0
  231. package/web/app/site/app/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
  232. package/web/app/site/app/assets/icons/user-follow-line-BXux0wSd.js +1 -0
  233. package/web/app/site/app/assets/icons/user-star-line-DdYlpHLU.js +1 -0
  234. package/web/app/site/app/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
  235. package/web/app/site/app/assets/icons/user-x-line-DIDgNlPH.js +1 -0
  236. package/web/app/site/app/assets/icons/volume-line-Bs390KQt.js +1 -0
  237. package/web/app/site/app/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
  238. package/web/app/site/app/assets/icons/walk-line-yNXfB-md.js +1 -0
  239. package/web/app/site/app/assets/icons/world-2-line-CenkGIwj.js +1 -0
  240. package/web/app/site/app/assets/icons/zoom-in-line-CaENUphf.js +1 -0
  241. package/web/app/site/app/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
  242. package/web/app/site/app/assets/instances-BNPptnn-.json +333 -0
  243. package/web/app/site/app/assets/locales/ar-SA-CfheRbCu.js +1 -0
  244. package/web/app/site/app/assets/locales/ca-ES-DHRW86Tz.js +1 -0
  245. package/web/app/site/app/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
  246. package/web/app/site/app/assets/locales/de-DE-ByRPMRFy.js +1 -0
  247. package/web/app/site/app/assets/locales/eo-UY-CPC7wLCS.js +1 -0
  248. package/web/app/site/app/assets/locales/es-ES-dJa0Fd9t.js +1 -0
  249. package/web/app/site/app/assets/locales/eu-ES-C3djlPR2.js +1 -0
  250. package/web/app/site/app/assets/locales/fa-IR-DTT490Ck.js +1 -0
  251. package/web/app/site/app/assets/locales/fi-FI-B23MCPuz.js +1 -0
  252. package/web/app/site/app/assets/locales/fr-FR-BcaQsO5C.js +1 -0
  253. package/web/app/site/app/assets/locales/gl-ES-BR-CVKBN.js +1 -0
  254. package/web/app/site/app/assets/locales/he-IL-CfheRbCu.js +1 -0
  255. package/web/app/site/app/assets/locales/hu-HU-CfheRbCu.js +1 -0
  256. package/web/app/site/app/assets/locales/it-IT-C1136UFV.js +1 -0
  257. package/web/app/site/app/assets/locales/ja-JP-CRZmRAhD.js +1 -0
  258. package/web/app/site/app/assets/locales/kab-Cz8xH_TT.js +1 -0
  259. package/web/app/site/app/assets/locales/ko-KR-CG9UiY4o.js +1 -0
  260. package/web/app/site/app/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
  261. package/web/app/site/app/assets/locales/nb-NO-CfheRbCu.js +1 -0
  262. package/web/app/site/app/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
  263. package/web/app/site/app/assets/locales/oc-FR-CfheRbCu.js +1 -0
  264. package/web/app/site/app/assets/locales/pl-PL-DkwGXf6P.js +1 -0
  265. package/web/app/site/app/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
  266. package/web/app/site/app/assets/locales/pt-BR-nGL0HdFS.js +1 -0
  267. package/web/app/site/app/assets/locales/pt-PT-Cp17aj2z.js +1 -0
  268. package/web/app/site/app/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
  269. package/web/app/site/app/assets/locales/th-TH-CfheRbCu.js +1 -0
  270. package/web/app/site/app/assets/locales/tok-CfheRbCu.js +1 -0
  271. package/web/app/site/app/assets/locales/tr-TR-CfheRbCu.js +1 -0
  272. package/web/app/site/app/assets/locales/uk-UA-C5E3dFBf.js +1 -0
  273. package/web/app/site/app/assets/locales/zh-CN-BHJi7_et.js +1 -0
  274. package/web/app/site/app/assets/locales/zh-TW-CfheRbCu.js +1 -0
  275. package/web/app/site/app/assets/main-BdqNbG-a.js +26 -0
  276. package/web/app/site/app/assets/main-BdqNbG-a.js.map +1 -0
  277. package/web/app/site/app/assets/mock-home-DGhR__He.js +2 -0
  278. package/web/app/site/app/assets/mock-home-DGhR__He.js.map +1 -0
  279. package/web/app/site/app/assets/multi-column-ROck0NVt.jpg +0 -0
  280. package/web/app/site/app/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
  281. package/web/app/site/app/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
  282. package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js +2 -0
  283. package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js.map +1 -0
  284. package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js +20 -0
  285. package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js.map +1 -0
  286. package/web/app/site/app/assets/style-B1iLPTPR.css +2 -0
  287. package/web/app/site/app/assets/temml-DuyIQMei.js +97 -0
  288. package/web/app/site/app/assets/temml-DuyIQMei.js.map +1 -0
  289. package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js +2 -0
  290. package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
  291. package/web/app/site/app/assets/year-in-posts-B2lywqdx.png +0 -0
  292. package/web/app/site/app/assets/year-in-posts-DJv_star.js +3 -0
  293. package/web/app/site/app/assets/year-in-posts-DJv_star.js.map +1 -0
  294. package/web/app/site/app/compose/index.html +20 -0
  295. package/web/app/site/app/favicon.ico +0 -0
  296. package/web/app/site/app/index.html +73 -0
  297. package/web/app/site/app/logo-192.png +0 -0
  298. package/web/app/site/app/logo-512.png +0 -0
  299. package/web/app/site/app/logo-badge-72.png +0 -0
  300. package/web/app/site/app/logo-maskable-512.png +0 -0
  301. package/web/app/site/app/logo-monochrome-512.png +0 -0
  302. package/web/app/site/app/logo-monochrome-maskable-512.png +0 -0
  303. package/web/app/site/app/manifest.webmanifest +1 -0
  304. package/web/app/site/app/og-image-2.jpg +0 -0
  305. package/web/app/site/app/og-image.png +0 -0
  306. package/web/app/site/app/robots.txt +2 -0
  307. package/web/app/site/app/version.json +1 -0
  308. package/web/app/site/boot.js +34067 -0
  309. package/web/app/site/index.html +203 -0
  310. package/web/app/site/sw.js +53745 -0
  311. package/web/app/sw-src.mjs +205 -0
  312. package/web/front/#new-account.html# +43 -0
  313. package/web/front/admin.html +1 -81
  314. package/web/front/admin.js +85 -0
  315. package/web/front/new-account.html +1 -9
  316. package/web/front/new-account.js +13 -0
  317. package/web/front/run.html +1 -92
  318. package/web/front/run.js +107 -0
  319. package/lib/podrdf.mjs +0 -132
package/lib/intake.mjs CHANGED
@@ -15,10 +15,14 @@
15
15
  // a poll every POLL_MS as fallback, plus a drain at startup.
16
16
 
17
17
  import * as $rdf from 'rdflib';
18
+ import * as podInbox from './pod/inbox.mjs';
19
+ import * as podNotifications from './pod/notifications.mjs';
20
+ import * as podNotes from './pod/notes.mjs';
18
21
  import { USER_AGENT } from './ua.mjs';
19
22
  import { PUBLIC } from './wire.mjs';
20
23
  import { HTTP_TIMEOUT_MS, readCapped } from './safefetch.mjs';
21
24
  import { linkTargets, REL } from './links.mjs';
25
+ import * as polls from './polls.mjs';
22
26
  import { dropFollower } from './store.mjs';
23
27
 
24
28
  const RDF = $rdf.Namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#');
@@ -95,10 +99,39 @@ const CONTENT_TYPES = new Set(['Note', 'Article', 'Question', 'Page', 'Video', '
95
99
  // several), each carrying the tally its author's server maintains.
96
100
  // Custom emojis ride the tag list; the images live at the author's server and
97
101
  // the client fetches them from there.
102
+ // Content, name and summary were capped; nothing else was. One remote Question
103
+ // with thousands of options, or thousands of emoji, mention or attachment
104
+ // entries, put megabytes into a single statuses.json row — a document rewritten
105
+ // whole on every change. These are display lists: past a few dozen, nothing can
106
+ // render them and nobody meant them to be rendered.
107
+ // http(s) only. An emoji, attachment or mention URL is written straight into
108
+ // the client's markup, so `javascript:` and `data:` have no business in one.
109
+ // The store has guarded avatars this way all along (safeUrl, lib/store.mjs);
110
+ // these three lists were simply never put through it.
111
+ const httpOnly = (u) => {
112
+ if (!u) return null;
113
+ try {
114
+ const p = new URL(String(u));
115
+ return (p.protocol === 'https:' || p.protocol === 'http:') ? String(u) : null;
116
+ } catch { return null; }
117
+ };
118
+
119
+ const MAX_MODQUEUE = 200;
120
+ const MAX_EMOJIS = 60;
121
+ const MAX_POLL_OPTIONS = 50;
122
+ const MAX_OPTION_CHARS = 200;
123
+ const MAX_MENTIONS = 60;
124
+ const MAX_URL_CHARS = 2048;
125
+
98
126
  function emojisOf(note) {
99
127
  return [].concat(note?.tag || [])
100
128
  .filter(t => t?.type === 'Emoji' && t.icon?.url && t.name)
101
- .map(t => ({ shortcode: String(t.name).replace(/^:|:$/g, ''), url: String(t.icon.url) }));
129
+ .slice(0, MAX_EMOJIS)
130
+ .map(t => ({
131
+ shortcode: String(t.name).replace(/^:|:$/g, '').slice(0, 64),
132
+ url: httpOnly(String(t.icon.url).slice(0, MAX_URL_CHARS)),
133
+ }))
134
+ .filter(e => e.url);
102
135
  }
103
136
 
104
137
  function pollOf(note) {
@@ -108,8 +141,8 @@ function pollOf(note) {
108
141
  multiple: !!note.anyOf,
109
142
  expiresAt: note.endTime || null,
110
143
  closed: !!note.closed,
111
- options: opts.map(o => ({
112
- title: String(o?.name ?? ''),
144
+ options: opts.slice(0, MAX_POLL_OPTIONS).map(o => ({
145
+ title: String(o?.name ?? '').slice(0, MAX_OPTION_CHARS),
113
146
  votes: Number(o?.replies?.totalItems) || 0,
114
147
  })),
115
148
  };
@@ -127,8 +160,77 @@ const isActorType = (t) => typesOf(t).some(x => ACTOR_TYPES.has(x));
127
160
  // stranger could have anything at all, of a type nothing here reads,
128
161
  // re-delivered to every follower over our signature.
129
162
  const FORWARDABLE = new Set(['Create', 'Update', 'Delete', 'Like', 'Announce', 'Undo']);
163
+ // Of those, the ones we may re-deliver to our own followers over our own
164
+ // signature (§7.1.2). Deliberately narrower than FORWARDABLE: these three are
165
+ // the ones whose object this drain fetched from the author's origin and checked
166
+ // before accepting. A Like, an Announce or an Undo is taken on the envelope's
167
+ // word alone — relaying one is signing for a claim nothing corroborated.
168
+ const FORWARD_TYPES = new Set(['Create', 'Update', 'Delete']);
169
+ // How many forwards one drain may send. A reply into a busy thread of ours is a
170
+ // handful; anything near this is a flood using us as an amplifier.
171
+ const MAX_FORWARDS_PER_DRAIN = 20;
130
172
  const ACCEPT_AP = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
131
173
 
174
+ // What is worth keeping of an activity we are filing rather than acting on.
175
+ //
176
+ // A dead letter, a moderation entry and a waiting follow request each used to
177
+ // hold the WHOLE delivered activity — up to the 512 KB item cap — in documents
178
+ // that are serialized and PUT whole on every change. A stranger could inflate
179
+ // deadletter.json to ~100 MB and requests.json to ~250 MB and make us rewrite
180
+ // them on every batch.
181
+ //
182
+ // Everything the consumers need survives: `acceptActivity` wraps this as the
183
+ // Accept's object (and {id, type, actor, object} IS the standard Follow shape),
184
+ // `applyModeration` reads only `object`, and a dead letter is read by a human
185
+ // who wants to know what arrived, not to replay it.
186
+ export function trimActivity(a) {
187
+ if (!a || typeof a !== 'object') return a ?? null;
188
+ const idOf = (v) => (typeof v === 'string' ? v : v?.id ?? null);
189
+ const out = {};
190
+ for (const k of ['id', 'type', 'actor', 'target']) {
191
+ const v = idOf(a[k]);
192
+ if (v) out[k] = String(v).slice(0, 2048);
193
+ }
194
+ const obj = idOf(a.object);
195
+ if (obj) out.object = String(obj).slice(0, 2048);
196
+ // A typed object with no id still says what it was — a Block of nobody, an
197
+ // Undo of a Follow — and that is the whole of what the queue reads.
198
+ else if (a.object && typeof a.object === 'object' && a.object.type) {
199
+ out.object = { type: String(a.object.type).slice(0, 64) };
200
+ }
201
+ return out;
202
+ }
203
+
204
+ // Same origin AND, where the origin holds more than one identity, the same
205
+ // identity within it.
206
+ //
207
+ // Origin alone is the right test when an origin is one person — the ordinary
208
+ // fediverse server, and a subdomain pod. It is the WRONG test on a path-based
209
+ // host, and above all on a multi-tenant front: every tenant of fedipod.net has
210
+ // ids under `https://fedipod.net/u/<name>/`, so origin-equality made every
211
+ // tenant able to vouch for every other. One tenant could publish posts
212
+ // attributed to another, Update a neighbour's post, or Delete it — and a
213
+ // receiving Mastodon would believe it for the same reason we did.
214
+ //
215
+ // So when both ids carry an identity prefix, the prefixes must match too. The
216
+ // two shapes that exist here are the front's `/u/<name>/` and a pod's own AP
217
+ // root (`…/<root>/ap/…`); anything else has no prefix and falls back to origin,
218
+ // which is what a plain remote server should be judged by.
219
+ function identityPrefix(u) {
220
+ const m = /^(https?:\/\/[^/]+\/u\/[^/]+\/)/u.exec(u);
221
+ if (m) return m[1];
222
+ const ap = /^(https?:\/\/[^/]+\/(?:[^/]+\/)*?)ap\//u.exec(u);
223
+ return ap ? ap[1] : null;
224
+ }
225
+
226
+ export function sameIdentity(a, b) {
227
+ if (!sameOrigin(a, b)) return false;
228
+ const pa = identityPrefix(String(a));
229
+ const pb = identityPrefix(String(b));
230
+ if (!pa || !pb) return true; // no prefix to compare: origin is the answer
231
+ return pa === pb;
232
+ }
233
+
132
234
  export function sameOrigin(a, b) {
133
235
  try { return new URL(a).origin === new URL(b).origin; } catch { return false; }
134
236
  }
@@ -148,13 +250,21 @@ export function sameOrigin(a, b) {
148
250
  // and two labels minimum so `.live` cannot pose as everyone's parent. Not a
149
251
  // public-suffix list — that is a dependency and a data file to keep current,
150
252
  // and the party this guards against is the pod you already chose to trust.
253
+ //
254
+ // `localhost` is the one single-label parent allowed, because it is the one that
255
+ // cannot be anybody else: it is reserved to the loopback interface (RFC 6761),
256
+ // so `alice.localhost` and `localhost` are the same machine by definition and
257
+ // there is no stranger for the rule to keep out. Without this a pod served from
258
+ // a subdomain of localhost — which is how a Solid server with subdomain pods
259
+ // runs on a developer's machine — refused its own socket and fell back to
260
+ // polling, so every delivery waited up to two minutes.
151
261
  export function sameSocketOrigin(socketUrl, podBase) {
152
262
  let s, p;
153
263
  try { s = new URL(socketUrl); p = new URL(podBase); } catch { return false; }
154
264
  if (s.protocol !== (p.protocol === 'https:' ? 'wss:' : 'ws:')) return false;
155
265
  if (s.host === p.host) return true;
156
266
  const parent = s.hostname.toLowerCase();
157
- return parent.split('.').length >= 2
267
+ return (parent.split('.').length >= 2 || parent === 'localhost')
158
268
  && s.port === p.port
159
269
  && p.hostname.toLowerCase().endsWith('.' + parent);
160
270
  }
@@ -185,16 +295,20 @@ export function authorOf(note, delivered = null) {
185
295
  .map(a => (typeof a === 'string' ? a : a?.id)).find(Boolean) || null;
186
296
  const author = claimed || delivered;
187
297
  if (!author) return null;
188
- return sameOrigin(note?.id, author) ? author : null;
298
+ // sameIdentity, not sameOrigin: on a multi-tenant front every tenant shares
299
+ // an origin, so origin-equality let any of them be credited with any other's
300
+ // post. See sameIdentity.
301
+ return sameIdentity(note?.id, author) ? author : null;
189
302
  }
190
303
 
191
304
  export class Intake {
192
- constructor({ config, urls, remote, local, store, deliverer, publisher, log = console.log, lease = null, archive = null, push = true, pollSeconds = null }) {
193
- Object.assign(this, { config, urls, remote, local, store, deliverer, publisher, log, lease, archive, push, pollSeconds });
305
+ constructor({ config, urls, remote, store, deliverer, publisher, log = console.log, lease = null, archive = null, push = true, pollSeconds = null }) {
306
+ Object.assign(this, { config, urls, remote, store, deliverer, publisher, log, lease, archive, push, pollSeconds });
194
307
  this.serial = Date.now();
195
308
  this.stopped = false;
196
309
  // (attempt counts are kept in pod state — see _bumpAttempt)
197
310
  this.lastDrain = null;
311
+ this._forwardBudget = MAX_FORWARDS_PER_DRAIN;
198
312
  this.lastDrainAtMs = 0;
199
313
  this.reconnectTries = 0;
200
314
  this.drainCooldownUntil = 0;
@@ -314,16 +428,8 @@ export class Intake {
314
428
  * pod that says nothing has always used.
315
429
  */
316
430
  async _storageDescriptionUrl() {
317
- try {
318
- const head = await fetch(this.urls.base, {
319
- method: 'HEAD',
320
- headers: { 'user-agent': USER_AGENT },
321
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
322
- });
323
- const [found] = linkTargets(head.headers.get('link'), REL.storageDescription, this.urls.base);
324
- if (found) return found;
325
- } catch { /* the well-known path below */ }
326
- return this.urls.base + '.well-known/solid';
431
+ return podNotifications.storageDescriptionUrl(this.urls.base,
432
+ { headers: { 'user-agent': USER_AGENT }, timeoutMs: HTTP_TIMEOUT_MS });
327
433
  }
328
434
 
329
435
  async _subscribeOnce() {
@@ -334,30 +440,16 @@ export class Intake {
334
440
  return;
335
441
  }
336
442
  const descUrl = await this._storageDescriptionUrl();
337
- const descRes = await fetch(descUrl, {
338
- headers: { accept: 'text/turtle', 'user-agent': USER_AGENT },
339
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
340
- });
341
- // The service description is RDF; ask rdflib which subject is the
342
- // WebSocketChannel2023 service rather than pattern-matching the document.
343
- const g = $rdf.graph();
344
- try { $rdf.parse(await readCapped(descRes), g, descUrl, 'text/turtle'); }
345
- catch (e) { this.wsState = 'unavailable'; this.log(`service description unparsable (${e.message}) — polling only`); return; }
346
- const channel = g.each(null, RDF('type'), NOTIFY('WebSocketChannel2023'), null)
347
- .map(n => n.value)
348
- .find(Boolean)
349
- || g.each(null, NOTIFY('channelType'), NOTIFY('WebSocketChannel2023'), null)
350
- .map(n => n.value).find(Boolean);
351
- if (!channel) { this.wsState = 'unavailable'; this.log('no WebSocketChannel2023 service — polling only'); return; }
352
- const sub = await this.remote.fetch(channel, {
353
- method: 'POST',
354
- headers: { 'content-type': 'application/ld+json' },
355
- body: JSON.stringify({
356
- '@context': ['https://www.w3.org/ns/solid/notification/v1'],
357
- type: 'http://www.w3.org/ns/solid/notifications#WebSocketChannel2023',
358
- topic: this.urls.inbox,
359
- }),
360
- });
443
+ const { channel, error } = await podNotifications.readWebSocketChannel(descUrl,
444
+ { headers: { 'user-agent': USER_AGENT }, timeoutMs: HTTP_TIMEOUT_MS });
445
+ if (!channel) { this.wsState = 'unavailable'; this.log(`${error} — polling only`); return; }
446
+ // The topic is a POD resource, and it travels in the BODY — so the url map
447
+ // RemotePod applies to the request line never reaches it. A fronted
448
+ // identity's inbox url names the front, which the pod cannot grant read on,
449
+ // and the subscription came back 403. A no-op when unfronted.
450
+ const topic = this.urls.toPod ? this.urls.toPod(this.urls.inbox) : this.urls.inbox;
451
+ const sub = await podNotifications.subscribeToInbox(this.remote,
452
+ { channelUrl: channel, podTopicUrl: topic });
361
453
  const body = await readCapped(sub).then(JSON.parse).catch(() => null);
362
454
  if (!body?.receiveFrom) {
363
455
  this.wsState = `subscribe-failed-${sub.status}`;
@@ -439,6 +531,9 @@ export class Intake {
439
531
  // invariant — commit() flushes whatever is pending either way — so a store
440
532
  // that does not implement it behaves exactly as before.
441
533
  this._inSweep = true;
534
+ // Fresh per sweep: the cap is on how much this drain may amplify, not a
535
+ // lifetime total (see _maybeForward).
536
+ this._forwardBudget = MAX_FORWARDS_PER_DRAIN;
442
537
  this.store.hold?.();
443
538
  this._draining = this._drainOnce().finally(async () => {
444
539
  this._inSweep = false;
@@ -475,7 +570,7 @@ export class Intake {
475
570
  async prune({ before, keepConcerning = false } = {}) {
476
571
  const cutoff = Date.parse(before);
477
572
  if (!Number.isFinite(cutoff)) throw new Error(`"${before}" is not a date`);
478
- const all = await this.remote.listContainer(this.urls.inbox);
573
+ const all = await podInbox.list(this.remote, this.urls);
479
574
  const older = all.filter(e => !e.url.endsWith('.keep')
480
575
  && e.modified && Date.parse(e.modified) < cutoff);
481
576
  const out = { considered: older.length, applied: 0, dropped: 0, discarded: 0, failed: 0 };
@@ -483,14 +578,14 @@ export class Intake {
483
578
  for (const item of older) {
484
579
  try {
485
580
  if (item.size > MAX_ITEM_BYTES) {
486
- await this.remote.delete(item.url); // unreadable by the drain either way
581
+ await podInbox.dropHandledItem(this.remote, item.url); // unreadable by the drain either way
487
582
  out.discarded++;
488
583
  } else {
489
- const res = await this.remote.fetch(item.url, { headers: { accept: '*/*' } });
490
584
  // Same rule as the drain: a read we could not make is not a Create to
491
- // be dropped. Let it count as failed and stay for the next pass.
492
- if (res.status >= 400 && res.status !== 404) throw new Error(`inbox item GET → ${res.status}`);
493
- const activity = res.status < 400 ? await res.json().catch(() => null) : null;
585
+ // be dropped. readItem throws on anything but 404, so it counts as
586
+ // failed and stays for the next pass.
587
+ const got = await podInbox.readItem(this.remote, item.url, { maxBytes: MAX_ITEM_BYTES, readCapped });
588
+ const activity = got.raw === null ? null : (() => { try { return JSON.parse(got.raw); } catch { return null; } })();
494
589
  // A Create is the content the owner just asked to be rid of. Anything
495
590
  // else changes state and is applied exactly as a drain would.
496
591
  if (keepConcerning) {
@@ -506,7 +601,7 @@ export class Intake {
506
601
  this.log(`state not written — stopping the prune with ${older.length - out.applied - out.dropped - out.discarded} left`);
507
602
  break;
508
603
  }
509
- await this.remote.delete(item.url);
604
+ await podInbox.dropHandledItem(this.remote, item.url);
510
605
  }
511
606
  this._clearAttempt(item.url);
512
607
  await new Promise(r => setTimeout(r, DELETE_GAP_MS));
@@ -589,7 +684,7 @@ export class Intake {
589
684
  this._pruneAttempts();
590
685
  let all;
591
686
  try {
592
- all = await this.remote.listContainer(this.urls.inbox);
687
+ all = await podInbox.list(this.remote, this.urls);
593
688
  this.drainFailures = 0;
594
689
  } catch (e) {
595
690
  this._backOff(`inbox unreadable (${e.message})`);
@@ -631,7 +726,7 @@ export class Intake {
631
726
  return false;
632
727
  }
633
728
  for (const url of pending.splice(0)) {
634
- if (!await this.remote.delete(url)) {
729
+ if (!await podInbox.dropHandledItem(this.remote, url)) {
635
730
  // Still in the mailbox. Handling is idempotent so seeing it again is
636
731
  // harmless, but counting it would clear the attempt record and report
637
732
  // progress that did not happen.
@@ -661,17 +756,14 @@ export class Intake {
661
756
  }
662
757
  let activity = null;
663
758
  try {
664
- const res = await this.remote.fetch(url, { headers: { accept: '*/*' } });
665
- // A pod that would not GIVE us the item has told us nothing about it.
666
- // Reading a 500 as an empty body made it "unparsable JSON", which is a
667
- // REJECTION: dead-lettered with both `activity` and `raw` null, and then
668
- // DELETEd a delivery destroyed by a transient fault, with no record of
669
- // what it was. Throwing puts it on the retry path the header promises.
670
- // 404 is the exception: the item is already gone, so deleting is right.
671
- if (res.status >= 400 && res.status !== 404) throw new Error(`inbox item GET → ${res.status}`);
672
- // Capped rather than res.text(): the listing's size is advisory, and the
673
- // inbox is public-Append, so the body is whatever a stranger chose.
674
- const raw = res.status < 400 ? await readCapped(res, MAX_ITEM_BYTES) : null;
759
+ const got = await podInbox.readItem(this.remote, url, { maxBytes: MAX_ITEM_BYTES, readCapped });
760
+ // readItem carries the rule that matters here: a pod that would not
761
+ // GIVE us the item has told us nothing about it, so anything but a 404
762
+ // throws rather than reading as an empty body. Reading a 500 as empty
763
+ // made it "unparsable JSON" a REJECTION, dead-lettered with both
764
+ // `activity` and `raw` null and then DELETEd, destroying a delivery on
765
+ // a transient fault with no record of what it had been.
766
+ const raw = got.raw;
675
767
  try { activity = raw ? JSON.parse(raw) : null; } catch { /* kept raw for the dead letter */ }
676
768
  // A gateway that verified this delivery left a receipt beside it. Read
677
769
  // it only when a gateway is configured (no config → no fetch, so an
@@ -684,7 +776,7 @@ export class Intake {
684
776
  if (!rejection) await this._maybeForward(activity); // §7.1.2, only what we accepted
685
777
  if (rejection) {
686
778
  this.store.addDeadLetter({
687
- inboxUrl: url, reason: rejection, activity,
779
+ inboxUrl: url, reason: rejection, activity: trimActivity(activity),
688
780
  ...(activity ? {} : { raw: raw?.slice(0, 2000) ?? null }),
689
781
  });
690
782
  this.log(`rejected (${rejection}) — dead-lettered: ${url}`);
@@ -694,7 +786,7 @@ export class Intake {
694
786
  const n = this._bumpAttempt(url, e.message);
695
787
  this.log(`inbox item ${url} attempt ${n}/${MAX_ITEM_ATTEMPTS}: ${e.message}`);
696
788
  if (n >= MAX_ITEM_ATTEMPTS) {
697
- this.store.addDeadLetter({ inboxUrl: url, reason: `failed ${n}x: ${e.message}`, activity });
789
+ this.store.addDeadLetter({ inboxUrl: url, reason: `failed ${n}x: ${e.message}`, activity: trimActivity(activity) });
698
790
  // The dead letter IS the record of this item — deleting before it is
699
791
  // written down would lose the only evidence it ever arrived, so it
700
792
  // goes through the same commit-then-delete batch as everything else.
@@ -759,6 +851,23 @@ export class Intake {
759
851
  let doc = null;
760
852
  try { doc = JSON.parse(await readCapped(res)); }
761
853
  catch (e) { this.log(`fetch ${url}: unreadable as JSON — ${e.message}`); return null; }
854
+ // A document is only evidence about its OWN origin.
855
+ //
856
+ // signedFetch follows redirects and hands back only the final response, and
857
+ // every caller checks `doc.id` against the URL it ASKED for — so an open
858
+ // redirector on a victim's origin let an attacker's server answer for a
859
+ // victim's URL and be believed by that check.
860
+ //
861
+ // Only a redirect that CROSSED AN ORIGIN is refused here. A same-origin
862
+ // redirect is ordinary (canonicalisation, trailing slashes), and a document
863
+ // fetched with no redirect at all is still returned whatever it claims —
864
+ // the callers reject it on id, and they say something more useful about it
865
+ // than this could ("actor id mismatch", "object not verifiable content").
866
+ const landed = res.finalUrl || url;
867
+ if (!sameOrigin(landed, url) && doc && doc.id && !sameOrigin(doc.id, landed)) {
868
+ this.log(`fetch ${url}: redirected to ${landed}, which is not where ${doc.id} lives — refused`);
869
+ return null;
870
+ }
762
871
  // Every actor type, not just Person. A Group was fetched, used and thrown
763
872
  // away, so nothing knew its preferredUsername — and a client rendering it
764
873
  // fell back to the last path segment of the actor URL, which is the literal
@@ -779,6 +888,8 @@ export class Intake {
779
888
  }
780
889
 
781
890
  sameOrigin(a, b) { return sameOrigin(a, b); }
891
+ // Overridable in tests the same way sameOrigin is.
892
+ sameIdentity(a, b) { return sameIdentity(a, b); }
782
893
 
783
894
  // Have we ever heard of this actor or object? Answered entirely from local
784
895
  // state, so asking costs nothing. It is what stops a stranger's Delete or
@@ -817,15 +928,26 @@ export class Intake {
817
928
  const secret = this.gatewaySecret();
818
929
  if (!secret) return null;
819
930
  try {
820
- const res = await this.remote.fetch(itemUrl + '.receipt.json', { headers: { accept: 'application/json' } });
821
- if (res.status >= 400) return null;
822
- const { readCapped } = await import('./safefetch.mjs');
823
- const receipt = JSON.parse(await readCapped(res, 64 * 1024));
931
+ const { readCapped: cap } = await import('./safefetch.mjs');
932
+ const receipt = await podInbox.readDeliveryReceipt(this.remote, itemUrl, { maxBytes: 64 * 1024, readCapped: cap });
933
+ if (!receipt) return null;
824
934
  const { verifyReceipt } = await import('./httpsig.mjs');
825
935
  return verifyReceipt(receipt, secret) ? receipt : null;
826
936
  } catch { return null; }
827
937
  }
828
938
 
939
+ // Whether a receipt says anything about THIS actor. Verified-and-about-someone
940
+ // -else is worth exactly as much as unverified, and is treated the same way:
941
+ // the drain's verify-by-dereference still stands behind it.
942
+ receiptVouchesFor(receipt, actor) {
943
+ if (!receipt?.verified) return false;
944
+ if (!receipt.actor || receipt.actor !== actor) return false;
945
+ // A missing keyId with verified:true cannot come from our own door
946
+ // (makeReceipt fills both from the same key), so refuse rather than waive.
947
+ if (!receipt.keyId || !sameOrigin(receipt.keyId, actor)) return false;
948
+ return true;
949
+ }
950
+
829
951
  async handle(activity, receipt = null) { // eslint-disable-line no-unused-vars
830
952
  const actor = typeof activity.actor === 'string' ? activity.actor : activity.actor?.id;
831
953
  if (!actor) return 'no actor';
@@ -838,6 +960,12 @@ export class Intake {
838
960
  if (!httpUrl(actor)) return `actor is not an http(s) URL (${actor})`;
839
961
  if (this.store.isBlocked(actor)) return `blocked sender (${actor})`;
840
962
 
963
+ // Whether the door vouched for this sender — read by the moderation queue
964
+ // just below as well as by the arms further down, so it is settled here,
965
+ // before its first use.
966
+ const trusted = this.receiptVouchesFor(receipt, actor)
967
+ && this.store.getConfig()?.gateway?.mode === 'trust';
968
+
841
969
  // FEP-1b12 moderation from a LISTED moderator. A delivery proves nothing
842
970
  // about its sender, which is why these are QUEUED for the operator rather
843
971
  // than run on arrival — the queue is where a claimed moderator's word
@@ -846,18 +974,26 @@ export class Intake {
846
974
  if (this.config.kind === 'group'
847
975
  && (this.config.moderators || []).includes(actor)
848
976
  && this.isModerationAsk(activity)) {
849
- return this.queueModeration(activity, actor);
977
+ return this.queueModeration(activity, actor, { trusted });
850
978
  }
851
979
 
852
- // A gateway receipt is trusted only in `trust` mode in `shadow` it is
853
- // measured but never acted on, which is what makes the shadow phase safe.
854
- const trusted = !!receipt?.verified && this.store.getConfig()?.gateway?.mode === 'trust';
980
+ // `trusted` was settled above, before the moderation queue reads it. Why it
981
+ // is not simply `receipt.verified`: that says the door checked a signature
982
+ // and the signature was good; it does NOT say whose. The door reports the
983
+ // signing key's owner separately, in `receipt.actor`, and nothing here used
984
+ // to read it — so ANY valid fediverse signing key, over an activity whose
985
+ // `actor` field named someone else entirely, arrived as trusted. In trust
986
+ // mode that is one delivery to evict any of your followers, or to have a
987
+ // Follow naming a third party auto-accepted. See receiptVouchesFor, which
988
+ // also holds the keyId to the actor's origin: a key document is fetched
989
+ // from wherever its id points, so one hosted elsewhere that merely CLAIMS
990
+ // `owner: <you>` would otherwise bind.
855
991
 
856
992
  switch (activity.type) {
857
993
  case 'Follow': return this.onFollow(activity, actor, { trusted });
858
994
  case 'Undo': return this.onUndo(activity, actor, { trusted });
859
995
  case 'Create': return this.onCreate(activity, actor);
860
- case 'Accept': return this.onAccept(activity, actor);
996
+ case 'Accept': return this.onAccept(activity, actor, { trusted });
861
997
  case 'Like': case 'Announce': {
862
998
  // FEP-1b12: a group Announces the member's whole Create, not the note.
863
999
  // Without unwrapping we try to ingest a Create as if it were a Note and
@@ -891,7 +1027,7 @@ export class Intake {
891
1027
  }
892
1028
  case 'Delete': return this.onDelete(activity, actor);
893
1029
  case 'Update': return this.onUpdate(activity, actor);
894
- case 'Reject': return this.onReject(activity, actor);
1030
+ case 'Reject': return this.onReject(activity, actor, { trusted });
895
1031
  case 'Move': return this.onMove(activity, actor);
896
1032
  case 'Add': case 'Remove': return this.onAddRemove(activity, actor);
897
1033
  default: this.log(`ignored ${activity.type} from ${actor}`);
@@ -923,17 +1059,42 @@ export class Intake {
923
1059
 
924
1060
  // Held, not run: one entry per distinct ask, capped, waiting for the
925
1061
  // operator to apply or dismiss it (social.applyModeration).
926
- queueModeration(activity, actor) {
1062
+ // A moderator's WORD, not their proof. `actor` is a field in an unsigned
1063
+ // body and a moderator's URL is public, so anyone can claim to be one — which
1064
+ // is exactly why these are QUEUED for the operator rather than run. What was
1065
+ // missing is that the queue did not say which is which, and a stranger could
1066
+ // fill all 200 slots and push the real asks out.
1067
+ //
1068
+ // So: the entry records whether the door vouched for the sender, and when the
1069
+ // queue is full the UNVERIFIED entries are what get dropped. A real
1070
+ // moderator's ask cannot be crowded out by someone impersonating them.
1071
+ queueModeration(activity, actor, { trusted = false } = {}) {
927
1072
  const q = this.store.read('modqueue.json', []);
928
1073
  const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
929
1074
  const key = [activity.type, actor, objectId || JSON.stringify(activity.object || null)].join(' ');
930
- if (q.some(e => e.key === key)) return;
1075
+ const seen = q.find(e => e.key === key);
1076
+ if (seen) {
1077
+ // The same ask arriving verified is worth more than the copy we hold.
1078
+ if (trusted && !seen.verified) {
1079
+ seen.verified = true;
1080
+ this.store.write('modqueue.json', q);
1081
+ }
1082
+ return;
1083
+ }
931
1084
  q.unshift({
932
1085
  key, id: (this.serial++).toString(36) + '-' + q.length,
933
- type: activity.type, moderator: actor, activity, at: new Date().toISOString(),
1086
+ type: activity.type, moderator: actor, activity: trimActivity(activity),
1087
+ verified: !!trusted, at: new Date().toISOString(),
934
1088
  });
935
- this.store.write('modqueue.json', q.slice(0, 200));
936
- this.log(`moderation queued from ${actor}: ${activity.type} ${objectId || ''}`);
1089
+ let kept = q;
1090
+ if (kept.length > MAX_MODQUEUE) {
1091
+ const verified = kept.filter(e => e.verified);
1092
+ const rest = kept.filter(e => !e.verified);
1093
+ // Verified first, then the newest unverified up to the cap.
1094
+ kept = [...verified, ...rest].slice(0, MAX_MODQUEUE);
1095
+ }
1096
+ this.store.write('modqueue.json', kept);
1097
+ this.log(`moderation queued from ${actor}${trusted ? '' : ' (unverified)'}: ${activity.type} ${objectId || ''}`);
937
1098
  }
938
1099
 
939
1100
  // §7.6 Add / §7.9 Remove. The side effect would be to add or remove the object
@@ -986,7 +1147,7 @@ export class Intake {
986
1147
  if (!reqs.some(r => r.actor === actor)) {
987
1148
  reqs.unshift({
988
1149
  actor, inbox: doc.inbox, sharedInbox: doc.endpoints?.sharedInbox,
989
- activity, at: new Date().toISOString(),
1150
+ activity: trimActivity(activity), at: new Date().toISOString(),
990
1151
  });
991
1152
  this.store.setRequests(reqs.slice(0, 500));
992
1153
  this.store.addNotification({ type: 'follow-request', actor });
@@ -1062,7 +1223,18 @@ export class Intake {
1062
1223
  // operator's queue forever asking about someone who left.
1063
1224
  if (!rec) {
1064
1225
  const reqs = this.store.getRequests();
1065
- if (reqs.some(r => r.actor === actor)) {
1226
+ const pending = reqs.find(r => r.actor === actor);
1227
+ if (pending) {
1228
+ // Bound the same way the follower eviction below is, and for the same
1229
+ // reason: `actor` is a field in an unsigned body, so without a match
1230
+ // anyone could withdraw anyone else's waiting request. The victim's
1231
+ // server believes the Follow is still pending and never resends, so
1232
+ // they simply never get followed and nobody sees why.
1233
+ const theirs = pending.activity?.id;
1234
+ if (!trusted && (!theirs || named !== theirs)) {
1235
+ this.log(`Undo from ${actor} does not name the request we hold — ignored`);
1236
+ return;
1237
+ }
1066
1238
  this.store.setRequests(reqs.filter(r => r.actor !== actor));
1067
1239
  await this.republish({ pending: true });
1068
1240
  this.log(`join request withdrawn: ${actor}`);
@@ -1155,6 +1327,21 @@ export class Intake {
1155
1327
  async _maybeForward(activity) {
1156
1328
  if (!activity || typeof activity !== 'object') return;
1157
1329
  if (!FORWARDABLE.has(activity.type)) return; // see FORWARDABLE
1330
+ // Forwarding signs somebody else's activity with OUR key and pushes it to
1331
+ // every follower we have. That is a lot to do on the word of an unsigned
1332
+ // POST, so it is narrowed three ways:
1333
+ //
1334
+ // 1. Only the types whose object this drain actually DEREFERENCED at the
1335
+ // author's own origin. A Create/Update/Delete went through onCreate /
1336
+ // onUpdate / onDelete, which fetch and check the author; a Like, an
1337
+ // Announce or an Undo is believed on the envelope alone, so relaying
1338
+ // one made us a signed relay for anything a stranger cared to write.
1339
+ // 2. The actor has to be someone we already know of. A complete stranger
1340
+ // addressing our followers collection is not a conversation we are
1341
+ // party to.
1342
+ // 3. A budget per drain, so a flood cannot turn one sweep into thousands
1343
+ // of outbound deliveries under our signature.
1344
+ if (!FORWARD_TYPES.has(activity.type)) return;
1158
1345
  try {
1159
1346
  const audience = []
1160
1347
  .concat(activity.to || [], activity.cc || [], activity.audience || [])
@@ -1163,6 +1350,14 @@ export class Intake {
1163
1350
  if (!this._referencesOurObject(activity)) return; // not into a thread of ours
1164
1351
  const actor = typeof activity.actor === 'string' ? activity.actor : activity.actor?.id;
1165
1352
  if (actor === this.urls.actor) return; // our own; nothing to forward
1353
+ if (!this.known(actor)) {
1354
+ this.log(`not forwarding ${activity.type} from ${actor}: nobody we know of`);
1355
+ return;
1356
+ }
1357
+ if (this._forwardBudget <= 0) {
1358
+ this.log(`not forwarding ${activity.type}: this drain's forwarding budget is spent`);
1359
+ return;
1360
+ }
1166
1361
  const id = typeof activity.id === 'string' ? activity.id : null;
1167
1362
  if (!id) return;
1168
1363
  const forwarded = this.store.read('forwarded.json', []);
@@ -1173,6 +1368,7 @@ export class Intake {
1173
1368
  .map(f => f.sharedInbox || f.inbox)
1174
1369
  .filter(Boolean))];
1175
1370
  if (!inboxes.length) return;
1371
+ this._forwardBudget -= 1;
1176
1372
  await this.deliverer.deliverToAll(inboxes, activity);
1177
1373
  this.store.write('forwarded.json', [...forwarded, id].slice(-MAX_FORWARDED));
1178
1374
  this.log(`forwarded ${activity.type} ${id} to ${inboxes.length} follower inbox(es)`);
@@ -1200,7 +1396,7 @@ export class Intake {
1200
1396
  const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
1201
1397
  if (!objectId) return 'Create without object id';
1202
1398
  if (this.store.isBlocked(objectId)) return `blocked domain (${objectId})`;
1203
- if (!this.sameOrigin(objectId, actor)) return `object/actor origin mismatch (${objectId})`;
1399
+ if (!this.sameIdentity(objectId, actor)) return `object/actor identity mismatch (${objectId})`;
1204
1400
  // The delivered copy is untrusted for CONTENT, but its addressing is
1205
1401
  // enough to decide whether to bother fetching the origin's copy.
1206
1402
  const envelope = typeof activity.object === 'object' ? { ...activity, ...activity.object } : activity;
@@ -1290,8 +1486,22 @@ export class Intake {
1290
1486
  // Wrap the member's own activity when we have it; a bare note URL is the
1291
1487
  // fallback, and renders as a plain boost rather than a group carry. The
1292
1488
  // group names itself as the audience (FEP-1b12).
1489
+ //
1490
+ // `activity` is the envelope as DELIVERED — a document the sender wrote,
1491
+ // which the group would otherwise re-sign and hand to every follower with
1492
+ // whatever addressing, tags and object body it carried. Only the note id was
1493
+ // ever verified (ingestNote fetched it from the author's origin and checked
1494
+ // the attribution), so only the note id is safe to pass on: send the bare
1495
+ // id unless the wrapper's own object id agrees with what we verified.
1496
+ const wrapperObject = (a) => {
1497
+ const inner = a?.object;
1498
+ const id = typeof inner === 'string' ? inner : inner?.id;
1499
+ return id === noteId ? a : null;
1500
+ };
1293
1501
  const act = announceActivity({
1294
- urls: this.urls, object: activity || held?.activity || noteId, serial: this.serial++,
1502
+ urls: this.urls,
1503
+ object: wrapperObject(activity) || wrapperObject(held?.activity) || noteId,
1504
+ serial: this.serial++,
1295
1505
  audience: this.urls.actor,
1296
1506
  });
1297
1507
  await this.deliverer.deliverToAll(inboxes, act);
@@ -1426,6 +1636,36 @@ export class Intake {
1426
1636
  // somebody else's boost, or through a hashtag feed.
1427
1637
  if (this.store.isBlocked(author)) return `blocked author (${author})`;
1428
1638
 
1639
+ // The ENVELOPE said this concerns us. The envelope is a document a stranger
1640
+ // wrote: onCreate reads addressing off the delivered copy to decide whether
1641
+ // fetching is worth it, and nothing re-asked the question of the copy that
1642
+ // came back from the author's own server. So anyone could take any public
1643
+ // post, address the delivery to us, and have it filed as "X mentioned you"
1644
+ // — or, to a group, have a member's post carried to every follower when the
1645
+ // member never sent it there.
1646
+ //
1647
+ // A boost is exempt (`via`): someone we follow deliberately putting a post
1648
+ // in front of us is the whole point, and the note will not address us.
1649
+ if (!via && !this.concernsUs(note, author)) {
1650
+ return `the note its own server serves does not address us (${objectId})`;
1651
+ }
1652
+
1653
+ // An answer to one of our polls is a number on a document, not a post. It
1654
+ // arrives as an ordinary reply naming an option and carrying nothing else,
1655
+ // so filing it as one would put a blank entry in the thread and ring the
1656
+ // owner once per voter. Counted or refused — a second answer, an option we
1657
+ // do not offer, a poll already shut — it stops here either way.
1658
+ const asked = note.inReplyTo && this.store.getStatuses()
1659
+ .find(x => x.noteId === String(note.inReplyTo) && x.kind === 'post' && x.poll);
1660
+ if (asked && polls.isVoteShape(note)) {
1661
+ const counted = await this.publisher.recordVote(asked.noteId, author, note.name)
1662
+ .catch(e => { this.log(`vote on ${asked.noteId}: ${e.message}`); return false; });
1663
+ this.log(counted
1664
+ ? `vote counted (${note.name}): ${asked.noteId}`
1665
+ : `vote not counted (${note.name}) from ${author}: ${asked.noteId}`);
1666
+ return;
1667
+ }
1668
+
1429
1669
  // Anyone can Append to a public inbox, so arriving is not the same as
1430
1670
  // belonging in the home timeline. Follow Mastodon's split: people you
1431
1671
  // follow (and their boosts) are HOME; anyone else is a MENTION — kept,
@@ -1445,24 +1685,13 @@ export class Intake {
1445
1685
  const followed = via || known || (!known && await this.isCoMember(author));
1446
1686
  const kind = followed ? 'timeline' : 'mention';
1447
1687
 
1448
- // `published` comes from a document at someone else's origin, and its first
1449
- // ten characters become the leading component of a storage path. A date is
1450
- // a date or it is not used: a value carrying `../` would otherwise be a
1451
- // remote party choosing where we write.
1452
- const day = String(note.published || '').slice(0, 10);
1453
- const slug = (/^\d{4}-\d{2}-\d{2}$/.test(day) ? day : new Date().toISOString().slice(0, 10)) + '-' +
1454
- (await import('node:crypto')).createHash('sha256').update(note.id).digest('hex').slice(0, 8);
1455
- if (kind === 'timeline') {
1456
- await this.local.writeNote('timeline', slug, {
1457
- noteId: note.id, actor: author, published: note.published, content,
1458
- inReplyTo: note.inReplyTo, attachments,
1459
- });
1460
- }
1461
1688
  // Mastodon carries a thread's mentions into every reply, which is the only
1462
1689
  // reason a reply ever reaches a group. Keep them so our composer can too.
1463
1690
  const mentions = [].concat(note.tag || [])
1464
1691
  .filter(t => t?.type === 'Mention' && t.href && t.name)
1465
- .map(t => ({ href: t.href, name: t.name }));
1692
+ .slice(0, MAX_MENTIONS)
1693
+ .map(t => ({ href: httpOnly(String(t.href).slice(0, MAX_URL_CHARS)), name: String(t.name).slice(0, 256) }))
1694
+ .filter(m => m.href);
1466
1695
  const emojis = emojisOf(note);
1467
1696
  const poll = pollOf(note);
1468
1697
  // Explicitly addressed, but to nobody public and to no followers
@@ -1485,7 +1714,6 @@ export class Intake {
1485
1714
  ...(poll ? { poll } : {}),
1486
1715
  ...(emojis.length ? { emojis } : {}),
1487
1716
  ...(mentions.length ? { mentions } : {}),
1488
- ...(kind === 'timeline' ? { slug } : {}),
1489
1717
  ...(attachments.length ? { attachments } : {}),
1490
1718
  ...(via ? { via } : {}),
1491
1719
  });
@@ -1524,7 +1752,7 @@ export class Intake {
1524
1752
  async onDelete(activity, actor) {
1525
1753
  const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
1526
1754
  if (!objectId) return 'Delete without object id';
1527
- if (!this.sameOrigin(objectId, actor)) return `Delete crosses origins (${objectId})`;
1755
+ if (!this.sameIdentity(objectId, actor)) return `Delete crosses identities (${objectId})`;
1528
1756
  // `objectId === actor` is NOT evidence we care: it is true of EVERY account
1529
1757
  // deletion, and Mastodon broadcasts those constantly. Taking it as known
1530
1758
  // meant a signed dereference to a stranger's server for each one.
@@ -1570,13 +1798,6 @@ export class Intake {
1570
1798
  if (s.announceActivity) {
1571
1799
  await this.retract(s.noteId, { collect }).catch(e => this.log(`retract: ${e.message}`));
1572
1800
  }
1573
- if (s.slug) {
1574
- // Said out loud rather than swallowed: the status row is about to go, so
1575
- // a failure here leaves the note document in the private tree with
1576
- // nothing indexing it, and no other record that it is there.
1577
- await this.local.delete(this.local.fedi + 'timeline/' + s.slug)
1578
- .catch(e => this.log(`forget ${s.noteId}: its RDF note could NOT be removed (${e.message})`));
1579
- }
1580
1801
  this.store.removeStatus(s.noteId);
1581
1802
  }
1582
1803
 
@@ -1607,7 +1828,7 @@ export class Intake {
1607
1828
  async onUpdate(activity, actor) {
1608
1829
  const objectId = typeof activity.object === 'string' ? activity.object : activity.object?.id;
1609
1830
  if (!objectId) return 'Update without object id';
1610
- if (!this.sameOrigin(objectId, actor)) return `Update crosses origins (${objectId})`;
1831
+ if (!this.sameIdentity(objectId, actor)) return `Update crosses identities (${objectId})`;
1611
1832
  if (objectId === actor) { // display name, avatar, bio
1612
1833
  // The same guard onDelete has, for the same reason: the inbox is
1613
1834
  // public-Append, so without it anyone can name any host and make us spend
@@ -1642,12 +1863,6 @@ export class Intake {
1642
1863
  poll: { ...freshPoll, voted: !!s.poll?.voted, ownVotes: s.poll?.ownVotes || [] },
1643
1864
  } : {}),
1644
1865
  });
1645
- if (s.slug) {
1646
- await this.local.writeNote('timeline', s.slug, {
1647
- noteId: note.id, actor: s.actor, published: note.published, content,
1648
- inReplyTo: note.inReplyTo, attachments,
1649
- }).catch(e => this.log(`rewrite ${s.slug}: ${e.message}`));
1650
- }
1651
1866
  this.log(`edited upstream: ${objectId}`);
1652
1867
  }
1653
1868
 
@@ -1673,11 +1888,11 @@ export class Intake {
1673
1888
  // collection, and rewriting on top of one erases every reply already
1674
1889
  // recorded. getJson returns null only for a genuine 404 — the document does
1675
1890
  // not exist yet — and throws otherwise, which the caller logs and retries.
1676
- const cur = await this.remote.getJson(url);
1891
+ const cur = await podNotes.readReplies(this.remote, url);
1677
1892
  const items = Array.isArray(cur?.items) ? cur.items : [];
1678
1893
  if (items.includes(replyId)) return;
1679
1894
  items.push(replyId);
1680
- await this.remote.putJson(url, collection(url, items.slice(-MAX_REPLIES_RECORDED)));
1895
+ await podNotes.writeReplies(this.remote, url, collection(url, items.slice(-MAX_REPLIES_RECORDED)));
1681
1896
  this.log(`reply recorded on ${parentId}`);
1682
1897
  }
1683
1898
 
@@ -1685,11 +1900,32 @@ export class Intake {
1685
1900
  // has recorded that we do not follow them; ours went on saying we did, and
1686
1901
  // published it — so the two disagreed permanently, and a retry would never
1687
1902
  // come because as far as they are concerned the question was answered.
1688
- async onReject(activity, actor) {
1903
+ //
1904
+ // It has to answer the Follow we actually SENT. Only the type was checked, so
1905
+ // one Append per account you follow — from anyone, naming no particular
1906
+ // follow — severed every one of them at once, and silently: their server
1907
+ // never hears about it, so nothing ever retries and nothing looks wrong until
1908
+ // the timeline goes quiet. `followActivity` is stored by followActor
1909
+ // (lib/social.mjs) for exactly this kind of comparison.
1910
+ async onReject(activity, actor, { trusted = false } = {}) {
1689
1911
  if (activity.object?.type && activity.object.type !== 'Follow') return;
1690
1912
  const contacts = this.store.getContacts();
1691
1913
  const rec = contacts.following.find(f => f.actor === actor);
1692
1914
  if (!rec) return; // nothing of ours to undo
1915
+ const named = typeof activity.object === 'string' ? activity.object : activity.object?.id;
1916
+ const ours = rec.followActivity?.id;
1917
+ // A gateway receipt bound to this actor is the other way to believe it —
1918
+ // the door checked a signature and said whose (see receiptVouchesFor).
1919
+ if (!trusted) {
1920
+ if (!ours) {
1921
+ this.log(`Reject from ${actor}: no follow id on record to match it against — ignored`);
1922
+ return;
1923
+ }
1924
+ if (named !== ours) {
1925
+ this.log(`Reject from ${actor} answers ${named || 'nothing'}, not the follow we sent — ignored`);
1926
+ return;
1927
+ }
1928
+ }
1693
1929
  contacts.following = contacts.following.filter(f => f.actor !== actor);
1694
1930
  this.store.setContacts(contacts);
1695
1931
  await this.republish({ following: true, pending: true });
@@ -1719,7 +1955,7 @@ export class Intake {
1719
1955
  this.log(`${actor} moved to ${target} — follow the new account to keep seeing them`);
1720
1956
  }
1721
1957
 
1722
- async onAccept(activity, actor) {
1958
+ async onAccept(activity, actor, { trusted = false } = {}) {
1723
1959
  const contacts = this.store.getContacts();
1724
1960
  const rec = contacts.following.find(f => f.actor === actor);
1725
1961
  // It has to answer the Follow we actually sent. followActor stores that
@@ -1728,8 +1964,11 @@ export class Intake {
1728
1964
  // including one answering a Follow we never made.
1729
1965
  const named = typeof activity.object === 'string' ? activity.object : activity.object?.id;
1730
1966
  const ours = rec?.followActivity?.id;
1731
- if (ours && named && named !== ours) {
1732
- this.log(`Accept from ${actor} answers ${named}, not the follow we sent ignored`);
1967
+ // `named &&` used to be part of this, so an Accept naming NOTHING sailed
1968
+ // past which is the easy one to send, and it marks a request to a locked
1969
+ // account as accepted when it is still sitting in their queue.
1970
+ if (ours && named !== ours && !trusted) {
1971
+ this.log(`Accept from ${actor} answers ${named || 'nothing'}, not the follow we sent — ignored`);
1733
1972
  return;
1734
1973
  }
1735
1974
  if (rec && !rec.accepted) {