fedipod 0.18.0 → 0.19.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 (320) hide show
  1. package/README.md +20 -0
  2. package/bin/fedipod.mjs +3 -5
  3. package/cli.md +13 -0
  4. package/groups.md +11 -0
  5. package/gui.md +15 -1
  6. package/lib/admin.mjs +3 -3
  7. package/lib/deliver.mjs +6 -0
  8. package/lib/export-collections.mjs +1 -1
  9. package/lib/front-core.mjs +272 -49
  10. package/lib/gateway-core.mjs +3 -7
  11. package/lib/httpsig.mjs +33 -2
  12. package/lib/import.mjs +1 -1
  13. package/lib/intake.mjs +335 -118
  14. package/lib/links.mjs +6 -32
  15. package/lib/mastoapi.mjs +372 -35
  16. package/lib/migrate.mjs +4 -18
  17. package/lib/pod/README.md +61 -0
  18. package/lib/pod/actor.mjs +96 -0
  19. package/lib/pod/collection.mjs +77 -0
  20. package/lib/pod/containers.mjs +136 -0
  21. package/lib/pod/discovery.mjs +46 -0
  22. package/lib/pod/featured.mjs +18 -0
  23. package/lib/pod/followers.mjs +24 -0
  24. package/lib/pod/following.mjs +10 -0
  25. package/lib/pod/http.mjs +74 -0
  26. package/lib/pod/inbox.mjs +144 -0
  27. package/lib/pod/links.mjs +35 -0
  28. package/lib/pod/media.mjs +27 -0
  29. package/lib/pod/notes.mjs +80 -0
  30. package/lib/pod/notifications.mjs +89 -0
  31. package/lib/pod/outbox.mjs +18 -0
  32. package/lib/pod/package.json +19 -0
  33. package/lib/pod/policy.mjs +58 -0
  34. package/lib/pod/private.mjs +19 -0
  35. package/lib/pod/root.mjs +63 -0
  36. package/lib/pod/state.mjs +39 -0
  37. package/lib/pod/transport.mjs +477 -0
  38. package/lib/pod/urls.mjs +71 -0
  39. package/lib/publisher.mjs +112 -183
  40. package/lib/remote.mjs +30 -395
  41. package/lib/social.mjs +18 -9
  42. package/lib/storage.mjs +2 -2
  43. package/lib/store.mjs +8 -1
  44. package/lib/tagfeed.mjs +19 -2
  45. package/lib/wire.mjs +48 -67
  46. package/package.json +9 -2
  47. package/run-agent.mjs +14 -39
  48. package/scripts/build-app.mjs +64 -0
  49. package/scripts/check-pod-calls.mjs +177 -0
  50. package/scripts/stage-site.mjs +144 -0
  51. package/web/admin/admin.js +141 -74
  52. package/web/admin/bar.css +9 -13
  53. package/web/admin/bar.js +2 -2
  54. package/web/admin/client/client.js +1 -1
  55. package/web/admin/client/index.html +5 -6
  56. package/web/admin/index.html +53 -65
  57. package/web/admin/setup/index.html +2 -1
  58. package/web/admin/setup/setup.js +1 -1
  59. package/web/admin/tokens.css +56 -0
  60. package/web/app/README.md +77 -0
  61. package/web/app/admin-facade.mjs +549 -0
  62. package/web/app/agent.mjs +490 -0
  63. package/web/app/atproto-browser.mjs +86 -0
  64. package/web/app/boot.mjs +340 -0
  65. package/web/app/deliver-relay.mjs +72 -0
  66. package/web/app/dist/boot.js +34067 -0
  67. package/web/app/dist/boot.js.map +7 -0
  68. package/web/app/dist/sw.js +53745 -0
  69. package/web/app/dist/sw.js.map +7 -0
  70. package/web/app/fediacct-browser.mjs +146 -0
  71. package/web/app/idb-kv.mjs +35 -0
  72. package/web/app/index.html +203 -0
  73. package/web/app/index.html~ +152 -0
  74. package/web/app/keys-browser.mjs +46 -0
  75. package/web/app/keystore.mjs +99 -0
  76. package/web/app/oidc-session.mjs +189 -0
  77. package/web/app/pod-auth.mjs +248 -0
  78. package/web/app/pod-remote.mjs +84 -0
  79. package/web/app/shims/_globals.mjs +2 -0
  80. package/web/app/shims/fedify-sig.mjs +77 -0
  81. package/web/app/shims/node-crypto.mjs +113 -0
  82. package/web/app/shims/node-fs.mjs +17 -0
  83. package/web/app/shims/node-path.mjs +19 -0
  84. package/web/app/shims/node-url.mjs +7 -0
  85. package/web/app/shims/prelude.js +58 -0
  86. package/web/app/shims/safefetch.mjs +58 -0
  87. package/web/app/shims/web-push.mjs +14 -0
  88. package/web/app/signup.mjs +248 -0
  89. package/web/app/site/_headers +9 -0
  90. package/web/app/site/_redirects +21 -0
  91. package/web/app/site/admin/admin.js +1181 -0
  92. package/web/app/site/admin/bar.css +60 -0
  93. package/web/app/site/admin/bar.js +61 -0
  94. package/web/app/site/admin/client/client.js +66 -0
  95. package/web/app/site/admin/client/index.html +43 -0
  96. package/web/app/site/admin/index.html +556 -0
  97. package/web/app/site/admin/setup/index.html +191 -0
  98. package/web/app/site/admin/setup/setup.js +308 -0
  99. package/web/app/site/admin/tokens.css +56 -0
  100. package/web/app/site/admin/window.css +55 -0
  101. package/web/app/site/admin/window.js +136 -0
  102. package/web/app/site/app/404.html +40 -0
  103. package/web/app/site/app/_headers +2 -0
  104. package/web/app/site/app/apple-touch-icon.png +0 -0
  105. package/web/app/site/app/assets/ICONS-DIPJeU0C.js +2 -0
  106. package/web/app/site/app/assets/ICONS-DIPJeU0C.js.map +1 -0
  107. package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js +183 -0
  108. package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
  109. package/web/app/site/app/assets/Temml-CCjoacWt.woff2 +0 -0
  110. package/web/app/site/app/assets/__vite-browser-external-C7iut881.js +2 -0
  111. package/web/app/site/app/assets/__vite-browser-external-C7iut881.js.map +1 -0
  112. package/web/app/site/app/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
  113. package/web/app/site/app/assets/catch-up-O5q5C75Z.png +0 -0
  114. package/web/app/site/app/assets/chunk-aKtaBQYM.js +1 -0
  115. package/web/app/site/app/assets/compose-CFEx7wb4.js +2 -0
  116. package/web/app/site/app/assets/compose-CFEx7wb4.js.map +1 -0
  117. package/web/app/site/app/assets/compose-ZJN5xSb4.js +35 -0
  118. package/web/app/site/app/assets/compose-ZJN5xSb4.js.map +1 -0
  119. package/web/app/site/app/assets/debug.module-YFQgwnIr.js +52 -0
  120. package/web/app/site/app/assets/debug.module-YFQgwnIr.js.map +1 -0
  121. package/web/app/site/app/assets/dom-p1YcJ5Rw.js +2 -0
  122. package/web/app/site/app/assets/dom-p1YcJ5Rw.js.map +1 -0
  123. package/web/app/site/app/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
  124. package/web/app/site/app/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
  125. package/web/app/site/app/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
  126. package/web/app/site/app/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
  127. package/web/app/site/app/assets/icons/add-circle-line-WD0mwORD.js +1 -0
  128. package/web/app/site/app/assets/icons/alert-line-CpFUMf9s.js +1 -0
  129. package/web/app/site/app/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
  130. package/web/app/site/app/assets/icons/announcement-line-5nwaJFJm.js +1 -0
  131. package/web/app/site/app/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
  132. package/web/app/site/app/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
  133. package/web/app/site/app/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
  134. package/web/app/site/app/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
  135. package/web/app/site/app/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
  136. package/web/app/site/app/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
  137. package/web/app/site/app/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
  138. package/web/app/site/app/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
  139. package/web/app/site/app/assets/icons/at-line-CF66Bznk.js +1 -0
  140. package/web/app/site/app/assets/icons/attachment-line-BY8It-l9.js +1 -0
  141. package/web/app/site/app/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
  142. package/web/app/site/app/assets/icons/board-line-C0UWXyA6.js +1 -0
  143. package/web/app/site/app/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
  144. package/web/app/site/app/assets/icons/building-5-line-Dd59MzbO.js +1 -0
  145. package/web/app/site/app/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
  146. package/web/app/site/app/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
  147. package/web/app/site/app/assets/icons/calendar-month-line-vommts2C.js +1 -0
  148. package/web/app/site/app/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
  149. package/web/app/site/app/assets/icons/camera-line-Cv69MEei.js +1 -0
  150. package/web/app/site/app/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
  151. package/web/app/site/app/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
  152. package/web/app/site/app/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
  153. package/web/app/site/app/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
  154. package/web/app/site/app/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
  155. package/web/app/site/app/assets/icons/clipboard-line-BBp364gJ.js +1 -0
  156. package/web/app/site/app/assets/icons/close-circle-line-CICdiuGz.js +1 -0
  157. package/web/app/site/app/assets/icons/close-line-DYrFWcjv.js +1 -0
  158. package/web/app/site/app/assets/icons/cloud-line-tiQAbah8.js +1 -0
  159. package/web/app/site/app/assets/icons/code-line-fAl8iiV3.js +1 -0
  160. package/web/app/site/app/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
  161. package/web/app/site/app/assets/icons/copy-2-line-CLW69yF5.js +1 -0
  162. package/web/app/site/app/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
  163. package/web/app/site/app/assets/icons/document-3-line-DBGNhAg2.js +1 -0
  164. package/web/app/site/app/assets/icons/document-line-C7mb9sb7.js +1 -0
  165. package/web/app/site/app/assets/icons/down-line-BGqSSThd.js +1 -0
  166. package/web/app/site/app/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
  167. package/web/app/site/app/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
  168. package/web/app/site/app/assets/icons/exit-line-Dc07BS-x.js +1 -0
  169. package/web/app/site/app/assets/icons/external-link-line-BvpSkmjO.js +1 -0
  170. package/web/app/site/app/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
  171. package/web/app/site/app/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
  172. package/web/app/site/app/assets/icons/filter-2-line-B_mR2usy.js +1 -0
  173. package/web/app/site/app/assets/icons/filter-line-C_nm-3cU.js +1 -0
  174. package/web/app/site/app/assets/icons/flag-1-line-C2HB05I0.js +1 -0
  175. package/web/app/site/app/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
  176. package/web/app/site/app/assets/icons/formula-line-DLrt2d6j.js +1 -0
  177. package/web/app/site/app/assets/icons/grid-line-CgUmEikf.js +1 -0
  178. package/web/app/site/app/assets/icons/group-line-C5fnX0wU.js +1 -0
  179. package/web/app/site/app/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
  180. package/web/app/site/app/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
  181. package/web/app/site/app/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
  182. package/web/app/site/app/assets/icons/heart-line-CJ7K47J4.js +1 -0
  183. package/web/app/site/app/assets/icons/history-2-line-kWWvFzqv.js +1 -0
  184. package/web/app/site/app/assets/icons/history-line-bGQiwbTH.js +1 -0
  185. package/web/app/site/app/assets/icons/home-3-line-fRU1zeLG.js +1 -0
  186. package/web/app/site/app/assets/icons/information-line-iNk6-sOY.js +1 -0
  187. package/web/app/site/app/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
  188. package/web/app/site/app/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
  189. package/web/app/site/app/assets/icons/layout-5-line-B_znt5wO.js +1 -0
  190. package/web/app/site/app/assets/icons/left-line-eUw0tya4.js +1 -0
  191. package/web/app/site/app/assets/icons/lightning-line-DaKoDdix.js +1 -0
  192. package/web/app/site/app/assets/icons/link-2-line-DCqLmMA7.js +1 -0
  193. package/web/app/site/app/assets/icons/list-check-line-BAxosBh9.js +1 -0
  194. package/web/app/site/app/assets/icons/lock-line-CPoaXeUW.js +1 -0
  195. package/web/app/site/app/assets/icons/mail-line-B0P8aa1L.js +1 -0
  196. package/web/app/site/app/assets/icons/moon-line-CEqNAToP.js +1 -0
  197. package/web/app/site/app/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
  198. package/web/app/site/app/assets/icons/more-3-line-BIiVjYTz.js +1 -0
  199. package/web/app/site/app/assets/icons/notification-line-BZZq2Gtu.js +1 -0
  200. package/web/app/site/app/assets/icons/pencil-line-CKh8-A1v.js +1 -0
  201. package/web/app/site/app/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
  202. package/web/app/site/app/assets/icons/pin-line-DpTolyhs.js +1 -0
  203. package/web/app/site/app/assets/icons/play-fill-B0-G_8lX.js +1 -0
  204. package/web/app/site/app/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
  205. package/web/app/site/app/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
  206. package/web/app/site/app/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
  207. package/web/app/site/app/assets/icons/radar-line-CKyroTth.js +1 -0
  208. package/web/app/site/app/assets/icons/react-line-CoIKhhtK.js +1 -0
  209. package/web/app/site/app/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
  210. package/web/app/site/app/assets/icons/right-line-B3KWJaqc.js +1 -0
  211. package/web/app/site/app/assets/icons/rocket-line-4KZl537D.js +1 -0
  212. package/web/app/site/app/assets/icons/round-fill-C69NTrZ0.js +1 -0
  213. package/web/app/site/app/assets/icons/round-line-B9k_1uo-.js +1 -0
  214. package/web/app/site/app/assets/icons/route-line-B28ReHcE.js +1 -0
  215. package/web/app/site/app/assets/icons/rows-4-line-CrBniagM.js +1 -0
  216. package/web/app/site/app/assets/icons/scan-line-DSf74YdP.js +1 -0
  217. package/web/app/site/app/assets/icons/search-2-line-B6geIU1z.js +1 -0
  218. package/web/app/site/app/assets/icons/settings-3-line-azJwK47w.js +1 -0
  219. package/web/app/site/app/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
  220. package/web/app/site/app/assets/icons/share-2-line-D5MxWjrm.js +1 -0
  221. package/web/app/site/app/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
  222. package/web/app/site/app/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
  223. package/web/app/site/app/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
  224. package/web/app/site/app/assets/icons/time-line-BF3eet1v.js +1 -0
  225. package/web/app/site/app/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
  226. package/web/app/site/app/assets/icons/translate-line-BFWol2Ce.js +1 -0
  227. package/web/app/site/app/assets/icons/unlock-line-X_x5vwv3.js +1 -0
  228. package/web/app/site/app/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
  229. package/web/app/site/app/assets/icons/user-4-line-BVSynZCp.js +1 -0
  230. package/web/app/site/app/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
  231. package/web/app/site/app/assets/icons/user-add-line-BbXdHG4z.js +1 -0
  232. package/web/app/site/app/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
  233. package/web/app/site/app/assets/icons/user-follow-line-BXux0wSd.js +1 -0
  234. package/web/app/site/app/assets/icons/user-star-line-DdYlpHLU.js +1 -0
  235. package/web/app/site/app/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
  236. package/web/app/site/app/assets/icons/user-x-line-DIDgNlPH.js +1 -0
  237. package/web/app/site/app/assets/icons/volume-line-Bs390KQt.js +1 -0
  238. package/web/app/site/app/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
  239. package/web/app/site/app/assets/icons/walk-line-yNXfB-md.js +1 -0
  240. package/web/app/site/app/assets/icons/world-2-line-CenkGIwj.js +1 -0
  241. package/web/app/site/app/assets/icons/zoom-in-line-CaENUphf.js +1 -0
  242. package/web/app/site/app/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
  243. package/web/app/site/app/assets/instances-BNPptnn-.json +333 -0
  244. package/web/app/site/app/assets/locales/ar-SA-CfheRbCu.js +1 -0
  245. package/web/app/site/app/assets/locales/ca-ES-DHRW86Tz.js +1 -0
  246. package/web/app/site/app/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
  247. package/web/app/site/app/assets/locales/de-DE-ByRPMRFy.js +1 -0
  248. package/web/app/site/app/assets/locales/eo-UY-CPC7wLCS.js +1 -0
  249. package/web/app/site/app/assets/locales/es-ES-dJa0Fd9t.js +1 -0
  250. package/web/app/site/app/assets/locales/eu-ES-C3djlPR2.js +1 -0
  251. package/web/app/site/app/assets/locales/fa-IR-DTT490Ck.js +1 -0
  252. package/web/app/site/app/assets/locales/fi-FI-B23MCPuz.js +1 -0
  253. package/web/app/site/app/assets/locales/fr-FR-BcaQsO5C.js +1 -0
  254. package/web/app/site/app/assets/locales/gl-ES-BR-CVKBN.js +1 -0
  255. package/web/app/site/app/assets/locales/he-IL-CfheRbCu.js +1 -0
  256. package/web/app/site/app/assets/locales/hu-HU-CfheRbCu.js +1 -0
  257. package/web/app/site/app/assets/locales/it-IT-C1136UFV.js +1 -0
  258. package/web/app/site/app/assets/locales/ja-JP-CRZmRAhD.js +1 -0
  259. package/web/app/site/app/assets/locales/kab-Cz8xH_TT.js +1 -0
  260. package/web/app/site/app/assets/locales/ko-KR-CG9UiY4o.js +1 -0
  261. package/web/app/site/app/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
  262. package/web/app/site/app/assets/locales/nb-NO-CfheRbCu.js +1 -0
  263. package/web/app/site/app/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
  264. package/web/app/site/app/assets/locales/oc-FR-CfheRbCu.js +1 -0
  265. package/web/app/site/app/assets/locales/pl-PL-DkwGXf6P.js +1 -0
  266. package/web/app/site/app/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
  267. package/web/app/site/app/assets/locales/pt-BR-nGL0HdFS.js +1 -0
  268. package/web/app/site/app/assets/locales/pt-PT-Cp17aj2z.js +1 -0
  269. package/web/app/site/app/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
  270. package/web/app/site/app/assets/locales/th-TH-CfheRbCu.js +1 -0
  271. package/web/app/site/app/assets/locales/tok-CfheRbCu.js +1 -0
  272. package/web/app/site/app/assets/locales/tr-TR-CfheRbCu.js +1 -0
  273. package/web/app/site/app/assets/locales/uk-UA-C5E3dFBf.js +1 -0
  274. package/web/app/site/app/assets/locales/zh-CN-BHJi7_et.js +1 -0
  275. package/web/app/site/app/assets/locales/zh-TW-CfheRbCu.js +1 -0
  276. package/web/app/site/app/assets/main-BdqNbG-a.js +26 -0
  277. package/web/app/site/app/assets/main-BdqNbG-a.js.map +1 -0
  278. package/web/app/site/app/assets/mock-home-DGhR__He.js +2 -0
  279. package/web/app/site/app/assets/mock-home-DGhR__He.js.map +1 -0
  280. package/web/app/site/app/assets/multi-column-ROck0NVt.jpg +0 -0
  281. package/web/app/site/app/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
  282. package/web/app/site/app/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
  283. package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js +2 -0
  284. package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js.map +1 -0
  285. package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js +20 -0
  286. package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js.map +1 -0
  287. package/web/app/site/app/assets/style-B1iLPTPR.css +2 -0
  288. package/web/app/site/app/assets/temml-DuyIQMei.js +97 -0
  289. package/web/app/site/app/assets/temml-DuyIQMei.js.map +1 -0
  290. package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js +2 -0
  291. package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
  292. package/web/app/site/app/assets/year-in-posts-B2lywqdx.png +0 -0
  293. package/web/app/site/app/assets/year-in-posts-DJv_star.js +3 -0
  294. package/web/app/site/app/assets/year-in-posts-DJv_star.js.map +1 -0
  295. package/web/app/site/app/compose/index.html +20 -0
  296. package/web/app/site/app/favicon.ico +0 -0
  297. package/web/app/site/app/index.html +73 -0
  298. package/web/app/site/app/logo-192.png +0 -0
  299. package/web/app/site/app/logo-512.png +0 -0
  300. package/web/app/site/app/logo-badge-72.png +0 -0
  301. package/web/app/site/app/logo-maskable-512.png +0 -0
  302. package/web/app/site/app/logo-monochrome-512.png +0 -0
  303. package/web/app/site/app/logo-monochrome-maskable-512.png +0 -0
  304. package/web/app/site/app/manifest.webmanifest +1 -0
  305. package/web/app/site/app/og-image-2.jpg +0 -0
  306. package/web/app/site/app/og-image.png +0 -0
  307. package/web/app/site/app/robots.txt +2 -0
  308. package/web/app/site/app/version.json +1 -0
  309. package/web/app/site/boot.js +34067 -0
  310. package/web/app/site/index.html +203 -0
  311. package/web/app/site/sw.js +53745 -0
  312. package/web/app/sw-src.mjs +205 -0
  313. package/web/front/#new-account.html# +43 -0
  314. package/web/front/admin.html +1 -81
  315. package/web/front/admin.js +85 -0
  316. package/web/front/new-account.html +1 -9
  317. package/web/front/new-account.js +13 -0
  318. package/web/front/run.html +1 -92
  319. package/web/front/run.js +107 -0
  320. package/lib/podrdf.mjs +0 -132
