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/guard.mjs ADDED
@@ -0,0 +1,213 @@
1
+ // guard.mjs — the browser-facing firewall. A loopback HTTP server is
2
+ // reachable by any web page the user visits, and DNS rebinding lets such a
3
+ // page keep its own origin while its requests land here — so binding to
4
+ // loopback is NOT an access control. Two header checks close that door:
5
+ //
6
+ // Host must name a loopback authority we expect (or an operator-declared
7
+ // host) — a rebound `evil.example` fails here.
8
+ // Origin when present, must be one of those same authorities — a
9
+ // cross-origin fetch/WebSocket fails here even without rebinding.
10
+ //
11
+ // AP_ALLOWED_HOSTS (comma-separated host[:port]) extends the set for
12
+ // deliberate exposure (tailnet name, reverse-proxy domain).
13
+ //
14
+ // An agent also answers at <handle>.localhost, so each identity on a machine
15
+ // gets an origin of its own — one browser storage jar per actor, instead of
16
+ // every agent sharing localhost:<port> and asking which instance you meant.
17
+ // `.localhost` is reserved by RFC 6761 and cannot be delegated in public DNS,
18
+ // so it is not the rebinding hole this file exists to close; the label comes
19
+ // from config, never from a request header, and matching stays exact-string.
20
+
21
+ const LOOPBACK_NAMES = ['localhost', '127.0.0.1', '[::1]', '::1'];
22
+
23
+ // A handle earns a named origin only when it is already a clean DNS label.
24
+ // Nothing is rewritten: a mangled name would be a different origin from the
25
+ // one we printed, and the user would land on a 403 with no way to see why.
26
+ export function hostLabel(handle) {
27
+ const s = String(handle || '').toLowerCase();
28
+ return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(s) ? s : null;
29
+ }
30
+
31
+ // The authorities that are this machine, and only this machine.
32
+ //
33
+ // The port-less form is a DIFFERENT origin: `http://localhost` means port 80
34
+ // and `https://localhost` means 443. Emitting it whatever port we are on put
35
+ // every other local server's pages inside our own origin set — a page served
36
+ // from 127.0.0.1:80 sends `Origin: http://localhost`, which passed the Origin
37
+ // check, and a form POST needs no preflight to reach a write route. So the
38
+ // bare form is emitted only when it really is this server's own authority.
39
+ export function loopbackAuthorities(port, labels = []) {
40
+ const out = [];
41
+ const bare = Number(port) === 80 || Number(port) === 443;
42
+ for (const name of LOOPBACK_NAMES) {
43
+ out.push(`${name}:${port}`);
44
+ if (bare) out.push(name);
45
+ }
46
+ for (const l of labels) {
47
+ const label = hostLabel(l);
48
+ if (!label) continue;
49
+ out.push(`${label}.localhost:${port}`);
50
+ if (bare) out.push(`${label}.localhost`);
51
+ }
52
+ return out;
53
+ }
54
+
55
+ const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
56
+
57
+ // Where the connection came FROM, as opposed to which authority it asked for.
58
+ // Both listeners bind loopback, so today this is true of everything that
59
+ // arrives — including a request relayed by a same-host reverse proxy, which is
60
+ // exactly why it is not what keeps an exposed agent safe (see exposureProblem).
61
+ // It is here so that binding to another address later cannot silently turn the
62
+ // local-only routes into public ones.
63
+ export function isLoopbackSocket(req) {
64
+ const a = req?.socket?.remoteAddress;
65
+ if (!a) return false;
66
+ return LOOPBACK_ADDRESSES.has(a) || a.startsWith('127.');
67
+ }
68
+
69
+ // AP_ALLOWED_HOSTS is the documented way to put the agent on a tailnet name or
70
+ // behind a reverse proxy. Doing so removes every transport signal that told the
71
+ // operator apart from everyone else: the listeners bind loopback, so exposure
72
+ // means something on this machine is relaying, and a stock
73
+ // `proxy_pass http://127.0.0.1:<port>;` rewrites Host to a loopback authority
74
+ // and connects from loopback. Every caller then looks local, which is what the
75
+ // local-only routes and the password-less authorize path were trusting.
76
+ //
77
+ // A secret is all that is left, and AP_GATE_TOKEN is the one every route
78
+ // checks — a uiPassword guards the OAuth login and nothing else. Returns a
79
+ // reason string when the agent must not start, or null.
80
+ export function exposureProblem({ allowedHosts, gateToken } = {}) {
81
+ const exposed = String(allowedHosts || '').split(',').map(s => s.trim()).filter(Boolean);
82
+ if (!exposed.length || gateToken) return null;
83
+ return `AP_ALLOWED_HOSTS names ${exposed.join(', ')}, but AP_GATE_TOKEN is not set.\n`
84
+ + 'An exposed agent cannot tell your requests from anyone else\'s — a reverse proxy\n'
85
+ + 'rewrites Host to a loopback authority and connects from loopback, so every caller\n'
86
+ + 'looks like this machine. Without a shared secret that means the admin API, the\n'
87
+ + 'setup routes and the OAuth mint answer whoever reaches the address.\n\n'
88
+ + 'Either set AP_GATE_TOKEN to a secret (send it as the x-dk-token header, or visit\n'
89
+ + '?dk-token=<secret> once to be given the cookie), or unset AP_ALLOWED_HOSTS.';
90
+ }
91
+
92
+ export function allowedAuthorities(port, labels = []) {
93
+ const extra = String(process.env.AP_ALLOWED_HOSTS || '')
94
+ .split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
95
+ return new Set([...loopbackAuthorities(port, labels), ...extra]);
96
+ }
97
+
98
+ // The set has to stay live. startAdmin runs before connect, so an unconfigured
99
+ // agent has no handle yet — and MastoApi consults this same object to decide
100
+ // whether an OAuth redirect_uri is one of ours, so a snapshot taken at listen
101
+ // time would reject the named origin for the life of the process.
102
+ export class Authorities {
103
+ constructor(port, handle = null, extraPorts = []) {
104
+ this.port = port;
105
+ // The https listener's port: same machine, same names, second authority.
106
+ this.extraPorts = extraPorts.filter(Boolean);
107
+ this.label = null;
108
+ this.set = null;
109
+ this.local = null;
110
+ this.rebuild();
111
+ this.setHandle(handle);
112
+ }
113
+
114
+ rebuild() {
115
+ const labels = this.labels();
116
+ this.set = allowedAuthorities(this.port, labels);
117
+ this.local = new Set(loopbackAuthorities(this.port, labels));
118
+ for (const p of this.extraPorts) {
119
+ for (const a of loopbackAuthorities(p, labels)) { this.set.add(a); this.local.add(a); }
120
+ }
121
+ }
122
+
123
+ // Returns true when the named origin actually changed.
124
+ setHandle(handle) {
125
+ const label = hostLabel(handle);
126
+ if (label === this.label) return false;
127
+ this.label = label;
128
+ this.rebuild();
129
+ return true;
130
+ }
131
+
132
+ labels() { return this.label ? [this.label] : []; }
133
+
134
+ has(authority) { return this.set.has(authority); }
135
+
136
+ // Machine-local, ignoring AP_ALLOWED_HOSTS: an agent deliberately exposed
137
+ // under a tailnet name answers the fediverse there, but must not offer
138
+ // account creation or config editing over it.
139
+ isLocal(host) { return this.local.has(String(host || '').toLowerCase()); }
140
+
141
+ // The Host header says which authority the client asked for; the socket says
142
+ // where it came from. A local-only route wants both to agree. Neither
143
+ // survives a same-host proxy on its own — exposureProblem is what covers
144
+ // that case — but requiring both costs nothing and closes the plain forgery.
145
+ isLocalRequest(req) {
146
+ return isLoopbackSocket(req) && this.isLocal(req?.headers?.host);
147
+ }
148
+ }
149
+
150
+ // The authority set for an identity served by somebody else's server: one
151
+ // origin, the pod's own, and no notion of "this machine" at all. Nothing is
152
+ // local here — a request from the next room and one from the far side of the
153
+ // world arrive the same way — so the routes that were local-only are guarded
154
+ // by the door's secret instead.
155
+ export class FixedAuthorities {
156
+ constructor(origin) {
157
+ const u = new URL(origin);
158
+ this.host = u.host.toLowerCase();
159
+ this.port = Number(u.port) || (u.protocol === 'https:' ? 443 : 80);
160
+ this.secure = u.protocol === 'https:';
161
+ this.label = null;
162
+ this.set = new Set([this.host]);
163
+ this.extraPorts = [];
164
+ }
165
+
166
+ rebuild() { /* one origin, fixed at construction */ }
167
+
168
+ setHandle() { return false; } // the origin does not follow the handle
169
+
170
+ labels() { return []; }
171
+
172
+ has(authority) { return this.set.has(String(authority || '').toLowerCase()); }
173
+
174
+ isLocal() { return false; }
175
+
176
+ isLocalRequest() { return false; }
177
+
178
+ // What a page here may open a streaming socket to. Built from the pod's own
179
+ // origin: the loopback list would leave the socket refused by the page's own
180
+ // content-security-policy, with nothing in the console to say why.
181
+ wsAuthorities() { return [`${this.secure ? 'wss' : 'ws'}://${this.host}`]; }
182
+ }
183
+
184
+ const authorityOf = (originHeader) => {
185
+ try { return new URL(originHeader).host.toLowerCase(); } catch { return null; }
186
+ };
187
+
188
+ // Returns null when the request is acceptable, else a reason string.
189
+ // `ignoreOrigin` keeps the Host check (what stops DNS rebinding) while letting
190
+ // a foreign Origin through — the bearer-gated API answers any origin the way
191
+ // a Mastodon server does, and a websocket with a live token likewise.
192
+ export function checkRequest(req, allowed, { ignoreOrigin = false } = {}) {
193
+ const host = String(req.headers.host || '').toLowerCase();
194
+ if (!host || !allowed.has(host)) return `unexpected Host "${host || '(none)'}"`;
195
+ if (ignoreOrigin) return null;
196
+ const origin = req.headers.origin;
197
+ // 'null' is what a sandboxed/file: page sends — never trusted here.
198
+ if (origin && origin !== 'null') {
199
+ const oh = authorityOf(origin);
200
+ if (!oh || !allowed.has(oh)) return `cross-origin request from "${origin}"`;
201
+ } else if (origin === 'null') {
202
+ return 'opaque origin';
203
+ }
204
+ return null;
205
+ }
206
+
207
+ // Same-site navigations and typed URLs only — blocks a visited page from
208
+ // driving a top-level navigation into an endpoint that mints credentials.
209
+ // Absent header (older browsers, curl) is allowed: Host/Origin still apply.
210
+ export function isCrossSiteNavigation(req) {
211
+ const site = req.headers['sec-fetch-site'];
212
+ return site === 'cross-site';
213
+ }
package/lib/home.mjs ADDED
@@ -0,0 +1,161 @@
1
+ // Where an install keeps its credential, signing keys, pidfile and log.
2
+ //
3
+ // The root was `~/.activitypod`, then `~/.solid-activitypub`, before the
4
+ // rename to fedipod. An install that already has one keeps using it: that
5
+ // directory holds the credential and the private key, and moving those is the
6
+ // owner's decision rather than an upgrade's side effect. `fedipod home
7
+ // --to <dir>` is how you take the new name when you want it.
8
+ //
9
+ // Resolve ONCE and derive `profiles/` from the same answer. Deciding the two
10
+ // separately is how `--profile solo` ends up looking in a different tree from
11
+ // the default agent.
12
+
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ export const CURRENT_ROOT = '.fedipod';
19
+ export const LEGACY_ROOTS = ['.solid-activitypub', '.activitypod']; // most recent first
20
+
21
+ // Existing install wins over the new name; a fresh one gets the new name.
22
+ export function apRoot(homedir = os.homedir()) {
23
+ const current = path.join(homedir, CURRENT_ROOT);
24
+ if (fs.existsSync(current)) return current;
25
+ for (const name of LEGACY_ROOTS) {
26
+ const legacy = path.join(homedir, name);
27
+ if (fs.existsSync(legacy)) return legacy;
28
+ }
29
+ return current;
30
+ }
31
+
32
+ export const isLegacyRoot = (root) => LEGACY_ROOTS.includes(path.basename(root));
33
+
34
+ // Write one of this home's files without a window in which it is half there.
35
+ //
36
+ // lib/storage.mjs goes to the trouble of tmp+rename for state documents that
37
+ // its own header calls rebuildable from the pod. The files here are the ones
38
+ // that are NOT: keys.json is the private key remote servers have cached, and
39
+ // credential.json holds a secret a Solid server mints once and will not mint
40
+ // again. A plain writeFileSync truncates first, so a crash, a power loss or a
41
+ // full disk between truncate and write leaves an empty or half-written file
42
+ // where the identity used to be.
43
+ //
44
+ // rename(2) is atomic on POSIX, so a reader — or a backup running at the wrong
45
+ // moment — sees either the old file or the new one. fsync before it, so the
46
+ // content is on the platter and not merely in the page cache when the rename
47
+ // lands. Windows has no atomic rename over an existing file; there the copy
48
+ // path is still better than a truncate, and this is a best effort by design.
49
+ export function writeFileAtomic(file, body, { mode = 0o600 } = {}) {
50
+ const tmp = `${file}.${process.pid}.tmp`;
51
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
52
+ const fd = fs.openSync(tmp, 'w', mode);
53
+ try {
54
+ fs.writeFileSync(fd, body);
55
+ fs.fsyncSync(fd);
56
+ } finally {
57
+ fs.closeSync(fd);
58
+ }
59
+ fs.renameSync(tmp, file);
60
+ }
61
+
62
+ // The same, for the JSON these files all happen to be.
63
+ export function writeJsonAtomic(file, obj, opts) {
64
+ writeFileAtomic(file, JSON.stringify(obj, null, 2) + '\n', opts);
65
+ }
66
+
67
+ export const profilesDir = (root) => path.join(root, 'profiles');
68
+
69
+ // The root an arbitrary home belongs to. `<root>/profiles/<name>` is a profile;
70
+ // anything else is its own root — so a custom AP_HOME reports no neighbours,
71
+ // which is the truth rather than a special case.
72
+ export function rootOf(home) {
73
+ const resolved = path.resolve(home);
74
+ const parent = path.dirname(resolved);
75
+ return path.basename(parent) === 'profiles' ? path.dirname(parent) : resolved;
76
+ }
77
+
78
+ // Every identity under a root — all of them under `profiles/`, none of them the
79
+ // root itself. Callers read `agent.json` from these — a port and a handle.
80
+ // Nothing here, and nothing that uses it, opens a sibling's credential or keys.
81
+ //
82
+ // The root used to BE one of these, and had no name, so this function invented
83
+ // `(default)` for it at display time. That was the tell: a thing you have to
84
+ // name when you show it does not have an identity, it has a position. It also
85
+ // meant one identity's home contained all the others — back it up and you
86
+ // backed up everyone; delete it and you deleted everyone.
87
+ export function identityHomes(root) {
88
+ const homes = [];
89
+ try {
90
+ for (const name of fs.readdirSync(profilesDir(root)).sort()) {
91
+ const dir = path.join(profilesDir(root), name);
92
+ if (fs.statSync(dir).isDirectory()) homes.push({ name, dir });
93
+ }
94
+ } catch { /* no identities yet */ }
95
+ return homes;
96
+ }
97
+
98
+ // The root's own record. Today it holds one thing: which identity was last
99
+ // started, which is what a plain command means afterwards. Nobody sets it —
100
+ // using an identity IS setting it, so there is no default to configure and no
101
+ // way for the configured answer to drift from the one you actually work in.
102
+ export const ROOT_FILE = 'root.json';
103
+
104
+ export function readRoot(root) {
105
+ try { return JSON.parse(fs.readFileSync(path.join(root, ROOT_FILE), 'utf8')) || {}; }
106
+ catch { return {}; }
107
+ }
108
+
109
+ export function writeRoot(root, fields) {
110
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
111
+ const rec = { ...readRoot(root), ...fields };
112
+ writeJsonAtomic(path.join(root, ROOT_FILE), rec);
113
+ return rec;
114
+ }
115
+
116
+ // Which identity you get with no `--profile`: the last one started. Failing
117
+ // that, one identity is unambiguous and needs no ceremony. Two, neither ever
118
+ // started, is a genuine question — and the answer is to ask rather than pick,
119
+ // because picking silently would be picking someone's fediverse account.
120
+ export function defaultProfile(root) {
121
+ const named = readRoot(root).default;
122
+ // Only a home holding a credential counts. A setup that was abandoned leaves a
123
+ // directory behind, and letting that become the default would answer "which
124
+ // identity" with one that does not exist yet.
125
+ const real = identityHomes(root).filter(h => fs.existsSync(path.join(h.dir, 'credential.json')));
126
+ if (named && real.some(h => h.name === named)) return named;
127
+ if (named) return { missing: named };
128
+ return real.length === 1 ? real[0].name : null;
129
+ }
130
+
131
+ export const profileHome = (root, name) => path.join(profilesDir(root), name);
132
+
133
+ // A root from before every identity moved under profiles/. Its presence is what
134
+ // every command checks to tell you to migrate, rather than quietly doing it —
135
+ // this directory holds a private key.
136
+ export const rootHoldsIdentity = (root) => fs.existsSync(path.join(root, 'credential.json'));
137
+
138
+
139
+ // For DISPLAY only: `~/.fedipod` reads at a glance where
140
+ // `/home/jeff/.fedipod` has to be parsed, and the home directory is
141
+ // the least interesting part of every path this prints. A `file:` URL is a path
142
+ // wearing a scheme, so it is shown as the path — which is also what you would
143
+ // type, since `state --to` takes either.
144
+ //
145
+ // Never used to build a path. `~` is the shell's, not the filesystem's, and
146
+ // anything that resolves one must expand it first.
147
+ export function tildify(p, homedir = os.homedir()) {
148
+ if (!p) return p;
149
+ let s = String(p);
150
+ if (s.startsWith('file://')) { try { s = fileURLToPath(s); } catch { return s; } }
151
+ if (s === homedir) return '~';
152
+ return s.startsWith(homedir + path.sep) ? '~' + s.slice(homedir.length) : s;
153
+ }
154
+
155
+ // Using an identity is what makes it the default. Called when an agent starts
156
+ // for one — not on every read, or `--profile other status` would quietly move
157
+ // the machine's idea of "you" while only asking a question.
158
+ export function recordLastUsed(root, name) {
159
+ if (!root || !name || readRoot(root).default === name) return;
160
+ try { writeRoot(root, { default: name, at: new Date().toISOString() }); } catch { /* not fatal */ }
161
+ }
@@ -0,0 +1,109 @@
1
+ // httpsig.mjs — inbound HTTP-Signature verification, and the verification
2
+ // receipt that carries the outcome from wherever the verify happened (an
3
+ // always-on gateway, or the agent's own endpoint when it is publicly reachable)
4
+ // into the drain, which cannot see the delivery's headers.
5
+ //
6
+ // We SIGN outbound with Fedify draft-cavage (lib/deliver.mjs); we VERIFY with
7
+ // the same library, so the signing-string reconstruction can never drift out
8
+ // of step with what we produce. The one thing Fedify must be prevented from
9
+ // doing on its own is fetching an attacker-named key over an unguarded socket
10
+ // — so the key deref is injected as a loader that goes through safefetch.
11
+
12
+ import crypto from 'node:crypto';
13
+ import { verifyRequest } from '@fedify/fedify/sig';
14
+ import { assertPublicUrl, safeFetch, readCapped } from './safefetch.mjs';
15
+
16
+ // An SSRF-safe JSON-LD document loader for Fedify's key fetch. The keyId in a
17
+ // delivery is chosen by the sender, so every deref it triggers is untrusted
18
+ // input and must pass the same address policy as the rest of our outbound
19
+ // path. `skipActor` forces a network read past any cache, for the one refetch
20
+ // a rotated key needs.
21
+ export function makeSafeLoader({ getActors = null, fetchImpl = fetch } = {}) {
22
+ return async (url) => {
23
+ // A cached actor document short-circuits the network entirely — and it was
24
+ // only cached after its own origin vouched for its id (intake.fetchAP).
25
+ if (getActors) {
26
+ const hit = getActors()[url];
27
+ if (hit) return { document: hit, documentUrl: url, contextUrl: null };
28
+ }
29
+ await assertPublicUrl(url);
30
+ const res = await safeFetch(url, {
31
+ headers: { accept: 'application/activity+json, application/ld+json' },
32
+ }, fetchImpl);
33
+ if (res.status >= 400) throw new Error(`key fetch ${url} → ${res.status}`);
34
+ const document = JSON.parse(await readCapped(res));
35
+ return { document, documentUrl: url, contextUrl: null };
36
+ };
37
+ }
38
+
39
+ // Verify a delivery's signature. `request` is a WHATWG Request (the gateway
40
+ // has one natively; an agent-side endpoint builds one from its req). Returns a
41
+ // normalized result both the gateway and the drain understand — never throws
42
+ // on a bad signature, because "unverified" is a routine outcome, not an error.
43
+ export async function verifyHttpSignature(request, { documentLoader, keyCache, timeWindow } = {}) {
44
+ const hadSig = request.headers.get('signature') != null;
45
+ let key = null;
46
+ try {
47
+ key = await verifyRequest(request, {
48
+ documentLoader,
49
+ ...(keyCache ? { keyCache } : {}),
50
+ timeWindow: timeWindow ?? { hours: 1 },
51
+ });
52
+ } catch {
53
+ key = null; // a loader that refused (SSRF, 401 from a secure-mode pod) → unverified
54
+ }
55
+ if (key) {
56
+ return {
57
+ verified: true, method: 'draft-cavage',
58
+ keyId: key.id?.href ?? null, actor: key.ownerId?.href ?? null, reason: null,
59
+ checks: { signature: true, digest: true, dateSkew: true, keyFetched: true },
60
+ };
61
+ }
62
+ return {
63
+ verified: false, method: hadSig ? 'draft-cavage' : 'none',
64
+ keyId: null, actor: null,
65
+ // The distinction the receipt needs: a forgery (present but invalid) is
66
+ // dropped at the edge; an absent or unfetchable-key signature degrades to
67
+ // buffered-unverified, exactly as an install with no gateway behaves.
68
+ reason: hadSig ? 'bad-signature-or-key-unfetchable' : 'no-signature',
69
+ checks: { signature: false },
70
+ };
71
+ }
72
+
73
+ // The receipt: what the door concluded, HMAC-stamped so the drain can trust it
74
+ // even while the inbox is still public-Append (a forger without the secret
75
+ // cannot mint `verified: true`). The HMAC is over the receipt minus the hmac
76
+ // field itself, with sorted keys so gateway and drain canonicalize alike.
77
+ function canonical(obj) {
78
+ return JSON.stringify(obj, Object.keys(obj).filter(k => k !== 'hmac').sort());
79
+ }
80
+
81
+ export function makeReceipt(result, { gateway = null } = {}) {
82
+ return {
83
+ v: 1,
84
+ verified: !!result.verified,
85
+ method: result.method,
86
+ keyId: result.keyId,
87
+ actor: result.actor,
88
+ checks: result.checks,
89
+ reason: result.reason,
90
+ gateway,
91
+ };
92
+ }
93
+
94
+ export function signReceipt(receipt, secret) {
95
+ const hmac = crypto.createHmac('sha256', secret).update(canonical(receipt)).digest('base64');
96
+ return { ...receipt, hmac };
97
+ }
98
+
99
+ // True only when the receipt carries a valid HMAC for `secret`. A missing
100
+ // secret, a missing hmac, or a mismatch all read as "not ours" → the drain
101
+ // treats the receipt as absent (today's unverified behavior). timingSafeEqual
102
+ // over equal-length buffers; unequal lengths are a straight mismatch.
103
+ export function verifyReceipt(receipt, secret) {
104
+ if (!receipt || typeof receipt !== 'object' || !receipt.hmac || !secret) return false;
105
+ const expected = crypto.createHmac('sha256', secret).update(canonical(receipt)).digest();
106
+ let given;
107
+ try { given = Buffer.from(receipt.hmac, 'base64'); } catch { return false; }
108
+ return given.length === expected.length && crypto.timingSafeEqual(given, expected);
109
+ }