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/keys.mjs ADDED
@@ -0,0 +1,139 @@
1
+ // keys.mjs — actor signing keys. RSA (Mastodon's draft-cavage HTTP Signatures
2
+ // require RSA-SHA256) + Ed25519 (stored for future FEP-8b32 use, not yet in
3
+ // the actor doc). PEM at rest (0600), CryptoKey in memory for Fedify's
4
+ // signRequest.
5
+ //
6
+ // The key lives on THIS MACHINE by default (AP_HOME/keys.json): the pod host
7
+ // then never holds it. `setup --keys pod` puts it in pod state instead,
8
+ // which is what lets several devices sign as the same actor without copying
9
+ // anything — see resolveKeys() for the guard that keeps the two modes from
10
+ // silently breaking each other.
11
+
12
+ import crypto from 'node:crypto';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { writeJsonAtomic } from './home.mjs';
16
+
17
+ const RSA_ALG = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
18
+
19
+ const localPath = (dir) => path.join(dir, 'keys.json');
20
+ const readLocal = (dir) => {
21
+ try { return JSON.parse(fs.readFileSync(localPath(dir), 'utf8')); } catch { return null; }
22
+ };
23
+ // Atomic: this is the private key remote servers have cached, and a truncated
24
+ // one cannot be recovered from anywhere — the pod does not hold it.
25
+ const writeLocal = (dir, rec) => {
26
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
27
+ writeJsonAtomic(localPath(dir), rec);
28
+ };
29
+
30
+ function generate() {
31
+ const rsa = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
32
+ const ed = crypto.generateKeyPairSync('ed25519');
33
+ return {
34
+ rsa: {
35
+ publicPem: rsa.publicKey.export({ type: 'spki', format: 'pem' }),
36
+ privatePem: rsa.privateKey.export({ type: 'pkcs8', format: 'pem' }),
37
+ },
38
+ ed25519: {
39
+ publicPem: ed.publicKey.export({ type: 'spki', format: 'pem' }),
40
+ privatePem: ed.privateKey.export({ type: 'pkcs8', format: 'pem' }),
41
+ },
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Find or create the key record.
47
+ *
48
+ * The dangerous case is minting a NEW key when the actor already advertises
49
+ * one: remote servers cache the public key, so a fresh keypair silently
50
+ * invalidates every signature until the actor document is republished — and
51
+ * then breaks whichever other device holds the old key. So when key material
52
+ * is missing but the actor doc already has a key, we refuse and explain,
53
+ * unless the caller passes rotate:true deliberately.
54
+ *
55
+ * @param {object} store pod-backed state store
56
+ * @param {object} o
57
+ * @param {string|null} o.localDir AP_HOME when keys are local (the default)
58
+ * @param {boolean} o.rotate mint a replacement on purpose
59
+ * @param {function} [o.actorHasKey] async () => boolean, consulted only when
60
+ * no key material exists anywhere
61
+ * @param {function} [o.log]
62
+ */
63
+ export async function resolveKeys(store, { localDir = null, rotate = false, actorHasKey = null, actorId = null, log = () => {} } = {}) {
64
+ let rec = localDir ? readLocal(localDir) : null;
65
+
66
+ // A key belongs to ONE actor. Adopting whatever keys.json happens to be in
67
+ // the directory is right within an identity and wrong across two: it is how
68
+ // a second setup in the same AP_HOME ended up signing as a different actor
69
+ // with the first one's key, which links the two publicly for anyone who
70
+ // compares the actor documents. A stamped key that names another actor is
71
+ // treated as absent, so the mint path (and its actorHasKey guard) applies.
72
+ if (rec && actorId && rec.mintedFor && rec.mintedFor !== actorId) {
73
+ log(`local keys.json belongs to ${rec.mintedFor} — not reusing it for ${actorId}`);
74
+ rec = null;
75
+ } else if (rec && actorId && !rec.mintedFor) {
76
+ // Written before keys were stamped: keep working, but say so, because it
77
+ // may be another actor's key.
78
+ log('keys.json predates per-identity stamping — if this actor was set up in a '
79
+ + 'home that already held a key, `setup --rotate-key` mints one of its own');
80
+ }
81
+
82
+ if (!rec) {
83
+ const podRec = store.read('keys.json', null);
84
+ if (podRec && localDir) {
85
+ // Migrating an existing actor to local keys: adopt the pod's key so
86
+ // the identity is preserved, then remove the pod's copy — leaving it
87
+ // would defeat the point of going local.
88
+ writeLocal(localDir, podRec);
89
+ rec = podRec;
90
+ const gone = await store.remove('keys.json');
91
+ log(gone
92
+ ? `signing key moved to ${localPath(localDir)} and deleted from pod state`
93
+ : `signing key copied to ${localPath(localDir)} — could NOT delete the pod copy, remove it by hand`);
94
+ } else {
95
+ rec = podRec;
96
+ }
97
+ }
98
+
99
+ if (!rec && !rotate && actorHasKey && await actorHasKey()) {
100
+ throw new Error(
101
+ 'this actor already publishes a signing key, but no key material is present here.\n'
102
+ + ` Copy keys.json from your other device into ${localDir || 'pod state'}, or\n`
103
+ + ' re-run setup with --keys pod to share the key through the pod, or\n'
104
+ // --force, because rotate-key connects before it rotates and would meet
105
+ // this same refusal: advice has to name a command that can actually run.
106
+ + ' run `fedipod rotate-key --force` to replace the published key (breaks other devices).');
107
+ }
108
+
109
+ if (!rec || rotate) {
110
+ rec = { ...generate(), ...(actorId ? { mintedFor: actorId } : {}) };
111
+ if (localDir) writeLocal(localDir, rec); else store.write('keys.json', rec);
112
+ log(rotate ? 'minted a REPLACEMENT signing key — republish the profile' : 'minted a signing key');
113
+ }
114
+
115
+ const der = crypto.createPrivateKey(rec.rsa.privatePem).export({ type: 'pkcs8', format: 'der' });
116
+ const rsaPrivate = await crypto.subtle.importKey('pkcs8', der, RSA_ALG, true, ['sign']);
117
+ return { rsaPrivate, rsaPublicPem: rec.rsa.publicPem };
118
+ }
119
+
120
+ // Move a local key into pod state — the arrangement that lets several
121
+ // devices sign as one actor once the state store is a pod they all reach.
122
+ // The pod write is forced and checked BEFORE the local file goes: a key
123
+ // remote servers have cached cannot be recovered from anywhere else.
124
+ export async function moveKeysToPod(store, { localDir, actorId = null, log = () => {} }) {
125
+ const rec = readLocal(localDir);
126
+ if (!rec) throw new Error(`no local key at ${localPath(localDir)} — nothing to move`);
127
+ if (actorId && rec.mintedFor && rec.mintedFor !== actorId) {
128
+ throw new Error(`local keys.json belongs to ${rec.mintedFor}, not ${actorId} — refusing to move it`);
129
+ }
130
+ store.write('keys.json', rec);
131
+ const landed = store.commit ? await store.commit() : await store.flush?.();
132
+ if (landed === false) throw new Error('the pod write did not land — the local key is untouched');
133
+ fs.rmSync(localPath(localDir), { force: true });
134
+ log('signing key moved to pod state and removed from this machine');
135
+ return true;
136
+ }
137
+
138
+ // Back-compat alias for callers that just want "the keys, wherever they are".
139
+ export const ensureKeys = (store, opts) => resolveKeys(store, opts);
package/lib/lease.mjs ADDED
@@ -0,0 +1,190 @@
1
+ // lease.mjs — single-active-agent coordination. The inbox drain is a
2
+ // destructive read and the state store is write-through-cached, so exactly
3
+ // one agent may ACT on a pod at a time; later arrivals run as read-only
4
+ // viewers. The lease is one JSON doc in ap-state, always read and written
5
+ // with FRESH fetches (never through the cached store), renewed while held.
6
+ // Best-effort by design: a lost renewal just means the other agent takes
7
+ // over at the next expiry.
8
+
9
+ import crypto from 'node:crypto';
10
+
11
+ // Renewal is the one thing that writes even when nothing is happening: at 30s
12
+ // it was 120 PUTs an hour per agent, each taking a write lock on the pod. 90s
13
+ // against a 5-minute TTL leaves three missed renewals of headroom and cuts that
14
+ // load by two thirds. The cost is automatic promotion after a crash waiting up
15
+ // to the TTL — a user acting on a second device does not wait, since takeover()
16
+ // claims the lease outright.
17
+ const TTL_MS = 300_000;
18
+ const RENEW_MS = 90_000;
19
+ const JITTER = () => 0.85 + Math.random() * 0.3;
20
+ // Distinct from null: null is "nobody holds it", this is "we could not ask".
21
+ const UNREADABLE = Symbol('lease-unreadable');
22
+
23
+ export class Lease {
24
+ constructor({ url, fetchImpl, log = console.log }) {
25
+ this.url = url;
26
+ this.fetchImpl = fetchImpl;
27
+ this.log = log;
28
+ this.id = crypto.randomUUID();
29
+ this.stopped = false;
30
+ this.timer = null;
31
+ this.heldUntil = 0; // wall-clock end of the lease we last wrote
32
+ }
33
+
34
+ // null means the lease document is NOT THERE — nobody holds it. UNREADABLE
35
+ // means we could not ask, which is a different answer and must not be read as
36
+ // "free": acquire() treated both as free, so a pod hiccup during acquire
37
+ // could hand a second agent a lease the first still holds, and both would
38
+ // drain the same inbox destructively.
39
+ async readFresh() {
40
+ let res, body;
41
+ try {
42
+ res = await this.fetchImpl(this.url, { headers: { accept: 'application/json' } });
43
+ if (res.status === 404 || res.status === 410) return null;
44
+ if (res.status >= 400) return UNREADABLE;
45
+ body = await res.text();
46
+ } catch { return UNREADABLE; }
47
+ this.etag = res.headers?.get?.('etag') || null;
48
+ // A 200 carrying garbage — a half-written PUT, a proxy error page served
49
+ // as 200 — is not a lease anybody holds, and overwriting it is the repair.
50
+ // Calling that UNREADABLE would leave the agent a viewer forever, because
51
+ // acquire() refuses to write over what it could not read.
52
+ try { return JSON.parse(body); }
53
+ catch { this.log('lease document is unparsable — treating it as unheld'); return null; }
54
+ }
55
+
56
+ // Returns false only for a refused If-Match — that is not an error, it is the
57
+ // answer. Anything else throws.
58
+ async write(doc, { ifMatch = null } = {}) {
59
+ const res = await this.fetchImpl(this.url, {
60
+ method: 'PUT',
61
+ headers: { 'content-type': 'application/json', ...(ifMatch ? { 'if-match': ifMatch } : {}) },
62
+ body: JSON.stringify(doc),
63
+ });
64
+ if (ifMatch && res.status === 412) return false;
65
+ if (res.status >= 400) throw new Error(`lease PUT → ${res.status}`);
66
+ this.etag = res.headers?.get?.('etag') || null;
67
+ return true;
68
+ }
69
+
70
+ // True when this agent now holds the lease. Write-then-confirm narrows the
71
+ // last-writer-wins race to a small window — acceptable for a human-scale
72
+ // "laptop vs phone" situation.
73
+ async acquire() {
74
+ const cur = await this.readFresh();
75
+ // Cannot read it: stay a viewer. A viewer that should have been active is
76
+ // an inconvenience; two agents both draining is the destructive one.
77
+ if (cur === UNREADABLE) { this.log('lease unreadable — staying a viewer'); return false; }
78
+ if (cur && cur.holder !== this.id && Date.now() < cur.expiresAt) return false;
79
+ const doc = { holder: this.id, expiresAt: Date.now() + TTL_MS };
80
+ if (!await this.write(doc)) return false;
81
+ const confirm = await this.readFresh();
82
+ if (confirm === UNREADABLE || confirm?.holder !== this.id) return false;
83
+ // From the document we WROTE, and only once the confirm agrees. Recomputing
84
+ // it here would claim a lease that outlives what the pod's copy grants, by
85
+ // however long the PUT and the confirming GET took.
86
+ this.heldUntil = doc.expiresAt;
87
+ return true;
88
+ }
89
+
90
+ // Renewal checks ownership first: a takeover by another agent must win,
91
+ // not be clobbered by a blind rewrite. Losing the lease fires onLost once
92
+ // and stops renewing.
93
+ async renewOnce() {
94
+ // The lease we hold has a wall-clock end. If renewals keep failing — the
95
+ // pod unreachable, refusing, or slow — the TTL passes, another agent is
96
+ // entitled to take over, and this one went on draining as though nothing
97
+ // had happened. Two agents on one destructive inbox read is exactly what
98
+ // the lease exists to prevent, so an expiry we could not extend is a loss.
99
+ if (this.heldUntil && Date.now() > this.heldUntil) {
100
+ this.stopRenewal();
101
+ this.etag = null;
102
+ this.log('lease expired and could not be renewed — standing down');
103
+ this.onLost?.();
104
+ return false;
105
+ }
106
+ const doc = { holder: this.id, expiresAt: Date.now() + TTL_MS };
107
+ // The steady path is a single conditional PUT. A 412 means somebody else
108
+ // wrote the lease — the only thing the preceding GET was ever there to
109
+ // detect — so the read is paid for exactly when it matters instead of every
110
+ // 90 seconds forever. No etag (a server that does not return one) falls
111
+ // back to read-then-write.
112
+ if (this.etag && await this.write(doc, { ifMatch: this.etag })) { this.heldUntil = doc.expiresAt; return true; }
113
+ const cur = await this.readFresh();
114
+ // Before the ownership test, or the sentinel answers it: a Symbol is truthy
115
+ // and its .holder is undefined, so "could not ask" read as "somebody else
116
+ // holds it" and stood a perfectly healthy agent down on one 500.
117
+ if (cur === UNREADABLE) return true; // keep it; the next tick retries
118
+ if (cur && cur.holder !== this.id) {
119
+ // clearTimeout alone was a no-op here — the timer being cleared is the one
120
+ // whose own callback is running, and the chain re-armed in `finally`
121
+ // regardless. Stopping has to outlive this call, so it is a flag.
122
+ this.stopRenewal();
123
+ // readFresh just cached the NEW holder's ETag. Keeping it would let the
124
+ // next conditional PUT succeed and take the lease back, silently, from an
125
+ // agent that has already stood down.
126
+ this.etag = null;
127
+ this.log('lease taken over by another agent');
128
+ this.onLost?.();
129
+ return false;
130
+ }
131
+ await this.write(doc);
132
+ this.heldUntil = doc.expiresAt;
133
+ return true;
134
+ }
135
+
136
+ // Self-scheduling rather than setInterval, so each agent's renewals drift
137
+ // apart instead of several beating in lockstep against one pod.
138
+ // Idempotent, like Deliverer.startQueue: startActive reaches this twice on the
139
+ // parked path, and two chains against one pod is the doubling this whole file
140
+ // is written to avoid.
141
+ startRenewal() {
142
+ if (this.timer) return;
143
+ this.stopped = false; // restartable across demote/takeover
144
+ const tick = () => {
145
+ if (this.stopped) return;
146
+ this.timer = setTimeout(() => {
147
+ this.renewOnce()
148
+ .catch(e => this.log(`lease renewal: ${e.message}`))
149
+ .finally(() => { if (!this.stopped) tick(); });
150
+ }, Math.round(RENEW_MS * JITTER()));
151
+ this.timer.unref?.();
152
+ };
153
+ tick();
154
+ }
155
+
156
+ // Stop renewing WITHOUT giving the lease up — a demoted agent stands down, it
157
+ // does not release. Unstopped, the chain outlives demotion for the life of the
158
+ // process: ~80 requests an hour from an agent that is no longer acting.
159
+ stopRenewal() {
160
+ this.stopped = true;
161
+ clearTimeout(this.timer);
162
+ this.timer = null;
163
+ }
164
+
165
+ // Forcibly claim the lease (a user acting on a viewer device outranks the
166
+ // idle active agent). Write-then-confirm with one retry against the small
167
+ // window where the current holder's renewal lands in between.
168
+ async takeover() {
169
+ for (let attempt = 0; attempt < 2; attempt++) {
170
+ const doc = { holder: this.id, expiresAt: Date.now() + TTL_MS };
171
+ await this.write(doc);
172
+ const confirm = await this.readFresh();
173
+ if (confirm !== UNREADABLE && confirm?.holder === this.id) {
174
+ this.heldUntil = doc.expiresAt;
175
+ return true;
176
+ }
177
+ }
178
+ return false;
179
+ }
180
+
181
+ // Asked by the destructive path before it acts. The stand-down otherwise
182
+ // waits for the next renewal tick, which is up to ~117s away, and the whole
183
+ // point of the TTL is that after it another agent may start draining.
184
+ stillHeld() { return !this.stopped && (!this.heldUntil || Date.now() < this.heldUntil); }
185
+
186
+ async release() {
187
+ this.stopRenewal();
188
+ await this.write({ holder: this.id, expiresAt: 0 }).catch(() => {});
189
+ }
190
+ }
@@ -0,0 +1,87 @@
1
+ // localapi.mjs — talking to an agent on this machine.
2
+ //
3
+ // The agent serves https and nothing else, on the one port it was given. Its
4
+ // certificate is signed by this install's own certificate authority, which no
5
+ // public trust store knows about, so a plain fetch() would refuse it. Every
6
+ // caller here is on loopback reaching a listener this install started, so the
7
+ // answer is to trust that one authority — never to skip verification.
8
+ //
9
+ // Returns a small fetch-shaped result ({ status, ok, text(), json() }) so call
10
+ // sites read the way they did when this was cleartext http.
11
+
12
+ import https from 'node:https';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { rootOf, apRoot } from './home.mjs';
16
+ import { certPaths } from './certs.mjs';
17
+
18
+ /**
19
+ * The authorities worth offering for a loopback call: this install's, and the
20
+ * machine's default install's. The well-known door on 8030 is held by
21
+ * whichever agent got there first, which may belong to a different root than
22
+ * the one this command is acting in — so both are candidates. An empty list
23
+ * means no certificate has been minted anywhere, and the call will fail the
24
+ * way any unreachable agent does.
25
+ */
26
+ export function localCa(home) {
27
+ const read = (root) => {
28
+ try { return fs.readFileSync(certPaths(path.join(root, 'certs')).caCert); } catch { return null; }
29
+ };
30
+ // This install's own authority decides, and alone: every install names its
31
+ // authority the same thing, so offering two lets the verifier match by name
32
+ // and then fail on the signature. Only an install with no authority of its
33
+ // own falls back to the machine's default — that is the case of a command
34
+ // run outside any install reaching the well-known door.
35
+ const mine = home ? read(rootOf(home)) : null;
36
+ if (mine) return [mine];
37
+ try { const fallback = read(apRoot()); if (fallback) return [fallback]; } catch { /* no home dir */ }
38
+ return null;
39
+ }
40
+
41
+ /**
42
+ * One request to an agent on this machine. `home` names any identity under the
43
+ * install, which is how the CA is found; `port` is the agent's own port.
44
+ */
45
+ export async function localFetch(home, port, pathname, opts = {}) {
46
+ const res = await once(home, port, pathname, opts);
47
+ // The well-known door answers with a redirect to whichever agent holds it,
48
+ // so a probe that lands there has to follow it the way fetch() would.
49
+ if (opts.redirect === 'manual' || res.status < 300 || res.status >= 400) return res;
50
+ const to = res.headers?.location;
51
+ if (!to) return res;
52
+ const target = new URL(to, `https://localhost:${port}`);
53
+ return once(home, Number(target.port) || 443, target.pathname + target.search, opts);
54
+ }
55
+
56
+ function once(home, port, pathname, { method = 'GET', headers = {}, body, timeout = 0 } = {}) {
57
+ const ca = localCa(home);
58
+ return new Promise((resolve, reject) => {
59
+ const req = https.request({
60
+ host: 'localhost', port, path: pathname, method,
61
+ // One-shot calls: a kept-alive socket holds the agent's listener open, so
62
+ // a shutdown asked for over this channel would never finish.
63
+ headers: { connection: 'close', ...headers },
64
+ ...(ca ? { ca } : {}),
65
+ // The certificate carries localhost and *.localhost; on some systems
66
+ // "localhost" resolves to ::1 first, which the name check still covers.
67
+ servername: 'localhost',
68
+ }, (res) => {
69
+ const chunks = [];
70
+ res.on('data', (c) => chunks.push(c));
71
+ res.on('end', () => {
72
+ const text = Buffer.concat(chunks).toString('utf8');
73
+ resolve({
74
+ status: res.statusCode,
75
+ ok: res.statusCode >= 200 && res.statusCode < 300,
76
+ headers: res.headers,
77
+ text: async () => text,
78
+ json: async () => JSON.parse(text),
79
+ });
80
+ });
81
+ });
82
+ req.on('error', reject);
83
+ if (timeout) req.setTimeout(timeout, () => req.destroy(new Error('timed out')));
84
+ if (body !== undefined && body !== null) req.write(body);
85
+ req.end();
86
+ });
87
+ }