package/README.md CHANGED
@@ -74,6 +74,20 @@ keyword filters, scheduled posts, pinned posts (visible from other servers),
74
74
  blocking and muting from the client, custom emojis, and web-push
75
75
  notifications that reach you while the client is closed.
76
76
 
77
+ <!-- CLAUDE 2026-09-09 — the browser build does not have three of the things
78
+ this paragraph promises, and it says so honestly now rather than
79
+ advertising them. Written as a first draft in your voice; rework or move
80
+ it wherever you think it belongs. Delete these markers when done. -->
81
+ Two of those are the installed agent only. **Scheduled posts** need something
82
+ running between now and the time you picked, and in the browser build nothing
83
+ is — so it refuses a scheduled post rather than accepting one it would silently
84
+ drop. **Web-push notifications** likewise: the browser build has no push
85
+ service behind it, so it does not advertise one and your client hides the
86
+ toggle instead of showing one that does nothing. Both work on the installed
87
+ agent. **Custom emojis** you receive are rendered everywhere; the picker for
88
+ sending your own is not built yet, on either.
89
+ <!-- /CLAUDE -->
90
+
77
91
  Polls: up to four options, one answer or several, running from five minutes to
78
92
  a month.
79
93
 
@@ -85,6 +99,12 @@ a month.
85
99
  custom instance.
