fedipod-server 0.10.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 (316) 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/deliver.mjs +6 -0
  7. package/lib/export-collections.mjs +1 -1
  8. package/lib/front-core.mjs +272 -49
  9. package/lib/gateway-core.mjs +3 -7
  10. package/lib/httpsig.mjs +33 -2
  11. package/lib/import.mjs +1 -1
  12. package/lib/intake.mjs +335 -118
  13. package/lib/links.mjs +6 -32
  14. package/lib/mastoapi.mjs +372 -35
  15. package/lib/migrate.mjs +4 -18
  16. package/lib/pod/README.md +61 -0
  17. package/lib/pod/actor.mjs +96 -0
  18. package/lib/pod/collection.mjs +77 -0
  19. package/lib/pod/containers.mjs +136 -0
  20. package/lib/pod/discovery.mjs +46 -0
  21. package/lib/pod/featured.mjs +18 -0
  22. package/lib/pod/followers.mjs +24 -0
  23. package/lib/pod/following.mjs +10 -0
  24. package/lib/pod/http.mjs +74 -0
  25. package/lib/pod/inbox.mjs +144 -0
  26. package/lib/pod/links.mjs +35 -0
  27. package/lib/pod/media.mjs +27 -0
  28. package/lib/pod/notes.mjs +80 -0
  29. package/lib/pod/notifications.mjs +89 -0
  30. package/lib/pod/outbox.mjs +18 -0
  31. package/lib/pod/package.json +19 -0
  32. package/lib/pod/policy.mjs +58 -0
  33. package/lib/pod/private.mjs +19 -0
  34. package/lib/pod/root.mjs +63 -0
  35. package/lib/pod/state.mjs +39 -0
  36. package/lib/pod/transport.mjs +477 -0
  37. package/lib/pod/urls.mjs +71 -0
  38. package/lib/publisher.mjs +112 -183
  39. package/lib/remote.mjs +30 -395
  40. package/lib/social.mjs +18 -9
  41. package/lib/storage.mjs +2 -2
  42. package/lib/store.mjs +8 -1
  43. package/lib/tagfeed.mjs +19 -2
  44. package/lib/wire.mjs +48 -67
  45. package/package.json +1 -1
  46. package/run-agent.mjs +14 -39
  47. package/web/admin/admin.js +141 -74
  48. package/web/admin/bar.css +9 -13
  49. package/web/admin/bar.js +2 -2
  50. package/web/admin/client/client.js +1 -1
  51. package/web/admin/client/index.html +5 -6
  52. package/web/admin/index.html +53 -65
  53. package/web/admin/setup/index.html +2 -1
  54. package/web/admin/setup/setup.js +1 -1
  55. package/web/admin/tokens.css +56 -0
  56. package/web/app/README.md +77 -0
  57. package/web/app/admin-facade.mjs +549 -0
  58. package/web/app/agent.mjs +490 -0
  59. package/web/app/atproto-browser.mjs +86 -0
  60. package/web/app/boot.mjs +340 -0
  61. package/web/app/deliver-relay.mjs +72 -0
  62. package/web/app/dist/boot.js +34067 -0
  63. package/web/app/dist/boot.js.map +7 -0
  64. package/web/app/dist/sw.js +53745 -0
  65. package/web/app/dist/sw.js.map +7 -0
  66. package/web/app/fediacct-browser.mjs +146 -0
  67. package/web/app/idb-kv.mjs +35 -0
  68. package/web/app/index.html +203 -0
  69. package/web/app/index.html~ +152 -0
  70. package/web/app/keys-browser.mjs +46 -0
  71. package/web/app/keystore.mjs +99 -0
  72. package/web/app/oidc-session.mjs +189 -0
  73. package/web/app/pod-auth.mjs +248 -0
  74. package/web/app/pod-remote.mjs +84 -0
  75. package/web/app/shims/_globals.mjs +2 -0
  76. package/web/app/shims/fedify-sig.mjs +77 -0
  77. package/web/app/shims/node-crypto.mjs +113 -0
  78. package/web/app/shims/node-fs.mjs +17 -0
  79. package/web/app/shims/node-path.mjs +19 -0
  80. package/web/app/shims/node-url.mjs +7 -0
  81. package/web/app/shims/prelude.js +58 -0
  82. package/web/app/shims/safefetch.mjs +58 -0
  83. package/web/app/shims/web-push.mjs +14 -0
  84. package/web/app/signup.mjs +248 -0
  85. package/web/app/site/_headers +9 -0
  86. package/web/app/site/_redirects +21 -0
  87. package/web/app/site/admin/admin.js +1181 -0
  88. package/web/app/site/admin/bar.css +60 -0
  89. package/web/app/site/admin/bar.js +61 -0
  90. package/web/app/site/admin/client/client.js +66 -0
  91. package/web/app/site/admin/client/index.html +43 -0
  92. package/web/app/site/admin/index.html +556 -0
  93. package/web/app/site/admin/setup/index.html +191 -0
  94. package/web/app/site/admin/setup/setup.js +308 -0
  95. package/web/app/site/admin/tokens.css +56 -0
  96. package/web/app/site/admin/window.css +55 -0
  97. package/web/app/site/admin/window.js +136 -0
  98. package/web/app/site/app/404.html +40 -0
  99. package/web/app/site/app/_headers +2 -0
  100. package/web/app/site/app/apple-touch-icon.png +0 -0
  101. package/web/app/site/app/assets/ICONS-DIPJeU0C.js +2 -0
  102. package/web/app/site/app/assets/ICONS-DIPJeU0C.js.map +1 -0
  103. package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js +183 -0
  104. package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
  105. package/web/app/site/app/assets/Temml-CCjoacWt.woff2 +0 -0
  106. package/web/app/site/app/assets/__vite-browser-external-C7iut881.js +2 -0
  107. package/web/app/site/app/assets/__vite-browser-external-C7iut881.js.map +1 -0
  108. package/web/app/site/app/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
  109. package/web/app/site/app/assets/catch-up-O5q5C75Z.png +0 -0
  110. package/web/app/site/app/assets/chunk-aKtaBQYM.js +1 -0
  111. package/web/app/site/app/assets/compose-CFEx7wb4.js +2 -0
  112. package/web/app/site/app/assets/compose-CFEx7wb4.js.map +1 -0
  113. package/web/app/site/app/assets/compose-ZJN5xSb4.js +35 -0
  114. package/web/app/site/app/assets/compose-ZJN5xSb4.js.map +1 -0
  115. package/web/app/site/app/assets/debug.module-YFQgwnIr.js +52 -0
  116. package/web/app/site/app/assets/debug.module-YFQgwnIr.js.map +1 -0
  117. package/web/app/site/app/assets/dom-p1YcJ5Rw.js +2 -0
  118. package/web/app/site/app/assets/dom-p1YcJ5Rw.js.map +1 -0
  119. package/web/app/site/app/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
  120. package/web/app/site/app/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
  121. package/web/app/site/app/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
  122. package/web/app/site/app/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
  123. package/web/app/site/app/assets/icons/add-circle-line-WD0mwORD.js +1 -0
  124. package/web/app/site/app/assets/icons/alert-line-CpFUMf9s.js +1 -0
  125. package/web/app/site/app/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
  126. package/web/app/site/app/assets/icons/announcement-line-5nwaJFJm.js +1 -0
  127. package/web/app/site/app/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
  128. package/web/app/site/app/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
  129. package/web/app/site/app/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
  130. package/web/app/site/app/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
  131. package/web/app/site/app/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
  132. package/web/app/site/app/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
  133. package/web/app/site/app/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
  134. package/web/app/site/app/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
  135. package/web/app/site/app/assets/icons/at-line-CF66Bznk.js +1 -0
  136. package/web/app/site/app/assets/icons/attachment-line-BY8It-l9.js +1 -0
  137. package/web/app/site/app/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
  138. package/web/app/site/app/assets/icons/board-line-C0UWXyA6.js +1 -0
  139. package/web/app/site/app/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
  140. package/web/app/site/app/assets/icons/building-5-line-Dd59MzbO.js +1 -0
  141. package/web/app/site/app/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
  142. package/web/app/site/app/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
  143. package/web/app/site/app/assets/icons/calendar-month-line-vommts2C.js +1 -0
  144. package/web/app/site/app/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
  145. package/web/app/site/app/assets/icons/camera-line-Cv69MEei.js +1 -0
  146. package/web/app/site/app/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
  147. package/web/app/site/app/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
  148. package/web/app/site/app/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
  149. package/web/app/site/app/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
  150. package/web/app/site/app/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
  151. package/web/app/site/app/assets/icons/clipboard-line-BBp364gJ.js +1 -0
  152. package/web/app/site/app/assets/icons/close-circle-line-CICdiuGz.js +1 -0
  153. package/web/app/site/app/assets/icons/close-line-DYrFWcjv.js +1 -0
  154. package/web/app/site/app/assets/icons/cloud-line-tiQAbah8.js +1 -0
  155. package/web/app/site/app/assets/icons/code-line-fAl8iiV3.js +1 -0
  156. package/web/app/site/app/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
  157. package/web/app/site/app/assets/icons/copy-2-line-CLW69yF5.js +1 -0
  158. package/web/app/site/app/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
  159. package/web/app/site/app/assets/icons/document-3-line-DBGNhAg2.js +1 -0
  160. package/web/app/site/app/assets/icons/document-line-C7mb9sb7.js +1 -0
  161. package/web/app/site/app/assets/icons/down-line-BGqSSThd.js +1 -0
  162. package/web/app/site/app/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
  163. package/web/app/site/app/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
  164. package/web/app/site/app/assets/icons/exit-line-Dc07BS-x.js +1 -0
  165. package/web/app/site/app/assets/icons/external-link-line-BvpSkmjO.js +1 -0
  166. package/web/app/site/app/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
  167. package/web/app/site/app/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
  168. package/web/app/site/app/assets/icons/filter-2-line-B_mR2usy.js +1 -0
  169. package/web/app/site/app/assets/icons/filter-line-C_nm-3cU.js +1 -0
  170. package/web/app/site/app/assets/icons/flag-1-line-C2HB05I0.js +1 -0
  171. package/web/app/site/app/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
  172. package/web/app/site/app/assets/icons/formula-line-DLrt2d6j.js +1 -0
  173. package/web/app/site/app/assets/icons/grid-line-CgUmEikf.js +1 -0
  174. package/web/app/site/app/assets/icons/group-line-C5fnX0wU.js +1 -0
  175. package/web/app/site/app/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
  176. package/web/app/site/app/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
  177. package/web/app/site/app/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
  178. package/web/app/site/app/assets/icons/heart-line-CJ7K47J4.js +1 -0
  179. package/web/app/site/app/assets/icons/history-2-line-kWWvFzqv.js +1 -0
  180. package/web/app/site/app/assets/icons/history-line-bGQiwbTH.js +1 -0
  181. package/web/app/site/app/assets/icons/home-3-line-fRU1zeLG.js +1 -0
  182. package/web/app/site/app/assets/icons/information-line-iNk6-sOY.js +1 -0
  183. package/web/app/site/app/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
  184. package/web/app/site/app/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
  185. package/web/app/site/app/assets/icons/layout-5-line-B_znt5wO.js +1 -0
  186. package/web/app/site/app/assets/icons/left-line-eUw0tya4.js +1 -0
  187. package/web/app/site/app/assets/icons/lightning-line-DaKoDdix.js +1 -0
  188. package/web/app/site/app/assets/icons/link-2-line-DCqLmMA7.js +1 -0
  189. package/web/app/site/app/assets/icons/list-check-line-BAxosBh9.js +1 -0
  190. package/web/app/site/app/assets/icons/lock-line-CPoaXeUW.js +1 -0
  191. package/web/app/site/app/assets/icons/mail-line-B0P8aa1L.js +1 -0
  192. package/web/app/site/app/assets/icons/moon-line-CEqNAToP.js +1 -0
  193. package/web/app/site/app/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
  194. package/web/app/site/app/assets/icons/more-3-line-BIiVjYTz.js +1 -0
  195. package/web/app/site/app/assets/icons/notification-line-BZZq2Gtu.js +1 -0
  196. package/web/app/site/app/assets/icons/pencil-line-CKh8-A1v.js +1 -0
  197. package/web/app/site/app/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
  198. package/web/app/site/app/assets/icons/pin-line-DpTolyhs.js +1 -0
  199. package/web/app/site/app/assets/icons/play-fill-B0-G_8lX.js +1 -0
  200. package/web/app/site/app/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
  201. package/web/app/site/app/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
  202. package/web/app/site/app/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
  203. package/web/app/site/app/assets/icons/radar-line-CKyroTth.js +1 -0
  204. package/web/app/site/app/assets/icons/react-line-CoIKhhtK.js +1 -0
  205. package/web/app/site/app/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
  206. package/web/app/site/app/assets/icons/right-line-B3KWJaqc.js +1 -0
  207. package/web/app/site/app/assets/icons/rocket-line-4KZl537D.js +1 -0
  208. package/web/app/site/app/assets/icons/round-fill-C69NTrZ0.js +1 -0
  209. package/web/app/site/app/assets/icons/round-line-B9k_1uo-.js +1 -0
  210. package/web/app/site/app/assets/icons/route-line-B28ReHcE.js +1 -0
  211. package/web/app/site/app/assets/icons/rows-4-line-CrBniagM.js +1 -0
  212. package/web/app/site/app/assets/icons/scan-line-DSf74YdP.js +1 -0
  213. package/web/app/site/app/assets/icons/search-2-line-B6geIU1z.js +1 -0
  214. package/web/app/site/app/assets/icons/settings-3-line-azJwK47w.js +1 -0
  215. package/web/app/site/app/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
  216. package/web/app/site/app/assets/icons/share-2-line-D5MxWjrm.js +1 -0
  217. package/web/app/site/app/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
  218. package/web/app/site/app/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
  219. package/web/app/site/app/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
  220. package/web/app/site/app/assets/icons/time-line-BF3eet1v.js +1 -0
  221. package/web/app/site/app/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
  222. package/web/app/site/app/assets/icons/translate-line-BFWol2Ce.js +1 -0
  223. package/web/app/site/app/assets/icons/unlock-line-X_x5vwv3.js +1 -0
  224. package/web/app/site/app/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
  225. package/web/app/site/app/assets/icons/user-4-line-BVSynZCp.js +1 -0
  226. package/web/app/site/app/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
  227. package/web/app/site/app/assets/icons/user-add-line-BbXdHG4z.js +1 -0
  228. package/web/app/site/app/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
  229. package/web/app/site/app/assets/icons/user-follow-line-BXux0wSd.js +1 -0
  230. package/web/app/site/app/assets/icons/user-star-line-DdYlpHLU.js +1 -0
  231. package/web/app/site/app/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
  232. package/web/app/site/app/assets/icons/user-x-line-DIDgNlPH.js +1 -0
  233. package/web/app/site/app/assets/icons/volume-line-Bs390KQt.js +1 -0
  234. package/web/app/site/app/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
  235. package/web/app/site/app/assets/icons/walk-line-yNXfB-md.js +1 -0
  236. package/web/app/site/app/assets/icons/world-2-line-CenkGIwj.js +1 -0
  237. package/web/app/site/app/assets/icons/zoom-in-line-CaENUphf.js +1 -0
  238. package/web/app/site/app/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
  239. package/web/app/site/app/assets/instances-BNPptnn-.json +333 -0
  240. package/web/app/site/app/assets/locales/ar-SA-CfheRbCu.js +1 -0
  241. package/web/app/site/app/assets/locales/ca-ES-DHRW86Tz.js +1 -0
  242. package/web/app/site/app/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
  243. package/web/app/site/app/assets/locales/de-DE-ByRPMRFy.js +1 -0
  244. package/web/app/site/app/assets/locales/eo-UY-CPC7wLCS.js +1 -0
  245. package/web/app/site/app/assets/locales/es-ES-dJa0Fd9t.js +1 -0
  246. package/web/app/site/app/assets/locales/eu-ES-C3djlPR2.js +1 -0
  247. package/web/app/site/app/assets/locales/fa-IR-DTT490Ck.js +1 -0
  248. package/web/app/site/app/assets/locales/fi-FI-B23MCPuz.js +1 -0
  249. package/web/app/site/app/assets/locales/fr-FR-BcaQsO5C.js +1 -0
  250. package/web/app/site/app/assets/locales/gl-ES-BR-CVKBN.js +1 -0
  251. package/web/app/site/app/assets/locales/he-IL-CfheRbCu.js +1 -0
  252. package/web/app/site/app/assets/locales/hu-HU-CfheRbCu.js +1 -0
  253. package/web/app/site/app/assets/locales/it-IT-C1136UFV.js +1 -0
  254. package/web/app/site/app/assets/locales/ja-JP-CRZmRAhD.js +1 -0
  255. package/web/app/site/app/assets/locales/kab-Cz8xH_TT.js +1 -0
  256. package/web/app/site/app/assets/locales/ko-KR-CG9UiY4o.js +1 -0
  257. package/web/app/site/app/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
  258. package/web/app/site/app/assets/locales/nb-NO-CfheRbCu.js +1 -0
  259. package/web/app/site/app/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
  260. package/web/app/site/app/assets/locales/oc-FR-CfheRbCu.js +1 -0
  261. package/web/app/site/app/assets/locales/pl-PL-DkwGXf6P.js +1 -0
  262. package/web/app/site/app/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
  263. package/web/app/site/app/assets/locales/pt-BR-nGL0HdFS.js +1 -0
  264. package/web/app/site/app/assets/locales/pt-PT-Cp17aj2z.js +1 -0
  265. package/web/app/site/app/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
  266. package/web/app/site/app/assets/locales/th-TH-CfheRbCu.js +1 -0
  267. package/web/app/site/app/assets/locales/tok-CfheRbCu.js +1 -0
  268. package/web/app/site/app/assets/locales/tr-TR-CfheRbCu.js +1 -0
  269. package/web/app/site/app/assets/locales/uk-UA-C5E3dFBf.js +1 -0
  270. package/web/app/site/app/assets/locales/zh-CN-BHJi7_et.js +1 -0
  271. package/web/app/site/app/assets/locales/zh-TW-CfheRbCu.js +1 -0
  272. package/web/app/site/app/assets/main-BdqNbG-a.js +26 -0
  273. package/web/app/site/app/assets/main-BdqNbG-a.js.map +1 -0
  274. package/web/app/site/app/assets/mock-home-DGhR__He.js +2 -0
  275. package/web/app/site/app/assets/mock-home-DGhR__He.js.map +1 -0
  276. package/web/app/site/app/assets/multi-column-ROck0NVt.jpg +0 -0
  277. package/web/app/site/app/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
  278. package/web/app/site/app/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
  279. package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js +2 -0
  280. package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js.map +1 -0
  281. package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js +20 -0
  282. package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js.map +1 -0
  283. package/web/app/site/app/assets/style-B1iLPTPR.css +2 -0
  284. package/web/app/site/app/assets/temml-DuyIQMei.js +97 -0
  285. package/web/app/site/app/assets/temml-DuyIQMei.js.map +1 -0
  286. package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js +2 -0
  287. package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
  288. package/web/app/site/app/assets/year-in-posts-B2lywqdx.png +0 -0
  289. package/web/app/site/app/assets/year-in-posts-DJv_star.js +3 -0
  290. package/web/app/site/app/assets/year-in-posts-DJv_star.js.map +1 -0
  291. package/web/app/site/app/compose/index.html +20 -0
  292. package/web/app/site/app/favicon.ico +0 -0
  293. package/web/app/site/app/index.html +73 -0
  294. package/web/app/site/app/logo-192.png +0 -0
  295. package/web/app/site/app/logo-512.png +0 -0
  296. package/web/app/site/app/logo-badge-72.png +0 -0
  297. package/web/app/site/app/logo-maskable-512.png +0 -0
  298. package/web/app/site/app/logo-monochrome-512.png +0 -0
  299. package/web/app/site/app/logo-monochrome-maskable-512.png +0 -0
  300. package/web/app/site/app/manifest.webmanifest +1 -0
  301. package/web/app/site/app/og-image-2.jpg +0 -0
  302. package/web/app/site/app/og-image.png +0 -0
  303. package/web/app/site/app/robots.txt +2 -0
  304. package/web/app/site/app/version.json +1 -0
  305. package/web/app/site/boot.js +34067 -0
  306. package/web/app/site/index.html +203 -0
  307. package/web/app/site/sw.js +53745 -0
  308. package/web/app/sw-src.mjs +205 -0
  309. package/web/front/#new-account.html# +43 -0
  310. package/web/front/admin.html +1 -81
  311. package/web/front/admin.js +85 -0
  312. package/web/front/new-account.html +1 -9
  313. package/web/front/new-account.js +13 -0
  314. package/web/front/run.html +1 -92
  315. package/web/front/run.js +107 -0
  316. package/lib/podrdf.mjs +0 -132
