fedipod 0.18.0 → 1.0.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 (324) hide show
  1. package/README.md +105 -115
  2. package/architecture.md +41 -0
  3. package/bin/fedipod.mjs +3 -5
  4. package/browser.svg +72 -0
  5. package/cli.md +9 -0
  6. package/gateway.md +160 -0
  7. package/groups.md +7 -2
  8. package/gui.md +11 -1
  9. package/installed-agent.md +97 -0
  10. package/lib/admin.mjs +3 -3
  11. package/lib/deliver.mjs +6 -0
  12. package/lib/export-collections.mjs +1 -1
  13. package/lib/front-core.mjs +272 -49
  14. package/lib/gateway-core.mjs +3 -7
  15. package/lib/httpsig.mjs +33 -2
  16. package/lib/import.mjs +1 -1
  17. package/lib/intake.mjs +335 -118
  18. package/lib/links.mjs +6 -32
  19. package/lib/mastoapi.mjs +372 -35
  20. package/lib/migrate.mjs +4 -18
  21. package/lib/pod/README.md +61 -0
  22. package/lib/pod/actor.mjs +96 -0
  23. package/lib/pod/collection.mjs +77 -0
  24. package/lib/pod/containers.mjs +136 -0
  25. package/lib/pod/discovery.mjs +46 -0
  26. package/lib/pod/featured.mjs +18 -0
  27. package/lib/pod/followers.mjs +24 -0
  28. package/lib/pod/following.mjs +10 -0
  29. package/lib/pod/http.mjs +74 -0
  30. package/lib/pod/inbox.mjs +144 -0
  31. package/lib/pod/links.mjs +35 -0
  32. package/lib/pod/media.mjs +27 -0
  33. package/lib/pod/notes.mjs +80 -0
  34. package/lib/pod/notifications.mjs +89 -0
  35. package/lib/pod/outbox.mjs +18 -0
  36. package/lib/pod/package.json +19 -0
  37. package/lib/pod/policy.mjs +58 -0
  38. package/lib/pod/private.mjs +19 -0
  39. package/lib/pod/root.mjs +63 -0
  40. package/lib/pod/state.mjs +39 -0
  41. package/lib/pod/transport.mjs +477 -0
  42. package/lib/pod/urls.mjs +71 -0
  43. package/lib/publisher.mjs +112 -183
  44. package/lib/remote.mjs +30 -395
  45. package/lib/social.mjs +18 -9
  46. package/lib/storage.mjs +2 -2
  47. package/lib/store.mjs +8 -1
  48. package/lib/tagfeed.mjs +19 -2
  49. package/lib/wire.mjs +48 -67
  50. package/package.json +14 -3
  51. package/run-agent.mjs +14 -39
  52. package/scripts/build-app.mjs +64 -0
  53. package/scripts/check-pod-calls.mjs +177 -0
  54. package/scripts/stage-site.mjs +144 -0
  55. package/web/admin/admin.js +141 -74
  56. package/web/admin/bar.css +9 -13
  57. package/web/admin/bar.js +2 -2
  58. package/web/admin/client/client.js +1 -1
  59. package/web/admin/client/index.html +5 -6
  60. package/web/admin/index.html +53 -65
  61. package/web/admin/setup/index.html +2 -1
  62. package/web/admin/setup/setup.js +1 -1
  63. package/web/admin/tokens.css +56 -0
  64. package/web/app/README.md +77 -0
  65. package/web/app/admin-facade.mjs +549 -0
  66. package/web/app/agent.mjs +490 -0
  67. package/web/app/atproto-browser.mjs +86 -0
  68. package/web/app/boot.mjs +340 -0
  69. package/web/app/deliver-relay.mjs +72 -0
  70. package/web/app/dist/boot.js +34067 -0
  71. package/web/app/dist/boot.js.map +7 -0
  72. package/web/app/dist/sw.js +53745 -0
  73. package/web/app/dist/sw.js.map +7 -0
  74. package/web/app/fediacct-browser.mjs +146 -0
  75. package/web/app/idb-kv.mjs +35 -0
  76. package/web/app/index.html +203 -0
  77. package/web/app/index.html~ +152 -0
  78. package/web/app/keys-browser.mjs +46 -0
  79. package/web/app/keystore.mjs +99 -0
  80. package/web/app/oidc-session.mjs +189 -0
  81. package/web/app/pod-auth.mjs +248 -0
  82. package/web/app/pod-remote.mjs +84 -0
  83. package/web/app/shims/_globals.mjs +2 -0
  84. package/web/app/shims/fedify-sig.mjs +77 -0
  85. package/web/app/shims/node-crypto.mjs +113 -0
  86. package/web/app/shims/node-fs.mjs +17 -0
  87. package/web/app/shims/node-path.mjs +19 -0
  88. package/web/app/shims/node-url.mjs +7 -0
  89. package/web/app/shims/prelude.js +58 -0
  90. package/web/app/shims/safefetch.mjs +58 -0
  91. package/web/app/shims/web-push.mjs +14 -0
  92. package/web/app/signup.mjs +248 -0
  93. package/web/app/site/_headers +9 -0
  94. package/web/app/site/_redirects +21 -0
  95. package/web/app/site/admin/admin.js +1181 -0
  96. package/web/app/site/admin/bar.css +60 -0
  97. package/web/app/site/admin/bar.js +61 -0
  98. package/web/app/site/admin/client/client.js +66 -0
  99. package/web/app/site/admin/client/index.html +43 -0
  100. package/web/app/site/admin/index.html +556 -0
  101. package/web/app/site/admin/setup/index.html +191 -0
  102. package/web/app/site/admin/setup/setup.js +308 -0
  103. package/web/app/site/admin/tokens.css +56 -0
  104. package/web/app/site/admin/window.css +55 -0
  105. package/web/app/site/admin/window.js +136 -0
  106. package/web/app/site/app/404.html +40 -0
  107. package/web/app/site/app/_headers +2 -0
  108. package/web/app/site/app/apple-touch-icon.png +0 -0
  109. package/web/app/site/app/assets/ICONS-DIPJeU0C.js +2 -0
  110. package/web/app/site/app/assets/ICONS-DIPJeU0C.js.map +1 -0
  111. package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js +183 -0
  112. package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
  113. package/web/app/site/app/assets/Temml-CCjoacWt.woff2 +0 -0
  114. package/web/app/site/app/assets/__vite-browser-external-C7iut881.js +2 -0
  115. package/web/app/site/app/assets/__vite-browser-external-C7iut881.js.map +1 -0
  116. package/web/app/site/app/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
  117. package/web/app/site/app/assets/catch-up-O5q5C75Z.png +0 -0
  118. package/web/app/site/app/assets/chunk-aKtaBQYM.js +1 -0
  119. package/web/app/site/app/assets/compose-CFEx7wb4.js +2 -0
  120. package/web/app/site/app/assets/compose-CFEx7wb4.js.map +1 -0
  121. package/web/app/site/app/assets/compose-ZJN5xSb4.js +35 -0
  122. package/web/app/site/app/assets/compose-ZJN5xSb4.js.map +1 -0
  123. package/web/app/site/app/assets/debug.module-YFQgwnIr.js +52 -0
  124. package/web/app/site/app/assets/debug.module-YFQgwnIr.js.map +1 -0
  125. package/web/app/site/app/assets/dom-p1YcJ5Rw.js +2 -0
  126. package/web/app/site/app/assets/dom-p1YcJ5Rw.js.map +1 -0
  127. package/web/app/site/app/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
  128. package/web/app/site/app/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
  129. package/web/app/site/app/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
  130. package/web/app/site/app/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
  131. package/web/app/site/app/assets/icons/add-circle-line-WD0mwORD.js +1 -0
  132. package/web/app/site/app/assets/icons/alert-line-CpFUMf9s.js +1 -0
  133. package/web/app/site/app/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
  134. package/web/app/site/app/assets/icons/announcement-line-5nwaJFJm.js +1 -0
  135. package/web/app/site/app/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
  136. package/web/app/site/app/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
  137. package/web/app/site/app/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
  138. package/web/app/site/app/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
  139. package/web/app/site/app/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
  140. package/web/app/site/app/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
  141. package/web/app/site/app/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
  142. package/web/app/site/app/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
  143. package/web/app/site/app/assets/icons/at-line-CF66Bznk.js +1 -0
  144. package/web/app/site/app/assets/icons/attachment-line-BY8It-l9.js +1 -0
  145. package/web/app/site/app/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
  146. package/web/app/site/app/assets/icons/board-line-C0UWXyA6.js +1 -0
  147. package/web/app/site/app/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
  148. package/web/app/site/app/assets/icons/building-5-line-Dd59MzbO.js +1 -0
  149. package/web/app/site/app/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
  150. package/web/app/site/app/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
  151. package/web/app/site/app/assets/icons/calendar-month-line-vommts2C.js +1 -0
  152. package/web/app/site/app/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
  153. package/web/app/site/app/assets/icons/camera-line-Cv69MEei.js +1 -0
  154. package/web/app/site/app/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
  155. package/web/app/site/app/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
  156. package/web/app/site/app/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
  157. package/web/app/site/app/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
  158. package/web/app/site/app/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
  159. package/web/app/site/app/assets/icons/clipboard-line-BBp364gJ.js +1 -0
  160. package/web/app/site/app/assets/icons/close-circle-line-CICdiuGz.js +1 -0
  161. package/web/app/site/app/assets/icons/close-line-DYrFWcjv.js +1 -0
  162. package/web/app/site/app/assets/icons/cloud-line-tiQAbah8.js +1 -0
  163. package/web/app/site/app/assets/icons/code-line-fAl8iiV3.js +1 -0
  164. package/web/app/site/app/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
  165. package/web/app/site/app/assets/icons/copy-2-line-CLW69yF5.js +1 -0
  166. package/web/app/site/app/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
  167. package/web/app/site/app/assets/icons/document-3-line-DBGNhAg2.js +1 -0
  168. package/web/app/site/app/assets/icons/document-line-C7mb9sb7.js +1 -0
  169. package/web/app/site/app/assets/icons/down-line-BGqSSThd.js +1 -0
  170. package/web/app/site/app/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
  171. package/web/app/site/app/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
  172. package/web/app/site/app/assets/icons/exit-line-Dc07BS-x.js +1 -0
  173. package/web/app/site/app/assets/icons/external-link-line-BvpSkmjO.js +1 -0
  174. package/web/app/site/app/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
  175. package/web/app/site/app/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
  176. package/web/app/site/app/assets/icons/filter-2-line-B_mR2usy.js +1 -0
  177. package/web/app/site/app/assets/icons/filter-line-C_nm-3cU.js +1 -0
  178. package/web/app/site/app/assets/icons/flag-1-line-C2HB05I0.js +1 -0
  179. package/web/app/site/app/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
  180. package/web/app/site/app/assets/icons/formula-line-DLrt2d6j.js +1 -0
  181. package/web/app/site/app/assets/icons/grid-line-CgUmEikf.js +1 -0
  182. package/web/app/site/app/assets/icons/group-line-C5fnX0wU.js +1 -0
  183. package/web/app/site/app/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
  184. package/web/app/site/app/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
  185. package/web/app/site/app/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
  186. package/web/app/site/app/assets/icons/heart-line-CJ7K47J4.js +1 -0
  187. package/web/app/site/app/assets/icons/history-2-line-kWWvFzqv.js +1 -0
  188. package/web/app/site/app/assets/icons/history-line-bGQiwbTH.js +1 -0
  189. package/web/app/site/app/assets/icons/home-3-line-fRU1zeLG.js +1 -0
  190. package/web/app/site/app/assets/icons/information-line-iNk6-sOY.js +1 -0
  191. package/web/app/site/app/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
  192. package/web/app/site/app/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
  193. package/web/app/site/app/assets/icons/layout-5-line-B_znt5wO.js +1 -0
  194. package/web/app/site/app/assets/icons/left-line-eUw0tya4.js +1 -0
  195. package/web/app/site/app/assets/icons/lightning-line-DaKoDdix.js +1 -0
  196. package/web/app/site/app/assets/icons/link-2-line-DCqLmMA7.js +1 -0
  197. package/web/app/site/app/assets/icons/list-check-line-BAxosBh9.js +1 -0
  198. package/web/app/site/app/assets/icons/lock-line-CPoaXeUW.js +1 -0
  199. package/web/app/site/app/assets/icons/mail-line-B0P8aa1L.js +1 -0
  200. package/web/app/site/app/assets/icons/moon-line-CEqNAToP.js +1 -0
  201. package/web/app/site/app/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
  202. package/web/app/site/app/assets/icons/more-3-line-BIiVjYTz.js +1 -0
  203. package/web/app/site/app/assets/icons/notification-line-BZZq2Gtu.js +1 -0
  204. package/web/app/site/app/assets/icons/pencil-line-CKh8-A1v.js +1 -0
  205. package/web/app/site/app/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
  206. package/web/app/site/app/assets/icons/pin-line-DpTolyhs.js +1 -0
  207. package/web/app/site/app/assets/icons/play-fill-B0-G_8lX.js +1 -0
  208. package/web/app/site/app/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
  209. package/web/app/site/app/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
  210. package/web/app/site/app/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
  211. package/web/app/site/app/assets/icons/radar-line-CKyroTth.js +1 -0
  212. package/web/app/site/app/assets/icons/react-line-CoIKhhtK.js +1 -0
  213. package/web/app/site/app/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
  214. package/web/app/site/app/assets/icons/right-line-B3KWJaqc.js +1 -0
  215. package/web/app/site/app/assets/icons/rocket-line-4KZl537D.js +1 -0
  216. package/web/app/site/app/assets/icons/round-fill-C69NTrZ0.js +1 -0
  217. package/web/app/site/app/assets/icons/round-line-B9k_1uo-.js +1 -0
  218. package/web/app/site/app/assets/icons/route-line-B28ReHcE.js +1 -0
  219. package/web/app/site/app/assets/icons/rows-4-line-CrBniagM.js +1 -0
  220. package/web/app/site/app/assets/icons/scan-line-DSf74YdP.js +1 -0
  221. package/web/app/site/app/assets/icons/search-2-line-B6geIU1z.js +1 -0
  222. package/web/app/site/app/assets/icons/settings-3-line-azJwK47w.js +1 -0
  223. package/web/app/site/app/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
  224. package/web/app/site/app/assets/icons/share-2-line-D5MxWjrm.js +1 -0
  225. package/web/app/site/app/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
  226. package/web/app/site/app/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
  227. package/web/app/site/app/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
  228. package/web/app/site/app/assets/icons/time-line-BF3eet1v.js +1 -0
  229. package/web/app/site/app/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
  230. package/web/app/site/app/assets/icons/translate-line-BFWol2Ce.js +1 -0
  231. package/web/app/site/app/assets/icons/unlock-line-X_x5vwv3.js +1 -0
  232. package/web/app/site/app/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
  233. package/web/app/site/app/assets/icons/user-4-line-BVSynZCp.js +1 -0
  234. package/web/app/site/app/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
  235. package/web/app/site/app/assets/icons/user-add-line-BbXdHG4z.js +1 -0
  236. package/web/app/site/app/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
  237. package/web/app/site/app/assets/icons/user-follow-line-BXux0wSd.js +1 -0
  238. package/web/app/site/app/assets/icons/user-star-line-DdYlpHLU.js +1 -0
  239. package/web/app/site/app/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
  240. package/web/app/site/app/assets/icons/user-x-line-DIDgNlPH.js +1 -0
  241. package/web/app/site/app/assets/icons/volume-line-Bs390KQt.js +1 -0
  242. package/web/app/site/app/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
  243. package/web/app/site/app/assets/icons/walk-line-yNXfB-md.js +1 -0
  244. package/web/app/site/app/assets/icons/world-2-line-CenkGIwj.js +1 -0
  245. package/web/app/site/app/assets/icons/zoom-in-line-CaENUphf.js +1 -0
  246. package/web/app/site/app/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
  247. package/web/app/site/app/assets/instances-BNPptnn-.json +333 -0
  248. package/web/app/site/app/assets/locales/ar-SA-CfheRbCu.js +1 -0
  249. package/web/app/site/app/assets/locales/ca-ES-DHRW86Tz.js +1 -0
  250. package/web/app/site/app/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
  251. package/web/app/site/app/assets/locales/de-DE-ByRPMRFy.js +1 -0
  252. package/web/app/site/app/assets/locales/eo-UY-CPC7wLCS.js +1 -0
  253. package/web/app/site/app/assets/locales/es-ES-dJa0Fd9t.js +1 -0
  254. package/web/app/site/app/assets/locales/eu-ES-C3djlPR2.js +1 -0
  255. package/web/app/site/app/assets/locales/fa-IR-DTT490Ck.js +1 -0
  256. package/web/app/site/app/assets/locales/fi-FI-B23MCPuz.js +1 -0
  257. package/web/app/site/app/assets/locales/fr-FR-BcaQsO5C.js +1 -0
  258. package/web/app/site/app/assets/locales/gl-ES-BR-CVKBN.js +1 -0
  259. package/web/app/site/app/assets/locales/he-IL-CfheRbCu.js +1 -0
  260. package/web/app/site/app/assets/locales/hu-HU-CfheRbCu.js +1 -0
  261. package/web/app/site/app/assets/locales/it-IT-C1136UFV.js +1 -0
  262. package/web/app/site/app/assets/locales/ja-JP-CRZmRAhD.js +1 -0
  263. package/web/app/site/app/assets/locales/kab-Cz8xH_TT.js +1 -0
  264. package/web/app/site/app/assets/locales/ko-KR-CG9UiY4o.js +1 -0
  265. package/web/app/site/app/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
  266. package/web/app/site/app/assets/locales/nb-NO-CfheRbCu.js +1 -0
  267. package/web/app/site/app/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
  268. package/web/app/site/app/assets/locales/oc-FR-CfheRbCu.js +1 -0
  269. package/web/app/site/app/assets/locales/pl-PL-DkwGXf6P.js +1 -0
  270. package/web/app/site/app/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
  271. package/web/app/site/app/assets/locales/pt-BR-nGL0HdFS.js +1 -0
  272. package/web/app/site/app/assets/locales/pt-PT-Cp17aj2z.js +1 -0
  273. package/web/app/site/app/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
  274. package/web/app/site/app/assets/locales/th-TH-CfheRbCu.js +1 -0
  275. package/web/app/site/app/assets/locales/tok-CfheRbCu.js +1 -0
  276. package/web/app/site/app/assets/locales/tr-TR-CfheRbCu.js +1 -0
  277. package/web/app/site/app/assets/locales/uk-UA-C5E3dFBf.js +1 -0
  278. package/web/app/site/app/assets/locales/zh-CN-BHJi7_et.js +1 -0
  279. package/web/app/site/app/assets/locales/zh-TW-CfheRbCu.js +1 -0
  280. package/web/app/site/app/assets/main-BdqNbG-a.js +26 -0
  281. package/web/app/site/app/assets/main-BdqNbG-a.js.map +1 -0
  282. package/web/app/site/app/assets/mock-home-DGhR__He.js +2 -0
  283. package/web/app/site/app/assets/mock-home-DGhR__He.js.map +1 -0
  284. package/web/app/site/app/assets/multi-column-ROck0NVt.jpg +0 -0
  285. package/web/app/site/app/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
  286. package/web/app/site/app/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
  287. package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js +2 -0
  288. package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js.map +1 -0
  289. package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js +20 -0
  290. package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js.map +1 -0
  291. package/web/app/site/app/assets/style-B1iLPTPR.css +2 -0
  292. package/web/app/site/app/assets/temml-DuyIQMei.js +97 -0
  293. package/web/app/site/app/assets/temml-DuyIQMei.js.map +1 -0
  294. package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js +2 -0
  295. package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
  296. package/web/app/site/app/assets/year-in-posts-B2lywqdx.png +0 -0
  297. package/web/app/site/app/assets/year-in-posts-DJv_star.js +3 -0
  298. package/web/app/site/app/assets/year-in-posts-DJv_star.js.map +1 -0
  299. package/web/app/site/app/compose/index.html +20 -0
  300. package/web/app/site/app/favicon.ico +0 -0
  301. package/web/app/site/app/index.html +73 -0
  302. package/web/app/site/app/logo-192.png +0 -0
  303. package/web/app/site/app/logo-512.png +0 -0
  304. package/web/app/site/app/logo-badge-72.png +0 -0
  305. package/web/app/site/app/logo-maskable-512.png +0 -0
  306. package/web/app/site/app/logo-monochrome-512.png +0 -0
  307. package/web/app/site/app/logo-monochrome-maskable-512.png +0 -0
  308. package/web/app/site/app/manifest.webmanifest +1 -0
  309. package/web/app/site/app/og-image-2.jpg +0 -0
  310. package/web/app/site/app/og-image.png +0 -0
  311. package/web/app/site/app/robots.txt +2 -0
  312. package/web/app/site/app/version.json +1 -0
  313. package/web/app/site/boot.js +34067 -0
  314. package/web/app/site/index.html +203 -0
  315. package/web/app/site/sw.js +53745 -0
  316. package/web/app/sw-src.mjs +205 -0
  317. package/web/front/#new-account.html# +43 -0
  318. package/web/front/admin.html +1 -81
  319. package/web/front/admin.js +85 -0
  320. package/web/front/new-account.html +1 -9
  321. package/web/front/new-account.js +13 -0
  322. package/web/front/run.html +1 -92
  323. package/web/front/run.js +107 -0
  324. package/lib/podrdf.mjs +0 -132