86
100
  - **Streaming**: the agent serves the Mastodon streaming API
87
101
  (`/api/v1/streaming`, WebSocket) so clients update live instead of polling.
102
+ <!-- CLAUDE 2026-09-09 — true of the installed agent; the browser build is a
103
+ service worker and cannot hold a socket. It advertises no streaming URL,
104
+ so clients fall back to polling on their own. Delete markers when done. -->
105
+ The browser build cannot: a service worker answers fetches, not sockets, so it
106
+ advertises no streaming URL and clients poll instead.
107
+ <!-- /CLAUDE -->
88
108
 
89
109
  ## Bluesky and ATProto
90
110
 
package/bin/fedipod.mjs CHANGED
@@ -964,13 +964,12 @@ if (cmd === 'up') {
964
964
  let copied;
965
965
  try {
966
966
  copied = await copyPrivateHalf({
967
- from: { state: agent.privateStorage(cred, 'state'), fediverse: agent.privateStorage(cred, 'fediverse') },
968
- to: { state: agent.privateStorage(destCred, 'state'), fediverse: agent.privateStorage(destCred, 'fediverse') },
967
+ from: { state: agent.privateStorage(cred, 'state') },
968
+ to: { state: agent.privateStorage(destCred, 'state') },
969
969
  log: (...a) => console.log('[state]', ...a),
970
970
  });
971
971
  } catch (e) { console.error(e.message); process.exit(1); }
972
972
  console.log(`copied ${copied.docs} state document(s)`);
973
- console.log(`copied ${copied.notes} note(s)`);
974
973
 
975
974
  // An empty source produces an empty destination, and every check above
976
975
  // passes: nothing failed to land because nothing was sent. The command then
@@ -2071,8 +2070,7 @@ WantedBy=default.target
2071
2070
  if (res.status >= 400) { console.error(body.error || `HTTP ${res.status}`); process.exit(1); }
2072
2071
  if (body.why) { console.error(body.why); process.exit(1); }
2073
2072
  console.log(`the pod indexed ${body.indexed} post(s); recovered ${body.recovered}`
2074
- + `${body.reblogs ? `, and marked ${body.reblogs} of them boosted` : ''}`
2075
- + `${body.rdf ? ` (${body.rdf} written back to the RDF)` : ''}`);
2073
+ + `${body.reblogs ? `, and marked ${body.reblogs} of them boosted` : ''}`);
2076
2074
  if (body.dropped) console.log(`${body.dropped} fell past the 1000-status cap`);
