fedipod 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/architecture.svg +89 -0
  4. package/bin/fedipod.mjs +2303 -0
  5. package/cli.md +243 -0
  6. package/groups.md +85 -0
  7. package/gui.md +99 -0
  8. package/lib/account.mjs +87 -0
  9. package/lib/admin.mjs +1606 -0
  10. package/lib/atproto.mjs +310 -0
  11. package/lib/bskyfeed.mjs +166 -0
  12. package/lib/bskygroup.mjs +162 -0
  13. package/lib/c2s.mjs +305 -0
  14. package/lib/certs.mjs +265 -0
  15. package/lib/deliver.mjs +249 -0
  16. package/lib/directory.mjs +90 -0
  17. package/lib/embed.mjs +214 -0
  18. package/lib/export-collections.mjs +161 -0
  19. package/lib/front-core.mjs +322 -0
  20. package/lib/gateway-core.mjs +115 -0
  21. package/lib/guard.mjs +213 -0
  22. package/lib/home.mjs +161 -0
  23. package/lib/httpsig.mjs +109 -0
  24. package/lib/import.mjs +357 -0
  25. package/lib/intake.mjs +1710 -0
  26. package/lib/keys.mjs +139 -0
  27. package/lib/lease.mjs +190 -0
  28. package/lib/localapi.mjs +87 -0
  29. package/lib/mastoapi.mjs +1588 -0
  30. package/lib/migrate.mjs +127 -0
  31. package/lib/oidc-auth.mjs +53 -0
  32. package/lib/podrdf.mjs +132 -0
  33. package/lib/ports.mjs +27 -0
  34. package/lib/publisher.mjs +1003 -0
  35. package/lib/remote.mjs +303 -0
  36. package/lib/safefetch.mjs +235 -0
  37. package/lib/setup.mjs +262 -0
  38. package/lib/social.mjs +505 -0
  39. package/lib/storage.mjs +170 -0
  40. package/lib/store.mjs +470 -0
  41. package/lib/streaming.mjs +154 -0
  42. package/lib/tagfeed.mjs +158 -0
  43. package/lib/ua.mjs +29 -0
  44. package/lib/update.mjs +91 -0
  45. package/lib/webpush.mjs +98 -0
  46. package/lib/wire.mjs +762 -0
  47. package/package.json +60 -0
  48. package/phanpy/dist/404.html +40 -0
  49. package/phanpy/dist/_headers +2 -0
  50. package/phanpy/dist/apple-touch-icon.png +0 -0
  51. package/phanpy/dist/assets/ICONS-DIPJeU0C.js +2 -0
  52. package/phanpy/dist/assets/ICONS-DIPJeU0C.js.map +1 -0
  53. package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js +183 -0
  54. package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
  55. package/phanpy/dist/assets/Temml-CCjoacWt.woff2 +0 -0
  56. package/phanpy/dist/assets/__vite-browser-external-C7iut881.js +2 -0
  57. package/phanpy/dist/assets/__vite-browser-external-C7iut881.js.map +1 -0
  58. package/phanpy/dist/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
  59. package/phanpy/dist/assets/catch-up-O5q5C75Z.png +0 -0
  60. package/phanpy/dist/assets/chunk-aKtaBQYM.js +1 -0
  61. package/phanpy/dist/assets/compose-CFEx7wb4.js +2 -0
  62. package/phanpy/dist/assets/compose-CFEx7wb4.js.map +1 -0
  63. package/phanpy/dist/assets/compose-ZJN5xSb4.js +35 -0
  64. package/phanpy/dist/assets/compose-ZJN5xSb4.js.map +1 -0
  65. package/phanpy/dist/assets/debug.module-YFQgwnIr.js +52 -0
  66. package/phanpy/dist/assets/debug.module-YFQgwnIr.js.map +1 -0
  67. package/phanpy/dist/assets/dom-p1YcJ5Rw.js +2 -0
  68. package/phanpy/dist/assets/dom-p1YcJ5Rw.js.map +1 -0
  69. package/phanpy/dist/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
  70. package/phanpy/dist/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
  71. package/phanpy/dist/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
  72. package/phanpy/dist/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
  73. package/phanpy/dist/assets/icons/add-circle-line-WD0mwORD.js +1 -0
  74. package/phanpy/dist/assets/icons/alert-line-CpFUMf9s.js +1 -0
  75. package/phanpy/dist/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
  76. package/phanpy/dist/assets/icons/announcement-line-5nwaJFJm.js +1 -0
  77. package/phanpy/dist/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
  78. package/phanpy/dist/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
  79. package/phanpy/dist/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
  80. package/phanpy/dist/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
  81. package/phanpy/dist/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
  82. package/phanpy/dist/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
  83. package/phanpy/dist/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
  84. package/phanpy/dist/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
  85. package/phanpy/dist/assets/icons/at-line-CF66Bznk.js +1 -0
  86. package/phanpy/dist/assets/icons/attachment-line-BY8It-l9.js +1 -0
  87. package/phanpy/dist/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
  88. package/phanpy/dist/assets/icons/board-line-C0UWXyA6.js +1 -0
  89. package/phanpy/dist/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
  90. package/phanpy/dist/assets/icons/building-5-line-Dd59MzbO.js +1 -0
  91. package/phanpy/dist/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
  92. package/phanpy/dist/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
  93. package/phanpy/dist/assets/icons/calendar-month-line-vommts2C.js +1 -0
  94. package/phanpy/dist/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
  95. package/phanpy/dist/assets/icons/camera-line-Cv69MEei.js +1 -0
  96. package/phanpy/dist/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
  97. package/phanpy/dist/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
  98. package/phanpy/dist/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
  99. package/phanpy/dist/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
  100. package/phanpy/dist/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
  101. package/phanpy/dist/assets/icons/clipboard-line-BBp364gJ.js +1 -0
  102. package/phanpy/dist/assets/icons/close-circle-line-CICdiuGz.js +1 -0
  103. package/phanpy/dist/assets/icons/close-line-DYrFWcjv.js +1 -0
  104. package/phanpy/dist/assets/icons/cloud-line-tiQAbah8.js +1 -0
  105. package/phanpy/dist/assets/icons/code-line-fAl8iiV3.js +1 -0
  106. package/phanpy/dist/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
  107. package/phanpy/dist/assets/icons/copy-2-line-CLW69yF5.js +1 -0
  108. package/phanpy/dist/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
  109. package/phanpy/dist/assets/icons/document-3-line-DBGNhAg2.js +1 -0
  110. package/phanpy/dist/assets/icons/document-line-C7mb9sb7.js +1 -0
  111. package/phanpy/dist/assets/icons/down-line-BGqSSThd.js +1 -0
  112. package/phanpy/dist/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
  113. package/phanpy/dist/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
  114. package/phanpy/dist/assets/icons/exit-line-Dc07BS-x.js +1 -0
  115. package/phanpy/dist/assets/icons/external-link-line-BvpSkmjO.js +1 -0
  116. package/phanpy/dist/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
  117. package/phanpy/dist/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
  118. package/phanpy/dist/assets/icons/filter-2-line-B_mR2usy.js +1 -0
  119. package/phanpy/dist/assets/icons/filter-line-C_nm-3cU.js +1 -0
  120. package/phanpy/dist/assets/icons/flag-1-line-C2HB05I0.js +1 -0
  121. package/phanpy/dist/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
  122. package/phanpy/dist/assets/icons/formula-line-DLrt2d6j.js +1 -0
  123. package/phanpy/dist/assets/icons/grid-line-CgUmEikf.js +1 -0
  124. package/phanpy/dist/assets/icons/group-line-C5fnX0wU.js +1 -0
  125. package/phanpy/dist/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
  126. package/phanpy/dist/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
  127. package/phanpy/dist/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
  128. package/phanpy/dist/assets/icons/heart-line-CJ7K47J4.js +1 -0
  129. package/phanpy/dist/assets/icons/history-2-line-kWWvFzqv.js +1 -0
  130. package/phanpy/dist/assets/icons/history-line-bGQiwbTH.js +1 -0
  131. package/phanpy/dist/assets/icons/home-3-line-fRU1zeLG.js +1 -0
  132. package/phanpy/dist/assets/icons/information-line-iNk6-sOY.js +1 -0
  133. package/phanpy/dist/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
  134. package/phanpy/dist/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
  135. package/phanpy/dist/assets/icons/layout-5-line-B_znt5wO.js +1 -0
  136. package/phanpy/dist/assets/icons/left-line-eUw0tya4.js +1 -0
  137. package/phanpy/dist/assets/icons/lightning-line-DaKoDdix.js +1 -0
  138. package/phanpy/dist/assets/icons/link-2-line-DCqLmMA7.js +1 -0
  139. package/phanpy/dist/assets/icons/list-check-line-BAxosBh9.js +1 -0
  140. package/phanpy/dist/assets/icons/lock-line-CPoaXeUW.js +1 -0
  141. package/phanpy/dist/assets/icons/mail-line-B0P8aa1L.js +1 -0
  142. package/phanpy/dist/assets/icons/moon-line-CEqNAToP.js +1 -0
  143. package/phanpy/dist/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
  144. package/phanpy/dist/assets/icons/more-3-line-BIiVjYTz.js +1 -0
  145. package/phanpy/dist/assets/icons/notification-line-BZZq2Gtu.js +1 -0
  146. package/phanpy/dist/assets/icons/pencil-line-CKh8-A1v.js +1 -0
  147. package/phanpy/dist/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
  148. package/phanpy/dist/assets/icons/pin-line-DpTolyhs.js +1 -0
  149. package/phanpy/dist/assets/icons/play-fill-B0-G_8lX.js +1 -0
  150. package/phanpy/dist/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
  151. package/phanpy/dist/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
  152. package/phanpy/dist/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
  153. package/phanpy/dist/assets/icons/radar-line-CKyroTth.js +1 -0
  154. package/phanpy/dist/assets/icons/react-line-CoIKhhtK.js +1 -0
  155. package/phanpy/dist/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
  156. package/phanpy/dist/assets/icons/right-line-B3KWJaqc.js +1 -0
  157. package/phanpy/dist/assets/icons/rocket-line-4KZl537D.js +1 -0
  158. package/phanpy/dist/assets/icons/round-fill-C69NTrZ0.js +1 -0
  159. package/phanpy/dist/assets/icons/round-line-B9k_1uo-.js +1 -0
  160. package/phanpy/dist/assets/icons/route-line-B28ReHcE.js +1 -0
  161. package/phanpy/dist/assets/icons/rows-4-line-CrBniagM.js +1 -0
  162. package/phanpy/dist/assets/icons/scan-line-DSf74YdP.js +1 -0
  163. package/phanpy/dist/assets/icons/search-2-line-B6geIU1z.js +1 -0
  164. package/phanpy/dist/assets/icons/settings-3-line-azJwK47w.js +1 -0
  165. package/phanpy/dist/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
  166. package/phanpy/dist/assets/icons/share-2-line-D5MxWjrm.js +1 -0
  167. package/phanpy/dist/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
  168. package/phanpy/dist/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
  169. package/phanpy/dist/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
  170. package/phanpy/dist/assets/icons/time-line-BF3eet1v.js +1 -0
  171. package/phanpy/dist/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
  172. package/phanpy/dist/assets/icons/translate-line-BFWol2Ce.js +1 -0
  173. package/phanpy/dist/assets/icons/unlock-line-X_x5vwv3.js +1 -0
  174. package/phanpy/dist/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
  175. package/phanpy/dist/assets/icons/user-4-line-BVSynZCp.js +1 -0
  176. package/phanpy/dist/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
  177. package/phanpy/dist/assets/icons/user-add-line-BbXdHG4z.js +1 -0
  178. package/phanpy/dist/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
  179. package/phanpy/dist/assets/icons/user-follow-line-BXux0wSd.js +1 -0
  180. package/phanpy/dist/assets/icons/user-star-line-DdYlpHLU.js +1 -0
  181. package/phanpy/dist/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
  182. package/phanpy/dist/assets/icons/user-x-line-DIDgNlPH.js +1 -0
  183. package/phanpy/dist/assets/icons/volume-line-Bs390KQt.js +1 -0
  184. package/phanpy/dist/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
  185. package/phanpy/dist/assets/icons/walk-line-yNXfB-md.js +1 -0
  186. package/phanpy/dist/assets/icons/world-2-line-CenkGIwj.js +1 -0
  187. package/phanpy/dist/assets/icons/zoom-in-line-CaENUphf.js +1 -0
  188. package/phanpy/dist/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
  189. package/phanpy/dist/assets/instances-BNPptnn-.json +333 -0
  190. package/phanpy/dist/assets/locales/ar-SA-CfheRbCu.js +1 -0
  191. package/phanpy/dist/assets/locales/ca-ES-DHRW86Tz.js +1 -0
  192. package/phanpy/dist/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
  193. package/phanpy/dist/assets/locales/de-DE-ByRPMRFy.js +1 -0
  194. package/phanpy/dist/assets/locales/eo-UY-CPC7wLCS.js +1 -0
  195. package/phanpy/dist/assets/locales/es-ES-dJa0Fd9t.js +1 -0
  196. package/phanpy/dist/assets/locales/eu-ES-C3djlPR2.js +1 -0
  197. package/phanpy/dist/assets/locales/fa-IR-DTT490Ck.js +1 -0
  198. package/phanpy/dist/assets/locales/fi-FI-B23MCPuz.js +1 -0
  199. package/phanpy/dist/assets/locales/fr-FR-BcaQsO5C.js +1 -0
  200. package/phanpy/dist/assets/locales/gl-ES-BR-CVKBN.js +1 -0
  201. package/phanpy/dist/assets/locales/he-IL-CfheRbCu.js +1 -0
  202. package/phanpy/dist/assets/locales/hu-HU-CfheRbCu.js +1 -0
  203. package/phanpy/dist/assets/locales/it-IT-C1136UFV.js +1 -0
  204. package/phanpy/dist/assets/locales/ja-JP-CRZmRAhD.js +1 -0
  205. package/phanpy/dist/assets/locales/kab-Cz8xH_TT.js +1 -0
  206. package/phanpy/dist/assets/locales/ko-KR-CG9UiY4o.js +1 -0
  207. package/phanpy/dist/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
  208. package/phanpy/dist/assets/locales/nb-NO-CfheRbCu.js +1 -0
  209. package/phanpy/dist/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
  210. package/phanpy/dist/assets/locales/oc-FR-CfheRbCu.js +1 -0
  211. package/phanpy/dist/assets/locales/pl-PL-DkwGXf6P.js +1 -0
  212. package/phanpy/dist/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
  213. package/phanpy/dist/assets/locales/pt-BR-nGL0HdFS.js +1 -0
  214. package/phanpy/dist/assets/locales/pt-PT-Cp17aj2z.js +1 -0
  215. package/phanpy/dist/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
  216. package/phanpy/dist/assets/locales/th-TH-CfheRbCu.js +1 -0
  217. package/phanpy/dist/assets/locales/tok-CfheRbCu.js +1 -0
  218. package/phanpy/dist/assets/locales/tr-TR-CfheRbCu.js +1 -0
  219. package/phanpy/dist/assets/locales/uk-UA-C5E3dFBf.js +1 -0
  220. package/phanpy/dist/assets/locales/zh-CN-BHJi7_et.js +1 -0
  221. package/phanpy/dist/assets/locales/zh-TW-CfheRbCu.js +1 -0
  222. package/phanpy/dist/assets/main-BdqNbG-a.js +26 -0
  223. package/phanpy/dist/assets/main-BdqNbG-a.js.map +1 -0
  224. package/phanpy/dist/assets/mock-home-DGhR__He.js +2 -0
  225. package/phanpy/dist/assets/mock-home-DGhR__He.js.map +1 -0
  226. package/phanpy/dist/assets/multi-column-ROck0NVt.jpg +0 -0
  227. package/phanpy/dist/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
  228. package/phanpy/dist/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
  229. package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js +2 -0
  230. package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js.map +1 -0
  231. package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js +20 -0
  232. package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js.map +1 -0
  233. package/phanpy/dist/assets/style-B1iLPTPR.css +2 -0
  234. package/phanpy/dist/assets/temml-DuyIQMei.js +97 -0
  235. package/phanpy/dist/assets/temml-DuyIQMei.js.map +1 -0
  236. package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js +2 -0
  237. package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
  238. package/phanpy/dist/assets/year-in-posts-B2lywqdx.png +0 -0
  239. package/phanpy/dist/assets/year-in-posts-DJv_star.js +3 -0
  240. package/phanpy/dist/assets/year-in-posts-DJv_star.js.map +1 -0
  241. package/phanpy/dist/compose/index.html +20 -0
  242. package/phanpy/dist/favicon.ico +0 -0
  243. package/phanpy/dist/index.html +73 -0
  244. package/phanpy/dist/logo-192.png +0 -0
  245. package/phanpy/dist/logo-512.png +0 -0
  246. package/phanpy/dist/logo-badge-72.png +0 -0
  247. package/phanpy/dist/logo-maskable-512.png +0 -0
  248. package/phanpy/dist/logo-monochrome-512.png +0 -0
  249. package/phanpy/dist/logo-monochrome-maskable-512.png +0 -0
  250. package/phanpy/dist/manifest.webmanifest +1 -0
  251. package/phanpy/dist/og-image-2.jpg +0 -0
  252. package/phanpy/dist/og-image.png +0 -0
  253. package/phanpy/dist/robots.txt +2 -0
  254. package/phanpy/dist/sw.js +25 -0
  255. package/phanpy/dist/version.json +1 -0
  256. package/run-agent.mjs +805 -0
  257. package/scripts/build-dist.mjs +37 -0
  258. package/scripts/verify-ui.mjs +60 -0
  259. package/ui/README.md +23 -0
  260. package/vendor/gate.cjs +132 -0
  261. package/vendor/idp-grant.cjs +394 -0
  262. package/vendor/jfetch.cjs +16 -0
  263. package/web/admin/admin.js +958 -0
  264. package/web/admin/bar.css +64 -0
  265. package/web/admin/bar.js +61 -0
  266. package/web/admin/client/client.js +66 -0
  267. package/web/admin/client/index.html +42 -0
  268. package/web/admin/index.html +493 -0
  269. package/web/admin/setup/index.html +172 -0
  270. package/web/admin/setup/setup.js +268 -0
  271. package/web/admin/window.css +55 -0
  272. package/web/admin/window.js +136 -0
  273. package/web/front/install.sh +69 -0
  274. package/web/front/new-account.html +424 -0
  275. package/web/front/run.html +138 -0
  276. package/web/front/solid-client-authn.bundle.js +2 -0