@@ -0,0 +1,97 @@
1
+ # The installed agent
2
+
3
+ FediPod can also run as a program on your own machine, in front of the same
4
+ kind of pod. It does everything the browser version at fedipod.net does, plus
5
+ what a browser tab cannot: it keeps running while no tab is open, so scheduled
6
+ posts go out and push notifications reach you; it serves the Mastodon streaming
7
+ API, so clients update live; any Mastodon client, phone app or desktop, can
8
+ connect to it; and it can host a [group](groups.md).
9
+
10
+ ## Requirements
11
+
12
+ - Node 20 or newer.
13
+ - A Solid pod with a host name of its own, such as
14
+ `https://alice.solidcommunity.net/`. A pod on a path of a shared host cannot
15
+ be a Fediverse address.
16
+ - Followers-only and direct posts need a pod that enforces WAC access control;
17
+ on one that does not, the composer refuses those two and says why.
18
+ - While the agent is off, your mail waits on your pod's host. Run it as a
19
+ service, or attach to a gateway, so it does not pile up there.
20
+
21
+ ## Installing
22
+
23
+ ```
24
+ npm install -g fedipod
25
+ ```
26
+
27
+ ## Running
28
+
29
+ Run `fedipod start`. Add a port to change the local agent's port, for example
30
+ `fedipod start --port 8081`; the default is 8030. Then point any browser at
31
+ `https://localhost:8030`, or the port you chose, and the setup pages take it
32
+ from there.
33
+
34
+ ## Running as a service
35
+
36
+ ```
37
+ fedipod install-service
38
+ ```
39
+
40
+ It registers every identity on this machine, one service each, so all of your
41
+ actors start at boot. An identity running in a terminal is stopped and taken
42
+ over by its service. `fedipod uninstall-service` reverses it.
43
+
44
+ ## Managing
45
+
46
+ Posts, logs, parking, moving, transferring and the rest are on the
47
+ [admin interface](gui.md); starting, stopping and what a page cannot do are in
48
+ [CLI admin](cli.md). The admin tools also create other actors, groups or
49
+ persons. You may have as many as you want on one machine, each with a pod of
50
+ its own.
51
+
52
+ Every agent checks once a day whether a newer FediPod is published. When one
53
+ exists, the record page offers **Update**, and `fedipod update` does the same
54
+ from the terminal. `AP_UPDATE_CHECK=0` turns the check off.
55
+
56
+ ## A gateway account
57
+
58
+ Most of what a Fediverse inbox receives is broadcast noise. A
59
+ [gateway](gateway.md) is a shared, always-on door that verifies each delivery,
60
+ drops the junk, and passes the rest to your pod, while your key and data stay
61
+ on your pod. There is a free one at [fedipod.net](https://fedipod.net/).
62
+ Attaching or detaching is a few wizard-guided clicks from your agent, and it
63
+ takes the mail load off your pod's host.
64
+
65
+ ## Clients
66
+
67
+ The bundled client is [Phanpy](https://github.com/cheeaun/phanpy) (MIT, by
68
+ Chee Aun), served by the agent itself, and logging in is one click. If you
69
+ ever enter the instance by hand, use the address on the record's **local
70
+ host** row.
71
+
72
+ - **Other web clients**: drop any static Mastodon client dist into
73
+ `ui/<name>/` and it is served at `/<name>/`; see `ui/README.md`.
74
+ - **Desktop and phone clients** (Tuba, Whalebird, and the like): add
75
+ `https://localhost:8030`, or your agent's port, as a custom instance.
76
+ - **Streaming**: the agent serves the Mastodon streaming API at
77
+ `/api/v1/streaming`, so clients update live instead of polling.
78
+ - **Web push**: notifications reach you while the client is closed.
79
+ - **Scheduled posts** go out at the time you picked.
80
+
81
+ Polls, content warnings, editing, all four visibility levels, direct
82
+ messages, bookmarks, favourites, lists, keyword filters, pinned posts,
83
+ blocking and muting, and custom emojis work as in the browser version.
84
+
85
+ ## Bluesky, and your other Fediverse accounts
86
+
87
+ A Bluesky connection lets the agent drive an existing Bluesky, or other
88
+ ATProto, account alongside your Fediverse identity: public posts are
89
+ cross-posted as a mirror, with a toggle to turn it off; Bluesky replies and
90
+ activity flow into your timeline; you can like, boost and reply to Bluesky
91
+ posts. Direct messages to Bluesky are not supported.
92
+
93
+ An account on Mastodon or any server speaking the Mastodon API can be
94
+ connected from the **Other identities** row of the admin page. Its home
95
+ timeline and notifications join your feed, a post both accounts see appears
96
+ once, and favouriting, boosting and replying act as the account the post came
97
+ through. The token it hands back stays on this machine.
package/lib/admin.mjs CHANGED
@@ -1379,15 +1379,15 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1379
1379
  await agent.store.flush();
1380
1380
  const destCred = { ...cred, privateRoot: target };
1381
1381
  const copied = await copyPrivateHalf({
1382
- from: { state: agent.privateStorage(cred, 'state'), fediverse: agent.privateStorage(cred, 'fediverse') },
1383
- to: { state: agent.privateStorage(destCred, 'state'), fediverse: agent.privateStorage(destCred, 'fediverse') },
1382
+ from: { state: agent.privateStorage(cred, 'state') },
1383
+ to: { state: agent.privateStorage(destCred, 'state') },
1384
1384
  log,
1385
1385
  });
1386
1386
  if (target) cred.privateRoot = target; else delete cred.privateRoot;
1387
1387
  if (isCurrent(cred)) cred.layout = CURRENT_LAYOUT; else delete cred.layout;
1388
1388
  writeJsonAtomic(path.join(agent.home, 'credential.json'), cred);
1389
1389
  // connect() rebuilds everything that pointed at the old location —
1390
- // store, RDF tree, publisher, intake — and restarts them.
1390
+ // store, publisher, intake — and restarts them.
1391
1391
  agent.store.attach(agent.privateStorage(cred, 'state'));
1392
1392
  agent.stateLoaded = false;
1393
1393
  await agent.connect();
package/lib/deliver.mjs CHANGED
@@ -107,6 +107,12 @@ export class Deliverer {
107
107
  current = new URL(res.headers.get('location'), current).href;
108
108
  continue;
109
109
  }
110
+ // Where this ended up, which is not always where it was asked to go.
111
+ // Callers compare the document's own id against the URL they REQUESTED,
112
+ // so without this an open redirector on a victim's origin let an
113
+ // attacker's server answer for a victim's URL and be believed.
114
+ try { Object.defineProperty(res, 'finalUrl', { value: current, configurable: true }); }
115
+ catch { /* a frozen Response: the caller falls back to the requested URL */ }
110
116
  return res;
111
117
  }