package/lib/mastoapi.mjs CHANGED
@@ -11,7 +11,9 @@
11
11
  // that log is the running punch list.
12
12
 
13
13
  import crypto from 'node:crypto';
14
+ import * as podMedia from './pod/media.mjs';
14
15
  import * as social from './social.mjs';
16
+ import { isCrossSiteNavigation } from './guard.mjs';
15
17
  import { sanitizeHtml, followsNeedApproval, publicHandle } from './wire.mjs';
16
18
  import { authorOf } from './intake.mjs';
17
19
  import { profileUrl, postUrl } from './bskyfeed.mjs';
@@ -74,13 +76,11 @@ const STUBS = new Map(Object.entries({
74
76
  '/api/v1/filters': [],
75
77
  '/api/v1/custom_emojis': [],
76
78
  '/api/v1/announcements': [],
77
- '/api/v1/follow_requests': [],
78
79
  '/api/v1/instance/peers': [],
79
80
  '/api/v1/trends/tags': [], '/api/v1/trends/links': [],
80
81
  '/api/v2/suggestions': [],
81
82
  '/api/v1/preferences': {},
82
- '/api/v1/followed_tags': [],
83
- }));
83
+ })); // followed_tags is served live from the tag feed, not stubbed
84
84
 
85
85
  const TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000; // tokens age out after 90 days