@@ -0,0 +1,161 @@
1
+ // export-collections.mjs — the account's collections as paged Turtle AS2
2
+ // collections: `as:Collection` / `as:OrderedCollectionPage` documents whose
3
+ // pages carry `as:items` arcs plus the activity's own triples. Produced ON
4
+ // DEMAND — for a migration, or for any AS2/RDF reader — so the live pod pays
5
+ // nothing to keep this shape fresh. The layout matches the one kept by
6
+ // https://github.com/jg10-mastodon-social/solid-activitypub-netlify.
7
+ //
8
+ // Fidelity: their skeleton plus AS2-subset activity triples (type, actor,
9
+ // object, published, to/cc, content, inReplyTo, attachment). Media blobs,
10
+ // DMs, pins and standalone note documents have no place in that layout and
11
+ // are not carried.
12
+ //
13
+ // Serialised by rdflib, never by hand — the podrdf.mjs rule.
14
+
15
+ import * as $rdf from 'rdflib';
16
+
17
+ const AS = $rdf.Namespace('https://www.w3.org/ns/activitystreams#');
18
+ const RDF = $rdf.Namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#');
19
+ const XSD = $rdf.Namespace('http://www.w3.org/2001/XMLSchema#');
20
+ const TURTLE = 'text/turtle';
21
+ const PAGE_SIZE = 20;
22
+
23
+ // Doc IRIs need a base even when the target is a directory; links between the
24
+ // export's own documents relativise against it, so the files stay portable.
25
+ export const DIR_BASE = 'https://fedipod-export.invalid/';
26
+
27
+ const sym = (v) => { try { return typeof v === 'string' && /^[a-z][a-z0-9+.-]*:/i.test(v) ? $rdf.sym(v) : null; } catch { return null; } };
28
+ const typeNode = (t) => (typeof t === 'string' && /^[A-Za-z]+$/.test(t) ? AS(t) : null);
29
+
30
+ // The object-ish AS2-subset fields, shared by activities, embedded objects,
31
+ // and bare Notes an outbox names by IRI.
32
+ function addObjectFields(g, doc, s, o) {
33
+ const t = typeNode(o.type);
34
+ if (t) g.add(s, RDF('type'), t, doc);
35
+ const by = sym(typeof o.attributedTo === 'string' ? o.attributedTo : o.attributedTo?.id);
36
+ if (by) g.add(s, AS('attributedTo'), by, doc);
37
+ if (o.published) g.add(s, AS('published'), $rdf.literal(o.published, XSD('dateTime')), doc);
38
+ if (typeof o.content === 'string') g.add(s, AS('content'), $rdf.literal(o.content), doc);
39
+ const irt = sym(typeof o.inReplyTo === 'string' ? o.inReplyTo : o.inReplyTo?.id);
40
+ if (irt) g.add(s, AS('inReplyTo'), irt, doc);
41
+ for (const at of [].concat(o.attachment || [])) {
42
+ const au = sym(at?.url);
43
+ if (!au) continue;
44
+ g.add(s, AS('attachment'), au, doc);
45
+ g.add(au, RDF('type'), AS('Document'), doc);
46
+ if (at.mediaType) g.add(au, AS('mediaType'), $rdf.literal(at.mediaType), doc);
47
+ if (at.name) g.add(au, AS('name'), $rdf.literal(at.name), doc);
48
+ }
49
+ }
50
+
51
+ // One activity's (or bare object's) AS2-subset triples into the page document.
52
+ function addActivity(g, doc, a) {
53
+ const s = sym(a.id);
54
+ if (!s) return false;
55
+ addObjectFields(g, doc, s, a);
56
+ const actor = sym(typeof a.actor === 'string' ? a.actor : a.actor?.id);
57
+ if (actor) g.add(s, AS('actor'), actor, doc);
58
+ for (const key of ['to', 'cc']) {
59
+ for (const addr of [].concat(a[key] || [])) {
60
+ const n = sym(addr);
61
+ if (n) g.add(s, AS(key), n, doc);
62
+ }
63
+ }
64
+ const o = a.object;
65
+ if (typeof o === 'string') {
66
+ const n = sym(o);
67
+ if (n) g.add(s, AS('object'), n, doc);
68
+ } else if (o && typeof o === 'object' && sym(o.id)) {
69
+ const n = sym(o.id);
70
+ g.add(s, AS('object'), n, doc);
71
+ addObjectFields(g, doc, n, o);
72
+ }
73
+ return true;
74
+ }
75
+
76
+ // A collection into documents: a head at <name>/collection.ttl pointing
77
+ // as:first at the newest page, pages at <name>/pages/<slot>.ttl chained
78
+ // as:next toward the older ones — the direction their layout walks. Items:
79
+ // activity objects (triples added) or bare IRIs (followers).
80
+ export function collectionDocs(name, items, base) {
81
+ const headUrl = `${base}${name}/collection.ttl`;
82
+ const slots = [];
83
+ const seen = new Set();
84
+ const pages = [];
85
+ for (let i = 0; i < items.length; i += PAGE_SIZE) pages.push(items.slice(i, i + PAGE_SIZE));
86
+ if (!pages.length) pages.push([]);
87
+ for (const page of pages) {
88
+ const first = page.find(it => typeof it === 'object' && it?.published);
89
+ let slot = String(first ? Date.parse(first.published) || 0 : 0);
90
+ while (seen.has(slot)) slot += 'x';
91
+ seen.add(slot);
92
+ slots.push(slot);
93
+ }
94
+ const pageUrl = (i) => `${base}${name}/pages/${slots[i]}.ttl`;
95
+ const docs = [];
96
+
97
+ const hg = $rdf.graph();
98
+ const head = $rdf.sym(headUrl);
99
+ hg.add(head, RDF('type'), AS('Collection'), head);
100
+ hg.add(head, AS('first'), $rdf.sym(pageUrl(0)), head);
101
+ docs.push({ path: `${name}/collection.ttl`, turtle: $rdf.serialize(head, hg, headUrl, TURTLE) });
102
+
103
+ let carried = 0;
104
+ pages.forEach((page, i) => {
105
+ const url = pageUrl(i);
106
+ const g = $rdf.graph();
107
+ const doc = $rdf.sym(url);
108
+ g.add(doc, RDF('type'), AS('OrderedCollectionPage'), doc);
109
+ if (i + 1 < pages.length) g.add(doc, AS('next'), $rdf.sym(pageUrl(i + 1)), doc);
110
+ for (const item of page) {
111
+ if (typeof item === 'string') {
112
+ const n = sym(item);
113
+ if (n) { g.add(doc, AS('items'), n, doc); carried++; }
114
+ } else if (item && addActivity(g, doc, item)) {
115
+ g.add(doc, AS('items'), $rdf.sym(item.id), doc);
116
+ carried++;
117
+ }
118
+ }
119
+ docs.push({ path: `${name}/pages/${slots[i]}.ttl`, turtle: $rdf.serialize(doc, g, url, TURTLE) });
120
+ });
121
+ return { docs, carried, pages: pages.length };
122
+ }
123
+
124
+ // The whole export. Inputs arrive as plain data so the CLI and the tests
125
+ // assemble them the same way; `storage` is a lib/storage.mjs container.
126
+ export async function exportCollections({ outboxItems = [], followers = [], inboxEntries = [], storage, base = DIR_BASE, log = console.log, resolve = null }) {
127
+ const out = { written: 0 };
128
+ // A published outbox may hold bare note IRIs rather than embedded
129
+ // activities; with a resolver those are fetched once each so their triples
130
+ // travel in the page instead of a pointer to a pod that may be retiring.
131
+ if (resolve) {
132
+ const resolved = [];
133
+ for (const it of outboxItems) {
134
+ if (typeof it !== 'string') { resolved.push(it); continue; }
135
+ const doc = await resolve(it).catch(() => null);
136
+ resolved.push(doc && doc.id ? doc : it);
137
+ }
138
+ outboxItems = resolved;
139
+ }
140
+ const write = async (name, items) => {
141
+ const { docs, carried, pages } = collectionDocs(name, items, base);
142
+ for (const d of docs) {
143
+ const w = await storage.write(d.path, d.turtle, TURTLE);
144
+ if (!w.ok) throw new Error(`write ${d.path}: ${w.why || 'failed'}`);
145
+ out.written++;
146
+ }
147
+ out[name] = { items: carried, pages };
148
+ };
149
+
150
+ await write('outbox', outboxItems);
151
+ await write('followers', followers);
152
+ // Newest first, like the outbox — receivedAt rides in each archive entry.
153
+ const inbox = [...inboxEntries]
154
+ .sort((a, b) => String(b.receivedAt || '').localeCompare(String(a.receivedAt || '')))
155
+ .map(e => { try { return { published: e.receivedAt, ...JSON.parse(e.raw) }; } catch { return null; } })
156
+ .filter(Boolean);
157
+ await write('inbox', inbox);
158
+ if (!inbox.length) log('inbox: no archived mail yet — history begins when the archive does');
159
+ log('carries what the collection layout can hold — media blobs, DMs, pins and standalone notes are not representable and were not exported');
160
+ return out;
161
+ }
@@ -0,0 +1,322 @@
1
+ // front-core.mjs — the multi-user front: one always-on box, run by a host, that
2
+ // fronts many independent FediPod users under a shared domain (@name@fedipod.net).
3
+ // It answers name lookups, serves each user's public face, and is the verifying
4
+ // inbox for all of them — routing every verified delivery into that user's own
5
+ // pod inbox. It is KEYLESS and holds no user data: only a directory (handle →
6
+ // where that user's pod is + their public key) and the per-user secrets needed
7
+ // to append verified mail to their inbox.
8
+ //
9
+ // The identity trick is rewrite-on-read (jg10's pattern): a user's public IRI
10
+ // space at `<front>/u/<handle>/` is a 1:1 path map of their pod's own tree, so
11
+ // the actor id and every id under it live on the shared domain while the bytes
12
+ // still come from the user's pod. The inbox maps the same way, so delivery to
13
+ // the fronted inbox is delivery this box verifies, then forwards to the pod.
14
+ //
15
+ // Runtime-agnostic: `netlify/functions/front.mjs` is one adapter; any always-on
16
+ // HTTPS box is another. UN-DEPLOYED — nothing in FediPod runs it.
17
+
18
+ import crypto from 'node:crypto';
19
+ import { handleDelivery } from './gateway-core.mjs';
20
+ import { readCapped } from './safefetch.mjs';
21
+
22
+ // The one WebFinger document, spelled out here rather than imported from
23
+ // wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
24
+ // friends), which a serverless front must never carry — it crashed the
25
+ // deployed function before it answered its first request.
26
+ const jrd = ({ handle, host, actor }) => ({
27
+ subject: `acct:${handle}@${host}`,
28
+ links: [{ rel: 'self', type: 'application/activity+json', href: actor }],
29
+ });
30
+
31
+ const AP_CT = 'application/activity+json';
32
+
33
+ // Verify a Solid-OIDC token (DPoP-bound) and return its WebID, or null. The
34
+ // verifier is injected so tests stub it; in production it is the same library
35
+ // the agent's own C2S auth uses.
36
+ async function verifyPodToken(request, pathname, verifier) {
37
+ const authz = request.headers.get('authorization');
38
+ if (!authz) return null;
39
+ try {
40
+ const v = verifier || (await import('@solid/access-token-verifier')).createSolidTokenVerifier();
41
+ const dpop = request.headers.get('dpop');
42
+ const url = request.url;
43
+ const { webid } = await v(authz, dpop ? { header: dpop, method: request.method, url } : undefined);
44
+ return webid || null;
45
+ } catch { return null; }
46
+ }
47
+
48
+ // Is this WebID served by the claimed pod? A pod owner's WebID lives on the pod
49
+ // origin — that is the whole proof: a token for a WebID under podHome could
50
+ // only be minted by someone who controls that pod's identity provider.
51
+ function webidUnderPod(webid, podHome) {
52
+ try { return new URL(webid).origin === new URL(podHome).origin; } catch { return false; }
53
+ }
54
+
55
+ const j = (status, obj, ct = 'application/json') =>
56
+ ({ status, headers: { 'content-type': ct, 'cache-control': 'no-store' }, body: JSON.stringify(obj) });
57
+ const notFound = () => ({ status: 404, headers: { 'content-type': 'text/plain' }, body: 'not found\n' });
58
+
59
+ // A user's public base on the front, a 1:1 mirror of their pod home.
60
+ export const userBase = (frontOrigin, handle) => `${frontOrigin}/u/${handle}/`;
61
+
62
+ // Rewrite every occurrence of the pod home to the front base (or back). The
63
+ // bodies are JSON, so a whole-string swap rewrites ids wherever they appear —
64
+ // actor id, collection ids, note ids — keeping the whole space self-consistent.
65
+ const swap = (text, from, to) => text.split(from).join(to);
66
+
67
+ // Which handle a /u/<handle>/... path names, and the pod path under it.
68
+ function parseUserPath(pathname) {
69
+ const m = /^\/u\/([^/]+)\/(.*)$/.exec(pathname);
70
+ return m ? { handle: decodeURIComponent(m[1]), rest: m[2] } : null;
71
+ }
72
+
73
+ // Top-level paths a handle may not take, so a name never shadows a route.
74
+ const RESERVED = new Set(['u', 'api', 'signup', 'new-account', 'run', 'admin', 'gateway',
75
+ 'gw', 'well-known', 'inbox', 'outbox', 'actor', 'install']);
76
+
77
+ // Why a handle is unusable, or null when it is fine. Lowercase letters, digits
78
+ // and hyphens; 2–30 chars; not edge-hyphenated; not a reserved route.
79
+ function handleProblem(h) {
80
+ if (!h) return 'empty';
81
+ if (!/^[a-z0-9-]{2,30}$/.test(h)) return 'letters, digits and hyphens only, 2–30 characters';
82
+ if (h.startsWith('-') || h.endsWith('-')) return 'cannot start or end with a hyphen';
83
+ if (RESERVED.has(h)) return 'that name is reserved';
84
+ return null;
85
+ }
86
+
87
+ // The person's own filtering policy, published on their pod and readable by
88
+ // anyone: their accepted following list and a mirror of their blocklist. A
89
+ // directory row carries neither — the row is written at attach time, before
90
+ // the agent has published anything — so without this the door would judge by
91
+ // addressing alone and forward the junk a blocklist exists to stop.
92
+ //
93
+ // Cached briefly: a delivery flood must not become a read per delivery on the
94
+ // person's pod, which is the load this whole door exists to spare them.
95
+ const POLICY_TTL_MS = 5 * 60_000;
96
+ const policyCache = new Map(); // podHome -> { at, policy }
97
+
98
+ async function policyFor(rec, fetchImpl = fetch) {
99
+ const key = rec.podHome;
100
+ const hit = policyCache.get(key);
101
+ if (hit && Date.now() - hit.at < POLICY_TTL_MS) return hit.policy;
102
+ let policy = null;
103
+ try {
104
+ const res = await fetchImpl(rec.podHome + 'ap/gateway-policy.json',
105
+ { headers: { accept: 'application/json' } });
106
+ if (res && res.status < 400) policy = JSON.parse(await readCapped(res, 256 * 1024));
107
+ } catch { /* unpublished or unreachable: the row's own fields stand */ }
108
+ policyCache.set(key, { at: Date.now(), policy });
109
+ return policy;
110
+ }
111
+
112
+ // The verified-delivery identity for a directory record: what gateway-core
113
+ // needs to check what concerns the user and to append to their pod inbox.
114
+ // `policy` is the person's published filtering policy when there is one.
115
+ function identFor(rec, policy = null) {
116
+ return {
117
+ inboxUrl: rec.podHome + 'ap/inbox/',
118
+ actorUrl: rec.actorUrl, // the FRONT actor id (what mail is addressed to)
119
+ followersUrl: rec.actorUrl.replace(/actor$/, 'followers'),
120
+ notesPrefix: rec.actorUrl.replace(/ap\/actor$/, 'ap/notes/'),
121
+ following: policy?.following || rec.following || [],
122
+ blocklist: policy?.blocklist || rec.blocklist || { domains: [], actors: [] },
123
+ kind: policy?.kind || rec.kind || 'person',
124
+ gatewayWebId: rec.gatewayWebId,
125
+ hmacSecret: rec.hmacSecret,
126
+ };
127
+ }
128
+
129
+ // The single entry point. `ctx`:
130
+ // host the front's own host, e.g. "fedipod.net" (for WebFinger subjects)
131
+ // frontOrigin "https://fedipod.net"
132
+ // lookup(handle) -> record | null the directory
133
+ // podPut(url, body, ct) -> boolean append to a user's pod (per-user cred inside)
134
+ // podGet(url) -> Response read a user's pod (public reads; plain fetch is fine)
135
+ export async function routeFront(request, ctx) {
136
+ const url = new URL(request.url);
137
+ const { pathname } = url;
138
+
139
+ // The new-account page: the host's front door, forking solo / group /
140
+ // manage. Served as a static page the adapter supplies; the page itself
141
+ // calls the availability API below. It only COLLECTS intent — creating an
142
+ // account is the Solid-OIDC attach step (proving the pod is the user's).
143
+ if (pathname === '/' || pathname === '/signup' || pathname === '/new-account') {
144
+ if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: {}, body: '' };
145
+ if (!ctx.signupPage) return notFound();
146
+ return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: ctx.signupPage };
147
+ }
148
+
149
+ // The run page: a pod owner opting their identity in or out of being run by
150
+ // this server. Meaningful only on a host that embeds the agent; a host that
151
+ // does not simply supplies no page.
152
+ if (pathname === '/run') {
153
+ if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: {}, body: '' };
154
+ if (!ctx.runPage) return notFound();
155
+ return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: ctx.runPage };
156
+ }
157
+
158
+ // Live handle check for the page: valid shape AND not already in the
159
+ // directory. Also tells the page whether this host offers pods.
160
+ if (pathname === '/api/handle') {
161
+ const h = (url.searchParams.get('handle') || '').toLowerCase();
162
+ const problem = handleProblem(h);
163
+ if (problem) {
164
+ return j(200, { handle: h, available: false, reason: problem,
165
+ offersPods: !!ctx.offersPods, version: ctx.version || null });
166
+ }
167
+ const taken = !!(await ctx.lookup(h));
168
+ return j(200, { handle: h, available: !taken, reason: taken ? 'that name is taken' : null,
169
+ offersPods: !!ctx.offersPods, version: ctx.version || null });
170
+ }
171
+
172
+ // Attach: create the account. The user proves control of the pod they claim
173
+ // by presenting a Solid-OIDC token whose WebID lives on that pod — no
174
+ // password reaches the front. On success the directory gets a row and the
175
+ // agent gets a one-time HMAC secret to stamp the receipts its front writes.
176
+ if (pathname === '/api/attach' && request.method === 'POST') {
177
+ if (!ctx.putDirectory) return j(501, { error: 'this front does not accept signups' });
178
+ let body;
179
+ try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
180
+ const handle = String(body.handle || '').toLowerCase();
181
+ const podHome = String(body.podHome || '');
182
+ const problem = handleProblem(handle);
183
+ if (problem) return j(400, { error: problem });
184
+ if (!/^https:\/\/\S+\/$/.test(podHome)) return j(400, { error: 'podHome must be an https URL ending in /' });
185
+ if (await ctx.lookup(handle)) return j(409, { error: 'that name is taken' });
186
+ const webid = await verifyPodToken(request, pathname, ctx.verifier);
187
+ if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
188
+ if (!webidUnderPod(webid, podHome)) {
189
+ return j(403, { error: 'the token proves a different pod than the one you listed' });
190
+ }
191
+ // The usual attachment keeps the user's identity on their pod and moves
192
+ // only the mail door here (@me@mypod). Fronted identity — actor ids on
193
+ // this domain — remains available to an explicit `fronted: true`.
194
+ const fronted = body.fronted === true;
195
+ const actorUrl = fronted ? `${ctx.frontOrigin}/u/${handle}/ap/actor` : podHome + 'ap/actor';
196
+ const hmacSecret = crypto.randomBytes(32).toString('base64');
197
+ const record = {
198
+ handle, podHome, webId: webid, actorUrl,
199
+ kind: body.kind === 'group' ? 'group' : 'person',
200
+ gatewayWebId: ctx.gatewayWebId || null, hmacSecret,
201
+ ...(fronted ? {} : { inboxOnly: true }),
202
+ };
203
+ await ctx.putDirectory(handle, record);
204
+ // The secret is returned ONCE, with the one command the user runs to
205
+ // point their agent at this gateway.
206
+ const doorInbox = `${ctx.frontOrigin}/u/${handle}/ap/inbox/`;
207
+ if (fronted) {
208
+ return j(201, { ok: true, handle, actorUrl, frontActor: actorUrl, hmacSecret,
209
+ address: `@${handle}@${ctx.host}`,
210
+ command: `fedipod gateway ${actorUrl} --secret ${hmacSecret}` });
211
+ }
212
+ return j(201, { ok: true, handle, doorInbox, hmacSecret,
213
+ command: `fedipod gateway ${doorInbox} --secret ${hmacSecret} --inbox-only` });
214
+ }
215
+
216
+ // Runtime opt-in: a pod owner asks the server this front sits on to RUN
217
+ // their identity. Same proof as attach — a Solid-OIDC token — but the
218
+ // ownership check is stricter: the WebID must live UNDER the pod base, not
219
+ // merely on its origin, or on a path-pod server one user could opt in
220
+ // another's pod. Offered only where the deployment wired agentControl in
221
+ // (a pod server running the CSS component); everywhere else it is 501.
222
+ if (pathname === '/api/agent' && request.method === 'POST') {
223
+ if (!ctx.agentControl) return j(501, { error: 'this server does not run identities' });
224
+ let body;
225
+ try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
226
+ const action = String(body.action || '');
227
+ const podBase = String(body.podBase || '');
228
+ if (!/^https?:\/\/\S+\/$/.test(podBase)) {
229
+ return j(400, { error: 'podBase must be a URL ending in /' });
230
+ }
231
+ // An identity needs an origin of its own, so only an origin root may opt
232
+ // in. Anything deeper would also let one path-pod user claim an ancestor
233
+ // of another's pod.
234
+ try {
235
+ if (new URL(podBase).pathname !== '/') {
236
+ return j(403, { error: 'podBase must be a pod origin root, like https://mei.example.org/' });
237
+ }
238
+ } catch { return j(400, { error: 'podBase is not a URL' }); }
239
+ const webid = await verifyPodToken(request, pathname, ctx.verifier);
240
+ if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
241
+ if (!webid.startsWith(podBase)) {
242
+ return j(403, { error: 'the token proves a different pod than the one you listed' });
243
+ }
244
+ if (action === 'opt-in') {
245
+ const { httpStatus, ...reply } = await ctx.agentControl.optIn({ podBase, webId: webid });
246
+ if (reply.doorSecret) {
247
+ // The secret appears here and nowhere else; the command is the
248
+ // paste-and-run way to open the owner door once.
249
+ reply.command = `curl -H 'x-dk-token: ${reply.doorSecret}' ${podBase.replace(/\/$/, '')}${reply.doorPath}status`;
250
+ }
251
+ return j(httpStatus, reply);
252
+ }
253
+ if (action === 'opt-out') {
254
+ const { httpStatus, ...reply } = await ctx.agentControl.optOut({ podBase });
255
+ return j(httpStatus, reply);
256
+ }
257
+ return j(400, { error: 'action must be opt-in or opt-out' });
258
+ }
259
+
260
+ // The vendored Solid-OIDC browser library the signup page loads — served
261
+ // here because this function owns every path on the domain.
262
+ if (pathname === '/solid-client-authn.bundle.js') {
263
+ if (!ctx.authBundle) return notFound();
264
+ return { status: 200, headers: { 'content-type': 'text/javascript' }, body: ctx.authBundle };
265
+ }
266
+
267
+ // The installer: curl -fsSL https://<host>/install | sh
268
+ if (pathname === '/install') {
269
+ if (!ctx.installScript) return notFound();
270
+ return { status: 200, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: ctx.installScript };
271
+ }
272
+
273
+ // WebFinger: acct:<handle>@<host> → the fronted actor. This is what makes
274
+ // @name@fedipod.net a real handle the fediverse can look up.
275
+ if (pathname === '/.well-known/webfinger') {
276
+ const resource = url.searchParams.get('resource') || '';
277
+ const m = /^acct:([^@]+)@(.+)$/.exec(resource);
278
+ if (!m || m[2] !== ctx.host) return notFound();
279
+ const rec = await ctx.lookup(m[1]);
280
+ if (!rec) return notFound();
281
+ return j(200, jrd({ handle: m[1], host: ctx.host, actor: rec.actorUrl }),
282
+ 'application/jrd+json');
283
+ }
284
+
285
+ const up = parseUserPath(pathname);
286
+ if (!up) return notFound();
287
+ const rec = await ctx.lookup(up.handle);
288
+ if (!rec) return notFound();
289
+ const base = userBase(ctx.frontOrigin, up.handle);
290
+
291
+ // Inbox: verify at the door, forward to the user's pod inbox. This is the
292
+ // gateway, per user.
293
+ if (up.rest === 'ap/inbox/' || up.rest === 'ap/inbox') {
294
+ if (request.method !== 'POST') return { status: 405, headers: {}, body: '' };
295
+ const policy = await policyFor(rec, ctx.fetchImpl || fetch);
296
+ const { status } = await handleDelivery(request, identFor(rec, policy),
297
+ { podPut: (u, b, ct) => ctx.podPut(up.handle, u, b, ct), fetchImpl: ctx.fetchImpl });
298
+ return { status, headers: {}, body: '' };
299
+ }
300
+
301
+ // Everything else is a public GET, served by reading the user's pod and
302
+ // rewriting pod ids to the front. The actor also gets its handle and inbox
303
+ // fixed to the front so a consumer cross-checks it consistently.
304
+ if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: {}, body: '' };
305
+ const podTarget = rec.podHome + up.rest;
306
+ const res = await (ctx.podGet ? ctx.podGet(podTarget) : fetch(podTarget, { headers: { accept: AP_CT } }));
307
+ if (!res || res.status >= 400) return { status: res?.status || 502, headers: {}, body: '' };
308
+ let text = await readCapped(res, 1024 * 1024);
309
+ text = swap(text, rec.podHome, base);
310
+ if (up.rest === 'ap/actor') {
311
+ try {
312
+ const doc = JSON.parse(text);
313
+ doc.preferredUsername = up.handle; // so @handle@front cross-checks
314
+ doc.inbox = base + 'ap/inbox/'; // deliveries come to the front to be verified
315
+ doc.endpoints = { ...(doc.endpoints || {}), sharedInbox: base + 'ap/inbox/' };
316
+ text = JSON.stringify(doc);
317
+ } catch { /* leave the rewritten text as-is if it will not parse */ }
318
+ }
319
+ return { status: 200, headers: { 'content-type': AP_CT, 'cache-control': 'no-store' }, body: text };
320
+ }
321
+
322
+ export const _internal = { parseUserPath, identFor, swap, handleProblem, policyFor, policyCache };
@@ -0,0 +1,115 @@
1
+ // gateway-core.mjs — the runtime-agnostic logic of an inbox gateway: verify a
2
+ // delivery at the door (where the HTTP-Signature headers still exist), drop
3
+ // spam and forgeries at the edge, and forward what remains — with a signed
4
+ // verification receipt — into the pod inbox for the ordinary drain.
5
+ //
6
+ // This holds NO signing key. It verifies with remotes' PUBLIC keys, writes to
7
+ // the pod inbox with an Append-only credential the adapter supplies, and stamps
8
+ // the receipt with a shared HMAC secret. A compromise here can inject inbox
9
+ // items (which still meet verify-by-dereference in the drain) but cannot
10
+ // impersonate the actor, read the private tree, or post as it.
11
+ //
12
+ // `netlify/functions/inbox.mjs` is a thin adapter over this; an agent-side
13
+ // endpoint could be another. The core is what the smoke suite exercises.
14
+
15
+ import crypto from 'node:crypto';
16
+ import { verifyHttpSignature, makeSafeLoader, makeReceipt, signReceipt } from './httpsig.mjs';
17
+
18
+ const DEFAULT_MAX_BYTES = 512 * 1024; // mirror intake.mjs MAX_ITEM_BYTES
19
+
20
+ // Control activities are the message itself and must always pass — you want
21
+ // Follows from strangers. Only CONTENT is subject to the concerns-us drop.
22
+ const CONTROL = new Set(['Follow', 'Undo', 'Accept', 'Reject', 'Delete', 'Move',
23
+ 'Add', 'Remove', 'Block']);
24
+
25
+ const idOf = (v) => (typeof v === 'string' ? v : v?.id) || null;
26
+ const sha256hex = (s) => crypto.createHash('sha256').update(s).digest('hex');
27
+
28
+ function httpUrl(u) {
29
+ try { const p = new URL(u).protocol; return p === 'http:' || p === 'https:'; } catch { return false; }
30
+ }
31
+
32
+ // Mirror of intake.isBlocked over the policy doc's public blocklist mirror.
33
+ function isBlocked(actor, blocklist = {}) {
34
+ if (!actor) return true;
35
+ if ((blocklist.actors || []).includes(actor)) return true;
36
+ let host; try { host = new URL(actor).host; } catch { return false; }
37
+ return (blocklist.domains || []).some(d => host === d || host.endsWith('.' + d));
38
+ }
39
+
40
+ // The edge form of intake.concernsUs — pure addressing, no I/O. Conservative:
41
+ // only DROPS content it is confident does not concern the identity. A GROUP
42
+ // owns conversations under anything it has carried, which the keyless edge
43
+ // cannot see, so a group defers the whole check to the drain (returns true).
44
+ function concernsUsAtEdge(activity, ident) {
45
+ if (ident.kind === 'group') return true;
46
+ const actor = idOf(activity.actor);
47
+ if ((ident.following || []).includes(actor)) return true;
48
+ const obj = typeof activity.object === 'object' ? activity.object : null;
49
+ const audience = []
50
+ .concat(activity.to || [], activity.cc || [], activity.bto || [], activity.bcc || [], activity.audience || [],
51
+ obj?.to || [], obj?.cc || [], obj?.audience || [])
52
+ .map(idOf).filter(Boolean);
53
+ if (audience.includes(ident.actorUrl) || audience.includes(ident.followersUrl)) return true;
54
+ const tag = [].concat(obj?.tag || activity.tag || []);
55
+ if (tag.some(t => t?.type === 'Mention' && (t.href === ident.actorUrl || t.name?.includes(ident.actorUrl)))) return true;
56
+ const inReplyTo = idOf(obj?.inReplyTo ?? activity.inReplyTo);
57
+ return !!inReplyTo && ident.notesPrefix && String(inReplyTo).startsWith(ident.notesPrefix);
58
+ }
59
+
60
+ // Handle one inbound delivery. `request` is a WHATWG Request. `ident` is the
61
+ // resolved identity policy: { inboxUrl, actorUrl, followersUrl, notesPrefix,
62
+ // following, blocklist, kind, gatewayWebId, hmacSecret }. `podPut(url, body,
63
+ // contentType) → boolean` appends to the pod with the gateway's credential.
64
+ // Returns { status, reason } — the adapter turns it into an HTTP response.
65
+ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch, maxBytes = DEFAULT_MAX_BYTES } = {}) {
66
+ // Read the body once from a clone; the original, unconsumed, goes to the
67
+ // verifier (which needs the body for the Digest check).
68
+ let raw;
69
+ try { raw = await request.clone().text(); } catch { return { status: 400, reason: 'unreadable body' }; }
70
+ if (Buffer.byteLength(raw) > maxBytes) return { status: 413, reason: 'too large' };
71
+
72
+ let activity;
73
+ try { activity = JSON.parse(raw); } catch { return { status: 400, reason: 'unparsable JSON' }; }
74
+ const actor = idOf(activity.actor);
75
+ if (!httpUrl(actor)) return { status: 400, reason: 'actor is not an http(s) URL' };
76
+
77
+ // Edge drops — silent 202 so a rejected sender does not retry a delivery we
78
+ // will never accept. None of these becomes a pod write.
79
+ if (isBlocked(actor, ident.blocklist)) return { status: 202, reason: 'blocked' };
80
+ if (!CONTROL.has(activity.type) && !concernsUsAtEdge(activity, ident)) {
81
+ return { status: 202, reason: 'does not concern us' };
82
+ }
83
+
84
+ const v = await verifyHttpSignature(request, {
85
+ documentLoader: makeSafeLoader({ fetchImpl }),
86
+ });
87
+ // A present-but-invalid signature is a forgery — dropped here, so it never
88
+ // reaches the pod (today it would, drain, and die unapplied). An absent or
89
+ // unfetchable-key signature is NOT dropped: it forwards unverified and the
90
+ // drain's verify-by-dereference still stands behind it.
91
+ if (v.verified === false && v.reason === 'bad-signature-or-key-unfetchable') {
92
+ return { status: 202, reason: 'forged signature' };
93
+ }
94
+
95
+ // No per-user secret on record = no receipt: the delivery still filters and
96
+ // forwards, it just reads as unverified at the drain — never a failure.
97
+ const receipt = ident.hmacSecret
98
+ ? signReceipt(makeReceipt(v, { gateway: ident.gatewayWebId }), ident.hmacSecret)
99
+ : null;
100
+ const hash = sha256hex(raw);
101
+ const okA = await podPut(ident.inboxUrl + hash, raw, 'application/activity+json');
102
+ // A pod-write failure returns 5xx so the ORIGIN retries over its own ladder —
103
+ // that is what preserves the pod's buffer property without the gateway
104
+ // holding any state.
105
+ if (!okA) return { status: 502, reason: 'pod inbox write failed' };
106
+ // The receipt is best-effort, like _archive: a delivery that landed but whose
107
+ // receipt did not is simply unverified, never lost.
108
+ if (receipt) {
109
+ await podPut(ident.inboxUrl + hash + '.receipt.json', JSON.stringify(receipt), 'application/json')
110
+ .catch(() => {});
111
+ }
112
+ return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified' };
113
+ }
114
+
115
+ export const _internal = { isBlocked, concernsUsAtEdge, httpUrl, sha256hex };