112
118
  throw new Error(`too many redirects from ${url}`);
@@ -10,7 +10,7 @@
10
10
  // DMs, pins and standalone note documents have no place in that layout and
11
11
  // are not carried.
12
12
  //
13
- // Serialised by rdflib, never by hand — the podrdf.mjs rule.
13
+ // Serialised by rdflib, never by hand.
14
14
 
15
15
  import * as $rdf from 'rdflib';
16
16
 
@@ -17,8 +17,9 @@
17
17
 
18
18
  import crypto from 'node:crypto';
19
19
  import { handleDelivery } from './gateway-core.mjs';
20
- import { readCapped } from './safefetch.mjs';
21
- import { linkTargets, REL } from './links.mjs';
20
+ import { readCapped, safeFetch, isLoopbackHost } from './safefetch.mjs';
21
+ import * as podRoot from './pod/root.mjs';
22
+ import * as podPolicy from './pod/policy.mjs';
22
23
 
23
24
  // The one WebFinger document, spelled out here rather than imported from
24
25
  // wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
@@ -58,25 +59,122 @@ const OWNER_LOOKUP_MS = 5_000;
58
59
  * authority on that, so when it answers, its answer decides. A server that
59
60
  * says nothing leaves where the WebID lives as the only evidence there is.
60
61
  */
