fedipod 0.8.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 (276) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/architecture.svg +89 -0
  4. package/bin/fedipod.mjs +2303 -0
  5. package/cli.md +243 -0
  6. package/groups.md +85 -0
  7. package/gui.md +99 -0
  8. package/lib/account.mjs +87 -0
  9. package/lib/admin.mjs +1606 -0
  10. package/lib/atproto.mjs +310 -0
  11. package/lib/bskyfeed.mjs +166 -0
  12. package/lib/bskygroup.mjs +162 -0
  13. package/lib/c2s.mjs +305 -0
  14. package/lib/certs.mjs +265 -0
  15. package/lib/deliver.mjs +249 -0
  16. package/lib/directory.mjs +90 -0
  17. package/lib/embed.mjs +214 -0
  18. package/lib/export-collections.mjs +161 -0
  19. package/lib/front-core.mjs +322 -0
  20. package/lib/gateway-core.mjs +115 -0
  21. package/lib/guard.mjs +213 -0
  22. package/lib/home.mjs +161 -0
  23. package/lib/httpsig.mjs +109 -0
  24. package/lib/import.mjs +357 -0
  25. package/lib/intake.mjs +1710 -0
  26. package/lib/keys.mjs +139 -0
  27. package/lib/lease.mjs +190 -0
  28. package/lib/localapi.mjs +87 -0
  29. package/lib/mastoapi.mjs +1588 -0
  30. package/lib/migrate.mjs +127 -0
  31. package/lib/oidc-auth.mjs +53 -0
  32. package/lib/podrdf.mjs +132 -0
  33. package/lib/ports.mjs +27 -0
  34. package/lib/publisher.mjs +1003 -0
  35. package/lib/remote.mjs +303 -0
  36. package/lib/safefetch.mjs +235 -0
  37. package/lib/setup.mjs +262 -0
  38. package/lib/social.mjs +505 -0
  39. package/lib/storage.mjs +170 -0
  40. package/lib/store.mjs +470 -0
  41. package/lib/streaming.mjs +154 -0
  42. package/lib/tagfeed.mjs +158 -0
  43. package/lib/ua.mjs +29 -0
  44. package/lib/update.mjs +91 -0
  45. package/lib/webpush.mjs +98 -0
  46. package/lib/wire.mjs +762 -0
  47. package/package.json +60 -0
  48. package/phanpy/dist/404.html +40 -0
  49. package/phanpy/dist/_headers +2 -0
  50. package/phanpy/dist/apple-touch-icon.png +0 -0
  51. package/phanpy/dist/assets/ICONS-DIPJeU0C.js +2 -0
  52. package/phanpy/dist/assets/ICONS-DIPJeU0C.js.map +1 -0
  53. package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js +183 -0
  54. package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
  55. package/phanpy/dist/assets/Temml-CCjoacWt.woff2 +0 -0
  56. package/phanpy/dist/assets/__vite-browser-external-C7iut881.js +2 -0
  57. package/phanpy/dist/assets/__vite-browser-external-C7iut881.js.map +1 -0
  58. package/phanpy/dist/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
  59. package/phanpy/dist/assets/catch-up-O5q5C75Z.png +0 -0
  60. package/phanpy/dist/assets/chunk-aKtaBQYM.js +1 -0
  61. package/phanpy/dist/assets/compose-CFEx7wb4.js +2 -0
  62. package/phanpy/dist/assets/compose-CFEx7wb4.js.map +1 -0
  63. package/phanpy/dist/assets/compose-ZJN5xSb4.js +35 -0
  64. package/phanpy/dist/assets/compose-ZJN5xSb4.js.map +1 -0
  65. package/phanpy/dist/assets/debug.module-YFQgwnIr.js +52 -0
  66. package/phanpy/dist/assets/debug.module-YFQgwnIr.js.map +1 -0
  67. package/phanpy/dist/assets/dom-p1YcJ5Rw.js +2 -0
  68. package/phanpy/dist/assets/dom-p1YcJ5Rw.js.map +1 -0
  69. package/phanpy/dist/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
  70. package/phanpy/dist/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
  71. package/phanpy/dist/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
  72. package/phanpy/dist/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
  73. package/phanpy/dist/assets/icons/add-circle-line-WD0mwORD.js +1 -0
  74. package/phanpy/dist/assets/icons/alert-line-CpFUMf9s.js +1 -0
  75. package/phanpy/dist/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
  76. package/phanpy/dist/assets/icons/announcement-line-5nwaJFJm.js +1 -0
  77. package/phanpy/dist/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
  78. package/phanpy/dist/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
  79. package/phanpy/dist/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
  80. package/phanpy/dist/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
  81. package/phanpy/dist/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
  82. package/phanpy/dist/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
  83. package/phanpy/dist/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
  84. package/phanpy/dist/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
  85. package/phanpy/dist/assets/icons/at-line-CF66Bznk.js +1 -0
  86. package/phanpy/dist/assets/icons/attachment-line-BY8It-l9.js +1 -0
  87. package/phanpy/dist/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
  88. package/phanpy/dist/assets/icons/board-line-C0UWXyA6.js +1 -0
  89. package/phanpy/dist/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
  90. package/phanpy/dist/assets/icons/building-5-line-Dd59MzbO.js +1 -0
  91. package/phanpy/dist/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
  92. package/phanpy/dist/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
  93. package/phanpy/dist/assets/icons/calendar-month-line-vommts2C.js +1 -0
  94. package/phanpy/dist/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
  95. package/phanpy/dist/assets/icons/camera-line-Cv69MEei.js +1 -0
  96. package/phanpy/dist/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
  97. package/phanpy/dist/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
  98. package/phanpy/dist/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
  99. package/phanpy/dist/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
  100. package/phanpy/dist/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
  101. package/phanpy/dist/assets/icons/clipboard-line-BBp364gJ.js +1 -0
  102. package/phanpy/dist/assets/icons/close-circle-line-CICdiuGz.js +1 -0
  103. package/phanpy/dist/assets/icons/close-line-DYrFWcjv.js +1 -0
  104. package/phanpy/dist/assets/icons/cloud-line-tiQAbah8.js +1 -0
  105. package/phanpy/dist/assets/icons/code-line-fAl8iiV3.js +1 -0
  106. package/phanpy/dist/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
  107. package/phanpy/dist/assets/icons/copy-2-line-CLW69yF5.js +1 -0
  108. package/phanpy/dist/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
  109. package/phanpy/dist/assets/icons/document-3-line-DBGNhAg2.js +1 -0
  110. package/phanpy/dist/assets/icons/document-line-C7mb9sb7.js +1 -0
  111. package/phanpy/dist/assets/icons/down-line-BGqSSThd.js +1 -0
  112. package/phanpy/dist/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
  113. package/phanpy/dist/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
  114. package/phanpy/dist/assets/icons/exit-line-Dc07BS-x.js +1 -0
  115. package/phanpy/dist/assets/icons/external-link-line-BvpSkmjO.js +1 -0
  116. package/phanpy/dist/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
  117. package/phanpy/dist/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
  118. package/phanpy/dist/assets/icons/filter-2-line-B_mR2usy.js +1 -0
  119. package/phanpy/dist/assets/icons/filter-line-C_nm-3cU.js +1 -0
  120. package/phanpy/dist/assets/icons/flag-1-line-C2HB05I0.js +1 -0
  121. package/phanpy/dist/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
  122. package/phanpy/dist/assets/icons/formula-line-DLrt2d6j.js +1 -0
  123. package/phanpy/dist/assets/icons/grid-line-CgUmEikf.js +1 -0
  124. package/phanpy/dist/assets/icons/group-line-C5fnX0wU.js +1 -0
  125. package/phanpy/dist/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
  126. package/phanpy/dist/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
  127. package/phanpy/dist/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
  128. package/phanpy/dist/assets/icons/heart-line-CJ7K47J4.js +1 -0
  129. package/phanpy/dist/assets/icons/history-2-line-kWWvFzqv.js +1 -0
  130. package/phanpy/dist/assets/icons/history-line-bGQiwbTH.js +1 -0
  131. package/phanpy/dist/assets/icons/home-3-line-fRU1zeLG.js +1 -0
  132. package/phanpy/dist/assets/icons/information-line-iNk6-sOY.js +1 -0
  133. package/phanpy/dist/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
  134. package/phanpy/dist/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
  135. package/phanpy/dist/assets/icons/layout-5-line-B_znt5wO.js +1 -0
  136. package/phanpy/dist/assets/icons/left-line-eUw0tya4.js +1 -0
  137. package/phanpy/dist/assets/icons/lightning-line-DaKoDdix.js +1 -0
  138. package/phanpy/dist/assets/icons/link-2-line-DCqLmMA7.js +1 -0
  139. package/phanpy/dist/assets/icons/list-check-line-BAxosBh9.js +1 -0
  140. package/phanpy/dist/assets/icons/lock-line-CPoaXeUW.js +1 -0
  141. package/phanpy/dist/assets/icons/mail-line-B0P8aa1L.js +1 -0
  142. package/phanpy/dist/assets/icons/moon-line-CEqNAToP.js +1 -0
  143. package/phanpy/dist/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
  144. package/phanpy/dist/assets/icons/more-3-line-BIiVjYTz.js +1 -0
  145. package/phanpy/dist/assets/icons/notification-line-BZZq2Gtu.js +1 -0
  146. package/phanpy/dist/assets/icons/pencil-line-CKh8-A1v.js +1 -0
  147. package/phanpy/dist/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
  148. package/phanpy/dist/assets/icons/pin-line-DpTolyhs.js +1 -0
  149. package/phanpy/dist/assets/icons/play-fill-B0-G_8lX.js +1 -0
  150. package/phanpy/dist/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
  151. package/phanpy/dist/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
  152. package/phanpy/dist/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
  153. package/phanpy/dist/assets/icons/radar-line-CKyroTth.js +1 -0
  154. package/phanpy/dist/assets/icons/react-line-CoIKhhtK.js +1 -0
  155. package/phanpy/dist/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
  156. package/phanpy/dist/assets/icons/right-line-B3KWJaqc.js +1 -0
  157. package/phanpy/dist/assets/icons/rocket-line-4KZl537D.js +1 -0
  158. package/phanpy/dist/assets/icons/round-fill-C69NTrZ0.js +1 -0
  159. package/phanpy/dist/assets/icons/round-line-B9k_1uo-.js +1 -0
  160. package/phanpy/dist/assets/icons/route-line-B28ReHcE.js +1 -0
  161. package/phanpy/dist/assets/icons/rows-4-line-CrBniagM.js +1 -0
  162. package/phanpy/dist/assets/icons/scan-line-DSf74YdP.js +1 -0
  163. package/phanpy/dist/assets/icons/search-2-line-B6geIU1z.js +1 -0
  164. package/phanpy/dist/assets/icons/settings-3-line-azJwK47w.js +1 -0
  165. package/phanpy/dist/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
  166. package/phanpy/dist/assets/icons/share-2-line-D5MxWjrm.js +1 -0
  167. package/phanpy/dist/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
  168. package/phanpy/dist/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
  169. package/phanpy/dist/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
  170. package/phanpy/dist/assets/icons/time-line-BF3eet1v.js +1 -0
  171. package/phanpy/dist/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
  172. package/phanpy/dist/assets/icons/translate-line-BFWol2Ce.js +1 -0
  173. package/phanpy/dist/assets/icons/unlock-line-X_x5vwv3.js +1 -0
  174. package/phanpy/dist/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
  175. package/phanpy/dist/assets/icons/user-4-line-BVSynZCp.js +1 -0
  176. package/phanpy/dist/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
  177. package/phanpy/dist/assets/icons/user-add-line-BbXdHG4z.js +1 -0
  178. package/phanpy/dist/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
  179. package/phanpy/dist/assets/icons/user-follow-line-BXux0wSd.js +1 -0
  180. package/phanpy/dist/assets/icons/user-star-line-DdYlpHLU.js +1 -0
  181. package/phanpy/dist/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
  182. package/phanpy/dist/assets/icons/user-x-line-DIDgNlPH.js +1 -0
  183. package/phanpy/dist/assets/icons/volume-line-Bs390KQt.js +1 -0
  184. package/phanpy/dist/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
  185. package/phanpy/dist/assets/icons/walk-line-yNXfB-md.js +1 -0
  186. package/phanpy/dist/assets/icons/world-2-line-CenkGIwj.js +1 -0
  187. package/phanpy/dist/assets/icons/zoom-in-line-CaENUphf.js +1 -0
  188. package/phanpy/dist/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
  189. package/phanpy/dist/assets/instances-BNPptnn-.json +333 -0
  190. package/phanpy/dist/assets/locales/ar-SA-CfheRbCu.js +1 -0
  191. package/phanpy/dist/assets/locales/ca-ES-DHRW86Tz.js +1 -0
  192. package/phanpy/dist/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
  193. package/phanpy/dist/assets/locales/de-DE-ByRPMRFy.js +1 -0
  194. package/phanpy/dist/assets/locales/eo-UY-CPC7wLCS.js +1 -0
  195. package/phanpy/dist/assets/locales/es-ES-dJa0Fd9t.js +1 -0
  196. package/phanpy/dist/assets/locales/eu-ES-C3djlPR2.js +1 -0
  197. package/phanpy/dist/assets/locales/fa-IR-DTT490Ck.js +1 -0
  198. package/phanpy/dist/assets/locales/fi-FI-B23MCPuz.js +1 -0
  199. package/phanpy/dist/assets/locales/fr-FR-BcaQsO5C.js +1 -0
  200. package/phanpy/dist/assets/locales/gl-ES-BR-CVKBN.js +1 -0
  201. package/phanpy/dist/assets/locales/he-IL-CfheRbCu.js +1 -0
  202. package/phanpy/dist/assets/locales/hu-HU-CfheRbCu.js +1 -0
  203. package/phanpy/dist/assets/locales/it-IT-C1136UFV.js +1 -0
  204. package/phanpy/dist/assets/locales/ja-JP-CRZmRAhD.js +1 -0
  205. package/phanpy/dist/assets/locales/kab-Cz8xH_TT.js +1 -0
  206. package/phanpy/dist/assets/locales/ko-KR-CG9UiY4o.js +1 -0
  207. package/phanpy/dist/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
  208. package/phanpy/dist/assets/locales/nb-NO-CfheRbCu.js +1 -0
  209. package/phanpy/dist/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
  210. package/phanpy/dist/assets/locales/oc-FR-CfheRbCu.js +1 -0
  211. package/phanpy/dist/assets/locales/pl-PL-DkwGXf6P.js +1 -0
  212. package/phanpy/dist/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
  213. package/phanpy/dist/assets/locales/pt-BR-nGL0HdFS.js +1 -0
  214. package/phanpy/dist/assets/locales/pt-PT-Cp17aj2z.js +1 -0
  215. package/phanpy/dist/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
  216. package/phanpy/dist/assets/locales/th-TH-CfheRbCu.js +1 -0
  217. package/phanpy/dist/assets/locales/tok-CfheRbCu.js +1 -0
  218. package/phanpy/dist/assets/locales/tr-TR-CfheRbCu.js +1 -0
  219. package/phanpy/dist/assets/locales/uk-UA-C5E3dFBf.js +1 -0
  220. package/phanpy/dist/assets/locales/zh-CN-BHJi7_et.js +1 -0
  221. package/phanpy/dist/assets/locales/zh-TW-CfheRbCu.js +1 -0
  222. package/phanpy/dist/assets/main-BdqNbG-a.js +26 -0
  223. package/phanpy/dist/assets/main-BdqNbG-a.js.map +1 -0
  224. package/phanpy/dist/assets/mock-home-DGhR__He.js +2 -0
  225. package/phanpy/dist/assets/mock-home-DGhR__He.js.map +1 -0
  226. package/phanpy/dist/assets/multi-column-ROck0NVt.jpg +0 -0
  227. package/phanpy/dist/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
  228. package/phanpy/dist/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
  229. package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js +2 -0
  230. package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js.map +1 -0
  231. package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js +20 -0
  232. package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js.map +1 -0
  233. package/phanpy/dist/assets/style-B1iLPTPR.css +2 -0
  234. package/phanpy/dist/assets/temml-DuyIQMei.js +97 -0
  235. package/phanpy/dist/assets/temml-DuyIQMei.js.map +1 -0
  236. package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js +2 -0
  237. package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
  238. package/phanpy/dist/assets/year-in-posts-B2lywqdx.png +0 -0
  239. package/phanpy/dist/assets/year-in-posts-DJv_star.js +3 -0
  240. package/phanpy/dist/assets/year-in-posts-DJv_star.js.map +1 -0
  241. package/phanpy/dist/compose/index.html +20 -0
  242. package/phanpy/dist/favicon.ico +0 -0
  243. package/phanpy/dist/index.html +73 -0
  244. package/phanpy/dist/logo-192.png +0 -0
  245. package/phanpy/dist/logo-512.png +0 -0
  246. package/phanpy/dist/logo-badge-72.png +0 -0
  247. package/phanpy/dist/logo-maskable-512.png +0 -0
  248. package/phanpy/dist/logo-monochrome-512.png +0 -0
  249. package/phanpy/dist/logo-monochrome-maskable-512.png +0 -0
  250. package/phanpy/dist/manifest.webmanifest +1 -0
  251. package/phanpy/dist/og-image-2.jpg +0 -0
  252. package/phanpy/dist/og-image.png +0 -0
  253. package/phanpy/dist/robots.txt +2 -0
  254. package/phanpy/dist/sw.js +25 -0
  255. package/phanpy/dist/version.json +1 -0
  256. package/run-agent.mjs +805 -0
  257. package/scripts/build-dist.mjs +37 -0
  258. package/scripts/verify-ui.mjs +60 -0
  259. package/ui/README.md +23 -0
  260. package/vendor/gate.cjs +132 -0
  261. package/vendor/idp-grant.cjs +394 -0
  262. package/vendor/jfetch.cjs +16 -0
  263. package/web/admin/admin.js +958 -0
  264. package/web/admin/bar.css +64 -0
  265. package/web/admin/bar.js +61 -0
  266. package/web/admin/client/client.js +66 -0
  267. package/web/admin/client/index.html +42 -0
  268. package/web/admin/index.html +493 -0
  269. package/web/admin/setup/index.html +172 -0
  270. package/web/admin/setup/setup.js +268 -0
  271. package/web/admin/window.css +55 -0
  272. package/web/admin/window.js +136 -0
  273. package/web/front/install.sh +69 -0
  274. package/web/front/new-account.html +424 -0
  275. package/web/front/run.html +138 -0
  276. package/web/front/solid-client-authn.bundle.js +2 -0