86
86
  const AUTHZ_WINDOW_MS = 60_000;
@@ -88,10 +88,14 @@ const AUTHZ_MAX_ATTEMPTS = 5;
88
88
  const CODE_TTL_MS = 5 * 60_000; // an authorization code is short-lived
89
89
  const MAX_APPS = 200; // registered third-party clients, capped
90
90
  const CLIENT_DOC_TTL_MS = 10 * 60_000; // how long a fetched client document is trusted
91
+ const CLIENT_DOC_MAX_CACHED = 200; // ids remembered at once; oldest out
92
+ const CLIENT_DOC_WINDOW_MS = 60_000;
93
+ const CLIENT_DOC_MAX_FETCHES = 20; // outbound lookups per window
91
94
  const CLIENT_DOC_MAX = 64 * 1024; // it names a client; it is not a payload
92
95
 
93
96
  export class MastoApi {
94
- constructor({ agent, log = console.log, allowed = null, scheme = null, embedded = false }) {
97
+ constructor({ agent, log = console.log, allowed = null, scheme = null, embedded = false,
98
+ streaming = true, webPush = true, scheduling = true }) {
95
99
  this.agent = agent;
96
100
  // A server-hosted identity has no CLI of its own, so the advice this gives
97
101
  // when it refuses has to name the route that identity really has.
@@ -101,6 +105,29 @@ export class MastoApi {
101
105
  // The scheme this identity is reached on, when the socket cannot say —
102
106
  // a server behind a TLS proxy terminates cleartext and still is https.
103
107
  this.scheme = scheme;
108
+ // Whether this deployment can serve the Mastodon streaming WebSocket. A
109
+ // Node agent can; the in-browser service-worker facade cannot (it is
110
+ // fetch-only), so it advertises NO streaming URL. Otherwise the client
111
+ // opens wss://localhost/... (the worker has no request host to name), which
112
+ // not only fails but, from a public origin, trips Chrome's private-network
113
+ // permission prompt ("access other apps and services on this device").
114
+ // With no streaming URL the client falls back to polling, which is served.
115
+ this.streaming = streaming;
116
+ // The same idea for two more capabilities the browser build does not have.
117
+ // A client decides what to offer from what the instance document says, so
118
+ // the honest thing is to say nothing rather than advertise and no-op.
119
+ //
120
+ // `webPush`: the browser build's web-push is a shim that mints a random
121
+ // placeholder VAPID key and whose sendNotification does nothing. Advertised,
122
+ // the client shows a notifications toggle that looks on and never fires.
123
+ // Omit `vapid` and it hides the toggle instead.
124
+ this.webPush = webPush;
125
+ // `scheduling`: a scheduled post is only a stored row until something
126
+ // publishes it when its time comes, and the only such tick is the Node
127
+ // agent's (run-agent.mjs). Where nothing ticks, accepting one is silent
128
+ // loss — the client says "scheduled" and the post never appears. Refusing
129
+ // it is worse UX and better behaviour.
130
+ this.scheduling = scheduling;
104
131
  this.authzAttempts = []; // password-attempt timestamps
105
132
  }
106
133
 
@@ -121,6 +148,7 @@ export class MastoApi {
121
148
  // that leaves it empty is not merely unhelpful — clients read it without a
122
149
  // guard and fall over, and every one of them loses live updates.
123
150
  streamingUrl(req) {
151
+ if (!this.streaming) return null; // fetch-only facade: no WebSocket, no wss://localhost prompt
124
152
  const host = req?.headers?.host || `localhost:${this.port || ''}`;
125
153
  const secure = this.scheme
126
154
  ? this.scheme.startsWith('https')
@@ -140,14 +168,54 @@ export class MastoApi {
140
168
  const now = Date.now();
141
169
  return this.tokenRecords().filter(r => now - (r.createdAt || 0) < TOKEN_TTL_MS).map(r => r.token);
142
170
  }
143
- mintToken() {
171
+ // `scope` is what the owner actually granted at /oauth/authorize. It used to
172
+ // be discarded: every token was full authority, so a client that asked for
173
+ // `read` could post, delete, and edit the profile. Recorded now, and enforced
174
+ // at the one gate every client route passes (see scopeFor / authed).
175
+ mintToken(scope = null) {
144
176
  const t = crypto.randomBytes(24).toString('hex');
145
177
  const now = Date.now();
146
178
  const kept = this.tokenRecords().filter(r => now - (r.createdAt || 0) < TOKEN_TTL_MS);
147
- this.store.write('masto-tokens.json', [...kept, { token: t, createdAt: now }].slice(-20));
179
+ this.store.write('masto-tokens.json',
180
+ [...kept, { token: t, createdAt: now, ...(scope ? { scope } : {}) }].slice(-20));
148
181
  return t;
149
182
  }
150
183
 
184
+ // What one request needs. Mastodon's four coarse scopes; a client that was
185
+ // granted a granular `write:statuses` satisfies `write` here, which is the
186
+ // direction that cannot let anything through that `write` would not.
187
+ //
188
+ // Reads are `read`, writes are `write`, and the two Mastodon carves out are
189
+ // kept: relationship changes accept the legacy `follow`, and push
190
+ // subscriptions want `push`. Nothing here grants across: `write` does NOT
191
+ // imply `read`, exactly as on Mastodon, so a write-only client cannot read
192
+ // the owner's direct messages.
193
+ static scopeFor(method, pathname) {
194
+ if (/^\/api\/v\d\/push\//u.test(pathname)) return 'push';
195
+ const relationship = /^\/api\/v1\/(accounts\/[a-f0-9]+\/(follow|unfollow|block|unblock|mute|unmute|remove_from_followers)|follow_requests\/)/u;
196
+ if (method !== 'GET' && method !== 'HEAD') {
197
+ return relationship.test(pathname) ? 'follow' : 'write';
198
+ }
199
+ return 'read';
200
+ }
201
+
202
+ // Whether a token's granted scopes satisfy `need`.
203
+ //
204
+ // A record with NO scope is a token minted before scopes were kept — it is
205
+ // full authority, because that is what it was granted, and quietly demoting
206
+ // live 90-day tokens would sign people out of working clients for a bug that
207
+ // was ours. New tokens all carry one.
208
+ static scopeAllows(granted, need) {
209
+ if (granted == null) return true; // pre-scope token
210
+ const have = String(granted).split(/[\s,+]+/u).filter(Boolean);
211
+ if (!have.length) return true;
212
+ if (need === 'follow') {
213
+ // Mastodon's `follow` is the legacy spelling; `write` covers it too.
214
+ return have.some((g) => g === 'follow' || g === 'write' || g.startsWith('write:'));
215
+ }
216
+ return have.some((g) => g === need || g.startsWith(`${need}:`));
217
+ }
218
+
151
219
  // Registered OAuth apps. A third-party (browser) client registers here, and
152
220
  // the authorization code it later receives is bound to the client_id and the
153
221
  // redirect_uri it registered — so the code reaches only where that client
@@ -194,31 +262,55 @@ export class MastoApi {
194
262
  if (!/^https:\/\//iu.test(String(clientId || ''))) return null; // cleartext is refused
195
263
  this.clientDocs = this.clientDocs || new Map();
196
264
  const seen = this.clientDocs.get(clientId);
265
+ // A cached FAILURE counts. Only successes were remembered, so a client id
266
+ // that 404s (or is not a client document at all) was re-fetched on every
267
+ // single call — and this runs on an unauthenticated GET, so a page could
268
+ // fire these in a loop and have the owner's machine hammer an address of
269
+ // the attacker's choosing, from the owner's IP, indefinitely.
197
270
  if (seen && Date.now() - seen.at < CLIENT_DOC_TTL_MS) return seen.client;
271
+ // And a budget, because caching alone still lets a fresh id per request
272
+ // through. `safeFetch` keeps every one of these to a public address, so
273
+ // this is not internal SSRF — it is amplification, and a cap is what
274
+ // amplification needs.
275
+ if (!this._clientDocFetches || Date.now() - this._clientDocWindow > CLIENT_DOC_WINDOW_MS) {
276
+ this._clientDocWindow = Date.now();
277
+ this._clientDocFetches = 0;
278
+ }
279
+ if (this._clientDocFetches >= CLIENT_DOC_MAX_FETCHES) {
280
+ this.log(`client document ${clientId} not fetched: too many lookups this minute`);
281
+ return null;
282
+ }
283
+ this._clientDocFetches += 1;
284
+ // The Map has a TTL but had no ceiling, so it grew for as long as an
285
+ // attacker cared to serve distinct documents. Oldest out when it is full;
286
+ // Map iterates in insertion order, so the first key is the oldest.
287
+ if (this.clientDocs.size >= CLIENT_DOC_MAX_CACHED) {
288
+ this.clientDocs.delete(this.clientDocs.keys().next().value);
289
+ }
290
+ const remember = (client) => { this.clientDocs.set(clientId, { at: Date.now(), client }); return client; };
198
291
  let doc;
199
292
  try {
200
293
  const res = await safeFetch(clientId, { headers: { accept: 'application/json' } });
201
- if (res.status >= 400) { this.log(`client document ${clientId} → ${res.status}`); return null; }
294
+ if (res.status >= 400) { this.log(`client document ${clientId} → ${res.status}`); return remember(null); }
202
295
  doc = JSON.parse(await readCapped(res, CLIENT_DOC_MAX));
203
296
  } catch (e) {
204
297
  this.log(`client document ${clientId} could not be read: ${e.message}`);
205
- return null;
298
+ return remember(null);
206
299
  }
207
300
  // It must claim to be itself: a document naming some other id would let
208
301
  // one client borrow another's name.
209
302
  if (doc?.client_id !== clientId) {
210
303
  this.log(`client document ${clientId} names ${doc?.client_id ?? 'nothing'} — refused`);
211
- return null;
304
+ return remember(null);
212
305
  }
213
306
  const redirectUris = [].concat(doc.redirect_uris || []).filter((u) => typeof u === 'string');
214
- if (!redirectUris.length) { this.log(`client document ${clientId} names no redirect — refused`); return null; }
307
+ if (!redirectUris.length) { this.log(`client document ${clientId} names no redirect — refused`); return remember(null); }
215
308
  const client = {
216
309
  clientId, redirectUris,
217
310
  name: String(doc.client_name || clientId).slice(0, 200),
218
311
  scopes: 'read write follow',
219
312
  };
220
- this.clientDocs.set(clientId, { at: Date.now(), client });
221
- return client;
313
+ return remember(client);
222
314
  }
223
315
 
224
316
  /**
@@ -294,10 +386,15 @@ export class MastoApi {
294
386
  if (rec) this.store.write('oauth-codes.json', all.filter(c => c.code !== code)); // single-use
295
387
  return rec || null;
296
388
  }
297
- authed(req) {
389
+ // The live record for the bearer on this request, or null.
390
+ tokenOf(req) {
298
391
  const m = /^Bearer (.+)$/.exec(req.headers.authorization || '');
299
- return !!m && this.tokens().includes(m[1]);
392
+ if (!m) return null;
393
+ const now = Date.now();
394
+ return this.tokenRecords().find(
395
+ (r) => r.token === m[1] && now - (r.createdAt || 0) < TOKEN_TTL_MS) || null;
300
396
  }
397
+ authed(req) { return !!this.tokenOf(req); }
301
398
 
302
399
  // A redirect_uri must name an authority this agent answers on — otherwise
303
400
  // a visited page could navigate to /oauth/authorize and have the freshly
@@ -328,6 +425,14 @@ export class MastoApi {
328
425
  return cfg?.handle ? `@${cfg.handle}@${this.host}` : 'FediPod';
329
426
  }
330
427
 
428
+ // A Mastodon Tag object. The client reads `following` in its Followed
429
+ // Hashtags view and toggles it with the follow/unfollow endpoints. No usage
430
+ // history — a single-actor instance has no firehose stats to report.
431
+ tagObject(name, following, req) {
432
+ const host = req?.headers?.host || this.host;
433
+ return { name, url: `https://${host}/tags/${name}`, history: [], following: !!following };
434
+ }
435
+
331
436
  instanceBlurb() {
332
437
  const kind = this.store.getConfig()?.kind === 'group' ? 'group' : 'actor';
333
438
  return `Solid pod ActivityPub ${kind}`;
@@ -575,9 +680,52 @@ export class MastoApi {
575
680
  shortcode: e.shortcode, url: e.url, static_url: e.url, visible_in_picker: false,
576
681
  })),
577
682
  card: null, poll: s.poll ? this.pollJson(s) : null,
683
+ // What a filter matched, if any. Mastodon's clients read this and do the
684
+ // hiding or warning; they do NOT match keywords themselves — Phanpy does
685
+ // not — so filters that were stored and served but never applied were a
686
+ // setting that did nothing. The README named them as a feature.
687
+ filtered: this.filtersFor(s),
578
688
  };
579
689
  }
580
690
 
691
+ // v2 filters against one status. `context` is the timeline the client is
692
+ // showing, which we do not know here, so every non-expired filter is offered
693
+ // and the client drops the ones whose context does not match — the same
694
+ // information it uses to decide anyway.
695
+ filtersFor(s) {
696
+ const now = Date.now();
697
+ const hay = `${s.content || ''} ${s.spoiler || ''}`
698
+ .replace(/<[^>]*>/gu, ' ') // the text, not the markup around it
699
+ .toLowerCase();
700
+ if (!hay.trim()) return [];
701
+ const out = [];
702
+ // `status()` renders on every timeline read, including from the Bluesky and
703
+ // connected-account paths whose stores are narrower than the pod's. A
704
+ // missing filter list means no filters, never a thrown render.
705
+ for (const f of this.store.getFilters?.() || []) {
706
+ if (f.expiresAt && Date.parse(f.expiresAt) <= now) continue;
707
+ const hit = (f.keywords || []).filter((k) => {
708
+ const word = String(k.keyword || '').toLowerCase().trim();
709
+ if (!word) return false;
710
+ if (!k.wholeWord) return hay.includes(word);
711
+ // A whole word, by the same rule Mastodon uses: a boundary that is not
712
+ // itself a word character, at both ends.
713
+ const esc = word.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
714
+ return new RegExp(`(?:^|[^\\p{L}\\p{N}_])${esc}(?:[^\\p{L}\\p{N}_]|$)`, 'u').test(hay);
715
+ }).map((k) => k.keyword);
716
+ if (!hit.length) continue;
717
+ out.push({
718
+ filter: {
719
+ id: f.id, title: f.title, context: f.context || ['home'],
720
+ expires_at: f.expiresAt || null, filter_action: f.action || 'warn',
721
+ },
722
+ keyword_matches: hit,
723
+ status_matches: [],
724
+ });
725
+ }
726
+ return out;
727
+ }
728
+
581
729
  // A timeline row's id may name the CARRY rather than the post — statusOrBoost
582
730
  // mints `via:<noteId>` for the envelope. A client that asks about a row it
583
731
  // was served has to get an answer: a 404 there makes it drop the row, which
@@ -662,7 +810,11 @@ export class MastoApi {
662
810
  mediaJson(a) {
663
811
  const kind = /^video\//.test(a.mediaType) ? 'video'
664
812
  : /^audio\//.test(a.mediaType) ? 'audio'
665
- : /^image\/gif/.test(a.mediaType) ? 'gifv' : 'image';
813
+ // NOT 'gifv'. Mastodon uses gifv for a silent MP4 it transcoded a GIF
814
+ // into, and a client renders one with <video src>; handed the raw .gif
815
+ // URL, every GIF came out blank. Nothing here transcodes, so what we
816
+ // have is an image and saying so is what makes it show.
817
+ : 'image';
666
818
  return {
667
819
  id: a.id || this.store.idFor(a.url),
668
820
  type: kind, url: a.url, preview_url: a.url, remote_url: null,
@@ -685,8 +837,14 @@ export class MastoApi {
685
837
  };
686
838
  }
687
839
 
840
+ // Ours is stored with a hyphen; Mastodon's API spells it with an underscore,
841
+ // and a client that does not know the type shows "Unknown notification type"
842
+ // — which is what every Mastodon client did with a follow request here. The
843
+ // stored spelling is left alone so existing state keeps reading.
844
+ notificationType(t) { return t === 'follow-request' ? 'follow_request' : t; }
845
+
688
846
  notification(n) {
689
- const out = { id: n.id, type: n.type, created_at: n.at, account: this.account(n.actor) };
847
+ const out = { id: n.id, type: this.notificationType(n.type), created_at: n.at, account: this.account(n.actor) };
690
848
  if (n.noteId) {
691
849
  const s = this.store.getStatuses().find(x => x.noteId === n.noteId);
692
850
  if (s) out.status = this.status(s);
@@ -747,7 +905,7 @@ export class MastoApi {
747
905
  id: app.clientId, name: app.name, website: app.website,
748
906
  client_id: app.clientId, client_secret: app.clientSecret,
749
907
  redirect_uri: redirectUris.join(' ') || 'urn:ietf:wg:oauth:2.0:oob',
750
- vapid_key: this.push.publicKey(),
908
+ ...(this.webPush ? { vapid_key: this.push.publicKey() } : {}),
751
909
  });
752
910
  }
753
911
  if (pathname === '/oauth/authorize' && (req.method === 'GET' || req.method === 'POST')) {
@@ -768,6 +926,42 @@ export class MastoApi {
768
926
  // code-is-the-token flow is only for the built-in client, which never
769
927
  // registers.
770
928
  const external = !!app || !!doc;
929
+
930
+ // S36. Two doors were open at once, and together they handed a bearer for
931
+ // this account to any page the owner happened to be reading.
932
+ //
933
+ // /api/v1/apps takes any redirect_uri from anyone — as it must, that is
934
+ // how a fediverse client registers — and the instant-authorize path below
935
+ // mints without a password whenever the request "is this machine". A page
936
+ // in another tab is this machine. So: register a client whose redirect is
937
+ // your own server, navigate the owner's browser to /oauth/authorize, and
938
+ // the code arrives at your address. The agent's own CA is in the browser's
939
+ // trust store, so https://localhost:<port> loads without a murmur.
940
+ //
941
+ // Two locks, either of which alone would do, both cheap:
942
+ //
943
+ // 1. A cross-site NAVIGATION may not reach the mint. A client signing in
944
+ // navigates from its own page and is same-site; a page on somebody
945
+ // else's site is not. (Absent Sec-Fetch-Site — curl, an old browser —
946
+ // is not cross-site and still passes, as everywhere else in this
947
+ // project; the second lock is what covers that case.)
948
+ if (isCrossSiteNavigation(req)) {
949
+ this.log(`authorize refused: cross-site navigation to the mint from ${req.headers.referer || 'nowhere'}`);
950
+ return send(403, { error: 'a cross-site navigation may not authorize a client' });
951
+ }
952
+ // 2. With NO password set there is nothing to authorize a third party
953
+ // WITH. The password-less path is honest only for a client on an
954
+ // address this agent itself answers on — where "whoever reaches the
955
+ // port is the owner" is a statement about the machine rather than
956
+ // about a web page. Anything pointing elsewhere has to be approved by
957
+ // somebody who knows the password.
958
+ if (external && !this.store.getConfig()?.uiPassword && !this.redirectAllowed(redirect)) {
959
+ this.log(`authorize refused: no UI password, and "${redirect}" is not an address of this agent`);
960
+ return send(403, {
961
+ error: 'this client asks to be sent somewhere other than this agent, and no password is '
962
+ + 'set to approve that with. Run `fedipod passwd` and try again.',
963
+ });
964
+ }
771
965
  const client = { name: app?.name || doc?.name || null, redirect, scope: params.get('scope') || 'read' };
772
966
  if (app) {
773
967
  if (!app.redirectUris.includes(redirect)) {
@@ -830,7 +1024,7 @@ export class MastoApi {
830
1024
  ? this.mintCode({ clientId: (app || doc).clientId, redirectUri: redirect, scope: client.scope,
831
1025
  challenge: params.get('code_challenge') || null,
832
1026
  challengeMethod: params.get('code_challenge_method') || null })
833
- : this.mintToken();
1027
+ : this.mintToken(client.scope);
834
1028
  if (!redirect || redirect === 'urn:ietf:wg:oauth:2.0:oob') return send(200, { code });
835
1029
  const target = new URL(redirect);
836
1030
  target.searchParams.set('code', code);
@@ -858,7 +1052,7 @@ export class MastoApi {
858
1052
  this.log('token refused: the verifier does not answer the challenge this code was made with');
859
1053
  return send(400, { error: 'invalid_grant' });
860
1054
  }
861
- return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
1055
+ return send(200, { access_token: this.mintToken(rec.scope || 'read'), token_type: 'Bearer',
862
1056
  scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
863
1057
  ...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
864
1058
  }
@@ -878,7 +1072,7 @@ export class MastoApi {
878
1072
  this.log('token refused: the verifier does not answer the challenge this code was made with');
879
1073
  return send(400, { error: 'invalid_grant' });
880
1074
  }
881
- return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
1075
+ return send(200, { access_token: this.mintToken(rec.scope || 'read'), token_type: 'Bearer',
882
1076
  scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
883
1077
  ...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
884
1078
  }
@@ -898,7 +1092,7 @@ export class MastoApi {
898
1092
  this.log('token refused: this code was made with a challenge and the verifier does not answer it');
899
1093
  return send(400, { error: 'invalid_grant' });
900
1094
  }
901
- return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
1095
+ return send(200, { access_token: this.mintToken(rec.scope || 'read'), token_type: 'Bearer',
902
1096
  scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
903
1097
  // Which actor the token acts for. A Mastodon client ignores it; an
904
1098
  // ActivityPub API client needs it, and asking for it separately
@@ -914,8 +1108,13 @@ export class MastoApi {
914
1108
  this.log('token refused: code is not a live authorization');
915
1109
  return send(400, { error: 'invalid_grant' });
916
1110
  }
1111
+ // The scope this token was actually minted with, not whatever the client
1112
+ // asks to be told — reporting one and enforcing another is how a client
1113
+ // ends up surprised by a 403 it was promised it would not get.
1114
+ const granted = this.tokenRecords().find((r) => r.token === body.code)?.scope
1115
+ || 'read write follow push';
917
1116
  return send(200, { access_token: body.code, token_type: 'Bearer',
918
- scope: body.scope || 'read write follow push', created_at: Math.floor(Date.now() / 1000),
1117
+ scope: granted, created_at: Math.floor(Date.now() / 1000),
919
1118
  ...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
920
1119
  }
921
1120
  if (pathname === '/oauth/revoke' && req.method === 'POST') {
@@ -936,10 +1135,11 @@ export class MastoApi {
936
1135
 
937
1136
  // --- instance (public) ---
938
1137
  if (pathname === '/api/v1/instance') {
1138
+ const su = this.streamingUrl(req);
939
1139
  return send(200, {
940
1140
  uri: this.host, title: this.instanceTitle(), short_description: this.instanceBlurb(),
941
1141
  description: this.instanceBlurb(), email: '', version: '4.2.0 (compatible; fedipod)',
942
- urls: { streaming_api: this.streamingUrl(req) },
1142
+ urls: su ? { streaming_api: su } : {},
943
1143
  stats: { user_count: 1, status_count: this.store.countStatuses(), domain_count: 1 },
944
1144
  languages: ['en'], registrations: false, approval_required: false, invites_enabled: false,
945
1145
  configuration: instanceConfig(),
@@ -947,6 +1147,7 @@ export class MastoApi {
947
1147
  });
948
1148
  }
949
1149
  if (pathname === '/api/v2/instance') {
1150
+ const su = this.streamingUrl(req);
950
1151
  return send(200, {
951
1152
  domain: this.host, title: this.instanceTitle(), version: '4.2.0 (compatible; fedipod)',
952
1153
  source_url: 'https://github.com/jeff-zucker/FediPod', description: this.instanceBlurb(),
@@ -955,11 +1156,12 @@ export class MastoApi {
955
1156
  languages: ['en'],
956
1157
  // Both spellings: v2 clients read configuration.urls.streaming, older
957
1158
  // ones the top-level urls.streaming_api, and some fall back blindly.
958
- urls: { streaming_api: this.streamingUrl(req) },
1159
+ // Omitted entirely when there is no streaming, so the client polls.
1160
+ urls: su ? { streaming_api: su } : {},
959
1161
  configuration: {
960
1162
  ...instanceConfig(),
961
- urls: { streaming: this.streamingUrl(req) },
962
- vapid: { public_key: this.push.publicKey() },
1163
+ ...(su ? { urls: { streaming: su } } : {}),
1164
+ ...(this.webPush ? { vapid: { public_key: this.push.publicKey() } } : {}),
963
1165
  },
964
1166
  registrations: { enabled: false, approval_required: false, message: null },
965
1167
  contact: { email: '', account: null }, rules: [],
@@ -970,7 +1172,16 @@ export class MastoApi {
970
1172
  if (stub !== undefined && req.method === 'GET') return send(200, stub);
971
1173
 
972
1174
  // --- everything below needs a bearer token + a configured agent ---
973
- if (!this.authed(req)) return send(401, { error: 'The access token is invalid' });
1175
+ const bearer = this.tokenOf(req);
1176
+ if (!bearer) return send(401, { error: 'The access token is invalid' });
1177
+ // One gate for every client route below, rather than a check in each of
1178
+ // the fifty-odd branches — which is how the scope came to be recorded and
1179
+ // never consulted in the first place.
1180
+ const need = MastoApi.scopeFor(req.method, pathname);
1181
+ if (!MastoApi.scopeAllows(bearer.scope, need)) {
1182
+ this.log(`refused ${req.method} ${pathname}: token has "${bearer.scope}", needs "${need}"`);
1183
+ return send(403, { error: `This action is outside the authorized scopes (needs ${need})` });
1184
+ }
974
1185
  if (!this.agent.configured()) return send(503, { error: 'agent not configured' });
975
1186
  // A viewer-mode agent (another agent holds the drain lease) may not act —
976
1187
  // but a user acting HERE outranks the idle active agent elsewhere, so a
@@ -1013,7 +1224,7 @@ export class MastoApi {
1013
1224
  const slug = new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex') + '.' + ext;
1014
1225
  const url = this.urls.media + slug;
1015
1226
  await this.agent.publisher.ensureMediaContainer();
1016
- await this.agent.remote.put(url, f.data, f.contentType);
1227
+ await podMedia.write(this.agent.remote, url, f.data, f.contentType);
1017
1228
  return url;
1018
1229
  };
1019
1230
 
@@ -1078,6 +1289,41 @@ export class MastoApi {
1078
1289
  return send(200, page.map(s => this.statusOrBoost(s, { all })), headers);
1079
1290
  }
1080
1291
 
1292
+ // --- hashtags: the client's Followed Hashtags surface drives the tag feed ---
1293
+ // followed_tags is what the feed currently pulls; follow/unfollow add and
1294
+ // remove a hashtag (unfollow the last and the topical feed goes quiet); the
1295
+ // tag timeline is the mirrored notes carrying that tag.
1296
+ if (pathname === '/api/v1/followed_tags') {
1297
+ const tags = this.agent.tagfeed?.config().tags || [];
1298
+ return send(200, tags.map(t => this.tagObject(t, true, req)));
1299
+ }
1300
+ const mTagFollow = pathname.match(/^\/api\/v1\/tags\/([^/]+)\/(follow|unfollow)$/);
1301
+ if (mTagFollow && req.method === 'POST') {
1302
+ const name = decodeURIComponent(mTagFollow[1]).replace(/^#/, '').toLowerCase();
1303
+ const follow = mTagFollow[2] === 'follow';
1304
+ const tf = this.agent.tagfeed;
1305
+ if (tf) {
1306
+ const cur = tf.config().tags;
1307
+ tf.setConfig({ tags: follow ? [...new Set([...cur, name])] : cur.filter(t => t !== name) });
1308
+ }
1309
+ return send(200, this.tagObject(name, follow, req));
1310
+ }
1311
+ const mTagGet = pathname.match(/^\/api\/v1\/tags\/([^/]+)$/);
1312
+ if (mTagGet && req.method === 'GET') {
1313
+ const name = decodeURIComponent(mTagGet[1]).replace(/^#/, '').toLowerCase();
1314
+ const following = (this.agent.tagfeed?.config().tags || []).includes(name);
1315
+ return send(200, this.tagObject(name, following, req));
1316
+ }
1317
+ const mTagTl = pathname.match(/^\/api\/v1\/timelines\/tag\/([^/]+)$/);
1318
+ if (mTagTl && req.method === 'GET') {
1319
+ const name = decodeURIComponent(mTagTl[1]).replace(/^#/, '').toLowerCase();
1320
+ const all = this.store.getStatuses();
1321
+ const items = all.filter(s => s.kind === 'tag' && s.tag === name)
1322
+ .sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
1323
+ const { items: page, headers } = this.page(items, url);
1324
+ return send(200, page.map(s => this.statusOrBoost(s, { all })), headers);
1325
+ }
1326
+
1081
1327
  if (pathname === '/api/v1/statuses' && req.method === 'POST') {
1082
1328
  const body = await readBody(req);
1083
1329
  if (!body.status) return send(422, { error: 'status text required' });
@@ -1140,6 +1386,12 @@ export class MastoApi {
1140
1386
  }
1141
1387
 
1142
1388
  if (body.scheduled_at) {
1389
+ // Nothing here publishes it when the time comes (see `scheduling` in the
1390
+ // constructor), so saying yes would be losing the post quietly.
1391
+ if (!this.scheduling) {
1392
+ return send(422, { error: 'this instance cannot schedule posts — it has no process '
1393
+ + 'running between now and then to publish one. Post it when you want it sent.' });
1394
+ }
1143
1395
  const at = Date.parse(body.scheduled_at);
1144
1396
  if (!Number.isFinite(at) || at < Date.now() + 60_000) {
1145
1397
  return send(422, { error: 'scheduled_at must be at least a minute from now' });
@@ -1369,7 +1621,9 @@ export class MastoApi {
1369
1621
  return send(200, page.map(s => this.statusOrBoost(s, { all })), headers);
1370
1622
  }
1371
1623
 
1372
- // v2 filters: stored and served; the client applies them to what it shows.
1624
+ // v2 filters: stored, served, and APPLIED every status carries what it
1625
+ // matched in `filtered` (see filtersFor), because no client matches
1626
+ // keywords for itself.
1373
1627
  const filterJson = (f) => ({
1374
1628
  id: f.id, title: f.title, context: f.context || ['home'],
1375
1629
  expires_at: f.expiresAt || null, filter_action: f.action || 'warn',
@@ -1519,15 +1773,94 @@ export class MastoApi {
1519
1773
  return send(200, this.status(updated || s));
1520
1774
  }
1521
1775
 
1776
+ // Follow requests. The queue, and the two answers to it, have existed since
1777
+ // groups did — `agent.store.getRequests()`, `admitRequest`, `refuseRequest`,
1778
+ // all driven from the record page — but the facade stubbed the list to `[]`
1779
+ // and offered no authorize/reject. So a locked account could see and answer
1780
+ // its requests in FediPod's own page and in NO Mastodon client: Phanpy,
1781
+ // Tuba and Whalebird all showed nothing waiting.
1782
+ if (pathname === '/api/v1/follow_requests' && req.method === 'GET') {
1783
+ const limit = Math.min(Number(url.searchParams.get('limit')) || 40, 80);
1784
+ return send(200, this.store.getRequests().slice(0, limit)
1785
+ .map((r) => this.account(r.actor)));
1786
+ }
1787
+ const mReq = /^\/api\/v1\/follow_requests\/([a-f0-9]+)\/(authorize|reject)$/.exec(pathname);
1788
+ if (mReq && req.method === 'POST') {
1789
+ // The client addresses an account by the id it was given for it, which is
1790
+ // this store's own hash of the actor URL — the same one every other
1791
+ // account route here uses.
1792
+ const actorUrl = this.store.urlFor(mReq[1]);
1793
+ if (!actorUrl) return send(404, { error: 'Record not found' });
1794
+ if (!this.store.getRequests().some((r) => r.actor === actorUrl)) {
1795
+ return send(404, { error: 'Record not found' });
1796
+ }
1797
+ try {
1798
+ if (mReq[2] === 'authorize') await social.admitRequest(this.agent, actorUrl);
1799
+ else await social.refuseRequest(this.agent, actorUrl);
1800
+ } catch (e) { return send(422, { error: e.message }); }
1801
+ await this.store.flush();
1802
+ return send(200, this.relationship(actorUrl));
1803
+ }
1804
+
1522
1805
  if (pathname === '/api/v1/notifications') {
1523
1806
  const limit = Math.min(Number(url.searchParams.get('limit')) || 30, 60);
1807
+ const q = url.searchParams;
1808
+ // Newest first, which is what every id rule below assumes.
1524
1809
  let items = this.store.getNotifications();
1525
- const maxId = url.searchParams.get('max_id');
1526
- if (maxId) {
1527
- const i = items.findIndex(n => n.id === maxId);
1528
- if (i >= 0) items = items.slice(i + 1);
1810
+
1811
+ // Which KINDS. A client that asks for mentions and is handed favourites
1812
+ // and boosts renders them as mentions — Phanpy's Mentions column did
1813
+ // exactly that, and re-pilled on every poll. Both spellings: clients send
1814
+ // `types[]=mention` and some send bare `types=mention`.
1815
+ const listParam = (name) => {
1816
+ const all = [...q.getAll(`${name}[]`), ...q.getAll(name)]
1817
+ .flatMap((v) => String(v).split(',')).map((v) => v.trim()).filter(Boolean);
1818
+ return all.length ? new Set(all) : null;
1819
+ };
1820
+ const want = listParam('types');
1821
+ const skip = listParam('exclude_types');
1822
+ // Mastodon spells the type with an underscore; ours is stored with a
1823
+ // hyphen, and notification() translates on the way out — so match on what
1824
+ // the client would have been given, not on what is on disk.
1825
+ const shown = (n) => this.notificationType(n.type);
1826
+ if (want) items = items.filter((n) => want.has(shown(n)));
1827
+ if (skip) items = items.filter((n) => !skip.has(shown(n)));
1828
+
1829
+ // Which SLICE. `max_id` walks backwards into history; `since_id` and
1830
+ // `min_id` both mean "newer than this", which is how a client asks
1831
+ // "anything since I last looked?". Ignoring them meant every poll
1832
+ // returned the whole list, so the client always saw unread items and the
1833
+ // bell never went out.
1834
+ const cut = (id, keepNewer) => {
1835
+ const i = items.findIndex((n) => n.id === id);
1836
+ if (i < 0) return;
1837
+ items = keepNewer ? items.slice(0, i) : items.slice(i + 1);
1838
+ };
1839
+ const maxId = q.get('max_id');
1840
+ if (maxId) cut(maxId, false);
1841
+ const sinceId = q.get('since_id') || q.get('min_id');
1842
+ if (sinceId) cut(sinceId, true);
1843
+ // min_id asks for the ones IMMEDIATELY newer, i.e. the oldest end of
1844
+ // what is newer; since_id asks for the newest. Only the window differs.
1845
+ const page = q.get('min_id') && !q.get('since_id')
1846
+ ? items.slice(Math.max(0, items.length - limit))
1847
+ : items.slice(0, limit);
1848
+
1849
+ // A client pages by following these rather than by guessing ids.
1850
+ if (page.length) {
1851
+ const base = `${this.scheme || (req.socket?.encrypted ? 'https' : 'http')}://${req.headers.host}${pathname}`;
1852
+ const link = (params) => {
1853
+ const u = new URL(base);
1854
+ for (const [k, v] of q) if (k !== 'max_id' && k !== 'since_id' && k !== 'min_id') u.searchParams.append(k, v);
1855
+ for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
1856
+ return u.href;
1857
+ };
1858
+ return send(200, page.map((n) => this.notification(n)), {
1859
+ link: `<${link({ max_id: page[page.length - 1].id })}>; rel="next", `
1860
+ + `<${link({ min_id: page[0].id })}>; rel="prev"`,
1861
+ });
1529
1862
  }
1530
- return send(200, items.slice(0, limit).map(n => this.notification(n)));
1863
+ return send(200, []);
1531
1864
  }
1532
1865
 
1533
1866
  if (pathname === '/api/v1/markers') {
@@ -1606,6 +1939,10 @@ export class MastoApi {
1606
1939
  if (pathname === '/api/v1/push/subscription') {
1607
1940
  const token = (/^Bearer (.+)$/.exec(req.headers.authorization || '') || [])[1];
1608
1941
  if (!token) return send(401, { error: 'The access token is invalid' });
1942
+ // A client that ignores the missing `vapid` and subscribes anyway must
1943
+ // not be told it worked — a stored subscription nothing ever pushes to is
1944
+ // the same silent nothing the toggle was.
1945
+ if (!this.webPush) return send(422, { error: 'this instance does not send web push' });
1609
1946
  if (req.method === 'GET') {
1610
1947
  const sub = this.push.get(token);
1611
1948
  return sub ? send(200, this.push.json(token, sub)) : send(404, { error: 'Record not found' });
@@ -1732,7 +2069,7 @@ export class MastoApi {
1732
2069
  const slug = new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex') + '.' + ext;
1733
2070
  const mediaUrl = this.urls.media + slug;
1734
2071
  await this.agent.publisher.ensureMediaContainer();
1735
- await this.agent.remote.put(mediaUrl, file.data, mediaType);
2072
+ await podMedia.write(this.agent.remote, mediaUrl, file.data, mediaType);
1736
2073
  const entry = { url: mediaUrl, mediaType, description: fields.description || '' };
1737
2074
  const id = this.store.idFor(mediaUrl);
1738
2075
  this.store.setMedia(id, entry);