61
- async function podOwners(podBase, fetchImpl = fetch) {
62
- try {
63
- const res = await fetchImpl(podBase, {
64
- method: 'HEAD', signal: AbortSignal.timeout(OWNER_LOOKUP_MS),
65
- });
66
- return linkTargets(res?.headers?.get?.('link'), REL.owner, podBase);
67
- } catch { return []; }
68
- }
62
+ const podOwners = (podBase, fetchImpl = fetch) =>
63
+ podRoot.readOwnerLinks(fetchImpl, podBase, { timeoutMs: OWNER_LOOKUP_MS });
69
64
 
65
+ // Whether a token's WebID vouches for this pod, on the evidence of where it
66
+ // lives. Weak on purpose, and never used alone any more — see provesPod.
70
67
  function webidUnderPod(webid, podHome) {
71
68
  try { return new URL(webid).origin === new URL(podHome).origin; } catch { return false; }
72
69
  }
73
70
 
71
+ /**
72
+ * Whether this WebID may speak for this pod.
73
+ *
74
+ * Sharing an origin was the whole test, which is right where an origin is one
75
+ * person (a subdomain pod) and wrong where it is not. On a path-based server
76
+ * every account is on the same origin, so any account holder could attach
77
+ * somebody else's pod under a name of their choosing — and, worse, re-attach a
78
+ * name that already existed and be handed its receipt secret (S7).
79
+ *
80
+ * The pod server is the authority on who owns the pod, and it will say so in a
81
+ * `solid:owner` link if asked. When it answers, its answer decides. Only when
82
+ * it says nothing does this fall back to where the WebID lives, which is the
83
+ * best a silent server leaves available. This is the rule /api/agent's opt-in
84
+ * has used all along; attach simply never asked.
85
+ */
86
+ async function provesPod(webid, podHome, fetchImpl) {
87
+ const owners = await podOwners(podHome, fetchImpl || fetch);
88
+ if (owners.length) return owners.includes(webid);
89
+ return webidUnderPod(webid, podHome);
90
+ }
91
+
92
+ // A pod this front will read from at all.
93
+ //
94
+ // The front reads `rec.podHome + <the rest of the request path>` and, on the
95
+ // CSS server component, that read goes straight into the resource store with
96
+ // no access control of its own — so a row whose podHome names the FRONT is a
97
+ // row that can read the server's own internals, `/.internal/fedipod/directory/`
98
+ // included, where every user's receipt secret is kept (S2). The front is not a
99
+ // pod and never was one; saying so outright is the fix, and it closes the
100
+ // self-recursion of S8 with the same line.
101
+ function podHomeProblem(podHome, frontOrigin) {
102
+ let u;
103
+ try { u = new URL(podHome); } catch { return 'podHome is not a URL'; }
104
+ if (frontOrigin) {
105
+ try {
106
+ if (u.origin === new URL(frontOrigin).origin) {
107
+ return 'a pod cannot live on this gateway — name the pod that holds your data';
108
+ }
109
+ } catch { /* no usable frontOrigin: the other checks still apply */ }
110
+ }
111
+ // Nothing may reach a server's private trees, whatever origin they are on.
112
+ if (/(^|\/)\.internal(\/|$)/u.test(u.pathname) || /(^|\/)\.\.(\/|$)/u.test(u.pathname)) {
113
+ return 'that is not a pod address';
114
+ }
115
+ return null;
116
+ }
117
+
118
+ const RELAY_MAX_REQUESTS = 20;
119
+ const RELAY_MAX_BODY = 1024 * 1024;
120
+ const RELAY_TIMEOUT_MS = 8_000;
121
+ // The only headers a relayed request may carry to the remote server. Host
122
+ // comes from the URL; the user agent from safeFetch.
123
+ const RELAY_HEADERS = new Set(['date', 'digest', 'signature', 'content-type', 'accept']);
124
+
125
+ const keyIdOf = (signature) => (/keyId="([^"]+)"/.exec(signature || '') || [])[1] || null;
126
+
127
+ async function relayOne(item, rec, fetchImpl) {
128
+ const url = String(item?.url || '');
129
+ const method = String(item?.method || 'POST').toUpperCase();
130
+ if (method !== 'GET' && method !== 'POST') return { url, status: 0, error: 'method must be GET or POST' };
131
+ let u;
132
+ try { u = new URL(url); } catch { return { url, status: 0, error: 'not a URL' }; }
133
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && process.env.AP_ALLOW_PRIVATE_TARGETS === '1')) {
134
+ return { url, status: 0, error: 'https only' };
135
+ }
136
+ const headers = {};
137
+ for (const [k, v] of Object.entries(item?.headers || {})) {
138
+ const name = k.toLowerCase();
139
+ if (RELAY_HEADERS.has(name) && typeof v === 'string') headers[name] = v;
140
+ }
141
+ const body = method === 'POST' ? String(item?.body ?? '') : undefined;
142
+ if (body !== undefined && Buffer.byteLength(body) > RELAY_MAX_BODY) return { url, status: 0, error: 'body too large' };
143
+ const keyId = keyIdOf(headers.signature);
144
+ if (method === 'POST' && !keyId) return { url, status: 0, error: 'a delivery must be signed' };
145
+ if (keyId && !keyId.startsWith(rec.actorUrl + '#')) return { url, status: 0, error: "signed with a key that is not this account's" };
146
+ if (headers.digest) {
147
+ const want = 'SHA-256=' + crypto.createHash('sha256').update(body || '').digest('base64');
148
+ if (headers.digest !== want) return { url, status: 0, error: 'digest does not match the body' };
149
+ }
150
+ try {
151
+ const res = await safeFetch(url, { method, headers, body, signal: AbortSignal.timeout(RELAY_TIMEOUT_MS) }, fetchImpl);
152
+ const out = { url, status: res.status };
153
+ // The far server asking to be left alone has to reach the agent that will
154
+ // do the asking again. Without this the browser build could not honour a
155
+ // Retry-After at all — every delivery it makes goes through here — and fell
156
+ // back to its own ladder against a server that had already said how long.
157
+ const retryAfter = res.headers.get('retry-after');
158
+ if (retryAfter) out.retryAfter = retryAfter;
159
+ if (method === 'GET') {
160
+ out.contentType = res.headers.get('content-type') || null;
161
+ out.body = await readCapped(res, RELAY_MAX_BODY);
162
+ }
163
+ return out;
164
+ } catch (e) { return { url, status: 0, error: e.message }; }
165
+ }
166
+
74
167
  const j = (status, obj, ct = 'application/json') =>