package/lib/wire.mjs ADDED
@@ -0,0 +1,762 @@
1
+ // wire.mjs — builders for the opaque AS2/JSON wire documents the remote pod
2
+ // serves (see claude/plans/ap-pod-mapping.md, "Remote pod layout"). These are
3
+ // protocol documents, not our RDF.
4
+
5
+ export const AS_CTX = 'https://www.w3.org/ns/activitystreams';
6
+ export const SEC_CTX = 'https://w3id.org/security/v1';
7
+ export const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
8
+
9
+ // URLs of the actor's documents on the remote pod. Everything the agent owns
10
+ // nests under ONE top-level container (`root`) so the pod stays tidy and one
11
+ // pod could host several actors under different roots. Webfinger + host-meta
12
+ // stay at the pod root — fediverse discovery requires the host root.
13
+ // The AP URLs for an identity. Normally everything hangs off the pod home.
14
+ //
15
+ // When `publicBase` is given (a fronted identity, e.g.
16
+ // "https://fedipod.net/u/me/"), the ADVERTISED ids — the actor and everything
17
+ // under `ap/` a remote server sees — build on the fronted home instead, while
18
+ // the pod-side trees (`fediverse/`, `ap-state/`) and the write targets stay on
19
+ // the pod. `toPod`/`toPublic` map between the two spaces; `RemotePod` applies
20
+ // `toPod` at its one request choke point, so publisher/social/intake keep
21
+ // passing advertised ids and the writes still land on the pod. With no
22
+ // `publicBase` the returned object is byte-identical to before — the invariant
23
+ // the smoke suite pins.
24
+ export function apUrls(remotePod, root = 'activitypods-js/', { publicBase = null } = {}) {
25
+ const base = remotePod.endsWith('/') ? remotePod : remotePod + '/';
26
+ const home = base + (!root || root.endsWith('/') ? root : root + '/');
27
+ // The face a remote sees: the fronted home, or the pod home when unfronted.
28
+ const face = publicBase ? (publicBase.endsWith('/') ? publicBase : publicBase + '/') : home;
29
+ const urls = {
30
+ base, home,
31
+ webfinger: base + '.well-known/webfinger',
32
+ actor: face + 'ap/actor',
33
+ inbox: face + 'ap/inbox/',
34
+ outbox: face + 'ap/outbox',
35
+ followers: face + 'ap/followers',
36
+ following: face + 'ap/following',
37
+ notes: face + 'ap/notes/',
38
+ privateNotes: face + 'ap/private/',
39
+ featured: face + 'ap/featured',
40
+ // FEP-1b12: the moderator roster a recipient validates announced
41
+ // moderation against. Published only when moderators are configured.
42
+ moderators: face + 'ap/moderators',
43
+ // FEP-4ccd and FEP-c648: follows in limbo and the block list, as
44
+ // collections. They live in the private container so the owner-only ACL
45
+ // is inherited, not re-stated per document.
46
+ pendingFollowers: face + 'ap/private/pending-followers',
47
+ pendingFollowing: face + 'ap/private/pending-following',
48
+ blocked: face + 'ap/private/blocked',
49
+ profileHtml: face + 'ap/profile.html',
50
+ // Media stays on the pod even when fronted: attachment urls are not
51
+ // identity-checked by remotes, and proxying blobs would be pure cost.
52
+ media: home + 'ap/media/',
53
+ fediverse: home + 'fediverse/',
54
+ state: home + 'ap-state/',
55
+ };
56
+ if (publicBase) {
57
+ urls.podHome = home;
58
+ urls.publicHome = face;
59
+ urls.toPod = (u) => (typeof u === 'string' && u.startsWith(face) ? home + u.slice(face.length) : u);
60
+ urls.toPublic = (u) => (typeof u === 'string' && u.startsWith(home) ? face + u.slice(home.length) : u);
61
+ }
62
+ return urls;
63
+ }
64
+
65
+ // The host a handle would resolve through, or null when no handle can point
66
+ // here. @name@host is looked up at https://host/.well-known/webfinger, so only
67
+ // a pod that owns the root of its host can answer for one; a pod living at
68
+ // https://server/name/ may publish the document but nothing will ever ask.
69
+ export function webfingerHost(podUrl) {
70
+ const u = new URL(podUrl);
71
+ return u.pathname === '/' ? u.host : null;
72
+ }
73
+
74
+ // RFC 6415 host-meta: several fediverse implementations discover WebFinger
75
+ // through this LRDD template rather than hitting /.well-known/webfinger
76
+ // directly.
77
+ export function hostMeta(base) {
78
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">\n <Link rel="lrdd" template="${base}.well-known/webfinger?resource={uri}"/>\n</XRD>\n`;
79
+ }
80
+
81
+ export function jrd({ handle, host, actor }) {
82
+ return {
83
+ subject: `acct:${handle}@${host}`,
84
+ links: [{ rel: 'self', type: 'application/activity+json', href: actor }],
85
+ };
86
+ }
87
+
88
+ // `kind: 'group'` publishes a Group rather than a Person, which is what makes
89
+ // Mastodon and Lemmy treat the actor as a community you join.
90
+ export function actorDoc({ urls, handle, name, publicKeyPem, movedTo = null, kind = 'person',
91
+ approveJoins = false, summary = null, icon = null, image = null, fields = [],
92
+ webId = null, aliases = [], moderators = null, pendingFollowers = null, pendingFollowing = null,
93
+ blocked = null, inbox = null }) {
94
+ // manuallyApprovesFollowers is NOT in the base AS2 context, so it is declared
95
+ // inline exactly as Mastodon declares it — and only when we actually use it.
96
+ // It is what makes a client show "Request to follow" rather than "Follow" and
97
+ // then sit in Requested with no explanation.
98
+ // PropertyValue is the same case: schema.org's, declared the way Mastodon
99
+ // declares it, and only when there are fields to carry. `image` (the banner)
100
+ // and `attachment` are both plain AS2 and need no declaration.
101
+ const context = [AS_CTX, SEC_CTX];
102
+ // `featured` is Mastodon's term for the pinned-posts collection, declared
103
+ // inline exactly as Mastodon declares it.
104
+ context.push({ toot: 'http://joinmastodon.org/ns#', featured: { '@id': 'toot:featured', '@type': '@id' } });
105
+ if (approveJoins) context.push({ manuallyApprovesFollowers: 'as:manuallyApprovesFollowers' });
106
+ // FEP-4ccd and FEP-c648 terms, declared exactly as those FEPs declare them,
107
+ // and only when the collections are actually published.
108
+ if (pendingFollowers || pendingFollowing) {
109
+ context.push({
110
+ pdg: 'https://purl.archive.org/socialweb/pending#',
111
+ pendingFollowers: { '@id': 'pdg:pendingFollowers', '@type': '@id' },
112
+ pendingFollowing: { '@id': 'pdg:pendingFollowing', '@type': '@id' },
113
+ });
114
+ }
115
+ if (blocked) {
116
+ context.push({
117
+ bl: 'https://purl.archive.org/socialweb/blocked#',
118
+ blocked: { '@id': 'bl:blocked', '@type': '@id' },
119
+ });
120
+ }
121
+ // The actor's other identities: the account's WebID, plus any aliases —
122
+ // old accounts elsewhere whose servers check for their own URL here before
123
+ // they will send a Move. Declared inline exactly as Mastodon declares it.
124
+ if (webId || aliases.length) context.push({ alsoKnownAs: { '@id': 'as:alsoKnownAs', '@type': '@id' } });
125
+ if (fields.length) {
126
+ context.push({ schema: 'http://schema.org#', PropertyValue: 'schema:PropertyValue', value: 'schema:value' });
127
+ }
128
+ return {
129
+ '@context': context,
130
+ id: urls.actor,
131
+ type: kind === 'group' ? 'Group' : 'Person',
132
+ ...(approveJoins ? { manuallyApprovesFollowers: true } : {}),
133
+ ...(movedTo ? { movedTo } : {}),
134
+ ...(webId || aliases.length ? { alsoKnownAs: [...(webId ? [webId] : []), ...aliases] } : {}),
135
+ preferredUsername: handle,
136
+ name: name || handle,
137
+ // The bio, and the avatar. For a group, `summary` is where what the group
138
+ // is for goes — there is nowhere else for it.
139
+ ...(summary ? { summary: contentHtml(summary) } : {}),
140
+ ...(icon ? { icon: { type: 'Image', url: icon } } : {}),
141
+ // The banner behind the avatar, and the labelled rows a client shows under
142
+ // the bio — what Mastodon's profile editor calls Header picture and Extra
143
+ // fields. Both are what every other server publishes for the same thing.
144
+ ...(image ? { image: { type: 'Image', url: image } } : {}),
145
+ // Escaped, not run through contentHtml: a field value is one inline row,
146
+ // and wrapping it in <p> renders as a paragraph inside a table cell.
147
+ ...(fields.length ? {
148
+ attachment: fields.map(f => ({
149
+ type: 'PropertyValue',
150
+ name: String(f.name).replace(/[&<>]/g, c => HTML_ESCAPES[c]),
151
+ value: String(f.value).replace(/[&<>]/g, c => HTML_ESCAPES[c]),
152
+ })),
153
+ } : {}),
154
+ // The human page. Remote servers send profile clicks here, so it must be
155
+ // something a person can read — the pod root is not.
156
+ url: urls.profileHtml,
157
+ // Normally the pod inbox (which buffers with nothing running). When an
158
+ // inbox gateway is advertised, deliveries go to it instead — it verifies
159
+ // and forwards into this same pod inbox for the drain.
160
+ inbox: inbox || urls.inbox,
161
+ // Every Mastodon actor publishes one. With a single actor per pod ours is
162
+ // just the inbox, but its absence is the non-standard thing.
163
+ endpoints: { sharedInbox: inbox || urls.inbox },
164
+ outbox: urls.outbox,
165
+ // The pinned posts, as the collection other servers read when they show
166
+ // this profile. Mastodon's term, declared the way Mastodon declares it.
167
+ featured: urls.featured,
168
+ followers: urls.followers,
169
+ following: urls.following,
170
+ // FEP-1b12: attributedTo names the moderators collection; recipients
171
+ // validate a group's announced moderation against it.
172
+ ...(moderators ? { attributedTo: moderators } : {}),
173
+ ...(pendingFollowers ? { pendingFollowers } : {}),
174
+ ...(pendingFollowing ? { pendingFollowing } : {}),
175
+ ...(blocked ? { blocked } : {}),
176
+ publicKey: { id: urls.actor + '#main-key', owner: urls.actor, publicKeyPem },
177
+ };
178
+ }
179
+
180
+ // The human half of the actor: a page a browser can open, published beside
181
+ // the actor document. It says who this is and how to follow — the handle to
182
+ // paste, and the remote-follow control that hands the visitor to their own
183
+ // server's follow screen.
184
+ export function profilePageHtml({ name, address, summary = null, icon = null, kind = 'person' }) {
185
+ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, c => ({ ...HTML_ESCAPES, '"': '&quot;' }[c]));
186
+ const what = kind === 'group' ? 'a group' : 'an account';
187
+ return `<!doctype html>
188
+ <html lang="en">
189
+ <head>
190
+ <meta charset="utf-8">
191
+ <meta name="viewport" content="width=device-width, initial-scale=1">
192
+ <title>${esc(name)} (${esc(address)})</title>
193
+ <style>
194
+ :root { color-scheme: light dark; }
195
+ body { font: 112.5%/1.5 system-ui, sans-serif; max-width: 34rem; margin: 3rem auto; padding: 0 1rem; }
196
+ img.avatar { width: 6rem; height: 6rem; border-radius: 1rem; object-fit: cover; }
197
+ h1 { margin: .5rem 0 0; }
198
+ .address { font-size: 1.1rem; user-select: all; }
199
+ form { margin-top: 2rem; }
200
+ label { display: block; margin-bottom: .3rem; }
201
+ input { font: inherit; padding: .5rem; width: 14rem; max-width: 100%; }
202
+ button { font: inherit; padding: .5rem 1rem; }
203
+ .hint { color: #777; font-size: .9rem; }
204
+ .err { color: #b00020; }
205
+ .err:empty { display: none; }
206
+ @media (prefers-color-scheme: dark) {
207
+ .hint { color: #aaa; }
208
+ .err { color: #ff8a80; }
209
+ }
210
+ </style>
211
+ </head>
212
+ <body>
213
+ <main>
214
+ ${icon ? `<img class="avatar" src="${esc(icon)}" alt="">` : ''}
215
+ <h1>${esc(name)}</h1>
216
+ <p class="address">${esc(address)}</p>
217
+ ${summary ? `<div>${summary}</div>` : ''}
218
+ <p>This is ${what} on the fediverse. To follow it, paste the address above
219
+ into the search box of Mastodon or any fediverse app — or use the form.</p>
220
+ <form id="follow">
221
+ <label for="server">your server</label>
222
+ <input id="server" type="text" placeholder="mastodon.social" autocomplete="off"
223
+ required aria-describedby="follow-err">
224
+ <button type="submit">Follow</button>
225
+ <p class="err" id="follow-err" role="alert"></p>
226
+ </form>
227
+ <p class="hint">The form sends you to your own server's follow screen.</p>
228
+ </main>
229
+ <script>
230
+ document.getElementById('follow').addEventListener('submit', (ev) => {
231
+ ev.preventDefault();
232
+ const s = document.getElementById('server').value.trim().replace(/^https?:\\/\\//, '').replace(/\\/.*$/, '');
233
+ const err = document.getElementById('follow-err');
234
+ const addr = document.querySelector('.address').textContent.trim();
235
+ if (!s) { err.textContent = 'Enter your server, e.g. mastodon.social'; return; }
236
+ err.textContent = '';
237
+ location.href = 'https://' + s + '/authorize_interaction?uri=' + encodeURIComponent(addr);
238
+ });
239
+ </script>
240
+ </body>
241
+ </html>
242
+ `;
243
+ }
244
+
245
+ // Retirement. An abandoned pod keeps accepting deliveries into a container
246
+ // nobody will ever drain, and no remote server has any way to learn that. A
247
+ // Delete of the actor is the signal to drop us; the Tombstone left in its place
248
+ // answers anyone who dereferences the actor afterwards.
249
+ export function deleteActorActivity(urls, stamp) {
250
+ return {
251
+ '@context': AS_CTX,
252
+ id: `${urls.actor}#delete-${stamp}`,
253
+ type: 'Delete',
254
+ actor: urls.actor,
255
+ object: urls.actor,
256
+ to: ['https://www.w3.org/ns/activitystreams#Public'],
257
+ };
258
+ }
259
+
260
+ // Move is the fediverse's "this account lives elsewhere now": remote servers
261
+ // stop delivering here and migrate their followers to the target, while the old
262
+ // handle keeps resolving. movedTo on the actor is the durable half of it.
263
+ export function moveActivity(urls, target, stamp) {
264
+ return {
265
+ '@context': AS_CTX,
266
+ id: `${urls.actor}#move-${stamp}`,
267
+ type: 'Move',
268
+ actor: urls.actor,
269
+ object: urls.actor,
270
+ target,
271
+ to: ['https://www.w3.org/ns/activitystreams#Public'],
272
+ };
273
+ }
274
+
275
+ export function tombstoneDoc(urls, deletedAt, kind = 'person') {
276
+ return {
277
+ '@context': AS_CTX,
278
+ id: urls.actor,
279
+ type: 'Tombstone',
280
+ formerType: kind === 'group' ? 'Group' : 'Person',
281
+ deleted: deletedAt,
282
+ };
283
+ }
284
+
285
+ // A deleted note is left as a Tombstone at its own URL — kept fetchable — so a
286
+ // peer that dereferences it learns the post was DELETED rather than getting a
287
+ // bare 404 it cannot tell from "never existed" (§7.4).
288
+ export function noteTombstone(noteId, deletedAt) {
289
+ return {
290
+ '@context': AS_CTX,
291
+ id: noteId,
292
+ type: 'Tombstone',
293
+ formerType: 'Note',
294
+ deleted: deletedAt,
295
+ };
296
+ }
297
+
298
+ // A note's replies live in a document of their own, so a server that was never
299
+ // delivered a reply can still discover it by dereferencing this.
300
+ export function repliesId(noteId) { return noteId + '-replies'; }
301
+
302
+ export function collection(id, items) {
303
+ return {
304
+ '@context': AS_CTX,
305
+ id, type: 'Collection',
306
+ totalItems: items.length,
307
+ items,
308
+ };
309
+ }
310
+
311
+ export function orderedCollection(id, items) {
312
+ return {
313
+ '@context': AS_CTX,
314
+ id, type: 'OrderedCollection',
315
+ totalItems: items.length,
316
+ orderedItems: items,
317
+ };
318
+ }
319
+
320
+ // A PAGED outbox, the shape Mastodon serves.
321
+ //
322
+ // One document holding every activity ever posted has to be rewritten whole on
323
+ // every post, so the cost of saying something grows with everything you have
324
+ // already said. Pages fix that without dropping anything: a new post rewrites
325
+ // the newest page and a head that is four lines long, and every sealed page is
326
+ // written once and never touched again.
327
+ //
328
+ // Pages are numbered from the OLDEST end, and that is the load-bearing part.
329
+ // Number them from the newest and every page boundary shifts each time you
330
+ // post, which is the problem again with more requests.
331
+ export const OUTBOX_PAGE_SIZE = 20;
332
+ export const outboxPageId = (outboxId, n) => `${outboxId}-${n}`;
333
+
334
+ // How many pages `total` activities occupy. Always at least one, so an empty
335
+ // outbox still has somewhere to point and a reader always has a `first`.
336
+ export const outboxPageCount = (total) => Math.max(1, Math.ceil(total / OUTBOX_PAGE_SIZE));
337
+
338
+ // `first` is the NEWEST page and `next` walks backwards in time, which is what
339
+ // a client following an outbox expects.
340
+ // `pageCount` is passed by anyone using outboxPaging, because once a page can
341
+ // be SHORT the count is no longer ceil(total/20) — deriving it there would put
342
+ // `first` below the newest page and hide everything above it.
343
+ export function outboxHead(id, total, pageCount = outboxPageCount(total)) {
344
+ const pages = Math.max(1, pageCount);
345
+ return {
346
+ '@context': AS_CTX,
347
+ id, type: 'OrderedCollection',
348
+ totalItems: total,
349
+ first: outboxPageId(id, pages),
350
+ last: outboxPageId(id, 1),
351
+ };
352
+ }
353
+
354
+ // `items` is this page's slice, newest-first within the page. `next` points at
355
+ // the older page; a sealed page never needs rewriting, so it carries no `prev`
356
+ // — that would have to be written when the page AFTER it is created.
357
+ export function outboxPage(outboxId, n, items) {
358
+ return {
359
+ '@context': AS_CTX,
360
+ id: outboxPageId(outboxId, n),
361
+ type: 'OrderedCollectionPage',
362
+ partOf: outboxId,
363
+ orderedItems: items,
364
+ ...(n > 1 ? { next: outboxPageId(outboxId, n - 1) } : {}),
365
+ };
366
+ }
367
+
368
+ // The slices, oldest page first. `outbox` arrives newest-first (recordOutbox
369
+ // unshifts), and paging has to be anchored at the oldest end or appending one
370
+ // activity moves every boundary.
371
+ //
372
+ // Anchoring at the oldest end is right for appends and wrong for REMOVALS,
373
+ // which is why nothing calls this to publish any more — see outboxPaging.
374
+ export function outboxPages(outbox) {
375
+ const chron = [...outbox].reverse();
376
+ const out = [];
377
+ for (let i = 0; i < Math.max(chron.length, 1); i += OUTBOX_PAGE_SIZE) {
378
+ out.push(chron.slice(i, i + OUTBOX_PAGE_SIZE).reverse()); // newest-first within the page
379
+ }
380
+ return out;
381
+ }
382
+
383
+ export const outboxItemId = (i) => (typeof i === 'string' ? i : i?.id || null);
384
+
385
+ // Assign entries to pages, and KEEP the assignment.
386
+ //
387
+ // Deriving the pages from position — which is what outboxPages does — makes an
388
+ // append cheap, because only the newest page moves. It makes a REMOVAL cost the
389
+ // whole history: every entry after the hole shifts back one, so every page from
390
+ // there to the newest gets different contents, a different digest and a PUT.
391
+ // Taking one old post down rewrote all of them, and an inbound Delete{actor}
392
+ // paid that once per post the actor had.
393
+ //
394
+ // Holding the assignment instead means a removal touches the single page that
395
+ // held it and leaves that page one item short. A short sealed page is legal
396
+ // AS2 — the newest page is already partial — and `totalItems` on the head is
397
+ // still the true count.
398
+ //
399
+ // `index` is the assignment we last published, oldest page first, ids only.
400
+ // Ids it no longer contains have been removed; ids it does not know are new and
401
+ // extend the newest page, spilling into fresh ones. An absent index (a first
402
+ // publish, or a machine that lost its state) re-derives the same boundaries
403
+ // outboxPages would have given, so upgrading rewrites nothing.
404
+ // The stable-paging core, shared by the outbox and the followers collection.
405
+ // `order` is the ids to place, in the order new ones should fill pages; `byId`
406
+ // maps each id back to its item. Ids the `index` no longer contains are dropped
407
+ // (a removal leaves that page one short); ids not in the index are appended to
408
+ // the newest page. A middle hole stays a hole — never a re-slice.
409
+ export function pageItems({ order, byId, index = [], pageSize }) {
410
+ const next = (Array.isArray(index) ? index : [])
411
+ .map(ids => (Array.isArray(ids) ? ids.filter(id => byId.has(id)) : []));
412
+ const placed = new Set(next.flat());
413
+ for (const id of order) {
414
+ if (id == null || placed.has(id)) continue;
415
+ placed.add(id);
416
+ const last = next[next.length - 1];
417
+ if (!last || last.length >= pageSize) next.push([id]);
418
+ else last.push(id);
419
+ }
420
+ // A trailing empty page would leave `first`/`last` pointing at nothing; a
421
+ // middle hole is the short page this exists to keep.
422
+ while (next.length > 1 && !next[next.length - 1].length) next.pop();
423
+ if (!next.length) next.push([]);
424
+ return { index: next, pages: next.map(ids => ids.map(id => byId.get(id))) };
425
+ }
426
+
427
+ export function outboxPaging(outbox, index = []) {
428
+ const byId = new Map();
429
+ for (const it of outbox) {
430
+ const id = outboxItemId(it);
431
+ if (id && !byId.has(id)) byId.set(id, it);
432
+ }
433
+ // Fill pages oldest-first (outbox.json is newest-first), then reverse each
434
+ // page back to newest-first for display.
435
+ const order = [...outbox].reverse().map(outboxItemId);
436
+ const { index: idx, pages } = pageItems({ order, byId, index, pageSize: OUTBOX_PAGE_SIZE });
437
+ return { index: idx, pages: pages.map(p => [...p].reverse()) };
438
+ }
439
+
440
+ // ── followers, paged the same way ────────────────────────────────────────
441
+ // A followers collection is a set of actor IRIs. Each IRI is its own id, so
442
+ // there is nothing to dereference to page it, and no within-page order to keep.
443
+ export const FOLLOWERS_PAGE_SIZE = 20;
444
+ export const followersPageId = (id, n) => `${id}-${n}`;
445
+ export const followersPageCount = (total) => Math.max(1, Math.ceil(total / FOLLOWERS_PAGE_SIZE));
446
+
447
+ export function followersHead(id, total, pageCount = followersPageCount(total)) {
448
+ const pages = Math.max(1, pageCount);
449
+ return {
450
+ '@context': AS_CTX,
451
+ id, type: 'OrderedCollection',
452
+ totalItems: total,
453
+ first: followersPageId(id, 1),
454
+ last: followersPageId(id, pages),
455
+ };
456
+ }
457
+
458
+ export function followersPage(id, n, items, pageCount) {
459
+ return {
460
+ '@context': AS_CTX,
461
+ id: followersPageId(id, n),
462
+ type: 'OrderedCollectionPage',
463
+ partOf: id,
464
+ orderedItems: items,
465
+ ...(n < pageCount ? { next: followersPageId(id, n + 1) } : {}),
466
+ };
467
+ }
468
+
469
+ export function followersPaging(actors, index = []) {
470
+ const list = actors.filter(a => typeof a === 'string');
471
+ const byId = new Map();
472
+ for (const a of list) if (!byId.has(a)) byId.set(a, a);
473
+ return pageItems({ order: list, byId, index, pageSize: FOLLOWERS_PAGE_SIZE });
474
+ }
475
+
476
+ // NodeInfo (the fediverse's server self-description): a root .well-known
477
+ // pointer plus the document it points at. Both are static, so the pod can
478
+ // serve them exactly like webfinger.
479
+ export function nodeinfoPointer(docHref) {
480
+ return {
481
+ links: [{ rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0', href: docHref }],
482
+ };
483
+ }
484
+
485
+ export function nodeinfoDoc({ version, localPosts = 0 }) {
486
+ return {
487
+ version: '2.0',
488
+ // "hometown", not "fedipod": clients built on megalodon (Whalebird,
489
+ // Fedistar) refuse any software name outside their list, and hometown —
490
+ // a small Mastodon fork — is the least false of the accepted names for
491
+ // what this API serves.
492
+ software: { name: 'hometown', version },
493
+ protocols: ['activitypub'],
494
+ services: { inbound: [], outbound: [] },
495
+ openRegistrations: false,
496
+ usage: { users: { total: 1, activeMonth: 1, activeHalfyear: 1 }, localPosts },
497
+ metadata: {},
498
+ };
499
+ }
500
+
501
+ // Federated post bodies are attacker-controlled HTML that clients render —
502
+ // and Mastodon clients (Phanpy included) trust the server's content and
503
+ // innerHTML it, because Mastodon sanitizes server-side. That makes THIS the
504
+ // only line of defence, so it runs on a real HTML parser (sanitize-html →
505
+ // htmlparser2) rather than pattern-matching: allowlisted tags and
506
+ // attributes, safe URL schemes, and script/style dropped with their text.
507
+ import sanitize from 'sanitize-html';
508
+
509
+ const ALLOWED_TAGS = ['p', 'br', 'a', 'span', 'em', 'strong', 'b', 'i', 'u', 'del',
510
+ 'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4'];
511
+ const ALLOWED_ATTRS = ['href', 'rel', 'class', 'lang', 'title'];
512
+
513
+ export function sanitizeHtml(html) {
514
+ if (!html) return '';
515
+ return sanitize(String(html), {
516
+ allowedTags: ALLOWED_TAGS,
517
+ allowedAttributes: { '*': ALLOWED_ATTRS },
518
+ allowedSchemes: ['http', 'https', 'mailto'],
519
+ allowedSchemesAppliedToAttributes: ['href'],
520
+ allowProtocolRelative: false,
521
+ disallowedTagsMode: 'discard',
522
+ nonTextTags: ['script', 'style', 'textarea', 'noscript'], // drop their text too
523
+ transformTags: {
524
+ a: sanitize.simpleTransform('a', { rel: 'nofollow noopener noreferrer' }),
525
+ },
526
+ });
527
+ }
528
+
529
+ // Normalize a wire Note's attachment list to { url, mediaType, description }.
530
+ export function attachmentsOf(note) {
531
+ const list = Array.isArray(note?.attachment) ? note.attachment : note?.attachment ? [note.attachment] : [];
532
+ return list.map(a => ({
533
+ url: typeof a?.url === 'string' ? a.url : a?.url?.href,
534
+ mediaType: a?.mediaType || '',
535
+ ...(a?.name ? { description: a.name } : {}),
536
+ })).filter(a => a.url);
537
+ }
538
+
539
+ const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;' };
540
+ // user@host in the text, which is what a Mention tag has to agree with.
541
+ export const MENTION_RE = /@([A-Za-z0-9_.-]+)@([A-Za-z0-9.-]+\.[A-Za-z]{2,}(?::\d+)?)/g;
542
+
543
+ export function mentionsIn(text) {
544
+ return [...new Set(String(text).match(MENTION_RE) || [])].map(m => m.slice(1));
545
+ }
546
+
547
+ // Escape first, then linkify: escaping cannot introduce an @handle, and the
548
+ // anchors must survive it. A mention nobody could resolve stays plain text.
549
+ export function contentHtml(text, mentions = []) {
550
+ let html = String(text).replace(/[&<>]/g, c => HTML_ESCAPES[c]);
551
+ for (const m of mentions) {
552
+ if (!m?.handle || !m?.actor) continue;
553
+ // The anchor is for PEOPLE — the mentioned actor's advertised page when it
554
+ // has one; a browser landing on a raw actor document reads nothing. The
555
+ // mention TAG keeps the actor id, which is what servers match on.
556
+ const href = String(m.page || m.actor).replace(/[&<>"]/g, c => (HTML_ESCAPES[c] || '&quot;'));
557
+ html = html.split('@' + m.handle).join(
558
+ `<a href="${href}" class="u-url mention">@${m.handle.split('@')[0]}</a>`);
559
+ }
560
+ return '<p>' + html.replace(/\n+/g, '</p><p>') + '</p>';
561
+ }
562
+
563
+ export function noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions = [],
564
+ visibility = 'public', summary = null, updated = null, container = null }) {
565
+ const id = (container || urls.notes) + slug;
566
+ const who = mentions.map(m => m.actor);
567
+ // Mastodon's four shapes. Public and unlisted are world-readable; private
568
+ // (followers-only) and direct carry no Public address at all and belong in
569
+ // the owner-only container. Mastodon addresses mentions in cc (to, for a
570
+ // direct post) and will not notify anyone it does not find there or in the
571
+ // tags.
572
+ const addressing = {
573
+ public: { to: [PUBLIC], cc: [urls.followers, ...who] },
574
+ unlisted: { to: [urls.followers], cc: [PUBLIC, ...who] },
575
+ private: { to: [urls.followers], cc: who },
576
+ direct: { to: who, cc: [] },
577
+ }[visibility] || { to: [PUBLIC], cc: [urls.followers, ...who] };
578
+ const note = {
579
+ '@context': AS_CTX,
580
+ id, type: 'Note',
581
+ attributedTo: urls.actor,
582
+ content: contentHtml(content, mentions),
583
+ published,
584
+ to: addressing.to,
585
+ cc: addressing.cc,
586
+ replies: repliesId(id),
587
+ };
588
+ if (summary) note.summary = summary; // the content warning
589
+ if (updated) note.updated = updated; // an edit's own stamp
590
+ if (mentions.length) {
591
+ note.tag = mentions.map(m => ({ type: 'Mention', href: m.actor, name: '@' + m.handle }));
592
+ }
593
+ if (inReplyTo) note.inReplyTo = inReplyTo;
594
+ if (attachments?.length) {
595
+ note.attachment = attachments.map(a => ({
596
+ type: 'Document', mediaType: a.mediaType, url: a.url,
597
+ ...(a.description ? { name: a.description } : {}),
598
+ }));
599
+ }
600
+ return note;
601
+ }
602
+
603
+ // The id is a document of its own, not `note.id + '#create'`. A group wraps this
604
+ // whole activity in its Announce (FEP-1b12), and the receiver resolves it by
605
+ // dereferencing this id — a fragment would just serve the Note back under a
606
+ // different id, which Mastodon rejects.
607
+ export function createActivityId(noteId) { return noteId + '-create'; }
608
+
609
+ export function createActivity(note, urls) {
610
+ return {
611
+ '@context': AS_CTX,
612
+ id: createActivityId(note.id),
613
+ type: 'Create',
614
+ actor: urls.actor,
615
+ published: note.published,
616
+ to: note.to, cc: note.cc,
617
+ object: note,
618
+ };
619
+ }
620
+
621
+ export function acceptActivity({ urls, followActivity, serial }) {
622
+ return {
623
+ '@context': AS_CTX,
624
+ id: urls.actor + '#accept-' + serial,
625
+ type: 'Accept',
626
+ actor: urls.actor,
627
+ object: followActivity,
628
+ };
629
+ }
630
+
631
+ // The other half of Accept: what a group sends to end a following it will no
632
+ // longer carry. Without it an ejected member's server never learns, and keeps
633
+ // receiving everything the group announces.
634
+ export function rejectActivity({ urls, followActivity, serial }) {
635
+ return {
636
+ '@context': AS_CTX,
637
+ id: urls.actor + '#reject-' + serial,
638
+ type: 'Reject',
639
+ actor: urls.actor,
640
+ object: followActivity,
641
+ };
642
+ }
643
+
644
+ export function followActivity({ urls, targetActor, serial }) {
645
+ return {
646
+ '@context': AS_CTX,
647
+ id: urls.actor + '#follow-' + serial,
648
+ type: 'Follow',
649
+ actor: urls.actor,
650
+ object: targetActor,
651
+ };
652
+ }
653
+
654
+ export function likeActivity({ urls, noteId, serial }) {
655
+ return {
656
+ '@context': AS_CTX,
657
+ id: urls.actor + '#like-' + serial,
658
+ type: 'Like',
659
+ actor: urls.actor,
660
+ object: noteId,
661
+ };
662
+ }
663
+
664
+ // `object` is a note URL for a personal boost — what Mastodon renders — or the
665
+ // whole received activity when a group carries a member's post, which is what
666
+ // FEP-1b12 requires and what lets a follower see the author's own activity
667
+ // rather than the group's summary of it. Preserved exactly as delivered.
668
+ export function announceActivity({ urls, object, serial, published = new Date().toISOString(),
669
+ audience = null }) {
670
+ return {
671
+ '@context': AS_CTX,
672
+ id: urls.actor + '#announce-' + serial,
673
+ type: 'Announce',
674
+ actor: urls.actor,
675
+ published,
676
+ to: [PUBLIC], cc: [urls.followers],
677
+ // FEP-1b12: a group's carry names the group as the audience.
678
+ ...(audience ? { audience } : {}),
679
+ object,
680
+ };
681
+ }
682
+
683
+ // Tell followers the actor document changed. Nothing obliges a server to fetch
684
+ // an actor it already holds — Mastodon shows its cached copy, follower count
685
+ // included, until something makes it look again — so a profile edit is
686
+ // invisible to everyone until this goes out.
687
+ //
688
+ // The actor's own @context is hoisted onto the activity and the embedded copy
689
+ // loses its: the document declares `schema:` when it carries PropertyValue
690
+ // attachments, and an object nested under a plainer context would lose them.
691
+ export function updateActorActivity({ urls, actor, serial, published = new Date().toISOString() }) {
692
+ const { '@context': ctx, ...object } = actor;
693
+ return {
694
+ '@context': ctx || AS_CTX,
695
+ id: urls.actor + '#update-' + serial,
696
+ type: 'Update',
697
+ actor: urls.actor,
698
+ published,
699
+ to: [PUBLIC], cc: [urls.followers],
700
+ object,
701
+ };
702
+ }
703
+
704
+ // An edit: same Note id, `updated` stamped, so receivers replace their copy
705
+ // and show it as edited. The activity id carries the stamp — each edit is a
706
+ // new activity, and a replay of an old one is already seen.
707
+ export function updateActivity(note, urls) {
708
+ return {
709
+ '@context': AS_CTX,
710
+ id: note.id + '#update-' + String(note.updated || '').replace(/[^0-9TZ]/g, ''),
711
+ type: 'Update',
712
+ actor: urls.actor,
713
+ to: note.to, cc: note.cc,
714
+ object: note,
715
+ };
716
+ }
717
+
718
+ export function deleteActivity({ urls, noteId }) {
719
+ return {
720
+ '@context': AS_CTX,
721
+ id: noteId + '#delete',
722
+ type: 'Delete',
723
+ actor: urls.actor,
724
+ to: [PUBLIC],
725
+ object: { id: noteId, type: 'Tombstone' },
726
+ };
727
+ }
728
+
729
+ export function undoActivity({ urls, activity, serial }) {
730
+ return {
731
+ '@context': AS_CTX,
732
+ id: urls.actor + '#undo-' + serial,
733
+ type: 'Undo',
734
+ actor: urls.actor,
735
+ object: activity,
736
+ };
737
+ }
738
+
739
+ // A group's ban (FEP-1b12 moderation). Only ever announced BY a group —
740
+ // a person's blocks are never put on the wire.
741
+ export function blockActivity({ urls, targetActor, serial }) {
742
+ return {
743
+ '@context': AS_CTX,
744
+ id: urls.actor + '#block-' + serial,
745
+ type: 'Block',
746
+ actor: urls.actor,
747
+ object: targetActor,
748
+ };
749
+ }
750
+
751
+ // Add/Remove on one of this actor's own collections — the moderator roster
752
+ // is the one that changes hands (FEP-1b12).
753
+ export function addRemoveActivity({ urls, type, object, target, serial }) {
754
+ return {
755
+ '@context': AS_CTX,
756
+ id: urls.actor + '#' + type.toLowerCase() + '-' + serial,
757
+ type,
758
+ actor: urls.actor,
759
+ object,
760
+ target,
761
+ };
762
+ }