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/admin.mjs ADDED
@@ -0,0 +1,1606 @@
1
+ // admin.mjs — loopback HTTP front: admin API + Mastodon client-API facade +
2
+ // the bundled Phanpy UI served same-origin (no CORS, no mixed content — the
3
+ // role data-kitchen's router plays for the in-app pane). Loopback-bound; the
4
+ // gate (vendor/gate.cjs) engages only when a token is configured, exactly
5
+ // its standalone behavior.
6
+
7
+ import crypto from 'node:crypto';
8
+ import http from 'node:http';
9
+ import https from 'node:https';
10
+ import net from 'node:net';
11
+ import fs from 'node:fs';
12
+ import { spawn } from 'node:child_process';
13
+ import { createRequire } from 'node:module';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+ import { fileURLToPath, pathToFileURL } from 'node:url';
17
+ import { followHandle, followActor, unfollowActor, ejectFollower, retractAnnouncement,
18
+ admitRequest, refuseRequest, resolveHandle, applyModeration, announceModeration } from './social.mjs';
19
+ import { addRemoveActivity, webfingerHost } from './wire.mjs';
20
+ import { MastoApi, hashPassword } from './mastoapi.mjs';
21
+ import { C2S } from './c2s.mjs';
22
+ import { makeC2sAuth } from './oidc-auth.mjs';
23
+ import { Streaming } from './streaming.mjs';
24
+ import { nodeinfoPointer, nodeinfoDoc } from './wire.mjs';
25
+ import { allowedAuthorities, checkRequest, isCrossSiteNavigation, Authorities, hostLabel } from './guard.mjs';
26
+ import { identityHomes, rootOf, tildify, defaultProfile, writeJsonAtomic } from './home.mjs';
27
+ import { copyPrivateHalf, isCurrent, CURRENT_LAYOUT } from './migrate.mjs';
28
+ import { normalizeImport, IMPORT_KINDS } from './import.mjs';
29
+ import { insecureUrlReason } from './safefetch.mjs';
30
+ import { newRun, preflight, runSetup, setupInputError, hasCredential } from './setup.mjs';
31
+ import { portFree, freePortFrom } from './ports.mjs';
32
+ import { claimDirectory, yieldDirectory } from './directory.mjs';
33
+ import { localFetch } from './localapi.mjs';
34
+ import { ensureTrustedTls } from './certs.mjs';
35
+
36
+ const require = createRequire(import.meta.url);
37
+ const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
38
+ const { makeGate } = require(path.join(projectRoot, 'vendor/gate.cjs'));
39
+
40
+ const PHANPY_DIR = path.join(projectRoot, 'phanpy/dist');
41
+ const UI_DIR = path.join(projectRoot, 'ui'); // extra client dists: ui/<name>/ → /<name>/
42
+ // Our own pages, kept out of ui/ for two reasons: a client dist dropped in
43
+ // there under the same name would shadow them, and a group serves these and
44
+ // nothing else — so the prefix has to be one nobody is invited to write into.
45
+ // One surface, /admin/, with setup as its first section: /admin/setup/ is the
46
+ // first run, /admin/ is the record, and there is room for the rest.
47
+ const WEB_DIR = path.join(projectRoot, 'web');
48
+ const WEB_MOUNTS = ['admin'];
49
+ const webMount = (pathname) => {
50
+ const seg = decodeURIComponent(pathname).replace(/^\/+/, '').split('/')[0];
51
+ return WEB_MOUNTS.includes(seg) ? seg : null;
52
+ };
53
+ const SETUP_PAGE = '/admin/setup/';
54
+
55
+ // The signup carry-over: gateway and identity parameters a signup page
56
+ // recorded for this install (written by the installer, consumed by the first
57
+ // setup run). Read fresh each time — the file is deleted once consumed.
58
+ function readFirstRun() {
59
+ const file = process.env.AP_FIRST_RUN;
60
+ if (!file) return null;
61
+ try {
62
+ const fr = JSON.parse(fs.readFileSync(file, 'utf8'));
63
+ return fr && typeof fr === 'object' ? fr : null;
64
+ } catch { return null; }
65
+ }
66
+
67
+ // The same config shape the `gateway` CLI command writes.
68
+ function gatewayConfigFrom(fr) {
69
+ if (!fr?.gateway || !fr?.secret) return null;
70
+ const target = String(fr.gateway);
71
+ return fr.fronted
72
+ ? { url: target.replace(/ap\/actor$/, 'ap/inbox/'), frontActor: target,
73
+ mode: 'shadow', hmacSecret: String(fr.secret) }
74
+ : { url: target.replace(/\/?$/, '/'), mode: 'shadow', hmacSecret: String(fr.secret) };
75
+ }
76
+ const AGENT_VERSION = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')).version;
77
+ // POSTs an unconfigured agent still answers: /block is worth having before
78
+ // federation starts, and /setup is how it stops being unconfigured.
79
+ // /shutdown is here because stopping an agent that was never set up is exactly
80
+ // the case it exists for; it is in LOCAL_ONLY_POSTS below, so it still answers
81
+ // only to this machine.
82
+ const OPEN_POSTS = new Set(['/block', '/unblock', '/setup', '/setup/check', '/shutdown']);
83
+ // Routes that manage a local agent process — spawning siblings, killing this
84
+ // one, moving files on the machine, running setup in a browser. Inside a pod
85
+ // server there is no such process and no such machine: identities come from
86
+ // the server's own configuration, so these are not there to be found.
87
+ const EMBEDDED_CUT = new Set(['/profiles', '/shutdown', '/new-actor', '/start-actor',
88
+ '/state-move', '/setup', '/setup/check']);
89
+ // AP_ALLOWED_HOSTS may name a tailnet host or a reverse-proxy domain. The
90
+ // fediverse is welcome there; creating accounts and editing the record is for
91
+ // whoever is sitting at the machine.
92
+ const LOCAL_ONLY_POSTS = new Set(['/setup', '/setup/check', '/config', '/new-actor', '/start-actor', '/shutdown', '/state-move', '/atproto/connect', '/gateway', '/alias', '/import', '/update']);
93
+ // The identity itself. Changing any of these means a different actor at a
94
+ // different address, which is a new setup, not an edit.
95
+ const PERMANENT_CONFIG = ['handle', 'remotePod', 'issuer', 'root', 'kind'];
96
+ // Config the actor document carries, so a change is not real until it is
97
+ // republished.
98
+ const WIRE_CONFIG = ['name', 'summary', 'icon', 'image', 'fields', 'approveJoins', 'moderators'];
99
+
100
+ const MIME = {
101
+ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css',
102
+ '.json': 'application/json', '.map': 'application/json', '.webmanifest': 'application/manifest+json',
103
+ '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.gif': 'image/gif',
104
+ '.jpg': 'image/jpeg', '.webp': 'image/webp', '.txt': 'text/plain', '.woff2': 'font/woff2',
105
+ };
106
+
107
+ // Sent on every response: nosniff and no-referrer everywhere, and for HTML a
108
+ // CSP that keeps SCRIPTS to our own origin while still allowing the remote
109
+ // avatars, media and instance calls a fediverse client must make. It is not an
110
+ // exfiltration boundary — connect-src has to allow https: for the client to
111
+ // work at all — it is a code-execution one.
112
+ // Phanpy's index.html carries an inline bootstrap script. Rather than
113
+ // opening the policy with 'unsafe-inline', hash the inline scripts we
114
+ // actually ship and allow exactly those.
115
+ let inlineHashes = null;
116
+ function inlineScriptHashes() {
117
+ if (inlineHashes) return inlineHashes;
118
+ inlineHashes = [];
119
+ try {
120
+ const html = fs.readFileSync(path.join(PHANPY_DIR, 'index.html'), 'utf8');
121
+ for (const m of html.matchAll(/<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/gi)) {
122
+ const digest = crypto.createHash('sha256').update(m[1], 'utf8').digest('base64');
123
+ inlineHashes.push(`'sha256-${digest}'`);
124
+ }
125
+ } catch { /* no inline scripts to allow */ }
126
+ return inlineHashes;
127
+ }
128
+
129
+ // The origin a browser should be sent to for an identity. A browser keys
130
+ // storage per ORIGIN, so linking every identity at localhost:<port> files them
131
+ // all in one bucket — which is how a client ends up holding one actor's login
132
+ // and showing it on another's page. Named whenever the handle is a legal host
133
+ // label; bare loopback when it is not, because a mangled name would be an
134
+ // origin the other agent's own guard refuses.
135
+ export function namedOrigin(handle, port) {
136
+ const label = hostLabel(handle);
137
+ return `http://${label ? label + '.' : ''}localhost:${port}`;
138
+ }
139
+
140
+ // The https origin for an identity, on the one port it was given.
141
+ // This is the advertised default — what gets printed, opened and linked; the
142
+ // http listener stays served beside it for anything that needs cleartext.
143
+ export function secureOrigin(handle, httpsPort) {
144
+ const label = hostLabel(handle);
145
+ return `https://${label ? label + '.' : ''}localhost:${httpsPort}`;
146
+ }
147
+
148
+ export function wsOrigins(port, labels = []) {
149
+ const out = new Set();
150
+ for (const a of allowedAuthorities(port, labels)) {
151
+ // No IPv6 at all. Bare `::1` cannot carry a port, and Chrome rejects the
152
+ // bracketed form inside a CSP source expression — one invalid source makes
153
+ // it drop the whole directive, which is worse than not listing the socket.
154
+ if (a.startsWith('::') || a.startsWith('[')) continue;
155
+ out.add(`wss://${/:\d+$/.test(a) ? a : `${a}:${port}`}`);
156
+ }
157
+ return [...out];
158
+ }
159
+
160
+ function securityHeaders(auth, isHtml) {
161
+ const h = {
162
+ 'x-content-type-options': 'nosniff',
163
+ 'referrer-policy': 'no-referrer',
164
+ // SAMEORIGIN, not DENY: /admin/client/ frames the bundled client so a bar
165
+ // of ours can sit above it. Only pages on this agent's own origins may —
166
+ // the same set the Host/Origin firewall already trusts.
167
+ 'x-frame-options': 'SAMEORIGIN',
168
+ };
169
+ if (isHtml) {
170
+ h['content-security-policy'] = [
171
+ "default-src 'self'",
172
+ `script-src 'self' 'wasm-unsafe-eval' ${inlineScriptHashes().join(' ')}`,
173
+ "style-src 'self' 'unsafe-inline'",
174
+ "img-src 'self' https: data: blob:",
175
+ "media-src 'self' https: data: blob:",
176
+ "font-src 'self' data:",
177
+ // Every authority the Host/Origin firewall accepts, so browsing an agent
178
+ // at its own name (solo.localhost:8041, a tailnet host) keeps streaming.
179
+ // Pinning this to localhost blocked the socket with no visible error.
180
+ //
181
+ // `https:` is still here and the comment above no longer claims otherwise:
182
+ // a fediverse client fetches remote instances by design — link previews,
183
+ // an actor's own server, media — so there is no narrower set that leaves
184
+ // it working. The XSS story is `script-src 'self'` plus hashes; treat
185
+ // connect-src as availability, not containment.
186
+ `connect-src 'self' https: ${(typeof auth.wsAuthorities === 'function'
187
+ ? auth.wsAuthorities()
188
+ : wsOrigins(auth.port, auth.labels())).join(' ')}`,
189
+ "frame-ancestors 'self'",
190
+ "base-uri 'self'",
191
+ "form-action 'self'",
192
+ ].join('; ');
193
+ }
194
+ return h;
195
+ }
196
+
197
+ function sendJson(res, status, obj, auth) {
198
+ res.writeHead(status, { 'content-type': 'application/json', ...securityHeaders(auth, false) });
199
+ res.end(JSON.stringify(obj) + '\n');
200
+ }
201
+
202
+ // A cross-origin form POST needs no CORS preflight, and JSON.parse does not
203
+ // care what Content-Type claimed — so parsing whatever arrived let a visited
204
+ // page reach every write route with a body of its choosing. Our own callers
205
+ // (web/admin/*.js, the CLI) all send application/json; `stop` sends no body at
206
+ // all, which is why an absent type is allowed only for an empty one.
207
+ function readBody(req) {
208
+ return new Promise((resolve, reject) => {
209
+ const ct = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
210
+ const isJson = ct === 'application/json';
211
+ const wrongType = () => reject(new Error('expected content-type: application/json'));
212
+ if (ct && !isJson) { req.resume(); wrongType(); return; }
213
+ let data = '';
214
+ req.on('data', c => {
215
+ data += c;
216
+ if (data.length > 1e6) { reject(new Error('request body too large')); req.destroy(); }
217
+ });
218
+ req.on('end', () => {
219
+ if (data && !isJson) return wrongType();
220
+ try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(e); }
221
+ });
222
+ req.on('error', reject);
223
+ });
224
+ }
225
+
226
+ // Static UI serving, path-jailed. Phanpy owns the root; any other client
227
+ // dist dropped into ui/<name>/ is served at /<name>/. Hash-routed apps:
228
+ // '/' (and directories) get their index.html; anything unknown 404s.
229
+ function serveStatic(res, pathname, auth) {
230
+ let rel = decodeURIComponent(pathname).replace(/^\/+/, '');
231
+ let baseDir = PHANPY_DIR;
232
+ const uiName = rel.split('/')[0];
233
+ // The mount name is joined to UI_DIR, so it must be CONTAINED by it — the
234
+ // same resolve-then-check sendFile does below, for the same reason.
235
+ //
236
+ // Decoding happens before the split, so a `%2f` in the first segment becomes
237
+ // a real separator afterwards and `..` arrives here as a mount name. It
238
+ // exists, and it is a directory, so baseDir was silently re-based to the
239
+ // project root — and sendFile's jail then enforced containment against THAT,
240
+ // dutifully approving `/..%2fpackage.json`, `/..%2f.git/config` and every
241
+ // source file under it for anyone who could reach the port.
242
+ // Resolved through symlinks, not just lexically: sendFile's own jail has
243
+ // always used realpath, and a lexical check here would still admit a mount
244
+ // that is a link pointing out of ui/.
245
+ let mount = '';
246
+ try {
247
+ if (uiName) {
248
+ const cand = path.resolve(UI_DIR, uiName);
249
+ if (cand.startsWith(UI_DIR + path.sep) && fs.statSync(cand).isDirectory()) {
250
+ const real = fs.realpathSync(cand);
251
+ if (real.startsWith(fs.realpathSync(UI_DIR) + path.sep)) mount = cand;
252
+ }
253
+ }
254
+ } catch { /* no such mount; fall through to the default base */ }
255
+ if (mount) {
256
+ baseDir = mount;
257
+ rel = rel.slice(uiName.length).replace(/^\/+/, '');
258
+ }
259
+ return sendFile(res, baseDir, rel, auth);
260
+ }
261
+
262
+ // '/admin/setup' names a directory, so it needs the slash the browser will
263
+ // resolve relative URLs against. Returns the corrected path, or null.
264
+ function webDirRedirect(pathname) {
265
+ if (pathname.endsWith('/')) return null;
266
+ const rel = decodeURIComponent(pathname).replace(/^\/+/, '');
267
+ const full = path.normalize(path.join(WEB_DIR, rel));
268
+ if (!full.startsWith(WEB_DIR + path.sep)) return null; // not ours to stat
269
+ try {
270
+ if (fs.statSync(full).isDirectory()) return pathname + '/';
271
+ } catch { /* not a directory here */ }
272
+ return null;
273
+ }
274
+
275
+ // web/<mount>/ → /<mount>/. Same jail, different mount rule.
276
+ function serveWeb(res, pathname, mount, auth) {
277
+ const rel = decodeURIComponent(pathname).replace(/^\/+/, '').slice(mount.length).replace(/^\/+/, '');
278
+ return sendFile(res, path.join(WEB_DIR, mount), rel, auth);
279
+ }
280
+
281
+ function sendFile(res, baseDir, rel, auth) {
282
+ if (rel === '' || rel.endsWith('/')) rel += 'index.html';
283
+ const file = path.normalize(path.join(baseDir, rel));
284
+ if (!file.startsWith(baseDir + path.sep) && file !== path.join(baseDir, 'index.html')) {
285
+ res.writeHead(403); res.end(); return true;
286
+ }
287
+ let target = file;
288
+ try {
289
+ if (fs.statSync(target).isDirectory()) target = path.join(target, 'index.html');
290
+ // Resolve symlinks before reading: a link inside a UI dir must not be a
291
+ // way out of the jail.
292
+ const real = fs.realpathSync(target);
293
+ const realBase = fs.realpathSync(baseDir);
294
+ if (!real.startsWith(realBase + path.sep) && real !== path.join(realBase, 'index.html')) {
295
+ res.writeHead(403); res.end(); return true;
296
+ }
297
+ const ext = path.extname(real);
298
+ const body = fs.readFileSync(real);
299
+ // Our own pages are read straight off disk and change whenever the project
300
+ // does. With no cache headers a browser is free to reuse them without
301
+ // asking, so an edited page keeps rendering the old one and looks like the
302
+ // edit never landed. The vendored client dists have hashed filenames and
303
+ // are left alone.
304
+ const ours = real.startsWith(fs.realpathSync(WEB_DIR) + path.sep);
305
+ res.writeHead(200, {
306
+ 'content-type': MIME[ext] || 'application/octet-stream',
307
+ ...(ours ? { 'cache-control': 'no-store' } : {}),
308
+ ...securityHeaders(auth, ext === '.html'),
309
+ });
310
+ res.end(body);
311
+ } catch {
312
+ res.writeHead(404, { 'content-type': 'text/plain', ...securityHeaders(auth, false) });
313
+ res.end('not found\n');
314
+ }
315
+ return true;
316
+ }
317
+
318
+ // The whole admin/facade surface as one request handler, with nothing that
319
+ // belongs to a process of its own. startAdmin wraps it in listeners; the CSS
320
+ // component hands it CSS's, so the same routes answer on the pod's origin.
321
+ //
322
+ // `embedded` cuts the routes that manage local agent processes — they have no
323
+ // meaning inside a server that is not one — and moves the gate: a pod that is
324
+ // a fediverse instance must let strangers reach /api and /oauth, so the gate
325
+ // guards the operator's door (basePath) instead of the whole surface.
326
+ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
327
+ port = null, handle = null, embedded = false, basePath = '/',
328
+ publicOrigin = null, scheme = null }) {
329
+ const json = (res, status, obj) => sendJson(res, status, obj, allowed);
330
+ const masto = new MastoApi({ agent, log, allowed, scheme, embedded });
331
+ // The spec's own write API (§6), beside the facade. Its bearer fallback is
332
+ // the facade's token, so the two surfaces share one notion of the operator.
333
+ const c2s = new C2S({ agent, log, auth: makeC2sAuth({ agent, masto, log, scheme }) });
334
+ const streaming = new Streaming({ masto, log, allowed, gate, gateOptional: embedded });
335
+ // Asked per request, not once here: startAdmin runs before connect, so the
336
+ // kind is not known yet at mount time.
337
+ const isGroup = () => agent.store.getConfig()?.kind === 'group';
338
+ // One setup at a time, and the record outlives the run: a reloaded page
339
+ // must still find out how the run it started ended.
340
+ let setupRun = null;
341
+ // New statuses/notifications flow to connected streaming clients live.
342
+ agent.store.onEvent = (type, obj) => {
343
+ try {
344
+ if (type === 'status') streaming.broadcast('update', masto.status(obj));
345
+ else if (type === 'notification') {
346
+ streaming.broadcast('notification', masto.notification(obj));
347
+ // and out to any closed client, via its push subscription
348
+ masto.pushNotify(obj).catch(e => log(`webpush: ${e.message}`));
349
+ }
350
+ } catch (e) { log(`streaming broadcast: ${e.message}`); }
351
+ };
352
+
353
+ // A path as the browser must ask for it: behind the door, prefixed with it.
354
+ const atPath = (p_) => (basePath === '/' ? p_ : basePath.slice(0, -1) + p_);
355
+
356
+ const handler = async (req, res) => {
357
+ const url = new URL(req.url, 'http://localhost');
358
+ // Mastodon-style: the bearer-gated client API and the OAuth + nodeinfo
359
+ // routes answer any origin — a browser client is served the way any
360
+ // instance serves it. CORS headers and the preflight make that work; the
361
+ // bearer stays the only credential, and the Host check below (which is
362
+ // what stops DNS rebinding) still runs.
363
+ const apiPath = url.pathname.startsWith('/api/') || url.pathname.startsWith('/oauth/')
364
+ || url.pathname === '/.well-known/nodeinfo' || url.pathname === '/nodeinfo/2.0';
365
+ if (apiPath) {
366
+ res.setHeader('access-control-allow-origin', '*');
367
+ res.setHeader('access-control-expose-headers', 'Link');
368
+ if (req.method === 'OPTIONS') {
369
+ res.writeHead(204, {
370
+ 'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
371
+ 'access-control-allow-headers': 'Authorization, Content-Type, Idempotency-Key',
372
+ 'access-control-max-age': '86400',
373
+ });
374
+ res.end();
375
+ return;
376
+ }
377
+ }
378
+ // Host/Origin firewall: loopback binding alone does not keep a visited web
379
+ // page (or a rebound DNS name) out. The API paths keep the Host check but
380
+ // answer a foreign Origin, per above.
381
+ const bad = checkRequest(req, allowed, { ignoreOrigin: apiPath });
382
+ if (bad) {
383
+ log(`refused: ${bad} (${req.method} ${req.url})`);
384
+ res.writeHead(403, { 'content-type': 'text/plain', ...securityHeaders(allowed, false) });
385
+ res.end('forbidden\n');
386
+ return;
387
+ }
388
+ let p = url.pathname;
389
+ // Embedded, the operator's door is one path on the pod's origin. Behind it
390
+ // is everything that was the admin server; in front of it are the protocol
391
+ // routes, which have to answer strangers because that is what makes the pod
392
+ // an instance other software can talk to.
393
+ let atDoor = !embedded;
394
+ if (embedded && basePath !== '/'
395
+ && (p === basePath.slice(0, -1) || p.startsWith(basePath))) {
396
+ atDoor = true;
397
+ p = p.slice(basePath.length - 1) || '/';
398
+ }
399
+ if (embedded && atDoor && EMBEDDED_CUT.has(p)) {
400
+ return json(res, 404, { error: 'not available on a server-hosted identity' });
401
+ }
402
+ try {
403
+ // C2S (ActivityPub §6) carries its own authentication — a Solid-OIDC
404
+ // DPoP proof or the facade's bearer — so the dk-token gate does not
405
+ // stand in front of it. The Host/Origin firewall above still does.
406
+ if (p === '/ap/outbox' || p === '/ap/actor') {
407
+ if (await c2s.handle(req, res, p, url)) return;
408
+ }
409
+ if (atDoor && gate(req, res)) return;
410
+ if (p === '/api/v1/streaming/health') {
411
+ res.writeHead(200, { 'content-type': 'text/plain' }); res.end('OK'); return;
412
+ }
413
+ // NodeInfo on the agent origin — clients probe it at login.
414
+ if (p === '/.well-known/nodeinfo') {
415
+ return json(res, 200, nodeinfoPointer(
416
+ `${req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'}://${req.headers.host}/nodeinfo/2.0`));
417
+ }
418
+ if (p === '/nodeinfo/2.0') {
419
+ return json(res, 200, nodeinfoDoc({
420
+ version: AGENT_VERSION,
421
+ localPosts: agent.store.countStatuses('post'),
422
+ }));
423
+ }
424
+ // A group serves the client too, as of 2026-08-01. It was withheld on the
425
+ // reasoning that a group has no timeline a human reads — which was wrong
426
+ // twice over: a group has statuses (what it carried) and notifications
427
+ // (who joined), and its operator has a bio to edit and a profile they
428
+ // want to see the way everyone else does. The surface this opens is a
429
+ // login and client tokens, and `passwd` has never had a group carve-out,
430
+ // so a group can be gated exactly like a person before it is exposed.
431
+ if (p.startsWith('/api/') || p.startsWith('/oauth/')) {
432
+ if (await masto.handle(req, res, p, url)) return;
433
+ }
434
+ if (embedded && !atDoor) return json(res, 404, { error: 'unknown endpoint' });
435
+ if (req.method === 'GET' && p === '/status') return json(res, 200, agent.status());
436
+ // The moderation queue: what listed moderators asked for over federation,
437
+ // waiting for the operator to vouch (or not). See POST /modqueue.
438
+ if (req.method === 'GET' && p === '/modqueue') {
439
+ return json(res, 200, agent.store.getConfig()?.kind === 'group'
440
+ ? agent.store.read('modqueue.json', []) : []);
441
+ }
442
+ // The inbox gateway (optional, opt-in). Its state, and the shadow-mode
443
+ // measurement that says whether it is worth trusting. The HMAC secret is
444
+ // never returned — only whether one is set.
445
+ if (req.method === 'GET' && p === '/gateway') {
446
+ const g = agent.store.getConfig()?.gateway || null;
447
+ return json(res, 200, {
448
+ configured: !!(g && g.url),
449
+ url: g?.url || null, webId: g?.webId || null,
450
+ frontActor: g?.frontActor || null,
451
+ mode: g?.mode || 'off', hasSecret: !!g?.hmacSecret,
452
+ stats: agent.store.read('gateway-stats.json', { verified: 0, unverified: 0, lastAt: null }),
453
+ });
454
+ }
455
+ // The other identities on this machine, so the page can link to each one.
456
+ // Their `agent.json` is the only file read — a port and a handle. A
457
+ // sibling's credential and keys are never opened; anything else shown
458
+ // here comes from that agent answering /status for itself.
459
+ if (req.method === 'GET' && p === '/profiles') {
460
+ const here = path.resolve(agent.home || '');
461
+ // Which identity a plain command means: the last one started. A
462
+ // different question from `current`, which is the page you are on.
463
+ const wasLast = defaultProfile(rootOf(here));
464
+ const rows = await Promise.all(identityHomes(rootOf(here)).map(async ({ name, dir }) => {
465
+ // agent.json holds a port AND a handle — setup records both, exactly
466
+ // so the named origin can be built before that agent has said a word.
467
+ let rec = {};
468
+ try { rec = JSON.parse(fs.readFileSync(path.join(dir, 'agent.json'), 'utf8')) || {}; } catch {}
469
+ const port = rec.port || null;
470
+ // A directory under profiles/ is not an identity until it holds a
471
+ // credential or has run somewhere. A half-finished setup leaves one
472
+ // behind, and listing it offers a page that cannot exist yet.
473
+ if (!port && !fs.existsSync(path.join(dir, 'credential.json'))) return null;
474
+ const current = path.resolve(dir) === here;
475
+ const live = current ? agent.status() : port
476
+ ? await localFetch(agent.home, port, '/status', { timeout: 1200 })
477
+ .then(r => r.json()).catch(() => null)
478
+ : null;
479
+ // The handle is right here — spending it on the origin is the whole
480
+ // point of having asked that agent who it is. A stopped one never
481
+ // answers, so its recorded handle stands in: the link has to be named
482
+ // before you get there, or starting it lands you on the shared origin.
483
+ const handle = live?.handle || rec.handle || null;
484
+ const origin = port
485
+ ? secureOrigin(handle, port)
486
+ : null;
487
+ // The fediverse address, assembled from the two things /status has:
488
+ // the handle, and the pod host its actor URL sits on.
489
+ let address = null;
490
+ if (live?.handle && live?.actor) {
491
+ try { address = `${live.handle}@${new URL(live.actor).host}`; } catch { /* not a URL yet */ }
492
+ }
493
+ return {
494
+ name, port, current,
495
+ admin: origin ? `${origin}/admin/` : null,
496
+ app: origin ? `${origin}/` : null,
497
+ handle,
498
+ address,
499
+ lastUsed: name === wasLast,
500
+ kind: live?.kind || null,
501
+ mode: live ? live.mode : null,
502
+ };
503
+ }));
504
+ return json(res, 200, { identities: rows.filter(Boolean) });
505
+ }
506
+ if (req.method === 'GET' && p === '/blocks') return json(res, 200, agent.store.getBlocklist());
507
+ if (req.method === 'GET' && p === '/log') return json(res, 200, { lines: agent.logLines(200) });
508
+ if (req.method === 'GET' && p === '/deadletter') return json(res, 200, { items: agent.store.getDeadLetters() });
509
+ if (req.method === 'GET' && p === '/tagfeed') {
510
+ return json(res, 200, agent.tagfeed
511
+ ? { ...agent.tagfeed.config(), lastSweep: agent.tagfeed.lastSweep, lastAdded: agent.tagfeed.lastAdded }
512
+ : { error: 'agent not configured' });
513
+ }
514
+ // ---- setup and configuration, for the pages under /admin/ ----
515
+ // These answer for a group too: a group is set up in the browser like
516
+ // anything else, and it has a display name to change.
517
+ if (req.method === 'GET' && p === '/setup/state') {
518
+ const home = agent.home || null;
519
+ const held = home && hasCredential(home) ? agent.readCredential?.() : null;
520
+ return json(res, 200, {
521
+ hasCredential: !!(home && hasCredential(home)),
522
+ configured: agent.configured(),
523
+ // Not the same question: a crashed setup leaves a credential with no
524
+ // actor behind it, and that is finishable without minting a second.
525
+ resumable: !!(home && hasCredential(home)) && !agent.configured(),
526
+ running: setupRun?.phase === 'running',
527
+ phase: setupRun?.phase || 'idle',
528
+ home,
529
+ port,
530
+ handle: agent.store.getConfig()?.handle || allowed.label || null,
531
+ kind: agent.store.getConfig()?.kind || null,
532
+ origins: {
533
+ loopback: publicOrigin
534
+ || `https://localhost:${port}/`,
535
+ named: publicOrigin
536
+ ? null
537
+ : allowed.label
538
+ ? (true
539
+ ? `https://${allowed.label}.localhost:${port}/`
540
+ : `http://${allowed.label}.localhost:${port}/`)
541
+ : null,
542
+ },
543
+ // Set in the environment, the password never needs to reach the page.
544
+ passwordSupplied: !!process.env.AP_PASSWORD,
545
+ identity: held ? { pod: held.remotePod, issuer: held.issuerOrigin, root: held.root || null } : null,
546
+ defaults: { issuer: 'https://solidcommunity.net', port: 8030 },
547
+ // What a signup page arranged for this install, so the form opens
548
+ // pre-filled. The receipt secret stays out of it — the /setup run
549
+ // reads that from the file itself.
550
+ firstRun: (() => {
551
+ if (agent.configured()) return null;
552
+ const fr = readFirstRun();
553
+ if (!fr?.handle) return null;
554
+ let gatewayHost = null;
555
+ try { gatewayHost = new URL(fr.gateway).host; } catch { /* no gateway named */ }
556
+ return { pod: fr.pod || null, issuer: fr.issuer || null, handle: String(fr.handle),
557
+ kind: fr.kind === 'group' ? 'group' : 'person', fronted: !!fr.fronted, gatewayHost };
558
+ })(),
559
+ });
560
+ }
561
+ if (req.method === 'GET' && p === '/setup/progress') {
562
+ return json(res, 200, setupRun || { phase: 'idle', steps: [], error: null, result: null });
563
+ }
564
+ if (req.method === 'GET' && p === '/config') {
565
+ const cfg = agent.store.getConfig();
566
+ if (!cfg) return json(res, 409, { error: 'agent not configured — set it up at /admin/setup/' });
567
+ const urls = agent.urls || agent.publisher?.urls || null;
568
+ const wfHost = urls ? new URL(urls.base).host : null;
569
+ return json(res, 200, {
570
+ // permanent
571
+ handle: cfg.handle, remotePod: cfg.remotePod, issuer: cfg.issuer,
572
+ root: cfg.root || null, kind: cfg.kind || 'person',
573
+ actor: urls?.actor || null, webId: agent.readCredential?.()?.webId || null,
574
+ // The opaque id the client addresses this actor by, so the record can
575
+ // link straight at its profile there. Derived from the actor URL the
576
+ // same way every other id is — computed here rather than in the page,
577
+ // which has no business knowing how they are made.
578
+ accountId: urls?.actor ? agent.store.idFor(urls.actor) : null,
579
+ address: wfHost ? `@${cfg.handle}@${wfHost}` : null,
580
+ // editable
581
+ name: cfg.name || null, summary: cfg.summary || null, icon: cfg.icon || null,
582
+ image: cfg.image || null, fields: cfg.fields || [],
583
+ approveJoins: !!cfg.approveJoins, review: !!cfg.review,
584
+ aliases: cfg.aliases || [],
585
+ autoAcceptFollows: !!cfg.autoAcceptFollows,
586
+ // never the record itself
587
+ hasUiPassword: !!cfg.uiPassword,
588
+ quiescedAt: cfg.quiescedAt || null, movedTo: cfg.movedTo || null,
589
+ // Per-machine, so it comes from the credential file, not pod config —
590
+ // and it is a fact here, not a setting: moving it means moving data,
591
+ // which is `fedipod state --to <url>`.
592
+ privateRoot: agent.readCredential?.()?.privateRoot || null,
593
+ mode: agent.status?.().mode || null, port, home: tildify(agent.home) || null,
594
+ update: agent.updateInfo || null,
595
+ pendingUpgrade: agent.pendingUpgrade || [],
596
+ // The connected Bluesky account, non-secret half. `connected` is the
597
+ // credential's word, so a config entry orphaned by a deleted
598
+ // atproto.json shows as disconnected rather than pretending.
599
+ atproto: cfg.atproto
600
+ ? { ...cfg.atproto, connected: !!agent.atproto?.connected() }
601
+ : null,
602
+ origins: {
603
+ loopback: publicOrigin
604
+ || `https://localhost:${port}/`,
605
+ named: publicOrigin
606
+ ? null
607
+ : allowed.label
608
+ ? (true
609
+ ? `https://${allowed.label}.localhost:${port}/`
610
+ : `http://${allowed.label}.localhost:${port}/`)
611
+ : null,
612
+ },
613
+ });
614
+ }
615
+ // How far the CSV import has gotten — polled by the CLI while it runs.
616
+ if (req.method === 'GET' && p === '/import') {
617
+ if (!agent.importer) return json(res, 409, { error: 'agent not connected yet' });
618
+ return json(res, 200, agent.importer.progress());
619
+ }
620
+ // Group-only: who is here, and what the group has carried. A group cannot
621
+ // force an unfollow, so muting — declining to carry — is its whole lever.
622
+ if (req.method === 'GET' && p === '/members') {
623
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
624
+ const muted = agent.store.getMuted().actors;
625
+ return json(res, 200, {
626
+ members: agent.store.getContacts().followers
627
+ .map(f => ({
628
+ actor: f.actor,
629
+ handle: agent.store.handleOf(f.actor),
630
+ inbox: f.sharedInbox || f.inbox,
631
+ muted: muted.includes(f.actor),
632
+ })),
633
+ });
634
+ }
635
+ if (req.method === 'GET' && p === '/announced') {
636
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
637
+ return json(res, 200, {
638
+ announced: agent.store.getStatuses().filter(s => s.announcedAt)
639
+ .map(s => ({ noteId: s.noteId, actor: s.actor, announcedAt: s.announcedAt })),
640
+ });
641
+ }
642
+ if (req.method === 'GET' && p === '/requests') {
643
+ // Not group-only any more: a person queues unverifiable follows here
644
+ // too, and a queue with no way to read it is worse than no queue.
645
+ return json(res, 200, {
646
+ approveJoins: !!agent.store.getConfig()?.approveJoins,
647
+ requests: agent.store.getRequests().map(r => ({ actor: r.actor, at: r.at })),
648
+ });
649
+ }
650
+ if (req.method === 'GET' && p === '/pending') {
651
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
652
+ return json(res, 200, {
653
+ review: !!agent.store.getConfig()?.review,
654
+ pending: agent.store.getPending(),
655
+ });
656
+ }
657
+ if (req.method === 'GET' || req.method === 'HEAD') {
658
+ // Our own pages come before the group check: a group is set up in the
659
+ // browser like anything else, and it has a record to edit. It still
660
+ // serves no fediverse client — see the 404 two lines down.
661
+ const mount = webMount(p);
662
+ if (mount) {
663
+ // Without the slash a page's own relative <script src> resolves one
664
+ // level up and 404s — and that is true at any depth, so ask the
665
+ // filesystem rather than only special-casing the mount itself.
666
+ const asDir = webDirRedirect(p);
667
+ if (asDir) {
668
+ res.writeHead(302, { location: atPath(asDir), ...securityHeaders(allowed, false) });
669
+ res.end();
670
+ return;
671
+ }
672
+ return serveWeb(res, p, mount, allowed);
673
+ }
674
+ // The bare URL means "show me what this agent wants from me now".
675
+ // Keyed on the credential FILE, never on configured(): a healthy
676
+ // install whose pod is briefly unreachable reports itself
677
+ // unconfigured for up to an hour, and must not be sent to setup.
678
+ if (p === '/' || p === '/index.html') {
679
+ if (!(agent.home && hasCredential(agent.home))) {
680
+ res.writeHead(302, { location: atPath(SETUP_PAGE), ...securityHeaders(allowed, false) });
681
+ res.end();
682
+ return;
683
+ }
684
+ // Opening the bare origin gets this actor's own client — the framed
685
+ // view with the bar — not the unbound app. The client page frames
686
+ // `/` itself and that load says so (Sec-Fetch-Dest: iframe), so only
687
+ // a top-level navigation is sent onward. The one top-level landing
688
+ // that must NOT be sent onward is the OAuth return, `/?code=…`: the
689
+ // client registered `/` as its redirect URI and only the app at `/`
690
+ // can exchange the code — the framed page would drop it and leave
691
+ // the client logged out.
692
+ const q = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
693
+ const oauthLanding = /[?&](code|error)=/.test(q);
694
+ if (!oauthLanding && req.headers['sec-fetch-dest'] === 'document') {
695
+ res.writeHead(302, { location: atPath('/admin/client/') + q, ...securityHeaders(allowed, false) });
696
+ res.end();
697
+ return;
698
+ }
699
+ }
700
+ return serveStatic(res, p, allowed);
701
+ }
702
+ if (req.method !== 'POST') return json(res, 405, { error: 'POST only' });
703
+ // A body we refuse to read is the caller's mistake, not ours — 400 says
704
+ // so, where the catch-all below would have called it a server fault.
705
+ let body;
706
+ try { body = await readBody(req); }
707
+ catch (e) { return json(res, 400, { error: e.message }); }
708
+ if (!OPEN_POSTS.has(p) && !agent.configured()) {
709
+ return json(res, 409, { error: 'agent not configured — set it up at /admin/setup/' });
710
+ }
711
+ if (!embedded && LOCAL_ONLY_POSTS.has(p) && !allowed.isLocalRequest(req)) {
712
+ return json(res, 403, { error: 'setup and configuration are available on this machine only' });
713
+ }
714
+ switch (p) {
715
+ // Stop an agent whose pidfile is gone and which no terminal owns
716
+ // (backgrounded, orphaned by a closed shell). It used to sit ABOVE both
717
+ // the configured() gate and the isLocal one, so it was the only
718
+ // state-changing route that needed neither — reachable from any host
719
+ // AP_ALLOWED_HOSTS named, on an agent that had never been set up.
720
+ case '/shutdown': {
721
+ json(res, 200, { ok: true, stopping: process.pid });
722
+ setTimeout(() => process.kill(process.pid, 'SIGTERM'), 50);
723
+ return;
724
+ }
725
+ // ---- setup, driven by the page at /admin/setup/ ----
726
+ case '/setup/check': return json(res, 200, preflight(body));
727
+ case '/setup': {
728
+ // A visited page must not be able to navigate this into existence.
729
+ if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
730
+ if (agent.configured()) {
731
+ return json(res, 409, {
732
+ error: 'this home already holds an identity',
733
+ pod: agent.store.getConfig()?.remotePod || null,
734
+ });
735
+ }
736
+ if (setupRun?.phase === 'running') {
737
+ return json(res, 409, { error: 'setup is already running', phase: 'running' });
738
+ }
739
+ const home = agent.home;
740
+ if (!home) return json(res, 500, { error: 'this agent has no AP_HOME to set up' });
741
+ // A crashed setup left a credential with no actor behind it. That is
742
+ // finishable, and must NOT mint a second credential — the first is
743
+ // unrecoverable and would be orphaned.
744
+ const resuming = hasCredential(home);
745
+ const answers = { ...body, password: body.password || process.env.AP_PASSWORD || '' };
746
+ // The signup carry-over: attach to the gateway the signup arranged,
747
+ // set before the first publish so the actor never advertises the pod
748
+ // inbox first. The secret comes from the installer's file, never the
749
+ // page.
750
+ const gatewayCfg = gatewayConfigFrom(readFirstRun());
751
+ if (gatewayCfg) answers.gateway = gatewayCfg;
752
+ const bad = setupInputError(answers, resuming);
753
+ if (bad) return json(res, 400, { error: bad });
754
+ if (!resuming) {
755
+ const pre = preflight(answers);
756
+ if (!pre.ok) return json(res, 400, { ...pre, error: pre.error || pre.refusal });
757
+ }
758
+ // Widen the allowlist now, not at the end: the page offers to move to
759
+ // the named origin, which has to answer before it is offered.
760
+ allowed.setHandle(answers.handle);
761
+ setupRun = newRun();
762
+ // Deliberately not awaited. The credential a CSS server mints is
763
+ // shown once, so what a run depends on is the file it writes, never
764
+ // a connection a closed tab can take with it.
765
+ runSetup({ home, agent, answers, run: setupRun, log })
766
+ .then((r) => {
767
+ // The carried secret is consumed; a finished setup deletes it.
768
+ if (r?.phase === 'done' && process.env.AP_FIRST_RUN) {
769
+ try { fs.unlinkSync(process.env.AP_FIRST_RUN); } catch { /* already gone */ }
770
+ }
771
+ })
772
+ .catch(e => { setupRun.phase = 'error'; setupRun.error = e.message; });
773
+ return json(res, 202, { ok: true, running: true, resuming });
774
+ }
775
+ // ---- the record, edited from the page at /admin/ ----
776
+ case '/config': {
777
+ const fixed = PERMANENT_CONFIG.filter(k => k in body);
778
+ if (fixed.length) {
779
+ return json(res, 400, {
780
+ error: `${fixed.join(', ')} cannot be changed — that is the identity itself, not a setting`,
781
+ });
782
+ }
783
+ for (const k of ['approveJoins', 'review']) {
784
+ if (k in body && !isGroup()) return json(res, 404, { error: 'not a group' });
785
+ }
786
+ // A user acting here outranks an idle active agent on another device:
787
+ // claim the lease rather than write state that one will clobber.
788
+ await agent.requestTakeover?.();
789
+ // Merge, never replace: the UI password and anything else set later
790
+ // must survive an edit that never mentions it.
791
+ const cfg = { ...agent.store.getConfig() };
792
+ if ('name' in body) {
793
+ if (!body.name) return json(res, 400, { error: 'a display name is required' });
794
+ cfg.name = String(body.name);
795
+ }
796
+ if ('summary' in body) cfg.summary = body.summary || undefined;
797
+ if ('icon' in body) cfg.icon = body.icon || undefined;
798
+ // The banner and the labelled rows, so a client and this page edit one
799
+ // record rather than each other's leftovers.
800
+ if ('image' in body) cfg.image = body.image || undefined;
801
+ if ('fields' in body) {
802
+ cfg.fields = (Array.isArray(body.fields) ? body.fields : [])
803
+ .filter(f => f?.name?.trim())
804
+ .map(f => ({ name: String(f.name).trim(), value: String(f.value ?? '').trim() }));
805
+ }
806
+ if ('approveJoins' in body) cfg.approveJoins = !!body.approveJoins;
807
+ if ('review' in body) cfg.review = !!body.review;
808
+ // A person's queue gate: on, and an inbound Follow is accepted on
809
+ // arrival instead of waiting in the requests queue. Local behavior,
810
+ // not on the wire — but intake reads the LIVE config, so it is
811
+ // patched below whether or not anything republishes.
812
+ if ('autoAcceptFollows' in body) cfg.autoAcceptFollows = !!body.autoAcceptFollows;
813
+ // The moderator roster (FEP-1b12): actor IRIs whose federated
814
+ // moderation asks are queued, published as attributedTo.
815
+ let rosterDiff = null;
816
+ if ('moderators' in body) {
817
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
818
+ const next = [...new Set((Array.isArray(body.moderators) ? body.moderators : [])
819
+ .map(String).filter(m => /^https?:\/\/\S+$/.test(m)))];
820
+ const prev = cfg.moderators || [];
821
+ rosterDiff = {
822
+ added: next.filter(m => !prev.includes(m)),
823
+ removed: prev.filter(m => !next.includes(m)),
824
+ };
825
+ cfg.moderators = next;
826
+ }
827
+ if ('password' in body) {
828
+ // '' clears it. The UI password is what turns the instant OAuth
829
+ // redirect into a login form; switching that off is a real choice.
830
+ if (body.password) cfg.uiPassword = hashPassword(body.password);
831
+ else delete cfg.uiPassword;
832
+ }
833
+ const republish = WIRE_CONFIG.some(k => k in body);
834
+ agent.store.setConfig(cfg);
835
+ if ('autoAcceptFollows' in body && agent.publisher) {
836
+ agent.publisher.config.autoAcceptFollows = cfg.autoAcceptFollows;
837
+ }
838
+ if (republish && agent.publisher) {
839
+ Object.assign(agent.publisher.config, {
840
+ name: cfg.name, summary: cfg.summary, icon: cfg.icon,
841
+ image: cfg.image, fields: cfg.fields,
842
+ approveJoins: !!cfg.approveJoins,
843
+ moderators: cfg.moderators,
844
+ aliases: cfg.aliases,
845
+ });
846
+ }
847
+ await agent.store.flush();
848
+ // Publishing is only half of it: publishProfile re-fetches the public
849
+ // documents unauthenticated and reports the ones a stranger's server
850
+ // could not read. Carrying that back is what lets this be the only
851
+ // republish control there is.
852
+ const pub = republish ? await agent.publisher.publishProfile() : null;
853
+ // Roster changes are announced to the membership (FEP-1b12), so
854
+ // their servers can mirror who moderates here.
855
+ if (rosterDiff && agent.publisher) {
856
+ const urls = agent.publisher.urls;
857
+ for (const [type, list] of [['Add', rosterDiff.added], ['Remove', rosterDiff.removed]]) {
858
+ for (const m of list) {
859
+ await announceModeration(agent, addRemoveActivity({
860
+ urls, type, object: m, target: urls.moderators, serial: Date.now(),
861
+ })).catch(e => log(`moderation announce failed: ${e.message}`));
862
+ }
863
+ }
864
+ }
865
+ return json(res, 200, {
866
+ ok: true, published: republish,
867
+ ...(pub?.unreachable?.length ? { unreachable: pub.unreachable } : {}),
868
+ config: {
869
+ name: cfg.name || null, summary: cfg.summary || null, icon: cfg.icon || null,
870
+ image: cfg.image || null, fields: cfg.fields || [],
871
+ approveJoins: !!cfg.approveJoins, review: !!cfg.review,
872
+ autoAcceptFollows: !!cfg.autoAcceptFollows,
873
+ ...(isGroup() ? { moderators: cfg.moderators || [] } : {}),
874
+ hasUiPassword: !!cfg.uiPassword,
875
+ },
876
+ });
877
+ }
878
+ // Both of these DELETE from the pod's inbox, which is the one thing the
879
+ // lease exists to keep to a single agent. A viewer must claim it first
880
+ // and give up if it cannot — the facade has refused viewer writes since
881
+ // multi-device landed, and these two were simply missed.
882
+ case '/drain': {
883
+ if (!await agent.requestTakeover?.()) {
884
+ return json(res, 503, { error: 'another agent is active for this pod — it is doing the draining' });
885
+ }
886
+ await agent.intake.drain();
887
+ return json(res, 200, agent.status());
888
+ }
889
+ // The owner's answer to "your inbox is very full". Never automatic:
890
+ // discarding someone's mail is their call, not the agent's.
891
+ case '/inbox/prune': {
892
+ if (!body.before) return json(res, 400, { error: 'before (a date) required' });
893
+ if (!await agent.requestTakeover?.()) {
894
+ return json(res, 503, { error: 'another agent is active for this pod — discard from that one' });
895
+ }
896
+ return json(res, 200, await agent.intake.prune({
897
+ before: body.before,
898
+ ...(body.keepConcerning ? { keepConcerning: true } : {}),
899
+ }));
900
+ }
901
+ // Put back the posts a lost or restored machine no longer knows about,
902
+ // from what the pod still serves. It writes the statuses store, which is
903
+ // the lease's business, so it refuses the same way the drain does.
904
+ case '/rebuild': {
905
+ if (!await agent.requestTakeover?.()) {
906
+ return json(res, 503, { error: 'another agent is active for this pod — rebuild from that one' });
907
+ }
908
+ return json(res, 200, await agent.publisher.rebuildStatuses({ fromNotes: !!body.fromNotes }));
909
+ }
910
+ // One identity per home, so a new actor means a new home, a free port
911
+ // and a process there — then its own setup, run with the answers this
912
+ // page collected. The reply is the address of the new actor's page,
913
+ // which is where the progress of that setup is reported.
914
+ case '/new-actor': {
915
+ if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
916
+ const handle = String(body.handle || '').trim().toLowerCase();
917
+ // The name first, and before anything is created: it is what becomes
918
+ // a directory, and "mode must be new or existing" is a poor answer to
919
+ // a handle that could climb out of profiles/.
920
+ // Named after the handle rather than generated, so `profiles` and
921
+ // `--profile <name>` stay legible a year from now.
922
+ if (!/^[a-z0-9][a-z0-9_-]{0,30}$/.test(handle)) {
923
+ return json(res, 400, { error: 'a handle is letters, digits, hyphens and underscores' });
924
+ }
925
+ const dir = path.join(rootOf(path.resolve(agent.home || '')), 'profiles', handle);
926
+ if (fs.existsSync(path.join(dir, 'credential.json'))) {
927
+ return json(res, 409, { error: `${handle} already exists — it is in the list above` });
928
+ }
929
+ // Then everything else setup needs, so a missing password is refused
930
+ // here rather than after a process has been started for it.
931
+ const bad = setupInputError({ ...body, handle });
932
+ if (bad) return json(res, 400, { error: bad });
933
+ const newPort = await freePortFrom(port + 1);
934
+ if (!newPort) return json(res, 503, { error: 'no free port in the next 50' });
935
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
936
+ const child = spawn(process.execPath, [path.join(projectRoot, 'run-agent.mjs')], {
937
+ detached: true, stdio: 'ignore',
938
+ env: { ...process.env, AP_HOME: dir, AP_PORT: String(newPort), AP_PROFILE: '' },
939
+ });
940
+ child.unref();
941
+ // Answer only once it is listening: handing back a URL that is not
942
+ // up yet shows a connection error instead of the setup form.
943
+ let listening = false;
944
+ for (let i = 0; i < 60 && !listening; i++) {
945
+ await new Promise(r => setTimeout(r, 250));
946
+ listening = await localFetch(agent.home, newPort, '/status', { timeout: 1000 })
947
+ .then(r => r.ok).catch(() => false);
948
+ }
949
+ if (!listening) return json(res, 504, { error: `started it on ${newPort} but it never answered` });
950
+
951
+ // Its own /setup owns the work — minting the credential, provisioning
952
+ // the pod, publishing. It answers 202 and runs in the background, so
953
+ // the page that opens next is the one reporting progress.
954
+ const started = await localFetch(agent.home, newPort, '/setup', {
955
+ method: 'POST',
956
+ headers: { 'content-type': 'application/json' },
957
+ body: JSON.stringify({ ...body, handle }),
958
+ }).then(async r => ({ status: r.status, json: await r.json().catch(() => null) }))
959
+ .catch(e => ({ status: 0, json: { error: e.message } }));
960
+ if (started.status !== 202) {
961
+ return json(res, started.status || 502, {
962
+ error: started.json?.error || `the new agent refused setup (HTTP ${started.status})`,
963
+ port: newPort, url: `${secureOrigin(handle, newPort)}${SETUP_PAGE}`,
964
+ });
965
+ }
966
+ return json(res, 200,
967
+ { ok: true, handle, port: newPort,
968
+ url: `${secureOrigin(handle, newPort)}${SETUP_PAGE}` });
969
+ }
970
+ // Start an actor that is not running, so its page can be visited. The
971
+ // same spawn as /new-actor, minus the making: this one has a home
972
+ // already. Its recorded port is preferred so its address stays what it
973
+ // was, but a port something else has taken is walked past, not fought.
974
+ case '/start-actor': {
975
+ if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
976
+ const name = String(body.name || '');
977
+ const found = identityHomes(rootOf(path.resolve(agent.home || ''))).find(h => h.name === name);
978
+ if (!found) return json(res, 404, { error: `no identity called ${name}` });
979
+
980
+ let want = null;
981
+ try { want = JSON.parse(fs.readFileSync(path.join(found.dir, 'agent.json'), 'utf8')).port || null; } catch {}
982
+ if (want) {
983
+ // Its own handle, not the profile directory's name: usually the
984
+ // same, but a renamed profile makes them differ and the origin has
985
+ // to be one that agent's own guard will accept.
986
+ const live = await localFetch(agent.home, want, '/status', { timeout: 1000 })
987
+ .then(r => (r.ok ? r.json() : null)).catch(() => null);
988
+ if (live) {
989
+ return json(res, 200, {
990
+ ok: true, name, port: want, already: true,
991
+ url: `${secureOrigin(live.handle || name, want)}/`,
992
+ });
993
+ }
994
+ }
995
+ // The port is not the only evidence it is running. An agent from
996
+ // before agent.json was written at startup has a live pidfile and no
997
+ // recorded port, and spawning past that gives one home two agents —
998
+ // which is exactly how this machine ended up with two on profiles/jeff.
999
+ let held = null;
1000
+ try { held = Number(fs.readFileSync(path.join(found.dir, 'agent.pid'), 'utf8').trim()) || null; } catch {}
1001
+ if (held) {
1002
+ let alive = false;
1003
+ try { process.kill(held, 0); alive = true; } catch { /* gone */ }
1004
+ if (alive) {
1005
+ return json(res, 409, {
1006
+ error: `${name} is already running as pid ${held}, on a port it never recorded. `
1007
+ + `Stop it first: kill ${held}`,
1008
+ });
1009
+ }
1010
+ }
1011
+ // A recorded port held by the directory door is still this
1012
+ // identity's port — the door steps aside for its owner.
1013
+ if (want && !await portFree(want)) await yieldDirectory(want, { portFree });
1014
+ const on = (want && await portFree(want)) ? want : await freePortFrom(port + 1);
1015
+ if (!on) return json(res, 503, { error: 'no free port in the next 50' });
1016
+
1017
+ const started = spawn(process.execPath, [path.join(projectRoot, 'run-agent.mjs')], {
1018
+ detached: true, stdio: 'ignore',
1019
+ env: { ...process.env, AP_HOME: found.dir, AP_PORT: String(on), AP_PROFILE: '' },
1020
+ });
1021
+ started.unref();
1022
+ for (let i = 0; i < 60; i++) {
1023
+ await new Promise(r => setTimeout(r, 250));
1024
+ const up = await localFetch(agent.home, on, '/status', { timeout: 1000 })
1025
+ .then(r => (r.ok ? r.json() : null)).catch(() => null);
1026
+ if (up) {
1027
+ return json(res, 200, { ok: true, name, port: on,
1028
+ url: `${secureOrigin(up.handle || name, on)}/` });
1029
+ }
1030
+ }
1031
+ return json(res, 504, { error: `started ${name} on ${on} but it never answered` });
1032
+ }
1033
+ // Pull the latest published FediPod and restart the agents. What the
1034
+ // installer does on a re-run, offered where the version line is.
1035
+ case '/update': {
1036
+ if (isCrossSiteNavigation(req)) return json(res, 403, { error: 'cross-site request' });
1037
+ const { runUpdate, restartAgents, repoRoot } = await import('./update.mjs');
1038
+ const r = runUpdate({ log });
1039
+ if (!r.ok) return json(res, 409, { error: r.note });
1040
+ json(res, 200, { ok: true, note: r.note, restarting: true });
1041
+ // The response first, the restart a beat later — a self-restart must
1042
+ // not take the answer down with it.
1043
+ setTimeout(() => {
1044
+ if (restartAgents({ log }) === 'self') {
1045
+ const child = spawn(process.execPath, [path.join(repoRoot(), 'run-agent.mjs')],
1046
+ { detached: true, stdio: 'ignore', env: process.env });
1047
+ child.unref();
1048
+ setTimeout(() => process.exit(0), 300);
1049
+ }
1050
+ }, 200);
1051
+ return;
1052
+ }
1053
+ case '/publish-profile': {
1054
+ // The explicit "republish now" control. Asking for it IS the reason.
1055
+ const r = await agent.publisher.publishProfile({ force: true });
1056
+ return json(res, 200, { ok: true, ...(r?.unreachable?.length ? { unreachable: r.unreachable } : {}) });
1057
+ }
1058
+ // ---- lifecycle: what the CLI calls park | revive | rotate-key | retire ----
1059
+ // Each claims the lease first, for the same reason /config does: someone
1060
+ // acting on this page outranks an idle active agent on another device.
1061
+ // Asking first is the page's job — see the warnings in its markup.
1062
+ case '/park': {
1063
+ await agent.requestTakeover?.();
1064
+ return json(res, 200, { ok: true, ...await agent.park() });
1065
+ }
1066
+ case '/revive': {
1067
+ await agent.requestTakeover?.();
1068
+ return json(res, 200, { ok: true, ...await agent.revive() });
1069
+ }
1070
+ case '/rotate-key': {
1071
+ await agent.requestTakeover?.();
1072
+ const r = await agent.rotateKey();
1073
+ return json(res, 200, { ok: true, changed: !!r?.changed });
1074
+ }
1075
+ // Hand the identity on. Federated and effectively one-way: the Move
1076
+ // tells every follower's server to migrate them, and the actor is left
1077
+ // advertising movedTo so the old handle redirects. Same typed-handle
1078
+ // interlock as retire — a stray click cannot produce it.
1079
+ case '/move': {
1080
+ if (!body.target) return json(res, 400, { error: 'target required' });
1081
+ if (!body.confirm || body.confirm !== agent.store.getConfig()?.handle) {
1082
+ return json(res, 400, { error: 'type the handle to confirm' });
1083
+ }
1084
+ await agent.requestTakeover?.();
1085
+ // A handle is not an actor URI, and the Move's target has to be one or
1086
+ // the far side has nothing to migrate anyone to. The CLI resolves it
1087
+ // the same way; doing it here rather than in the page keeps the one
1088
+ // WebFinger lookup on the side that already knows how.
1089
+ let target = String(body.target).trim();
1090
+ if (!/^https?:\/\//.test(target)) {
1091
+ const doc = await resolveHandle(agent, target);
1092
+ if (!doc?.id) return json(res, 400, { error: `could not resolve ${target}` });
1093
+ target = doc.id;
1094
+ }
1095
+ return json(res, 200, { ok: true, ...await agent.moveTo(target) });
1096
+ }
1097
+ // The inbound half of a migration: list an old account elsewhere in
1098
+ // this actor's alsoKnownAs, which is what the old server checks for
1099
+ // before it will send a Move here. Whatever is entered is resolved to
1100
+ // the account's canonical id — the check on the far side is an exact
1101
+ // string match, so a pasted profile URL stored as typed would fail it.
1102
+ case '/alias': {
1103
+ const urls = agent.publisher?.urls;
1104
+ if (!urls) return json(res, 409, { error: 'agent not connected yet' });
1105
+ if (!body.add && !body.remove) return json(res, 400, { error: 'add or remove required' });
1106
+ await agent.requestTakeover?.();
1107
+ const cfg = { ...agent.store.getConfig() };
1108
+ const aliases = [...(cfg.aliases || [])];
1109
+ if (body.add) {
1110
+ // A Move target nobody can resolve is a landing pad nobody lands on.
1111
+ if (!webfingerHost(urls.base) && !cfg.gateway?.frontActor) {
1112
+ return json(res, 400, {
1113
+ error: 'this pod is a path on a shared host, so WebFinger cannot answer for it '
1114
+ + '— other servers could never resolve this account as a Move target',
1115
+ });
1116
+ }
1117
+ const input = String(body.add).trim().replace(/^@/, '');
1118
+ let doc = /^https?:\/\//.test(input)
1119
+ ? await agent.intake.fetchAP(input).catch(() => null)
1120
+ : await resolveHandle(agent, input).catch(() => null);
1121
+ // A URL may answer with any id it likes; before that id is stored
1122
+ // it must vouch for itself — the intake's verify-by-deref.
1123
+ if (doc?.id && /^https?:\/\//.test(input) && doc.id !== input) {
1124
+ const own = await agent.intake.fetchAP(doc.id).catch(() => null);
1125
+ doc = own?.id === doc.id ? own : null;
1126
+ }
1127
+ if (!doc?.id) {
1128
+ return json(res, 400, { error: `could not fetch the old account (${input}) — it must exist and answer` });
1129
+ }
1130
+ if (doc.id === urls.actor) return json(res, 400, { error: 'that is this account' });
1131
+ if (!aliases.includes(doc.id)) aliases.push(doc.id);
1132
+ } else {
1133
+ const target = String(body.remove).trim();
1134
+ if (!aliases.includes(target)) return json(res, 404, { error: 'no such alias' });
1135
+ // Follower servers process a Move on their own retry schedules,
1136
+ // over days — an alias removed early strands the late ones.
1137
+ if (!body.confirm) {
1138
+ return json(res, 409, {
1139
+ error: 'servers still retrying the Move check this alias and would strand '
1140
+ + 'their followers — send confirm: true to remove it anyway',
1141
+ });
1142
+ }
1143
+ aliases.splice(aliases.indexOf(target), 1);
1144
+ }
1145
+ cfg.aliases = aliases;
1146
+ agent.store.setConfig(cfg);
1147
+ agent.publisher.config.aliases = aliases;
1148
+ await agent.store.flush();
1149
+ const pub = await agent.publisher.publishProfile();
1150
+ return json(res, 200, {
1151
+ ok: true, aliases,
1152
+ ...(pub?.unreachable?.length ? { unreachable: pub.unreachable } : {}),
1153
+ });
1154
+ }
1155
+ // Stage a CSV export from the old account. Rows are applied by the
1156
+ // agent's paced worker, not in this request — big lists take a while,
1157
+ // and GET /import is the window onto how far it has gotten.
1158
+ case '/import': {
1159
+ if (!agent.importer) return json(res, 409, { error: 'agent not connected yet' });
1160
+ // Unlike the one-shot routes, staging arms a worker that keeps
1161
+ // writing for minutes — a device that could not take the lease must
1162
+ // not run one alongside the device that holds it.
1163
+ const took = await agent.requestTakeover?.();
1164
+ if (took === false) {
1165
+ return json(res, 503, { error: 'another device is active for this pod — import from there' });
1166
+ }
1167
+ if (body.clear === true) {
1168
+ agent.importer.clear();
1169
+ return json(res, 200, { ok: true, cleared: true });
1170
+ }
1171
+ if (!IMPORT_KINDS.includes(body.kind)) {
1172
+ return json(res, 400, { error: `kind must be one of: ${IMPORT_KINDS.join(', ')}` });
1173
+ }
1174
+ if (typeof body.text !== 'string' || !body.text.trim()) {
1175
+ return json(res, 400, { error: 'text required — the CSV file contents' });
1176
+ }
1177
+ const { values, invalid } = normalizeImport(body.kind, body.text);
1178
+ const r = agent.importer.stage(body.kind, values);
1179
+ await agent.store.flush();
1180
+ return json(res, 200, {
1181
+ ok: true, kind: body.kind, ...r,
1182
+ invalid: invalid.length,
1183
+ ...(invalid.length ? { invalidSample: invalid.slice(0, 5) } : {}),
1184
+ });
1185
+ }
1186
+ // Move the private half — every state document and the RDF tree — while
1187
+ // the agent runs: quiesce this process's writers, flush, copy, verify,
1188
+ // repoint the credential, then reconnect on the new location. The old
1189
+ // copy is left where it was, exactly as the CLI move leaves it.
1190
+ case '/state-move': {
1191
+ const cred = agent.readCredential?.();
1192
+ if (!cred) return json(res, 409, { error: 'no credential — run setup first' });
1193
+ const to = String(body.to || '').trim();
1194
+ if (!to) return json(res, 400, { error: 'say where it should go' });
1195
+ let target = null;
1196
+ if (to !== 'pod') {
1197
+ // A path or a URL — two chars before the colon, so a Windows drive
1198
+ // letter reads as a path rather than a scheme.
1199
+ const asPath = !/^[a-z][a-z0-9+.-]+:/i.test(to);
1200
+ const raw = asPath
1201
+ ? pathToFileURL(path.resolve(to.replace(/^~(?=[/\\]|$)/, os.homedir()))).href
1202
+ : to;
1203
+ target = raw.endsWith('/') ? raw : raw + '/';
1204
+ try { new URL(target); } catch { return json(res, 400, { error: `"${to}" is not a container URL or a path` }); }
1205
+ if (/^https?:/i.test(target)) {
1206
+ const bad = insecureUrlReason(target, 'private-data address');
1207
+ if (bad) return json(res, 400, { error: bad });
1208
+ }
1209
+ }
1210
+ const whereState = (c) => (c.privateRoot ? tildify(c.privateRoot) : 'on the pod');
1211
+ if ((cred.privateRoot || null) === target) {
1212
+ return json(res, 200, { ok: true, docs: 0, notes: 0, unchanged: true, now: whereState(cred) });
1213
+ }
1214
+ await agent.requestTakeover?.();
1215
+ agent.intake?.stop(); agent.deliverer?.stop(); agent.tagfeed?.stop(); agent.importer?.stop();
1216
+ try {
1217
+ await agent.store.flush();
1218
+ const destCred = { ...cred, privateRoot: target };
1219
+ const copied = await copyPrivateHalf({
1220
+ from: { state: agent.privateStorage(cred, 'state'), fediverse: agent.privateStorage(cred, 'fediverse') },
1221
+ to: { state: agent.privateStorage(destCred, 'state'), fediverse: agent.privateStorage(destCred, 'fediverse') },
1222
+ log,
1223
+ });
1224
+ if (target) cred.privateRoot = target; else delete cred.privateRoot;
1225
+ if (isCurrent(cred)) cred.layout = CURRENT_LAYOUT; else delete cred.layout;
1226
+ writeJsonAtomic(path.join(agent.home, 'credential.json'), cred);
1227
+ // connect() rebuilds everything that pointed at the old location —
1228
+ // store, RDF tree, publisher, intake — and restarts them.
1229
+ agent.store.attach(agent.privateStorage(cred, 'state'));
1230
+ agent.stateLoaded = false;
1231
+ await agent.connect();
1232
+ return json(res, 200, { ok: true, ...copied, now: whereState(cred) });
1233
+ } catch (e) {
1234
+ // The credential is only repointed after a verified copy, so on any
1235
+ // failure reconnect puts the agent back to work where it was.
1236
+ agent.stateLoaded = false;
1237
+ await agent.connect().catch(() => {});
1238
+ return json(res, 502, { error: e.message });
1239
+ }
1240
+ }
1241
+ case '/retire': {
1242
+ // Irreversible and federated: a Delete leaves for every follower and
1243
+ // the actor becomes a Tombstone. The typed handle is the interlock —
1244
+ // a stray click cannot produce it, and nor can anything that never
1245
+ // read the record.
1246
+ if (!body.confirm || body.confirm !== agent.store.getConfig()?.handle) {
1247
+ return json(res, 400, { error: 'type the handle to confirm' });
1248
+ }
1249
+ await agent.requestTakeover?.();
1250
+ return json(res, 200, { ok: true, ...await agent.publisher.retireActor() });
1251
+ }
1252
+ case '/post': {
1253
+ if (!body.content) return json(res, 400, { error: 'content required' });
1254
+ const note = await agent.publisher.publishNote(body.content, { inReplyTo: body.inReplyTo });
1255
+ return json(res, 200, { ok: true, id: note.id });
1256
+ }
1257
+ case '/tagfeed': return json(res, 200, agent.tagfeed.setConfig(body));
1258
+ case '/atproto/connect': {
1259
+ // The one endpoint that ever sees the app password; loopback-only.
1260
+ if (!body.identifier || !body.appPassword) {
1261
+ return json(res, 400, { error: 'identifier and appPassword required' });
1262
+ }
1263
+ const conn = await agent.atproto.connect({
1264
+ service: body.service, identifier: body.identifier, appPassword: body.appPassword,
1265
+ });
1266
+ const cfg = agent.store.getConfig();
1267
+ agent.store.setConfig({
1268
+ ...cfg,
1269
+ atproto: { ...conn, crossPost: cfg.atproto?.crossPost ?? true },
1270
+ });
1271
+ await agent.store.flush();
1272
+ agent.startBsky?.();
1273
+ return json(res, 200, { ok: true, ...agent.atproto.status() });
1274
+ }
1275
+ case '/atproto/disconnect': {
1276
+ agent.stopBsky?.();
1277
+ await agent.atproto.disconnect();
1278
+ const { atproto, ...rest } = agent.store.getConfig();
1279
+ agent.store.setConfig(rest);
1280
+ await agent.store.flush();
1281
+ return json(res, 200, { ok: true });
1282
+ }
1283
+ case '/atproto': {
1284
+ // Non-secret settings only — today that is the cross-post toggle.
1285
+ const cfg = agent.store.getConfig();
1286
+ if (!cfg.atproto) return json(res, 400, { error: 'no bluesky account connected' });
1287
+ agent.store.setConfig({
1288
+ ...cfg, atproto: { ...cfg.atproto, crossPost: !!body.crossPost },
1289
+ });
1290
+ await agent.store.flush();
1291
+ return json(res, 200, { ok: true, atproto: { ...cfg.atproto, crossPost: !!body.crossPost } });
1292
+ }
1293
+ case '/archive': {
1294
+ // Whether drained mail's original bytes are kept in the private
1295
+ // half's inbox-archive/. Absent means on.
1296
+ const cfg = agent.store.getConfig();
1297
+ agent.store.setConfig({ ...cfg, archiveInbox: !!body.on });
1298
+ await agent.store.flush();
1299
+ return json(res, 200, { ok: true, archiveInbox: !!body.on });
1300
+ }
1301
+ case '/follow': {
1302
+ // By handle normally; by actor URL when there is no handle to resolve —
1303
+ // a pod on a path, or anything WebFinger cannot answer for. Pasting an
1304
+ // actor URL is a normal way to follow in Mastodon too.
1305
+ if (body.actor) { await followActor(agent, body.actor); return json(res, 200, { ok: true, actor: body.actor }); }
1306
+ if (!body.handle) return json(res, 400, { error: 'handle or actor required' });
1307
+ return json(res, 200, await followHandle(agent, body.handle));
1308
+ }
1309
+ case '/unfollow': return json(res, 200, await unfollowActor(agent, body.actor));
1310
+ case '/mute':
1311
+ case '/unmute': {
1312
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
1313
+ if (!body.actor) return json(res, 400, { error: 'actor required' });
1314
+ const m = agent.store.getMuted();
1315
+ m.actors = p === '/mute'
1316
+ ? [...new Set([...m.actors, body.actor])]
1317
+ : m.actors.filter(a => a !== body.actor);
1318
+ agent.store.setMuted(m);
1319
+ await agent.store.flush();
1320
+ return json(res, 200, { ok: true, actors: m.actors });
1321
+ }
1322
+ case '/eject': {
1323
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
1324
+ if (!body.actor) return json(res, 400, { error: 'actor required' });
1325
+ const r = await ejectFollower(agent, body.actor);
1326
+ await agent.store.flush();
1327
+ return json(res, 200, r);
1328
+ }
1329
+ case '/retract': {
1330
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
1331
+ if (!body.noteId) return json(res, 400, { error: 'noteId required' });
1332
+ const r = await retractAnnouncement(agent, body.noteId);
1333
+ await agent.store.flush();
1334
+ return json(res, 200, r);
1335
+ }
1336
+ case '/review': {
1337
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
1338
+ agent.store.setConfig({ ...agent.store.getConfig(), review: !!body.on });
1339
+ await agent.store.flush();
1340
+ return json(res, 200, { ok: true, review: !!body.on });
1341
+ }
1342
+ case '/describe': {
1343
+ const cfg = { ...agent.store.getConfig() };
1344
+ if ('summary' in body) cfg.summary = body.summary || undefined;
1345
+ if ('icon' in body) cfg.icon = body.icon || undefined;
1346
+ agent.store.setConfig(cfg);
1347
+ Object.assign(agent.publisher.config, { summary: cfg.summary, icon: cfg.icon });
1348
+ await agent.store.flush();
1349
+ await agent.publisher.publishProfile(); // the bio and avatar are on the wire
1350
+ return json(res, 200, { ok: true, summary: cfg.summary || null, icon: cfg.icon || null });
1351
+ }
1352
+ case '/joins': {
1353
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
1354
+ const cfg = { ...agent.store.getConfig(), approveJoins: !!body.approve };
1355
+ agent.store.setConfig(cfg);
1356
+ agent.publisher.config.approveJoins = !!body.approve;
1357
+ await agent.store.flush();
1358
+ // Unlike review, this one is visible to the fediverse: the actor
1359
+ // document carries manuallyApprovesFollowers, so nothing changes for
1360
+ // anyone until it is republished.
1361
+ await agent.publisher.publishProfile();
1362
+ return json(res, 200, { ok: true, approveJoins: !!body.approve });
1363
+ }
1364
+ case '/admit':
1365
+ case '/refuse': {
1366
+ // Kind-agnostic, like the queue they act on: admitting adds a
1367
+ // follower and Accepts, refusing Rejects. A person needs both.
1368
+ // `all` answers the whole queue — the shape a migration wave arrives
1369
+ // in — with one collections republish for the lot.
1370
+ if (p === '/admit' && body.all === true) {
1371
+ const waiting = agent.store.getRequests().map(r => r.actor);
1372
+ let admitted = 0;
1373
+ for (const actor of waiting) {
1374
+ try { await admitRequest(agent, actor, { publish: false }); admitted++; }
1375
+ catch (e) { log(`admit ${actor} failed: ${e.message}`); }
1376
+ }
1377
+ if (admitted) await agent.publisher.publishCollections({ followers: true, pending: true });
1378
+ await agent.store.flush();
1379
+ return json(res, 200, { ok: true, admitted, requests: agent.store.getRequests().length });
1380
+ }
1381
+ if (!body.actor) return json(res, 400, { error: 'actor required' });
1382
+ const r = p === '/admit' ? await admitRequest(agent, body.actor)
1383
+ : await refuseRequest(agent, body.actor);
1384
+ await agent.store.flush();
1385
+ return json(res, 200, { ...r, requests: agent.store.getRequests().length });
1386
+ }
1387
+ case '/approve':
1388
+ case '/decline': {
1389
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
1390
+ if (!body.noteId) return json(res, 400, { error: 'noteId required' });
1391
+ const held = agent.store.getPending().some(x => x.noteId === body.noteId);
1392
+ if (!held) return json(res, 404, { error: 'not held for review' });
1393
+ if (p === '/approve') await agent.intake.amplify(body.noteId, { approved: true });
1394
+ else agent.store.setPending(agent.store.getPending().filter(x => x.noteId !== body.noteId));
1395
+ await agent.store.flush();
1396
+ return json(res, 200, { ok: true, noteId: body.noteId, pending: agent.store.getPending().length });
1397
+ }
1398
+ // Answer one queued moderation ask: apply it (the operator vouching
1399
+ // for a delivery nothing else can vouch for) or dismiss it.
1400
+ case '/modqueue': {
1401
+ if (!isGroup()) return json(res, 404, { error: 'not a group' });
1402
+ const q = agent.store.read('modqueue.json', []);
1403
+ const entry = q.find(e => e.id === body.id);
1404
+ if (!entry) return json(res, 404, { error: 'no such queued action' });
1405
+ if (body.action !== 'apply' && body.action !== 'dismiss') {
1406
+ return json(res, 400, { error: 'action must be apply or dismiss' });
1407
+ }
1408
+ let applied = null;
1409
+ if (body.action === 'apply') applied = await applyModeration(agent, entry);
1410
+ agent.store.write('modqueue.json', q.filter(e => e.id !== entry.id));
1411
+ await agent.store.flush();
1412
+ return json(res, 200, {
1413
+ ok: true, action: body.action, type: entry.type, moderator: entry.moderator,
1414
+ ...(applied && typeof applied === 'object' ? { result: applied } : {}),
1415
+ remaining: agent.store.read('modqueue.json', []).length,
1416
+ });
1417
+ }
1418
+ // The inbox gateway lifecycle (opt-in). `configure` points at a
1419
+ // deployed gateway (and reveals the HMAC secret once, to paste into
1420
+ // its env); `mode` walks off→shadow→trust→locked and back, each step
1421
+ // reversible; `forget` clears it and restores the pod inbox.
1422
+ case '/gateway': {
1423
+ const cfg = { ...agent.store.getConfig() };
1424
+ const g = { ...(cfg.gateway || {}) };
1425
+ const inboxUrl = agent.publisher?.urls?.inbox;
1426
+ await agent.requestTakeover?.();
1427
+ const persist = async () => {
1428
+ cfg.gateway = g; agent.store.setConfig(cfg);
1429
+ if (agent.publisher) agent.publisher.config.gateway = g;
1430
+ await agent.store.flush();
1431
+ };
1432
+ if (body.action === 'configure') {
1433
+ if (!/^https:\/\/\S+$/.test(String(body.url || ''))) return json(res, 400, { error: 'gateway url must be https' });
1434
+ if (!/^https?:\/\/\S+$/.test(String(body.webId || ''))) return json(res, 400, { error: 'gateway webId must be a URL' });
1435
+ g.url = String(body.url); g.webId = String(body.webId);
1436
+ g.hmacSecret = g.hmacSecret || crypto.randomBytes(32).toString('base64');
1437
+ g.mode = g.mode || 'off';
1438
+ // The fronted actor URL, for a multi-user front that gives this
1439
+ // identity a @name@front handle: the agent then publishes and signs
1440
+ // under it. Optional; setting it on a published identity renames
1441
+ // every id (a Move), so it belongs at setup or a fresh identity.
1442
+ if ('frontActor' in body) {
1443
+ const fa = String(body.frontActor || '');
1444
+ if (fa && !/^https:\/\/\S+\/ap\/actor$/.test(fa)) {
1445
+ return json(res, 400, { error: 'frontActor must be an https …/ap/actor URL' });
1446
+ }
1447
+ g.frontActor = fa || undefined;
1448
+ }
1449
+ await persist();
1450
+ // The one time the secret is returned: the operator pastes it into
1451
+ // the gateway's deploy env. /gateway GET never reveals it again.
1452
+ return json(res, 200, { ok: true, mode: g.mode, url: g.url, webId: g.webId, hmacSecret: g.hmacSecret });
1453
+ }
1454
+ if (body.action === 'mode') {
1455
+ const target = body.mode;
1456
+ if (!['off', 'shadow', 'trust', 'locked'].includes(target)) {
1457
+ return json(res, 400, { error: 'mode must be off, shadow, trust or locked' });
1458
+ }
1459
+ if (target !== 'off' && !g.url) return json(res, 400, { error: 'point at a gateway first (action: configure)' });
1460
+ // Locking needs the door's own WebID to name in the ACL. Without it
1461
+ // the mode used to persist and the ACL be written for an undefined
1462
+ // agent, leaving config and pod disagreeing about who may write.
1463
+ if (target === 'locked' && !g.webId) {
1464
+ return json(res, 400, {
1465
+ error: 'locked needs the gateway\'s WebID — set it with action: configure, webId: <door-webid>',
1466
+ });
1467
+ }
1468
+ const prev = g.mode || 'off';
1469
+ // The ACL first: a lock that fails must not leave the mode recorded
1470
+ // as locked while the pod still accepts anyone's writes.
1471
+ if (target === 'locked') await agent.publisher?.lockInboxToGateway(g.webId);
1472
+ else if (prev === 'locked' && inboxUrl) await agent.remote.setAcl(inboxUrl, ['Append']);
1473
+ g.mode = target;
1474
+ await persist();
1475
+ // Advertisement flips only when crossing the off boundary (the actor
1476
+ // doc's inbox changes → publishProfile republishes, digest-gated).
1477
+ if ((prev === 'off') !== (target === 'off')) await agent.publisher?.publishProfile();
1478
+ else if (target !== 'off') await agent.publisher?.publishGatewayPolicy().catch(() => {});
1479
+ return json(res, 200, { ok: true, mode: g.mode });
1480
+ }
1481
+ if (body.action === 'forget') {
1482
+ const wasLocked = g.mode === 'locked';
1483
+ delete cfg.gateway; agent.store.setConfig(cfg);
1484
+ if (agent.publisher) agent.publisher.config.gateway = undefined;
1485
+ await agent.store.flush();
1486
+ if (wasLocked && inboxUrl) await agent.remote.setAcl(inboxUrl, ['Append']).catch(() => {});
1487
+ await agent.publisher?.publishProfile(); // re-advertise the pod inbox
1488
+ return json(res, 200, { ok: true, mode: 'off', forgotten: true });
1489
+ }
1490
+ return json(res, 400, { error: 'action must be configure, mode or forget' });
1491
+ }
1492
+ // Symmetrical with /block, and open for the same reason: a block made
1493
+ // by mistake is worth undoing before federation is even configured.
1494
+ case '/unblock': {
1495
+ const b = agent.store.getBlocklist();
1496
+ const before = b.domains.length + b.actors.length;
1497
+ if (body.actor) b.actors = b.actors.filter(a => a !== body.actor);
1498
+ else if (body.domain) b.domains = b.domains.filter(d => d !== body.domain);
1499
+ else return json(res, 400, { error: 'domain or actor required' });
1500
+ agent.store.setBlocklist(b);
1501
+ // The published blocked collection (FEP-c648) follows, best-effort.
1502
+ Promise.resolve(agent.publisher?.publishCollections?.({ blocked: true })).catch(() => {});
1503
+ return json(res, 200, {
1504
+ ok: true, removed: before - (b.domains.length + b.actors.length),
1505
+ domains: b.domains, actors: b.actors,
1506
+ });
1507
+ }
1508
+ case '/block': {
1509
+ const b = agent.store.getBlocklist();
1510
+ if (body.actor) {
1511
+ if (!/^https?:\/\/\S+$/.test(body.actor)) return json(res, 400, { error: 'actor must be a URL' });
1512
+ if (!b.actors.includes(body.actor)) b.actors.push(body.actor);
1513
+ } else if (body.domain) {
1514
+ if (!b.domains.includes(body.domain)) b.domains.push(body.domain);
1515
+ } else {
1516
+ return json(res, 400, { error: 'domain or actor required' });
1517
+ }
1518
+ agent.store.setBlocklist(b);
1519
+ // The published blocked collection (FEP-c648) follows, best-effort.
1520
+ Promise.resolve(agent.publisher?.publishCollections?.({ blocked: true })).catch(() => {});
1521
+ return json(res, 200, { ok: true, domains: b.domains, actors: b.actors });
1522
+ }
1523
+ default: return json(res, 404, { error: 'unknown endpoint' });
1524
+ }
1525
+ } catch (e) {
1526
+ // The caller gets the real message: this server binds loopback only, sets
1527
+ // no CORS headers, and its one reader is the operator. Hiding the reason
1528
+ // from them buys nothing and costs a trip to the log. Stack stays here.
1529
+ log(`admin ${p}: ${e.stack || e.message}`);
1530
+ return json(res, 500, { error: e.message || String(e) });
1531
+ }
1532
+ };
1533
+
1534
+ return { handler, masto, c2s, streaming };
1535
+ }
1536
+
1537
+ export function startAdmin({ port, gateToken, agent, log = console.log, handle = null, tls = null }) {
1538
+ const gate = makeGate(gateToken);
1539
+ // Live, so the named origin appears the moment connect() reads the handle
1540
+ // out of pod state — including for the OAuth redirect check in MastoApi.
1541
+ // The https listener's port joins the authority set: same names, second port.
1542
+ const allowed = new Authorities(port, handle);
1543
+ agent.authorities = allowed;
1544
+ const { handler, streaming } = buildAdminSurface({ agent, gate, allowed, log, port, handle });
1545
+
1546
+ // Loopback both ways: the canonical URL is https://localhost:<port>/, and
1547
+ // "localhost" resolves to ::1 on many systems before falling back to IPv4 —
1548
+ // answer on both so the same origin always works (one origin = one
1549
+ // browser storage = one login). There is one listener and it is https: the
1550
+ // port you name is the port you browse.
1551
+ // Every listener here is https, so a caller that did not bring a certificate
1552
+ // gets this install's own rather than an exception: throwing here killed the
1553
+ // signal handlers registered after the call, and the agent became unstoppable.
1554
+ if (!tls) {
1555
+ tls = ensureTrustedTls(path.join(rootOf(agent.home || os.tmpdir()), 'certs'),
1556
+ { log, names: allowed.label ? [`${allowed.label}.localhost`] : [] });
1557
+ }
1558
+ const tlsOpts = { key: tls.key, cert: tls.cert };
1559
+ const server = https.createServer(tlsOpts, handler);
1560
+ streaming.attach(server);
1561
+ const server6 = https.createServer(tlsOpts, handler);
1562
+ streaming.attach(server6);
1563
+ server6.on('error', () => { /* no IPv6 loopback on this system — IPv4 covers it */ });
1564
+ const onListen = () => {
1565
+ // Pidfile for `fedipod stop` — written only AFTER the listen
1566
+ // succeeds, so a port-race loser can never clobber the live agent's pid.
1567
+ try {
1568
+ if (agent.home) fs.writeFileSync(path.join(agent.home, 'agent.pid'), String(process.pid) + '\n');
1569
+ } catch { /* stop will report no pidfile */ }
1570
+ // The named origin when there is one: on a detached start this line is the
1571
+ // only record of where to browse, and sending you to the shared origin is
1572
+ // how two identities end up in one browser storage bucket.
1573
+ log(`FediPod on ${secureOrigin(allowed.label, port)}/ (UI + API)`
1574
+ + (tls.trust ? '' : ' (self-signed — your client may ask once to trust it)'));
1575
+ // Hold the well-known door: whoever answers on the directory port sends the
1576
+ // browser to its own record page, which lists every identity on this
1577
+ // machine. Only a configured agent qualifies — an unconfigured one's record
1578
+ // page is a setup form with no identity list.
1579
+ const door = claimDirectory({ port,
1580
+ origin: () => secureOrigin(allowed.label, port),
1581
+ log, eligible: () => agent.configured(), tls });
1582
+ // Closing the IPv4 listener has to take the IPv6 one and the door with it,
1583
+ // or a shutdown leaves a listening handle and the process never exits.
1584
+ server.on('close', () => { door.stop(); try { server6.close(); } catch { /* already down */ } });
1585
+ };
1586
+ let retriedDoor = false;
1587
+ server.on('error', async (e) => {
1588
+ if (e.code === 'EADDRINUSE') {
1589
+ // The directory door yields to a real owner of its port; anything else
1590
+ // holding it is a genuine conflict.
1591
+ if (!retriedDoor && await yieldDirectory(port, { portFree, home: agent.home })) {
1592
+ retriedDoor = true;
1593
+ server.listen(port, '127.0.0.1', onListen);
1594
+ if (!server6.listening) server6.listen(port, '::1');
1595
+ return;
1596
+ }
1597
+ log(`port ${port} is already in use by another server — set AP_PORT to a free port and retry`);
1598
+ process.exit(1);
1599
+ }
1600
+ throw e;
1601
+ });
1602
+ server.listen(port, '127.0.0.1', onListen);
1603
+ server6.listen(port, '::1');
1604
+
1605
+ return server;
1606
+ }