75
168
  ({ status, headers: { 'content-type': ct, 'cache-control': 'no-store' }, body: JSON.stringify(obj) });
76
169
  const notFound = () => ({ status: 404, headers: { 'content-type': 'text/plain' }, body: 'not found\n' });
77
170
 
78
171
  // A user's public base on the front, a 1:1 mirror of their pod home.
79
- export const userBase = (frontOrigin, handle) => `${frontOrigin}/u/${handle}/`;
172
+ // The directory key is the FULL fediverse address — handle@host — never the
173
+ // bare handle, which is not unique: two different pods can each have a "me".
174
+ // A fronted identity answers at this front's host; a mail-door identity at its
175
+ // own pod's host. The `/u/` path segment carries that same key.
176
+ export const addressKey = (handle, host) => `${handle}@${host}`;
177
+ export const userBase = (frontOrigin, key) => `${frontOrigin}/u/${encodeURIComponent(key)}/`;
80
178
 
81
179
  // Rewrite every occurrence of the pod home to the front base (or back). The
82
180
  // bodies are JSON, so a whole-string swap rewrites ids wherever they appear —
@@ -90,7 +188,7 @@ function parseUserPath(pathname) {
90
188
  }
91
189
 
92
190
  // Top-level paths a handle may not take, so a name never shadows a route.
