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/c2s.mjs ADDED
@@ -0,0 +1,305 @@
1
+ // c2s.mjs — ActivityPub client-to-server (§6): the spec's own write API,
2
+ // beside the Mastodon facade. A client POSTs an activity — or a bare object,
3
+ // which is wrapped in a Create (§6.2.1) — to this actor's outbox; the agent
4
+ // assigns ids, applies the side-effects and delivers. Every verb lands on the
5
+ // SAME helper the facade and admin surfaces use — this module re-implements
6
+ // no persistence and no delivery, so one write path stays one.
7
+ //
8
+ // GETs are redirects: the pod's documents are the canonical actor and outbox,
9
+ // and a second renderer here would only drift from them.
10
+
11
+ import * as social from './social.mjs';
12
+ import * as wire from './wire.mjs';
13
+
14
+ const MAX_BODY = 512 * 1024; // same ceiling the inbox drain enforces
15
+
16
+ // §6 names activities; anything else with a type is an object to wrap.
17
+ const ACTIVITY_TYPES = new Set([
18
+ 'Create', 'Update', 'Delete', 'Follow', 'Like', 'Announce', 'Undo',
19
+ 'Block', 'Add', 'Remove', 'Accept', 'Reject', 'Move',
20
+ ]);
21
+
22
+ const arr = (v) => (v === undefined || v === null ? [] : Array.isArray(v) ? v : [v]);
23
+ const idOf = (v) => (typeof v === 'string' ? v : v?.id || null);
24
+
25
+ function readBody(req) {
26
+ return new Promise((resolve, reject) => {
27
+ let size = 0;
28
+ const chunks = [];
29
+ req.on('data', (c) => {
30
+ size += c.length;
31
+ if (size > MAX_BODY) { reject(new Error('body too large')); req.destroy(); return; }
32
+ chunks.push(c);
33
+ });
34
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
35
+ req.on('error', reject);
36
+ });
37
+ }
38
+
39
+ export class C2S {
40
+ constructor({ agent, log = console.log, auth }) {
41
+ this.agent = agent;
42
+ this.log = log;
43
+ this.auth = auth;
44
+ }
45
+
46
+ get store() { return this.agent.store; }
47
+ get urls() { return this.agent.publisher?.urls; }
48
+
49
+ send(res, status, obj, headers = {}) {
50
+ res.writeHead(status, {
51
+ 'content-type': 'application/json; charset=utf-8',
52
+ 'cache-control': 'no-store',
53
+ ...headers,
54
+ });
55
+ res.end(JSON.stringify(obj));
56
+ return true;
57
+ }
58
+
59
+ byIri(iri) {
60
+ return iri ? this.store.getStatuses().find((s) => s.noteId === iri) : null;
61
+ }
62
+
63
+ async handle(req, res, pathname, url) { // eslint-disable-line no-unused-vars
64
+ if (pathname !== '/ap/outbox' && pathname !== '/ap/actor') return false;
65
+ if (req.method === 'OPTIONS') {
66
+ res.writeHead(204, { allow: 'GET, POST, OPTIONS' }); res.end(); return true;
67
+ }
68
+ if (!this.agent.configured() || !this.urls) {
69
+ return this.send(res, 409, { error: 'agent not configured' });
70
+ }
71
+ if (req.method === 'GET' || req.method === 'HEAD') {
72
+ // The pod's copy is the document; send the reader there.
73
+ const target = pathname === '/ap/actor' ? this.urls.actor : this.urls.outbox;
74
+ res.writeHead(303, { location: target, 'cache-control': 'no-store' });
75
+ res.end();
76
+ return true;
77
+ }
78
+ if (pathname !== '/ap/outbox' || req.method !== 'POST') {
79
+ return this.send(res, 405, { error: 'POST the outbox; GET redirects to the pod' });
80
+ }
81
+
82
+ const who = await this.auth(req, pathname);
83
+ if (!who.ok) return this.send(res, who.status, { error: who.error });
84
+
85
+ // A viewer (another agent holds the lease) may not act — but the user
86
+ // acting HERE outranks an idle active agent elsewhere, exactly as on the
87
+ // facade: a write attempt claims the lease, and only a failed claim 503s.
88
+ if (this.agent.viewer) {
89
+ const took = await this.agent.requestTakeover?.();
90
+ if (!took) return this.send(res, 503, { error: 'another agent is active for this pod — takeover failed, try again' });
91
+ }
92
+
93
+ let activity;
94
+ try {
95
+ activity = JSON.parse(await readBody(req));
96
+ } catch (e) {
97
+ return this.send(res, 400, { error: `unreadable body: ${e.message}` });
98
+ }
99
+ if (!activity || typeof activity !== 'object' || Array.isArray(activity) || !activity.type) {
100
+ return this.send(res, 400, { error: 'a typed ActivityStreams object is required' });
101
+ }
102
+ // A bare object arrives without an activity around it; the server supplies
103
+ // the Create (§6.2.1), carrying the object's own addressing up onto it.
104
+ if (!ACTIVITY_TYPES.has(activity.type)) {
105
+ activity = { type: 'Create', object: activity, to: activity.to, cc: activity.cc };
106
+ }
107
+
108
+ try {
109
+ return await this.dispatch(res, activity);
110
+ } catch (e) {
111
+ this.log(`c2s ${activity.type}: ${e.message}`);
112
+ return this.send(res, 422, { error: e.message || String(e) });
113
+ }
114
+ }
115
+
116
+ // Addressing → the facade's four visibilities, inverting the table the
117
+ // composer writes (wire.noteDoc). Addressing is required: a post whose
118
+ // audience the client never stated is not guessed at in either direction.
119
+ visibilityOf(activity, object) {
120
+ const to = arr(activity.to ?? object?.to).map(idOf);
121
+ const cc = arr(activity.cc ?? object?.cc).map(idOf);
122
+ if (!to.length && !cc.length) return null;
123
+ if (to.includes(wire.PUBLIC)) return 'public';
124
+ if (cc.includes(wire.PUBLIC)) return 'unlisted';
125
+ if (to.includes(this.urls.followers)) return 'private';
126
+ return 'direct';
127
+ }
128
+
129
+ async dispatch(res, activity) {
130
+ const agent = this.agent;
131
+ const object = typeof activity.object === 'object' && activity.object !== null
132
+ ? activity.object : null;
133
+ const objectId = idOf(activity.object);
134
+
135
+ switch (activity.type) {
136
+ case 'Create': {
137
+ if (!object || (object.type && object.type !== 'Note')) {
138
+ return this.send(res, 422, { error: 'only a Note (or a bare Note) can be created here' });
139
+ }
140
+ const visibility = this.visibilityOf(activity, object);
141
+ if (!visibility) {
142
+ return this.send(res, 400, { error: 'state the audience: to/cc must address someone (as:Public, your followers collection, or actors)' });
143
+ }
144
+ // `source.content` is the client's plain text when it sends one; bare
145
+ // `content` is TREATED as plain text and escaped — markup survives as
146
+ // visible characters rather than as markup. Documented v1 limit.
147
+ const text = String(object.source?.content ?? object.content ?? '');
148
+ if (!text.trim()) return this.send(res, 422, { error: 'the note has no content' });
149
+ const attachments = arr(object.attachment).map((a) => ({
150
+ url: a?.url, mediaType: a?.mediaType,
151
+ ...(a?.name ? { description: a.name } : {}),
152
+ })).filter((a) => a.url);
153
+ const note = await agent.publisher.publishNote(text, {
154
+ inReplyTo: idOf(object.inReplyTo) || undefined,
155
+ attachments,
156
+ visibility,
157
+ spoilerText: object.summary || null,
158
+ });
159
+ return this.send(res, 201, { id: wire.createActivityId(note.id), object: note.id },
160
+ { location: wire.createActivityId(note.id) });
161
+ }
162
+
163
+ case 'Update': {
164
+ if (objectId === this.urls.actor) {
165
+ return this.send(res, 422, { error: 'edit the profile on the admin surface; actor updates are not taken here' });
166
+ }
167
+ const s = this.byIri(objectId);
168
+ if (!s) return this.send(res, 404, { error: 'no such note here' });
169
+ if (s.actor !== this.urls.actor || s.kind !== 'post') {
170
+ return this.send(res, 403, { error: 'not your note' });
171
+ }
172
+ const text = String(object?.source?.content ?? object?.content ?? '');
173
+ if (!text.trim()) return this.send(res, 422, { error: 'the edit has no content' });
174
+ const attachments = object?.attachment !== undefined
175
+ ? arr(object.attachment).map((a) => ({
176
+ url: a?.url, mediaType: a?.mediaType,
177
+ ...(a?.name ? { description: a.name } : {}),
178
+ })).filter((a) => a.url)
179
+ : null;
180
+ await agent.publisher.updateNote(s, {
181
+ content: text, spoilerText: object?.summary || null, attachments,
182
+ });
183
+ return this.send(res, 200, { ok: true, object: s.noteId });
184
+ }
185
+
186
+ case 'Delete': {
187
+ if (objectId === this.urls.actor) {
188
+ return this.send(res, 422, { error: 'retiring the actor is done on the admin surface, where it asks twice' });
189
+ }
190
+ const s = this.byIri(objectId);
191
+ if (!s) return this.send(res, 404, { error: 'no such note here' });
192
+ if (s.actor !== this.urls.actor || s.kind !== 'post') {
193
+ return this.send(res, 403, { error: 'not your note' });
194
+ }
195
+ const r = await social.deleteNote(agent, s);
196
+ if (!r.ok) return this.send(res, 502, { error: r.error, stillPublished: r.stillPublished });
197
+ return this.send(res, 200, { ok: true });
198
+ }
199
+
200
+ case 'Follow': {
201
+ if (!objectId) return this.send(res, 400, { error: 'whom? object must name an actor' });
202
+ // An acct: form or bare handle resolves through WebFinger; an https
203
+ // IRI is fetched directly.
204
+ if (/^acct:|^@|^[^/@]+@[^/@]+$/.test(objectId) && !/^https?:/.test(objectId)) {
205
+ const r = await social.followHandle(agent, objectId.replace(/^acct:/, ''));
206
+ const rec = this.store.getContacts().following.find((f) => f.actor === r.actor);
207
+ return this.send(res, 201, { id: rec?.followActivity?.id, object: r.actor },
208
+ rec?.followActivity?.id ? { location: rec.followActivity.id } : {});
209
+ }
210
+ const doc = await social.followActor(agent, objectId);
211
+ const rec = this.store.getContacts().following.find((f) => f.actor === doc.id);
212
+ return this.send(res, 201, { id: rec?.followActivity?.id, object: doc.id },
213
+ rec?.followActivity?.id ? { location: rec.followActivity.id } : {});
214
+ }
215
+
216
+ case 'Like': {
217
+ const s = this.byIri(objectId);
218
+ if (!s) return this.send(res, 422, { error: 'that note is not held here — like what the timeline holds' });
219
+ const updated = await social.favourite(agent, s);
220
+ return this.send(res, 201, { id: updated.likeActivity?.id, object: s.noteId },
221
+ updated.likeActivity?.id ? { location: updated.likeActivity.id } : {});
222
+ }
223
+
224
+ case 'Announce': {
225
+ const s = this.byIri(objectId);
226
+ if (!s) return this.send(res, 422, { error: 'that note is not held here — boost what the timeline holds' });
227
+ const updated = await social.reblog(agent, s);
228
+ return this.send(res, 201, { id: updated.announceActivity?.id, object: s.noteId },
229
+ updated.announceActivity?.id ? { location: updated.announceActivity.id } : {});
230
+ }
231
+
232
+ case 'Undo': {
233
+ // What is being unsaid — by the inner activity's id when the client
234
+ // sends one, by its type+object when it re-states it instead.
235
+ const inner = object;
236
+ const innerId = idOf(activity.object);
237
+ if (inner?.type === 'Block') {
238
+ const target = idOf(inner.object);
239
+ if (!target) return this.send(res, 400, { error: 'unblock whom?' });
240
+ await social.unblockActor(agent, target);
241
+ return this.send(res, 200, { ok: true, object: target });
242
+ }
243
+ const statuses = this.store.getStatuses();
244
+ let s = innerId ? statuses.find((x) => x.likeActivity?.id === innerId) : null;
245
+ if (!s && inner?.type === 'Like') s = this.byIri(idOf(inner.object));
246
+ if (s?.favourited) {
247
+ const updated = await social.unfavourite(agent, s);
248
+ return this.send(res, 200, { ok: true, object: updated.noteId });
249
+ }
250
+ s = innerId ? statuses.find((x) => x.announceActivity?.id === innerId) : null;
251
+ if (!s && inner?.type === 'Announce') s = this.byIri(idOf(inner.object));
252
+ if (s?.reblogged) {
253
+ const updated = await social.unreblog(agent, s);
254
+ return this.send(res, 200, { ok: true, object: updated.noteId });
255
+ }
256
+ const following = this.store.getContacts().following;
257
+ const rec = following.find((f) => f.followActivity?.id === innerId)
258
+ || (inner?.type === 'Follow' ? following.find((f) => f.actor === idOf(inner.object)) : null);
259
+ if (rec) {
260
+ await social.unfollowActor(agent, rec.actor);
261
+ return this.send(res, 200, { ok: true, object: rec.actor });
262
+ }
263
+ return this.send(res, 422, { error: 'nothing here matches what that Undo names' });
264
+ }
265
+
266
+ case 'Block': {
267
+ if (!objectId) return this.send(res, 400, { error: 'block whom? object must name an actor' });
268
+ await social.blockActor(agent, objectId);
269
+ return this.send(res, 200, { ok: true, object: objectId });
270
+ }
271
+
272
+ case 'Add':
273
+ case 'Remove': {
274
+ // The one collection a client may edit is the pins (§7.6/§7.9 in the
275
+ // other direction): target must be the featured collection.
276
+ if (idOf(activity.target) !== this.urls.featured) {
277
+ return this.send(res, 422, { error: 'the featured collection is the one Add/Remove edits here' });
278
+ }
279
+ const s = this.byIri(objectId);
280
+ if (!s) return this.send(res, 404, { error: 'no such note here' });
281
+ const updated = await social.pinStatus(agent, s, activity.type === 'Add');
282
+ return this.send(res, 200, { ok: true, object: updated.noteId, pinned: !!updated.pinned });
283
+ }
284
+
285
+ case 'Accept':
286
+ case 'Reject': {
287
+ // Answering a held follow request: the object is the Follow (or the
288
+ // requester). Which request is meant comes from the Follow's actor.
289
+ const requester = object?.actor ? idOf(object.actor) : objectId;
290
+ if (!requester) return this.send(res, 400, { error: 'whose request? object must name the Follow or its actor' });
291
+ const r = activity.type === 'Accept'
292
+ ? await social.admitRequest(agent, requester).catch((e) => ({ error: e.message }))
293
+ : await social.refuseRequest(agent, requester).catch((e) => ({ error: e.message }));
294
+ if (r.error) return this.send(res, 404, { error: r.error });
295
+ return this.send(res, 200, { ok: true, object: requester });
296
+ }
297
+
298
+ case 'Move':
299
+ return this.send(res, 422, { error: 'moving the account is done on the admin surface, where it asks twice' });
300
+
301
+ default:
302
+ return this.send(res, 422, { error: `no handler for ${activity.type}` });
303
+ }
304
+ }
305
+ }
package/lib/certs.mjs ADDED
@@ -0,0 +1,265 @@
1
+ // certs.mjs — TLS for the local agent, minted per machine. Nothing here is
2
+ // ever packaged or shared: a certificate that ships with the app would carry
3
+ // one private key for every install, which protects nobody.
4
+ //
5
+ // The first agent start mints a local CA and signs the server certificate
6
+ // with it, and the CA — never the server key — is placed in the user's
7
+ // browser trust store where that can be done without privileges (NSS on
8
+ // Linux). The CA carries a critical name constraint limiting it to localhost
9
+ // names and loopback addresses, so even a stolen CA key can never vouch for
10
+ // a real site. `fedipod https --trust` remains for the system-wide store and
11
+ // for setups where the automatic step could not run.
12
+ //
13
+ // The certificate covers localhost, *.localhost (every named agent origin),
14
+ // and both loopback addresses, so one file serves all identities on the
15
+ // machine. Files live under <root>/certs/, 0600.
16
+
17
+ import crypto from 'node:crypto';
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { execFileSync } from 'node:child_process';
22
+ import { createRequire } from 'node:module';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const forge = require('node-forge');
26
+
27
+ const FILES = {
28
+ key: 'localhost-key.pem',
29
+ cert: 'localhost-cert.pem',
30
+ caKey: 'ca-key.pem',
31
+ caCert: 'ca-cert.pem',
32
+ };
33
+ // Apple platforms reject leaf certificates valid longer than ~825 days.
34
+ const LEAF_DAYS = 820;
35
+ const CA_DAYS = 3650;
36
+ const REMINT_DAYS = 30; // re-mint when this close to expiry
37
+
38
+ const SANS = [
39
+ { type: 2, value: 'localhost' },
40
+ { type: 2, value: '*.localhost' },
41
+ { type: 7, ip: '127.0.0.1' },
42
+ { type: 7, ip: '::1' },
43
+ ];
44
+
45
+ const p = (dir, name) => path.join(dir, name);
46
+ const readPem = (dir, name) => {
47
+ try { return fs.readFileSync(p(dir, name), 'utf8'); } catch { return null; }
48
+ };
49
+ const writePem = (dir, name, pem) => {
50
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
51
+ fs.writeFileSync(p(dir, name), pem, { mode: 0o600 });
52
+ };
53
+
54
+ // RFC 5280 name constraints, permitted-only: dNSName "localhost" (which by
55
+ // the RFC's left-labels rule also covers every *.localhost), 127.0.0.0/8 and
56
+ // ::1. Critical, so a validator that cannot enforce it rejects the chain
57
+ // rather than ignoring the limit. forge has no builder for this extension,
58
+ // so the DER is assembled here.
59
+ function nameConstraintsExtension() {
60
+ const { asn1 } = forge;
61
+ const dns = (name) => asn1.create(asn1.Class.CONTEXT_SPECIFIC, 2, false, name);
62
+ const ip = (bytes) => asn1.create(asn1.Class.CONTEXT_SPECIFIC, 7, false, bytes);
63
+ const subtree = (generalName) =>
64
+ asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, [generalName]);
65
+ const permitted = asn1.create(asn1.Class.CONTEXT_SPECIFIC, 0, true, [
66
+ subtree(dns('localhost')),
67
+ subtree(ip('\x7f\x00\x00\x00\xff\x00\x00\x00')), // 127.0.0.0/8
68
+ subtree(ip('\x00'.repeat(15) + '\x01' + '\xff'.repeat(16))), // ::1/128
69
+ ]);
70
+ const der = asn1.toDer(asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, [permitted]));
71
+ return { id: '2.5.29.30', critical: true, value: der.getBytes() };
72
+ }
73
+
74
+ function makeCert({ subjectCN, issuer = null, issuerKeyPem = null, isCA = false, days, extraDns = [] }) {
75
+ const keys = forge.pki.rsa.generateKeyPair({ bits: 2048 });
76
+ const cert = forge.pki.createCertificate();
77
+ cert.publicKey = keys.publicKey;
78
+ cert.serialNumber = '02' + crypto.randomBytes(15).toString('hex');
79
+ cert.validity.notBefore = new Date(Date.now() - 24 * 3600e3);
80
+ cert.validity.notAfter = new Date(Date.now() + days * 24 * 3600e3);
81
+ const subject = [{ name: 'commonName', value: subjectCN }, { name: 'organizationName', value: 'FediPod' }];
82
+ cert.setSubject(subject);
83
+ cert.setIssuer(issuer || subject);
84
+ cert.setExtensions(isCA
85
+ ? [{ name: 'basicConstraints', cA: true, critical: true },
86
+ { name: 'keyUsage', keyCertSign: true, cRLSign: true, critical: true },
87
+ nameConstraintsExtension(),
88
+ { name: 'subjectKeyIdentifier' }]
89
+ : [{ name: 'basicConstraints', cA: false },
90
+ { name: 'keyUsage', digitalSignature: true, keyEncipherment: true },
91
+ { name: 'extKeyUsage', serverAuth: true },
92
+ { name: 'subjectAltName',
93
+ altNames: [...SANS, ...extraDns.map(v => ({ type: 2, value: v }))] }]);
94
+ const signingKey = issuerKeyPem ? forge.pki.privateKeyFromPem(issuerKeyPem) : keys.privateKey;
95
+ cert.sign(signingKey, forge.md.sha256.create());
96
+ return {
97
+ keyPem: forge.pki.privateKeyToPem(keys.privateKey),
98
+ certPem: forge.pki.certificateToPem(cert),
99
+ };
100
+ }
101
+
102
+ // The DNS names a certificate carries. Wildcards do not help here: strict
103
+ // validators (OpenSSL, browsers) refuse `*.localhost` as a wildcard over a
104
+ // top-level name, so every agent's own <handle>.localhost must be listed
105
+ // outright, and a new identity's first start re-mints to include its name.
106
+ const certDnsNames = (certPem) => {
107
+ try {
108
+ return (new crypto.X509Certificate(certPem).subjectAltName || '')
109
+ .split(',').map(s => s.trim()).filter(s => s.startsWith('DNS:')).map(s => s.slice(4));
110
+ } catch { return []; }
111
+ };
112
+
113
+ const daysLeft = (certPem) => {
114
+ try {
115
+ const x = new crypto.X509Certificate(certPem);
116
+ return (new Date(x.validTo).getTime() - Date.now()) / (24 * 3600e3);
117
+ } catch { return -1; }
118
+ };
119
+ const coversLocalhost = (certPem) => {
120
+ try {
121
+ return /\*\.localhost/.test(new crypto.X509Certificate(certPem).subjectAltName || '');
122
+ } catch { return false; }
123
+ };
124
+
125
+ // What an agent calls at boot: the current server key + cert, minted or
126
+ // re-minted as needed. Trust mode is sticky — once a CA exists, re-mints are
127
+ // signed by it, so an installed trust anchor keeps working across renewals.
128
+ export function ensureLocalTls(dir, { log = () => {}, names = [] } = {}) {
129
+ const caKey = readPem(dir, FILES.caKey);
130
+ const caCert = readPem(dir, FILES.caCert);
131
+ const trust = !!(caKey && caCert);
132
+ const isStale = (k, c) => !k || !c || daysLeft(c) < REMINT_DAYS || !coversLocalhost(c)
133
+ || (trust && !issuedBy(c, caCert))
134
+ || names.some(n => !certDnsNames(c).includes(n));
135
+ let key = readPem(dir, FILES.key);
136
+ let cert = readPem(dir, FILES.cert);
137
+ if (isStale(key, cert)) {
138
+ // One minter at a time: agents on a machine start together, and the key
139
+ // and certificate are two files — unlocked, two concurrent minters can
140
+ // interleave and pair one's key with the other's certificate. Losers
141
+ // wait for the winner and take its files; a lock left by a crash is
142
+ // stolen once it is a minute old.
143
+ const lock = p(dir, '.mint-lock');
144
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
145
+ let locked = false;
146
+ try { fs.mkdirSync(lock); locked = true; } catch { /* someone else is minting */ }
147
+ if (!locked) {
148
+ const tick = new Int32Array(new SharedArrayBuffer(4));
149
+ for (let i = 0; i < 100; i++) {
150
+ Atomics.wait(tick, 0, 0, 100);
151
+ if (!fs.existsSync(lock)) break;
152
+ try {
153
+ if (Date.now() - fs.statSync(lock).mtimeMs > 60_000) { fs.rmdirSync(lock); break; }
154
+ } catch { break; }
155
+ }
156
+ key = readPem(dir, FILES.key);
157
+ cert = readPem(dir, FILES.cert);
158
+ if (!isStale(key, cert)) return { key, cert, trust, dir, expiresInDays: Math.floor(daysLeft(cert)) };
159
+ try { fs.mkdirSync(lock); locked = true; } catch { /* still contended — mint anyway */ }
160
+ }
161
+ try {
162
+ // Keep every name the outgoing certificate carried: agents share one
163
+ // file, and a re-mint for one identity must not orphan the others.
164
+ const carried = [...new Set([...certDnsNames(cert || ''), ...names])]
165
+ .filter(n => n !== 'localhost' && n !== '*.localhost');
166
+ const minted = makeCert({
167
+ subjectCN: 'localhost',
168
+ ...(trust ? { issuer: caSubject(caCert), issuerKeyPem: caKey } : {}),
169
+ days: LEAF_DAYS,
170
+ extraDns: carried,
171
+ });
172
+ writePem(dir, FILES.key, minted.keyPem);
173
+ writePem(dir, FILES.cert, minted.certPem);
174
+ key = minted.keyPem;
175
+ cert = minted.certPem;
176
+ log(`minted a ${trust ? 'CA-signed' : 'self-signed'} https certificate for localhost + *.localhost`);
177
+ } finally {
178
+ if (locked) { try { fs.rmdirSync(lock); } catch { /* already gone */ } }
179
+ }
180
+ }
181
+ return { key, cert, trust, dir, expiresInDays: Math.floor(daysLeft(cert)) };
182
+ }
183
+
184
+ const caSubject = (caCertPem) => forge.pki.certificateFromPem(caCertPem).subject.attributes
185
+ .map(a => ({ name: a.name, value: a.value }));
186
+ const issuedBy = (certPem, caCertPem) => {
187
+ try {
188
+ return new crypto.X509Certificate(certPem).checkIssued(new crypto.X509Certificate(caCertPem));
189
+ } catch { return false; }
190
+ };
191
+
192
+ // Mint the local CA once. First writer wins: agents on one machine start
193
+ // together at boot, and the key file is claimed exclusively so exactly one
194
+ // of them mints. A loser whose caCert read races the winner's second write
195
+ // runs self-signed for that one boot and heals on the next.
196
+ function mintCaOnce(dir, log) {
197
+ if (readPem(dir, FILES.caKey)) return;
198
+ const hostname = (process.env.HOSTNAME || 'this machine').toString().slice(0, 32);
199
+ const ca = makeCert({ subjectCN: `FediPod Local CA (${hostname})`, isCA: true, days: CA_DAYS });
200
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
201
+ try {
202
+ fs.writeFileSync(p(dir, FILES.caKey), ca.keyPem, { mode: 0o600, flag: 'wx' });
203
+ } catch (e) {
204
+ if (e.code === 'EEXIST') return; // another agent minted first
205
+ throw e;
206
+ }
207
+ fs.writeFileSync(p(dir, FILES.caCert), ca.certPem, { mode: 0o600 });
208
+ log('minted the local CA (name-constrained to localhost)');
209
+ }
210
+
211
+ // Put the CA where this user's browsers look, where that needs no privilege
212
+ // (NSS on Linux — Chromium reads it). Once per CA: a marker beside the certs
213
+ // remembers the install, and a rotated CA installs again. AP_TRUST_INSTALL=0
214
+ // disables the whole step (the test suite sets it, so throwaway agents never
215
+ // touch a real profile's trust store).
216
+ export function installBrowserTrust(dir, { log = () => {} } = {}) {
217
+ if (process.env.AP_TRUST_INSTALL === '0') return false;
218
+ const caCert = readPem(dir, FILES.caCert);
219
+ if (!caCert) return false;
220
+ const digest = crypto.createHash('sha256').update(caCert).digest('hex');
221
+ const markerFile = p(dir, 'trust-installed.json');
222
+ try {
223
+ if (JSON.parse(fs.readFileSync(markerFile, 'utf8')).sha256 === digest) return true;
224
+ } catch { /* not installed yet */ }
225
+ if (process.platform !== 'linux') return false; // elevated stores; `https --trust` prints the step
226
+ try {
227
+ execFileSync('certutil', ['-d', `sql:${path.join(os.homedir(), '.pki/nssdb')}`, '-A',
228
+ '-t', 'C,,', '-n', 'FediPod Local CA', '-i', p(dir, FILES.caCert)], { stdio: 'pipe' });
229
+ } catch {
230
+ log('browser trust store not updated (certutil unavailable) — `fedipod https --trust` prints the manual step');
231
+ return false;
232
+ }
233
+ fs.writeFileSync(markerFile, JSON.stringify({ sha256: digest, at: new Date().toISOString() }) + '\n',
234
+ { mode: 0o600 });
235
+ log('installed the local CA in your browser trust store (NSS)');
236
+ return true;
237
+ }
238
+
239
+ // What an agent calls at boot: trusted https with nothing asked of the user.
240
+ // The CA is minted on first use, the leaf rides it from then on, and the CA
241
+ // lands in the browser trust store when that can be done quietly.
242
+ export function ensureTrustedTls(dir, { log = () => {}, names = [] } = {}) {
243
+ try { mintCaOnce(dir, log); } catch { /* self-signed this boot */ }
244
+ const tls = ensureLocalTls(dir, { log, names });
245
+ if (tls.trust) installBrowserTrust(dir, { log });
246
+ return tls;
247
+ }
248
+
249
+ // The manual path: mint the CA (once) and re-mint the server certificate
250
+ // under it, for the printed system-store step. The CA's key never leaves the
251
+ // certs directory; the CA's CERT is what a trust store accepts.
252
+ export function enableTrust(dir, { log = () => {} } = {}) {
253
+ mintCaOnce(dir, log);
254
+ // Force a re-mint of the leaf under the CA.
255
+ fs.rmSync(p(dir, FILES.cert), { force: true });
256
+ fs.rmSync(p(dir, FILES.key), { force: true });
257
+ const tls = ensureLocalTls(dir, { log });
258
+ installBrowserTrust(dir, { log });
259
+ return { ...tls, caCertPath: p(dir, FILES.caCert) };
260
+ }
261
+
262
+ export const certPaths = (dir) => ({
263
+ key: p(dir, FILES.key), cert: p(dir, FILES.cert),
264
+ caKey: p(dir, FILES.caKey), caCert: p(dir, FILES.caCert),
265
+ });