2077
2075
  if (!body.landed) { console.error('the state write did NOT land — nothing is saved'); process.exit(1); }
2078
2076
  if (!body.recovered && !has('from-notes')) {
package/cli.md CHANGED
@@ -70,6 +70,19 @@ fedipod revoke-credential --email EMAIL # cut this machine off from the pod acc
70
70
  ```
71
71
  A new password takes effect when a running agent restarts.
72
72
 
73
+ <!-- CLAUDE 2026-09-09 — `passwd` stopped being optional for one case, and a
74
+ user who hits it will want to know why here. First draft in your voice;
75
+ delete these markers when done. -->
76
+ **A client hosted somewhere else now needs one.** A client running on its own
77
+ site (Elk, Phanpy on someone's own domain) asks the agent to send the sign-in
78
+ result back to that site. Without a password there is nothing to approve that
79
+ with — and the agent used to do it anyway, which meant any page you happened
80
+ to have open could ask for the same thing and be given a key to your account.
81
+ So: no password, no sending credentials off this machine. Clients that live on
82
+ the agent itself (the bundled one, a dist in `ui/`, anything on
83
+ `https://localhost:<port>`) are unaffected and need no password.
84
+ <!-- /CLAUDE -->
85
+
73
86
  ## Bluesky
74
87
 
75
88
  ```
package/groups.md CHANGED
@@ -14,6 +14,17 @@ and pick group rather than person, or take the group path on a signup page.
14
14
  You can turn on join review as you create it. The group's pod must be the root
15
15
  of its own host, so its handle resolves.
16
16
 
17
+ <!-- CLAUDE 2026-09-09 — nothing anywhere said this, and a browser user who
18
+ went looking for the group option would just not have found it. First
19
+ draft in your voice; move or reword as you like. Delete markers when done. -->
20
+ **Running a group needs the installed agent.** The in-browser build makes
21
+ personal identities only — the sign-up wizard has no group option, and the
22
+ moderation surface (join review, members, muting, the moderation queue) is not
23
+ part of that build. You can *join* a group from the browser exactly as from
24
+ anywhere else: joining is following, and that works everywhere. It is hosting
25
+ one that needs an install.
26
+ <!-- /CLAUDE -->
27
+
17
28
  Being in a group also connects you to the people in it: posts from fellow
18
29
  members reach your timeline even when you do not follow them individually,
19
30
  so a reply in the room is something you see rather than something filed
package/gui.md CHANGED
@@ -91,7 +91,15 @@ is marked **sign in again** rather than quietly dropped.
91
91
  ## Handling follow requests
92
92
 
93
93
  New followers appear under **Follow requests** with **Accept** and **Refuse**
94
- beside them; nothing is accepted without you. Groups are different — joining
94
+ beside them; nothing is accepted without you.
95
+ <!-- CLAUDE 2026-09-09 — these were visible on this page and NOWHERE else
96
+ until today: the client API stubbed the list empty and offered no way to
97
+ answer one, so no Mastodon client could show them. Delete markers when
98
+ read. -->
99
+ They show in any Mastodon client as well, now that the client API serves the
100
+ queue and takes both answers — before this they were visible on this page
101
+ alone.
102
+ <!-- /CLAUDE --> Groups are different — joining
95
103
  follows the group's own moderation settings; see [Groups](groups.md).
96
104
 
97
105
  **Accept all** answers the whole queue at once, and the identity pane's
@@ -123,6 +131,12 @@ account*); your followers arrive by themselves. Removing an alias asks
123
131
  twice — servers still processing the move check it while they retry. The CSV
124
132
  files from the old server's export are imported with the CLI; see
125
133
  [CLI admin](cli.md).
134
+ <!-- CLAUDE 2026-09-09 — the browser build runs the same importer now, so
135
+ "with the CLI" is no longer the whole story. Reword as you like; delete
136
+ these markers when done. -->
137
+ The in-browser build runs the same importer, so a CSV can be handed to it
138
+ there too.
139
+ <!-- /CLAUDE -->
126
140
 
127
141
  ## Protecting & recovering your data
128
142
 
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