93
- const RESERVED = new Set(['u', 'api', 'signup', 'new-account', 'run', 'admin', 'gateway',
191
+ const RESERVED = new Set(['u', 'api', 'signup', 'new-account', 'run', 'admin', 'roster', 'gateway',
94
192
  'gw', 'well-known', 'inbox', 'outbox', 'actor', 'install']);
95
193
 
96
194
  // Why a handle is unusable, or null when it is fine. Lowercase letters, digits
@@ -114,19 +212,12 @@ function handleProblem(h) {
114
212
  const POLICY_TTL_MS = 5 * 60_000;
115
213
  const policyCache = new Map(); // podHome -> { at, policy }
116
214
 
117
- async function policyFor(rec, fetchImpl = fetch) {
118
- const key = rec.podHome;
119
- const hit = policyCache.get(key);
120
- if (hit && Date.now() - hit.at < POLICY_TTL_MS) return hit.policy;
121
- let policy = null;
122
- try {
123
- const res = await fetchImpl(rec.podHome + 'ap/gateway-policy.json',
124
- { headers: { accept: 'application/json' } });
125
- if (res && res.status < 400) policy = JSON.parse(await readCapped(res, 256 * 1024));
126
- } catch { /* unpublished or unreachable: the row's own fields stand */ }
127
- policyCache.set(key, { at: Date.now(), policy });
128
- return policy;
129
- }
215
+ // The cache is OURS, not the library's: on a warm serverless container one Map
216
+ // is shared across every user this front serves, so what it is keyed by is a
217
+ // correctness property. Keyed by podHome, it is one person's policy under one
218
+ // person's key.
219
+ const policyFor = (rec, fetchImpl = fetch) =>
220
+ podPolicy.read(fetchImpl, rec.podHome, { cache: policyCache, ttlMs: POLICY_TTL_MS });
130
221
 
131
222
  // The verified-delivery identity for a directory record: what gateway-core
132
223
  // needs to check what concerns the user and to append to their pod inbox.
@@ -152,8 +243,61 @@ function identFor(rec, policy = null) {
152
243
  // listDirectory() -> { handle: record } every row, for the admin roster
153
244
  // removeDirectory(handle) -> boolean drop a row; false when a seeded row remains
154
245
  // podPut(url, body, ct) -> boolean append to a user's pod (per-user cred inside)
155
- // podGet(url) -> Response read a user's pod (public reads; plain fetch is fine)
246
+ // podGet(url, { podHome }) -> Response read a user's pod (public reads; plain fetch
247
+ // is fine). `podHome` is the row's own pod, for
248
+ // an adapter that reads a store directly and so
249
+ // must confine the read itself.
156
250
  export async function routeFront(request, ctx) {
251
+ const out = await route(request, ctx);
252
+ return { ...out, headers: withSecurityHeaders(out.headers, out.body) };
253
+ }
254
+
255
+ // Every response this file makes, hardened in one place rather than in each of
256
+ // the dozen shapes below.
257
+ //
258
+ // `nosniff` matters most: the front serves user-supplied JSON straight from
259
+ // somebody's pod (the proxied actor and object documents), and without it a
260
+ // browser is free to decide for itself that a document is HTML and run what is
261
+ // inside it. The rest is the same posture the app already has — nothing may be
262
+ // framed, no base tag may be rewritten, no plugin content.
263
+ //
264
+ // A content-security-policy goes on the HTML only: it would mean nothing on a
265
+ // JSON document, and `frame-ancestors` has to be a header rather than a meta
266
+ // tag anyway.
267
+ //
268
+ // `script-src 'self'` is the one that matters, and it is only possible because
269
+ // none of these pages carries inline script any more — each has its own file and
270
+ // its own route above. A policy cannot tell an inline block the author wrote
271
+ // from one an attacker injected, so as long as any inline script has to run,
272
+ // every inline script may.
273
+ //
274
+ // `connect-src` allows https: because the pages sign in against the user's own
275
+ // pod, which is a different origin by definition and not one we can name here.
276
+ function withSecurityHeaders(headers = {}, body = null) {
277
+ const ct = String(headers['content-type'] || '');
278
+ const isHtml = ct.startsWith('text/html');
279
+ return {
280
+ ...headers,
281
+ 'x-content-type-options': 'nosniff',
282
+ 'referrer-policy': 'same-origin',
283
+ 'x-frame-options': 'SAMEORIGIN',
284
+ ...(isHtml && body ? {
285
+ 'content-security-policy': [
286
+ "default-src 'self'",
287
+ "script-src 'self'", // no inline script: see above
288
+ "style-src 'self' 'unsafe-inline'",
289
+ "img-src 'self' https: data:",
290
+ "connect-src 'self' https:", // sign-in goes to the user's own pod
291
+ "object-src 'none'", // no plugin content, ever
292
+ "base-uri 'none'", // no rewriting where relative URLs resolve
293
+ "frame-ancestors 'self'", // nobody else may frame these pages
294
+ "form-action 'self'", // a form here submits here
295
+ ].join('; '),
296
+ } : {}),
297
+ };
298
+ }
299
+
300
+ async function route(request, ctx) {
157
301
  const url = new URL(request.url);
158
302
  const { pathname } = url;
159
303
 
@@ -176,9 +320,10 @@ export async function routeFront(request, ctx) {
176
320
  return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: ctx.runPage };
177
321
  }
178
322
 
179
- // The admin page: the host reading who has accounts here. The page signs in
323
+ // The roster page: the host reading who has accounts here. The page signs in
180
324
  // and calls the roster API below; a deploy with no admin supplies no page.
181
- if (pathname === '/admin') {
325
+ // Off /admin, which is the owner's own record page on every agent.
326
+ if (pathname === '/roster') {
182
327
  if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: {}, body: '' };
183
328
  if (!ctx.adminPage) return notFound();
184
329
  return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: ctx.adminPage };
@@ -197,9 +342,9 @@ export async function routeFront(request, ctx) {
197
342
  .map((r) => ({
198
343
  handle: r.handle, kind: r.kind || 'person', fronted: !r.inboxOnly,
199
344
  podHome: r.podHome, webId: r.webId || null, actorUrl: r.actorUrl,
200
- address: `@${r.handle}@${ctx.host}`,
345
+ address: r.address || `@${r.handle}@${ctx.host}`,
201
346
  }))
202
- .sort((a, b) => a.handle.localeCompare(b.handle));
347
+ .sort((a, b) => (a.address || a.handle).localeCompare(b.address || b.handle));
203
348
  return j(200, { host: ctx.host, accounts });
204
349
  }
205
350
 
@@ -213,11 +358,13 @@ export async function routeFront(request, ctx) {
213
358
  if (webid !== ctx.adminWebId) return j(403, { error: 'that WebID is not the admin of this front' });
214
359
  let body;
215
360
  try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
216
- const handle = String(body.handle || '').toLowerCase();
217
- if (!(await ctx.lookup(handle))) return j(404, { error: 'no such account' });
218
- const removed = (await ctx.removeDirectory(handle)) === true;
219
- return j(200, removed ? { handle, removed }
220
- : { handle, removed: false,
361
+ // Revoke by full address (what the roster lists); a bare handle still works
362
+ // for any legacy row keyed that way.
363
+ const key = String(body.address || body.handle || '').replace(/^@/, '').toLowerCase();
364
+ if (!key || !(await ctx.lookup(key))) return j(404, { error: 'no such account' });
365
+ const removed = (await ctx.removeDirectory(key)) === true;
366
+ return j(200, removed ? { account: key, removed }
367
+ : { account: key, removed: false,
221
368
  reason: 'this row is seeded in the deploy environment (FEDIPOD_DIRECTORY_JSON) — remove it there and redeploy' });
222
369
  }
223
370
 
@@ -247,38 +394,94 @@ export async function routeFront(request, ctx) {
247
394
  const podHome = String(body.podHome || '');
248
395
  const problem = handleProblem(handle);
249
396
  if (problem) return j(400, { error: problem });
250
- if (!/^https:\/\/\S+\/$/.test(podHome)) return j(400, { error: 'podHome must be an https URL ending in /' });
251
- if (await ctx.lookup(handle)) return j(409, { error: 'that name is taken' });
397
+ const podLoopback = (() => { try { return isLoopbackHost(new URL(podHome).hostname); } catch { return false; } })();
398
+ if (!(/^https:\/\/\S+\/$/.test(podHome) || (/^http:\/\/\S+\/$/.test(podHome) && podLoopback))) {
399
+ return j(400, { error: 'podHome must be an https URL ending in /' });
400
+ }
401
+ const badPod = podHomeProblem(podHome, ctx.frontOrigin);
402
+ if (badPod) return j(400, { error: badPod });
252
403
  const webid = await verifyPodToken(request, pathname, ctx.verifier);
253
404
  if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
254
- if (!webidUnderPod(webid, podHome)) {
405
+ if (!await provesPod(webid, podHome, ctx.fetchImpl)) {
255
406
  return j(403, { error: 'the token proves a different pod than the one you listed' });
256
407
  }
257
408
  // The usual attachment keeps the user's identity on their pod and moves
258
409
  // only the mail door here (@me@mypod). Fronted identity — actor ids on
259
410
  // this domain — remains available to an explicit `fronted: true`.
260
411
  const fronted = body.fronted === true;
261
- const actorUrl = fronted ? `${ctx.frontOrigin}/u/${handle}/ap/actor` : podHome + 'ap/actor';
262
- const hmacSecret = crypto.randomBytes(32).toString('base64');
412
+ // The full address is the key: a fronted name answers at this front's host,
413
+ // a mail-door name at its pod's host. The same handle on two pods is two
414
+ // distinct addresses, so it never collides.
415
+ let addressHost; try { addressHost = fronted ? ctx.host : new URL(podHome).host; } catch { return j(400, { error: 'bad podHome' }); }
416
+ // A fronted name answers at this front's host, where the handle alone is the
417
+ // whole address and is unique — so it keeps keying by bare handle (and its
418
+ // actor id stays /u/<handle>/, unchanged for existing accounts). A mail-door
419
+ // name answers at its own pod, where "me" is not unique, so it keys by the
420
+ // full address handle@podhost, which the /u/ segment then carries.
421
+ const key = fronted ? handle : addressKey(handle, addressHost);
422
+ const address = `@${handle}@${addressHost}`;
423
+ // "Taken" only when this exact address already belongs to a DIFFERENT pod.
424
+ // Re-attaching your own pod — a retry, a re-provision, a second browser — is
425
+ // idempotent: it updates the row in place and keeps the same door secret.
426
+ const prior = await ctx.lookup(key);
427
+ if (prior && prior.podHome !== podHome) return j(409, { error: 'that name is taken' });
428
+ // Re-attaching is idempotent — a retry, a re-provision, a second browser —
429
+ // and it hands back the EXISTING receipt secret. So it has to be the same
430
+ // person: matching podHome was enough on a subdomain server and, on a
431
+ // path-based one, meant any co-tenant could re-attach your row, flip its
432
+ // kind, and be given your secret (S7). A row that predates `webId` has
433
+ // nothing to compare and falls back to the pod check above.
434
+ if (prior?.webId && prior.webId !== webid) {
435
+ return j(409, { error: 'that name belongs to a different account on this pod' });
436
+ }
437
+ // The pod's actor lives under its AP root container, which the pod owner
438
+ // knows and the front does not — so take the caller's actorUrl when it is on
439
+ // this pod, and fall back only when none is given.
440
+ const claimedActor = typeof body.actorUrl === 'string' && body.actorUrl.startsWith(podHome) ? body.actorUrl : null;
441
+ const actorUrl = fronted ? `${userBase(ctx.frontOrigin, key)}ap/actor` : (claimedActor || podHome + 'ap/actor');
442
+ const hmacSecret = prior?.hmacSecret || crypto.randomBytes(32).toString('base64');
263
443
  const record = {
264
- handle, podHome, webId: webid, actorUrl,
444
+ handle, address, podHome, webId: webid, actorUrl,
265
445
  kind: body.kind === 'group' ? 'group' : 'person',
266
446
  gatewayWebId: ctx.gatewayWebId || null, hmacSecret,
267
447
  ...(fronted ? {} : { inboxOnly: true }),
268
448
  };
269
- await ctx.putDirectory(handle, record);
449
+ await ctx.putDirectory(key, record);
270
450
  // The secret is returned ONCE, with the one command the user runs to
271
451
  // point their agent at this gateway.
272
- const doorInbox = `${ctx.frontOrigin}/u/${handle}/ap/inbox/`;
452
+ const doorInbox = `${userBase(ctx.frontOrigin, key)}ap/inbox/`;
273
453
  if (fronted) {
274
- return j(201, { ok: true, handle, actorUrl, frontActor: actorUrl, hmacSecret,
275
- address: `@${handle}@${ctx.host}`,
454
+ return j(201, { ok: true, handle, address, actorUrl, frontActor: actorUrl, hmacSecret,
276
455
  command: `fedipod gateway ${actorUrl} --secret ${hmacSecret}` });
277
456
  }
278
- return j(201, { ok: true, handle, doorInbox, hmacSecret,
457
+ return j(201, { ok: true, handle, address, doorInbox, hmacSecret,
279
458
  command: `fedipod gateway ${doorInbox} --secret ${hmacSecret} --inbox-only` });
280
459
  }
281
460
 
461
+ // The relay: the front sends requests a browser has already signed. A page
462
+ // may not set the Date or Host header, and both are inside an HTTP
463
+ // signature, so a browser-run agent signs and hands the request here; the
464
+ // front sends it with exactly the headers that were signed. It can forward
465
+ // a signature and cannot make one. Only the account's own key may sign,
466
+ // only the account's own pod may call, and nothing unsigned-for reaches
467
+ // the remote server.
468
+ if (pathname === '/api/relay' && request.method === 'POST') {
469
+ let body;
470
+ try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
471
+ const handle = String(body.handle || '').toLowerCase();
472
+ const rec = await ctx.lookup(handle);
473
+ if (!rec) return j(404, { error: 'no such account' });
474
+ const webid = await verifyPodToken(request, pathname, ctx.verifier);
475
+ if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
476
+ const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
477
+ if (!owner) return j(403, { error: "the token proves a different pod than this account's" });
478
+ const items = Array.isArray(body.requests) ? body.requests : [];
479
+ if (!items.length) return j(400, { error: 'requests must be a non-empty list' });
480
+ if (items.length > RELAY_MAX_REQUESTS) return j(400, { error: `at most ${RELAY_MAX_REQUESTS} requests per call` });
481
+ const results = await Promise.all(items.map((it) => relayOne(it, rec, ctx.fetchImpl || fetch)));
482
+ return j(200, { results });
483
+ }
484
+
282
485
  // Runtime opt-in: a pod owner asks the server this front sits on to RUN
283
486
  // their identity. Same proof as attach — a Solid-OIDC token — but the
284
487
  // ownership check is stricter: the WebID must live UNDER the pod base, not
@@ -327,6 +530,20 @@ export async function routeFront(request, ctx) {
327
530
  return j(400, { error: 'action must be opt-in or opt-out' });
328
531
  }
329
532
 
533
+ // Each page's own script. They were inline until 2026-09-09; out here the
534
+ // pages can be served under `script-src 'self'`, which is the directive that
535
+ // tells running code from injected markup — and inline script defeats it by
536
+ // construction. Same mechanism the vendored library below has always used.
537
+ //
538
+ // The name is matched against a literal list, so nothing about the request
539
+ // chooses a file.
540
+ const mPageScript = /^\/(new-account|run|admin)\.js$/u.exec(pathname);
541
+ if (mPageScript) {
542
+ const body = ctx.pageScripts?.[`${mPageScript[1]}.js`];
543
+ if (!body) return notFound();
544
+ return { status: 200, headers: { 'content-type': 'text/javascript; charset=utf-8' }, body };
545
+ }
546
+
330
547
  // The vendored Solid-OIDC browser library the /run and /admin pages load —
331
548
  // served here because this function owns every path on the domain.
332
549
  if (pathname === '/solid-oidc-client.js') {
@@ -373,9 +590,15 @@ export async function routeFront(request, ctx) {
373
590
  // fixed to the front so a consumer cross-checks it consistently.
374
591
  if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: {}, body: '' };
375
592
  const podTarget = rec.podHome + up.rest;
376
- const res = await (ctx.podGet ? ctx.podGet(podTarget) : fetch(podTarget, { headers: { accept: AP_CT } }));
377
- if (!res || res.status >= 400) return { status: res?.status || 502, headers: {}, body: '' };
378
- let text = await readCapped(res, 1024 * 1024);
593
+ // The pod this read belongs to travels with it: an adapter reading a store
594
+ // directly (the CSS server component) has no access control of its own and
595
+ // needs to be told what it may reach. See podHomeProblem above for the other
596
+ // half — a row that names something which is not a pod.
597
+ const got = await podRoot.readPublicDocument(
598
+ ctx.podGet || ((u) => fetch(u, { headers: { accept: AP_CT } })),
599
+ podTarget, { podHome: rec.podHome });
600
+ if (got.text === null) return { status: got.status, headers: {}, body: '' };
601
+ let text = got.text;
379
602
  text = swap(text, rec.podHome, base);
380
603
  if (up.rest === 'ap/actor') {
381
604
  try {
@@ -14,6 +14,7 @@
14
14
 
15
15
  import crypto from 'node:crypto';
16
16
  import { verifyHttpSignature, makeSafeLoader, makeReceipt, signReceipt } from './httpsig.mjs';
17
+ import * as inbox from './pod/inbox.mjs';
17
18
 
18
19
  const DEFAULT_MAX_BYTES = 512 * 1024; // mirror intake.mjs MAX_ITEM_BYTES
19
20
 
@@ -100,17 +101,12 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
100
101
  ? signReceipt(makeReceipt(v, { gateway: ident.gatewayWebId }), ident.hmacSecret)
101
102
  : null;
102
103
  const hash = sha256hex(raw);
103
- const okA = await podPut(ident.inboxUrl + hash, raw, 'application/activity+json');
104
+ const okA = await inbox.appendVerifiedDelivery(podPut, ident.inboxUrl, hash, raw);
104
105
  // A pod-write failure returns 5xx so the ORIGIN retries over its own ladder —
105
106
  // that is what preserves the pod's buffer property without the gateway
106
107
  // holding any state.
107
108
  if (!okA) return { status: 502, reason: 'pod inbox write failed' };
108
- // The receipt is best-effort, like _archive: a delivery that landed but whose
109
- // receipt did not is simply unverified, never lost.
110
- if (receipt) {
111
- await podPut(ident.inboxUrl + hash + '.receipt.json', JSON.stringify(receipt), 'application/json')
112
- .catch(() => {});
113
- }
109
+ if (receipt) await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
114
110
  return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified' };
115
111
  }
116
112