fedipod-server 0.9.0 → 0.11.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 (319) hide show
  1. package/dist/claims.js +1 -1
  2. package/dist/handler.d.ts +1 -1
  3. package/dist/handler.js +35 -2
  4. package/dist/handler.jsonld +1 -1
  5. package/lib/admin.mjs +3 -3
  6. package/lib/c2s.mjs +27 -2
  7. package/lib/deliver.mjs +6 -0
  8. package/lib/embed.mjs +1 -0
  9. package/lib/export-collections.mjs +1 -1
  10. package/lib/front-core.mjs +272 -49
  11. package/lib/gateway-core.mjs +3 -7
  12. package/lib/httpsig.mjs +33 -2
  13. package/lib/import.mjs +1 -1
  14. package/lib/intake.mjs +357 -118
  15. package/lib/links.mjs +6 -32
  16. package/lib/mastoapi.mjs +449 -38
  17. package/lib/migrate.mjs +4 -18
  18. package/lib/pod/README.md +61 -0
  19. package/lib/pod/actor.mjs +96 -0
  20. package/lib/pod/collection.mjs +77 -0
  21. package/lib/pod/containers.mjs +136 -0
  22. package/lib/pod/discovery.mjs +46 -0
  23. package/lib/pod/featured.mjs +18 -0
  24. package/lib/pod/followers.mjs +24 -0
  25. package/lib/pod/following.mjs +10 -0
  26. package/lib/pod/http.mjs +74 -0
  27. package/lib/pod/inbox.mjs +144 -0
  28. package/lib/pod/links.mjs +35 -0
  29. package/lib/pod/media.mjs +27 -0
  30. package/lib/pod/notes.mjs +80 -0
  31. package/lib/pod/notifications.mjs +89 -0
  32. package/lib/pod/outbox.mjs +18 -0
  33. package/lib/pod/package.json +19 -0
  34. package/lib/pod/policy.mjs +58 -0
  35. package/lib/pod/private.mjs +19 -0
  36. package/lib/pod/root.mjs +63 -0
  37. package/lib/pod/state.mjs +39 -0
  38. package/lib/pod/transport.mjs +477 -0
  39. package/lib/pod/urls.mjs +71 -0
  40. package/lib/polls.mjs +105 -0
  41. package/lib/publisher.mjs +357 -188
  42. package/lib/remote.mjs +30 -395
  43. package/lib/social.mjs +20 -9
  44. package/lib/storage.mjs +2 -2
  45. package/lib/store.mjs +8 -1
  46. package/lib/tagfeed.mjs +19 -2
  47. package/lib/wire.mjs +87 -67
  48. package/package.json +1 -1
  49. package/run-agent.mjs +20 -39
  50. package/web/admin/admin.js +141 -74
  51. package/web/admin/bar.css +9 -13
  52. package/web/admin/bar.js +2 -2
  53. package/web/admin/client/client.js +1 -1
  54. package/web/admin/client/index.html +5 -6
  55. package/web/admin/index.html +53 -65
  56. package/web/admin/setup/index.html +2 -1
  57. package/web/admin/setup/setup.js +1 -1
  58. package/web/admin/tokens.css +56 -0
  59. package/web/app/README.md +77 -0
  60. package/web/app/admin-facade.mjs +549 -0
  61. package/web/app/agent.mjs +490 -0
  62. package/web/app/atproto-browser.mjs +86 -0
  63. package/web/app/boot.mjs +340 -0
  64. package/web/app/deliver-relay.mjs +72 -0
  65. package/web/app/dist/boot.js +34067 -0
  66. package/web/app/dist/boot.js.map +7 -0
  67. package/web/app/dist/sw.js +53745 -0
  68. package/web/app/dist/sw.js.map +7 -0
  69. package/web/app/fediacct-browser.mjs +146 -0
  70. package/web/app/idb-kv.mjs +35 -0
  71. package/web/app/index.html +203 -0
  72. package/web/app/index.html~ +152 -0
  73. package/web/app/keys-browser.mjs +46 -0
  74. package/web/app/keystore.mjs +99 -0
  75. package/web/app/oidc-session.mjs +189 -0
  76. package/web/app/pod-auth.mjs +248 -0
  77. package/web/app/pod-remote.mjs +84 -0
  78. package/web/app/shims/_globals.mjs +2 -0
  79. package/web/app/shims/fedify-sig.mjs +77 -0
  80. package/web/app/shims/node-crypto.mjs +113 -0
  81. package/web/app/shims/node-fs.mjs +17 -0
  82. package/web/app/shims/node-path.mjs +19 -0
  83. package/web/app/shims/node-url.mjs +7 -0
  84. package/web/app/shims/prelude.js +58 -0
  85. package/web/app/shims/safefetch.mjs +58 -0
  86. package/web/app/shims/web-push.mjs +14 -0
  87. package/web/app/signup.mjs +248 -0
  88. package/web/app/site/_headers +9 -0
  89. package/web/app/site/_redirects +21 -0
  90. package/web/app/site/admin/admin.js +1181 -0
  91. package/web/app/site/admin/bar.css +60 -0
  92. package/web/app/site/admin/bar.js +61 -0
  93. package/web/app/site/admin/client/client.js +66 -0
  94. package/web/app/site/admin/client/index.html +43 -0
  95. package/web/app/site/admin/index.html +556 -0
  96. package/web/app/site/admin/setup/index.html +191 -0
  97. package/web/app/site/admin/setup/setup.js +308 -0
  98. package/web/app/site/admin/tokens.css +56 -0
  99. package/web/app/site/admin/window.css +55 -0
  100. package/web/app/site/admin/window.js +136 -0
  101. package/web/app/site/app/404.html +40 -0
  102. package/web/app/site/app/_headers +2 -0
  103. package/web/app/site/app/apple-touch-icon.png +0 -0
  104. package/web/app/site/app/assets/ICONS-DIPJeU0C.js +2 -0
  105. package/web/app/site/app/assets/ICONS-DIPJeU0C.js.map +1 -0
  106. package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js +183 -0
  107. package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
  108. package/web/app/site/app/assets/Temml-CCjoacWt.woff2 +0 -0
  109. package/web/app/site/app/assets/__vite-browser-external-C7iut881.js +2 -0
  110. package/web/app/site/app/assets/__vite-browser-external-C7iut881.js.map +1 -0
  111. package/web/app/site/app/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
  112. package/web/app/site/app/assets/catch-up-O5q5C75Z.png +0 -0
  113. package/web/app/site/app/assets/chunk-aKtaBQYM.js +1 -0
  114. package/web/app/site/app/assets/compose-CFEx7wb4.js +2 -0
  115. package/web/app/site/app/assets/compose-CFEx7wb4.js.map +1 -0
  116. package/web/app/site/app/assets/compose-ZJN5xSb4.js +35 -0
  117. package/web/app/site/app/assets/compose-ZJN5xSb4.js.map +1 -0
  118. package/web/app/site/app/assets/debug.module-YFQgwnIr.js +52 -0
  119. package/web/app/site/app/assets/debug.module-YFQgwnIr.js.map +1 -0
  120. package/web/app/site/app/assets/dom-p1YcJ5Rw.js +2 -0
  121. package/web/app/site/app/assets/dom-p1YcJ5Rw.js.map +1 -0
  122. package/web/app/site/app/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
  123. package/web/app/site/app/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
  124. package/web/app/site/app/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
  125. package/web/app/site/app/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
  126. package/web/app/site/app/assets/icons/add-circle-line-WD0mwORD.js +1 -0
  127. package/web/app/site/app/assets/icons/alert-line-CpFUMf9s.js +1 -0
  128. package/web/app/site/app/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
  129. package/web/app/site/app/assets/icons/announcement-line-5nwaJFJm.js +1 -0
  130. package/web/app/site/app/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
  131. package/web/app/site/app/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
  132. package/web/app/site/app/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
  133. package/web/app/site/app/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
  134. package/web/app/site/app/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
  135. package/web/app/site/app/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
  136. package/web/app/site/app/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
  137. package/web/app/site/app/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
  138. package/web/app/site/app/assets/icons/at-line-CF66Bznk.js +1 -0
  139. package/web/app/site/app/assets/icons/attachment-line-BY8It-l9.js +1 -0
  140. package/web/app/site/app/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
  141. package/web/app/site/app/assets/icons/board-line-C0UWXyA6.js +1 -0
  142. package/web/app/site/app/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
  143. package/web/app/site/app/assets/icons/building-5-line-Dd59MzbO.js +1 -0
  144. package/web/app/site/app/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
  145. package/web/app/site/app/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
  146. package/web/app/site/app/assets/icons/calendar-month-line-vommts2C.js +1 -0
  147. package/web/app/site/app/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
  148. package/web/app/site/app/assets/icons/camera-line-Cv69MEei.js +1 -0
  149. package/web/app/site/app/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
  150. package/web/app/site/app/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
  151. package/web/app/site/app/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
  152. package/web/app/site/app/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
  153. package/web/app/site/app/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
  154. package/web/app/site/app/assets/icons/clipboard-line-BBp364gJ.js +1 -0
  155. package/web/app/site/app/assets/icons/close-circle-line-CICdiuGz.js +1 -0
  156. package/web/app/site/app/assets/icons/close-line-DYrFWcjv.js +1 -0
  157. package/web/app/site/app/assets/icons/cloud-line-tiQAbah8.js +1 -0
  158. package/web/app/site/app/assets/icons/code-line-fAl8iiV3.js +1 -0
  159. package/web/app/site/app/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
  160. package/web/app/site/app/assets/icons/copy-2-line-CLW69yF5.js +1 -0
  161. package/web/app/site/app/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
  162. package/web/app/site/app/assets/icons/document-3-line-DBGNhAg2.js +1 -0
  163. package/web/app/site/app/assets/icons/document-line-C7mb9sb7.js +1 -0
  164. package/web/app/site/app/assets/icons/down-line-BGqSSThd.js +1 -0
  165. package/web/app/site/app/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
  166. package/web/app/site/app/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
  167. package/web/app/site/app/assets/icons/exit-line-Dc07BS-x.js +1 -0
  168. package/web/app/site/app/assets/icons/external-link-line-BvpSkmjO.js +1 -0
  169. package/web/app/site/app/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
  170. package/web/app/site/app/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
  171. package/web/app/site/app/assets/icons/filter-2-line-B_mR2usy.js +1 -0
  172. package/web/app/site/app/assets/icons/filter-line-C_nm-3cU.js +1 -0
  173. package/web/app/site/app/assets/icons/flag-1-line-C2HB05I0.js +1 -0
  174. package/web/app/site/app/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
  175. package/web/app/site/app/assets/icons/formula-line-DLrt2d6j.js +1 -0
  176. package/web/app/site/app/assets/icons/grid-line-CgUmEikf.js +1 -0
  177. package/web/app/site/app/assets/icons/group-line-C5fnX0wU.js +1 -0
  178. package/web/app/site/app/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
  179. package/web/app/site/app/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
  180. package/web/app/site/app/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
  181. package/web/app/site/app/assets/icons/heart-line-CJ7K47J4.js +1 -0
  182. package/web/app/site/app/assets/icons/history-2-line-kWWvFzqv.js +1 -0
  183. package/web/app/site/app/assets/icons/history-line-bGQiwbTH.js +1 -0
  184. package/web/app/site/app/assets/icons/home-3-line-fRU1zeLG.js +1 -0
  185. package/web/app/site/app/assets/icons/information-line-iNk6-sOY.js +1 -0
  186. package/web/app/site/app/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
  187. package/web/app/site/app/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
  188. package/web/app/site/app/assets/icons/layout-5-line-B_znt5wO.js +1 -0
  189. package/web/app/site/app/assets/icons/left-line-eUw0tya4.js +1 -0
  190. package/web/app/site/app/assets/icons/lightning-line-DaKoDdix.js +1 -0
  191. package/web/app/site/app/assets/icons/link-2-line-DCqLmMA7.js +1 -0
  192. package/web/app/site/app/assets/icons/list-check-line-BAxosBh9.js +1 -0
  193. package/web/app/site/app/assets/icons/lock-line-CPoaXeUW.js +1 -0
  194. package/web/app/site/app/assets/icons/mail-line-B0P8aa1L.js +1 -0
  195. package/web/app/site/app/assets/icons/moon-line-CEqNAToP.js +1 -0
  196. package/web/app/site/app/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
  197. package/web/app/site/app/assets/icons/more-3-line-BIiVjYTz.js +1 -0
  198. package/web/app/site/app/assets/icons/notification-line-BZZq2Gtu.js +1 -0
  199. package/web/app/site/app/assets/icons/pencil-line-CKh8-A1v.js +1 -0
  200. package/web/app/site/app/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
  201. package/web/app/site/app/assets/icons/pin-line-DpTolyhs.js +1 -0
  202. package/web/app/site/app/assets/icons/play-fill-B0-G_8lX.js +1 -0
  203. package/web/app/site/app/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
  204. package/web/app/site/app/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
  205. package/web/app/site/app/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
  206. package/web/app/site/app/assets/icons/radar-line-CKyroTth.js +1 -0
  207. package/web/app/site/app/assets/icons/react-line-CoIKhhtK.js +1 -0
  208. package/web/app/site/app/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
  209. package/web/app/site/app/assets/icons/right-line-B3KWJaqc.js +1 -0
  210. package/web/app/site/app/assets/icons/rocket-line-4KZl537D.js +1 -0
  211. package/web/app/site/app/assets/icons/round-fill-C69NTrZ0.js +1 -0
  212. package/web/app/site/app/assets/icons/round-line-B9k_1uo-.js +1 -0
  213. package/web/app/site/app/assets/icons/route-line-B28ReHcE.js +1 -0
  214. package/web/app/site/app/assets/icons/rows-4-line-CrBniagM.js +1 -0
  215. package/web/app/site/app/assets/icons/scan-line-DSf74YdP.js +1 -0
  216. package/web/app/site/app/assets/icons/search-2-line-B6geIU1z.js +1 -0
  217. package/web/app/site/app/assets/icons/settings-3-line-azJwK47w.js +1 -0
  218. package/web/app/site/app/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
  219. package/web/app/site/app/assets/icons/share-2-line-D5MxWjrm.js +1 -0
  220. package/web/app/site/app/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
  221. package/web/app/site/app/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
  222. package/web/app/site/app/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
  223. package/web/app/site/app/assets/icons/time-line-BF3eet1v.js +1 -0
  224. package/web/app/site/app/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
  225. package/web/app/site/app/assets/icons/translate-line-BFWol2Ce.js +1 -0
  226. package/web/app/site/app/assets/icons/unlock-line-X_x5vwv3.js +1 -0
  227. package/web/app/site/app/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
  228. package/web/app/site/app/assets/icons/user-4-line-BVSynZCp.js +1 -0
  229. package/web/app/site/app/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
  230. package/web/app/site/app/assets/icons/user-add-line-BbXdHG4z.js +1 -0
  231. package/web/app/site/app/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
  232. package/web/app/site/app/assets/icons/user-follow-line-BXux0wSd.js +1 -0
  233. package/web/app/site/app/assets/icons/user-star-line-DdYlpHLU.js +1 -0
  234. package/web/app/site/app/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
  235. package/web/app/site/app/assets/icons/user-x-line-DIDgNlPH.js +1 -0
  236. package/web/app/site/app/assets/icons/volume-line-Bs390KQt.js +1 -0
  237. package/web/app/site/app/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
  238. package/web/app/site/app/assets/icons/walk-line-yNXfB-md.js +1 -0
  239. package/web/app/site/app/assets/icons/world-2-line-CenkGIwj.js +1 -0
  240. package/web/app/site/app/assets/icons/zoom-in-line-CaENUphf.js +1 -0
  241. package/web/app/site/app/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
  242. package/web/app/site/app/assets/instances-BNPptnn-.json +333 -0
  243. package/web/app/site/app/assets/locales/ar-SA-CfheRbCu.js +1 -0
  244. package/web/app/site/app/assets/locales/ca-ES-DHRW86Tz.js +1 -0
  245. package/web/app/site/app/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
  246. package/web/app/site/app/assets/locales/de-DE-ByRPMRFy.js +1 -0
  247. package/web/app/site/app/assets/locales/eo-UY-CPC7wLCS.js +1 -0
  248. package/web/app/site/app/assets/locales/es-ES-dJa0Fd9t.js +1 -0
  249. package/web/app/site/app/assets/locales/eu-ES-C3djlPR2.js +1 -0
  250. package/web/app/site/app/assets/locales/fa-IR-DTT490Ck.js +1 -0
  251. package/web/app/site/app/assets/locales/fi-FI-B23MCPuz.js +1 -0
  252. package/web/app/site/app/assets/locales/fr-FR-BcaQsO5C.js +1 -0
  253. package/web/app/site/app/assets/locales/gl-ES-BR-CVKBN.js +1 -0
  254. package/web/app/site/app/assets/locales/he-IL-CfheRbCu.js +1 -0
  255. package/web/app/site/app/assets/locales/hu-HU-CfheRbCu.js +1 -0
  256. package/web/app/site/app/assets/locales/it-IT-C1136UFV.js +1 -0
  257. package/web/app/site/app/assets/locales/ja-JP-CRZmRAhD.js +1 -0
  258. package/web/app/site/app/assets/locales/kab-Cz8xH_TT.js +1 -0
  259. package/web/app/site/app/assets/locales/ko-KR-CG9UiY4o.js +1 -0
  260. package/web/app/site/app/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
  261. package/web/app/site/app/assets/locales/nb-NO-CfheRbCu.js +1 -0
  262. package/web/app/site/app/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
  263. package/web/app/site/app/assets/locales/oc-FR-CfheRbCu.js +1 -0
  264. package/web/app/site/app/assets/locales/pl-PL-DkwGXf6P.js +1 -0
  265. package/web/app/site/app/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
  266. package/web/app/site/app/assets/locales/pt-BR-nGL0HdFS.js +1 -0
  267. package/web/app/site/app/assets/locales/pt-PT-Cp17aj2z.js +1 -0
  268. package/web/app/site/app/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
  269. package/web/app/site/app/assets/locales/th-TH-CfheRbCu.js +1 -0
  270. package/web/app/site/app/assets/locales/tok-CfheRbCu.js +1 -0
  271. package/web/app/site/app/assets/locales/tr-TR-CfheRbCu.js +1 -0
  272. package/web/app/site/app/assets/locales/uk-UA-C5E3dFBf.js +1 -0
  273. package/web/app/site/app/assets/locales/zh-CN-BHJi7_et.js +1 -0
  274. package/web/app/site/app/assets/locales/zh-TW-CfheRbCu.js +1 -0
  275. package/web/app/site/app/assets/main-BdqNbG-a.js +26 -0
  276. package/web/app/site/app/assets/main-BdqNbG-a.js.map +1 -0
  277. package/web/app/site/app/assets/mock-home-DGhR__He.js +2 -0
  278. package/web/app/site/app/assets/mock-home-DGhR__He.js.map +1 -0
  279. package/web/app/site/app/assets/multi-column-ROck0NVt.jpg +0 -0
  280. package/web/app/site/app/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
  281. package/web/app/site/app/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
  282. package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js +2 -0
  283. package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js.map +1 -0
  284. package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js +20 -0
  285. package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js.map +1 -0
  286. package/web/app/site/app/assets/style-B1iLPTPR.css +2 -0
  287. package/web/app/site/app/assets/temml-DuyIQMei.js +97 -0
  288. package/web/app/site/app/assets/temml-DuyIQMei.js.map +1 -0
  289. package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js +2 -0
  290. package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
  291. package/web/app/site/app/assets/year-in-posts-B2lywqdx.png +0 -0
  292. package/web/app/site/app/assets/year-in-posts-DJv_star.js +3 -0
  293. package/web/app/site/app/assets/year-in-posts-DJv_star.js.map +1 -0
  294. package/web/app/site/app/compose/index.html +20 -0
  295. package/web/app/site/app/favicon.ico +0 -0
  296. package/web/app/site/app/index.html +73 -0
  297. package/web/app/site/app/logo-192.png +0 -0
  298. package/web/app/site/app/logo-512.png +0 -0
  299. package/web/app/site/app/logo-badge-72.png +0 -0
  300. package/web/app/site/app/logo-maskable-512.png +0 -0
  301. package/web/app/site/app/logo-monochrome-512.png +0 -0
  302. package/web/app/site/app/logo-monochrome-maskable-512.png +0 -0
  303. package/web/app/site/app/manifest.webmanifest +1 -0
  304. package/web/app/site/app/og-image-2.jpg +0 -0
  305. package/web/app/site/app/og-image.png +0 -0
  306. package/web/app/site/app/robots.txt +2 -0
  307. package/web/app/site/app/version.json +1 -0
  308. package/web/app/site/boot.js +34067 -0
  309. package/web/app/site/index.html +203 -0
  310. package/web/app/site/sw.js +53745 -0
  311. package/web/app/sw-src.mjs +205 -0
  312. package/web/front/#new-account.html# +43 -0
  313. package/web/front/admin.html +1 -81
  314. package/web/front/admin.js +85 -0
  315. package/web/front/new-account.html +1 -9
  316. package/web/front/new-account.js +13 -0
  317. package/web/front/run.html +1 -92
  318. package/web/front/run.js +107 -0
  319. package/lib/podrdf.mjs +0 -132
@@ -0,0 +1,477 @@
1
+ // transport.mjs — every authenticated request this library makes to a pod, and
2
+ // the manners that must hold for all of them.
3
+ //
4
+ // The session is injected and never built here: a Node agent hands in a DPoP
5
+ // client-credentials grant, a browser hands in a Solid-OIDC session, a pod
6
+ // server hands in a shim straight onto its own store. That is what lets one
7
+ // implementation serve all three, and it is why this file has no `import` of
8
+ // anything with a runtime.
9
+ //
10
+ // Two copies of this used to exist — lib/remote.mjs and web/app/pod-remote.mjs
11
+ // — hand-kept in sync, with the browser copy quietly behind on five separate
12
+ // hardenings. The subclasses below now add only what genuinely differs: how the
13
+ // session is obtained, and whether a throttled pod is waited out or refused.
14
+
15
+ import * as $rdf from 'rdflib';
16
+ import { readCapped, retryAfterMs } from './http.mjs';
17
+ import { linkTargets, REL } from './links.mjs';
18
+
19
+ const LDP = $rdf.Namespace('http://www.w3.org/ns/ldp#');
20
+ const DC = $rdf.Namespace('http://purl.org/dc/terms/');
21
+ const POSIX = $rdf.Namespace('http://www.w3.org/ns/posix/stat#');
22
+ const RDF = $rdf.Namespace('http://www.w3.org/1999/02/22-rdf-syntax-ns#');
23
+ const ACL = $rdf.Namespace('http://www.w3.org/ns/auth/acl#');
24
+ const FOAF = $rdf.Namespace('http://xmlns.com/foaf/0.1/');
25
+ const AS = $rdf.Namespace('https://www.w3.org/ns/activitystreams#');
26
+
27
+ // A pod whose access rules are ACP policies, not WAC authorizations. This
28
+ // library writes WAC; over an ACP resource that would be noise where the pod's
29
+ // real rules used to be, so it stops instead.
30
+ const ACP_NS = 'http://www.w3.org/ns/solid/acp#';
31
+
32
+ // The inbox is public-Append, so the listing's size is in other people's
33
+ // hands; reading it whole must still have a ceiling.
34
+ const LISTING_MAX_BYTES = 10 * 1024 * 1024;
35
+
36
+ // No Retry-After header still means "not now". Sixty seconds is the answer
37
+ // when the server declined to give one.
38
+ const COOLDOWN_DEFAULT_MS = 60_000;
39
+
40
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
41
+
42
+ // A pod's ability to BE a pod. Not data, and never ours to remove: delete one
43
+ // of these and the pod does not degrade, it stops — and it cannot be repaired
44
+ // by the tool that broke it, because that tool can no longer authenticate. A
45
+ // pod was crippled exactly this way.
46
+ //
47
+ // Here rather than in any one caller, because every DELETE this library sends
48
+ // goes through PodTransport.fetch — so this is the one place that cannot be
49
+ // routed around. A DENY-list, not an allow-prefix: the next script will have a
50
+ // different prefix and the same list of things it must never touch.
51
+ const PROTECTED = [
52
+ [/\/profile(\/|$)/, 'the WebID document — nothing could authenticate as this pod again'],
53
+ [/\/settings(\/|$)/, "the pod's own settings"],
54
+ [/\/\.well-known(\/|$)/, 'discovery — the handle would stop resolving'],
55
+ [/\.acl$/, 'an access-control document — what it governs becomes unreachable'],
56
+ [/\.meta$/, 'a resource description the server itself reads'],
57
+ // The single-active-agent lock. It is on the pod precisely because the
58
+ // private half need not be — a lease only one machine can reach coordinates
59
+ // nothing — so it survives moving the state off, and nothing ever deletes it:
60
+ // release() writes an expiry, it does not remove the document. Two agents
61
+ // both believing they hold it is silent, destructive, duplicated inbox
62
+ // draining.
63
+ [/\/lease\.json$/, 'the lease — two agents would drain the same inbox'],
64
+ ];
65
+
66
+ export function protectedFromDeletion(url) {
67
+ let p;
68
+ try { p = new URL(url).pathname; } catch { throw new Error(`refusing to DELETE an unparsable URL: ${url}`); }
69
+ if (p === '/' || p === '') throw new Error(`refusing to DELETE the pod root: ${url}`);
70
+ for (const [re, why] of PROTECTED) {
71
+ if (re.test(p)) {
72
+ throw new Error(`refusing to DELETE ${url} — ${why}. Deny-list: lib/pod/transport.mjs.`);
73
+ }
74
+ }
75
+ }
76
+
77
+ export class PodTransport {
78
+ /**
79
+ * @param {object} session `{ fetch(url, init) }` — injected, never built here
80
+ * @param {object} opts
81
+ * @param {string} opts.webId who this transport acts as; the ACL owner
82
+ * @param {string} [opts.role] 'agent' | 'gateway' | 'signup' — what is talking
83
+ * @param {string} [opts.runtime] 'node' | 'browser' — where it is talking from
84
+ * @param {string} [opts.cooldownMode] 'refuse' fails fast for the window;
85
+ * 'wait' sleeps it out and continues
86
+ * @param {number} [opts.maxCooldownMs] ceiling on a Retry-After we will honour
87
+ */
88
+ constructor(session, {
89
+ webId, log = () => {}, role = 'agent', runtime = 'node',
90
+ cooldownMode = 'refuse', maxCooldownMs = 30 * 60_000,
91
+ } = {}) {
92
+ this.session = session;
93
+ this.webId = webId;
94
+ this.log = log;
95
+ this.role = role;
96
+ this.runtime = runtime;
97
+ this.cooldownMode = cooldownMode;
98
+ this.maxCooldownMs = maxCooldownMs;
99
+ this.pausedUntil = 0;
100
+ this.probeCount = 0;
101
+ // Where each resource's access control lives, as the pod itself said. WAC
102
+ // forbids working it out from the resource's own URL, so it is asked for
103
+ // and remembered rather than assembled.
104
+ this.aclUrls = new Map();
105
+ this.aclFlavour = null; // null until the first write asks what this pod speaks
106
+ this._listCache = new Map();
107
+ // A fronted identity advertises ids on a shared domain but writes to the
108
+ // pod. The caller installs the advertised→pod mapping here, so every
109
+ // request built from an advertised id lands on the pod — one choke point.
110
+ // Unset → identity, byte-for-byte the unfronted behaviour.
111
+ this.toPod = null;
112
+ }
113
+
114
+ /** Who is talking, and from where — the prefix on every log line and error. */
115
+ get label() { return `${this.role}/${this.runtime}`; }
116
+
117
+ /** One line for a boot log: what this transport is and what it points at. */
118
+ describe() {
119
+ return `${this.role} over a ${this.runtime} session → ${this.webId?.split('#')[0] || 'unknown'}`;
120
+ }
121
+
122
+ /** Install the advertised→pod url map for a fronted identity (urls.toPod). */
123
+ setUrlMap(fn) { this.toPod = typeof fn === 'function' ? fn : null; }
124
+
125
+ async warmup() { return this.session.warmup?.(); }
126
+
127
+ /** What we have asked of this pod, for a status page or an operator. */
128
+ stats() {
129
+ return {
130
+ role: this.role,
131
+ runtime: this.runtime,
132
+ probes: this.probeCount,
133
+ pausedFor: Math.max(0, Math.round((this.pausedUntil - Date.now()) / 1000)),
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Observe a throttling answer and arm the pod-wide cooldown.
139
+ *
140
+ * One pause for the whole pod, not one per request: without it every
141
+ * in-flight call rides its own ladder into a server that has already said it
142
+ * is overloaded, which is how a throttle becomes a stampede. A 429 or 503
143
+ * with NO Retry-After still arms the window — the browser copy used to arm
144
+ * nothing at all in that case, which is most cases.
145
+ */
146
+ _observe(res) {
147
+ if (res.status !== 429 && res.status !== 503) return res;
148
+ // What the server ASKED for is always honoured. What to do when it asked
149
+ // for nothing depends on what the cooldown costs here: a caller that
150
+ // REFUSES for the window loses nothing by assuming a minute, while one that
151
+ // WAITS it out would freeze a person's tab for a minute over a throttle
152
+ // that clears in seconds — so there, absence arms nothing and the caller's
153
+ // own retry ladder paces it instead.
154
+ const asked = retryAfterMs(res, this.maxCooldownMs);
155
+ const ms = asked ?? (this.cooldownMode === 'refuse' ? COOLDOWN_DEFAULT_MS : 0);
156
+ if (!ms) return res;
157
+ this.pausedUntil = Date.now() + ms;
158
+ this.log(`[${this.label}] pod returned ${res.status}${asked ? ', Retry-After' : ''} — `
159
+ + `holding all requests for ${Math.round(ms / 1000)}s`);
160
+ return res;
161
+ }
162
+
163
+ /** Honour an armed cooldown: fail fast, or wait it out, as configured. */
164
+ async _cooldownGate() {
165
+ const left = this.pausedUntil - Date.now();
166
+ if (left <= 0) return;
167
+ if (this.cooldownMode === 'wait') {
168
+ await sleep(Math.min(left, this.maxCooldownMs));
169
+ return;
170
+ }
171
+ throw new Error(`[${this.label}] pod asked us to back off — ${Math.ceil(left / 1000)}s left of its Retry-After`);
172
+ }
173
+
174
+ /**
175
+ * The seam a subclass overrides to add transport behaviour — the browser's
176
+ * retry ladder lives here. Overriding THIS rather than `fetch` is deliberate:
177
+ * an override cannot skip the url map, the cooldown accounting or the
178
+ * deletion deny-list, because those are in `fetch` above it.
179
+ */
180
+ async _send(url, init) { return this.session.fetch(url, init); }
181
+
182
+ /**
183
+ * A deliberately CREDENTIAL-FREE request: it asks what a stranger would see,
184
+ * so it cannot go through the session without answering a different question.
185
+ * It still belongs to this pod, though — it opens a socket to it and takes
186
+ * one of its workers — so it observes the same cooldown and is counted.
187
+ *
188
+ * No url map: a probe asks about the ADVERTISED face, which is the whole
189
+ * point of asking.
190
+ */
191
+ async probe(url, init = {}) {
192
+ await this._cooldownGate();
193
+ this.probeCount++;
194
+ return this._observe(await fetch(url, init));
195
+ }
196
+
197
+ /**
198
+ * Every authenticated request. Nothing below this reaches the session
199
+ * directly: this is the only place the cooldown is both OBSERVED and ARMED,
200
+ * the url map applied, and the deny-list enforced.
201
+ */
202
+ async fetch(url, init) {
203
+ await this._cooldownGate();
204
+ if (this.toPod) url = this.toPod(url);
205
+ if (String(init?.method || '').toUpperCase() === 'DELETE') this._guardDelete(url);
206
+ return this._observe(await this._send(url, init));
207
+ }
208
+
209
+ /** Both refusals a DELETE must pass, wherever it was issued from. */
210
+ _guardDelete(url) {
211
+ protectedFromDeletion(url);
212
+ // The pattern list knows the usual name for an access-control document.
213
+ // One the pod named itself is just as fatal to remove. Tolerating an
214
+ // absent map matters: this now runs for EVERY delete, including one issued
215
+ // through `fetch` by a caller holding a minimally-built transport.
216
+ for (const acl of this.aclUrls?.values() ?? []) {
217
+ if (acl === url) throw new Error(`refusing to DELETE an access-control document: ${url}`);
218
+ }
219
+ }
220
+
221
+ async put(url, body, contentType) {
222
+ const res = await this.fetch(url, {
223
+ method: 'PUT', headers: { 'content-type': contentType }, body,
224
+ });
225
+ if (res.status >= 400) throw new Error(`[${this.label}] PUT ${url} → ${res.status}`);
226
+ // Writing a document is usually the step before setting its access, and
227
+ // the answer to the write already says where that lives. Taking it here
228
+ // spares the extra request the ACL write would otherwise make.
229
+ this.noteAclLink(url, res);
230
+ return res;
231
+ }
232
+
233
+ async putJson(url, obj, contentType = 'application/activity+json') {
234
+ return this.put(url, JSON.stringify(obj), contentType);
235
+ }
236
+
237
+ // A read that FAILED is not a document that is ABSENT. Returning null for
238
+ // both let a 429 read as "no replies yet", and the caller then rewrote the
239
+ // collection from empty — erasing every reply already recorded. Only a real
240
+ // 404/410 is absence; anything else throws and the caller retries later.
241
+ async getJson(url) {
242
+ const res = await this.fetch(url, { headers: { accept: '*/*' } });
243
+ if (res.status === 404 || res.status === 410) return null;
244
+ if (res.status >= 400) throw new Error(`[${this.label}] GET ${url} → ${res.status}`);
245
+ return res.json().catch(() => null);
246
+ }
247
+
248
+ async delete(url) {
249
+ const res = await this.fetch(url, { method: 'DELETE' });
250
+ return res.status < 400 || res.status === 404;
251
+ }
252
+
253
+ /** Remember an access-control location the pod volunteered on a response. */
254
+ noteAclLink(url, res) {
255
+ if (this.aclUrls.has(url)) return;
256
+ const [acl] = linkTargets(res?.headers?.get?.('link'), REL.acl, url);
257
+ if (acl) this.aclUrls.set(url, acl);
258
+ }
259
+
260
+ /**
261
+ * Where this resource's access control lives. The pod says so on any
262
+ * response about the resource; a pod that says nothing is taken to keep it
263
+ * at the usual suffix, which is what every server this runs against does.
264
+ */
265
+ async aclUrlFor(targetUrl) {
266
+ const known = this.aclUrls.get(targetUrl);
267
+ if (known) return known;
268
+ try {
269
+ const res = await this.fetch(targetUrl, { method: 'HEAD' });
270
+ this.noteAclLink(targetUrl, res);
271
+ } catch { /* unreachable or no such resource yet: the suffix below */ }
272
+ const resolved = this.aclUrls.get(targetUrl) || targetUrl + '.acl';
273
+ this.aclUrls.set(targetUrl, resolved);
274
+ return resolved;
275
+ }
276
+
277
+ /**
278
+ * Whether writing a WAC document here is meaningful. Asked once per pod, on
279
+ * the first access-control write. A pod that answers with ACP policies is
280
+ * left alone: replacing them with authorizations it does not read would take
281
+ * away the rules actually protecting it.
282
+ */
283
+ async aclWritable(aclUrl) {
284
+ if (this.aclFlavour !== null) return this.aclFlavour;
285
+ this.aclFlavour = true;
286
+ try {
287
+ const res = await this.fetch(aclUrl, { headers: { accept: 'text/turtle' } });
288
+ if (res.status < 300) {
289
+ const g = $rdf.graph();
290
+ $rdf.parse(await res.text(), g, aclUrl, 'text/turtle');
291
+ const acp = g.statements.some(st => st.predicate.value.startsWith(ACP_NS)
292
+ || st.object.value.startsWith(ACP_NS));
293
+ if (acp) {
294
+ this.aclFlavour = false;
295
+ this.log(`[${this.label}] this pod states access as ACP policies, which this library does not write — `
296
+ + 'its access rules are left exactly as they are, and nothing here is published private');
297
+ }
298
+ }
299
+ } catch { /* absent, unreadable or unparsable: WAC is what we write */ }
300
+ return this.aclFlavour;
301
+ }
302
+
303
+ // WAC doc granting the public `publicModes` on target, owner full control.
304
+ // An empty publicModes list yields an owner-only document.
305
+ //
306
+ // Built and serialised by rdflib, like every other document written here.
307
+ // This is the highest-consequence RDF in the project: an ACL that comes out
308
+ // malformed, or naming the wrong subject, either locks the owner out or
309
+ // leaves the private trees world-readable. $rdf.sym() also throws on an
310
+ // illegal IRI, so a pod URL with something odd in it fails here rather than
311
+ // silently producing a document that means something else.
312
+ aclDoc(targetUrl, publicModes, { appendAgents = [], aclUrl = null } = {}) {
313
+ const url = aclUrl || targetUrl + '.acl';
314
+ const doc = $rdf.sym(url);
315
+ const target = $rdf.sym(targetUrl);
316
+ const g = $rdf.graph();
317
+ const authorize = (subject, agentPred, agent, modes) => {
318
+ g.add(subject, RDF('type'), ACL('Authorization'), doc);
319
+ g.add(subject, agentPred, agent, doc);
320
+ g.add(subject, ACL('accessTo'), target, doc);
321
+ g.add(subject, ACL('default'), target, doc);
322
+ for (const m of modes) g.add(subject, ACL('mode'), ACL(m), doc);
323
+ };
324
+ if (publicModes.length) {
325
+ authorize($rdf.sym(url + '#public'), ACL('agentClass'), FOAF('Agent'), publicModes);
326
+ }
327
+ // Named agents granted Append but not public: this is how the inbox is
328
+ // locked to a gateway — public loses Append, the gateway's WebID keeps it.
329
+ appendAgents.forEach((webId, i) =>
330
+ authorize($rdf.sym(url + `#gw${i}`), ACL('agent'), $rdf.sym(webId), ['Append']));
331
+ authorize($rdf.sym(url + '#owner'), ACL('agent'), $rdf.sym(this.webId),
332
+ ['Read', 'Write', 'Control']);
333
+ return $rdf.serialize(doc, g, url, 'text/turtle');
334
+ }
335
+
336
+ async setAcl(targetUrl, publicModes, opts = {}) {
337
+ const url = await this.aclUrlFor(targetUrl);
338
+ if (!await this.aclWritable(url)) return null;
339
+ return this.put(url, this.aclDoc(targetUrl, publicModes, { ...opts, aclUrl: url }), 'text/turtle');
340
+ }
341
+
342
+ // Child documents of an LDP container (URLs under it, excluding aux docs).
343
+ // Revalidated: the inbox is polled every couple of minutes and is usually
344
+ // unchanged, so ask conditionally and let the server answer 304.
345
+ async listContainer(url) {
346
+ // Lazily, not only in the constructor: a caller may hold a transport built
347
+ // with Object.create for a test, setting just the few fields it drives.
348
+ this._listCache ||= new Map();
349
+ const known = this._listCache.get(url);
350
+ const res = await this.fetch(url, {
351
+ headers: { accept: 'text/turtle', ...(known?.etag ? { 'if-none-match': known.etag } : {}) },
352
+ });
353
+ if (res.status === 304 && known) return known.children;
354
+ if (res.status >= 400) return [];
355
+ // rdflib, not a regex: a container listing is RDF, and the gap between
356
+ // "any angle-bracketed thing in the document" and "what this container
357
+ // actually contains" is where quiet bugs live.
358
+ //
359
+ // CSS puts dc:modified and posix:size on every child of the same listing,
360
+ // so age and weight cost nothing extra — which is what lets a drain work
361
+ // oldest-first and lets a backlog be measured without reading any of it.
362
+ let body;
363
+ try {
364
+ body = await readCapped(res, LISTING_MAX_BYTES);
365
+ } catch (e) {
366
+ this.log(`[${this.label}] listing at ${url}: ${e.message} — using the last known listing`);
367
+ return known?.children ?? [];
368
+ }
369
+ // A parse failure is a real fault, not a cache hit: it propagates. The
370
+ // browser copy used to swallow it and hand back a stale listing, which
371
+ // turns a corrupt container into silently missing work.
372
+ const g = $rdf.graph();
373
+ $rdf.parse(body, g, url, 'text/turtle');
374
+ const here = $rdf.sym(url);
375
+ const seen = new Set();
376
+ const list = [];
377
+ for (const child of g.each(here, LDP('contains'), null, here)) {
378
+ const u = child.value;
379
+ // `.receipt.json` is a verification receipt a gateway wrote beside an
380
+ // inbox item — read with the item, never enumerated as an item itself.
381
+ if (!u.startsWith(url) || u === url || /\.(acl|meta|receipt\.json)$/.test(u) || seen.has(u)) continue;
382
+ seen.add(u);
383
+ list.push({
384
+ url: u,
385
+ size: Number(g.any(child, POSIX('size'), null, here)?.value || 0),
386
+ modified: g.any(child, DC('modified'), null, here)?.value || null,
387
+ });
388
+ }
389
+ // Oldest first. An LDP listing is a set, so without this a drain works in
390
+ // whatever order the graph happened to parse — a mention from last week
391
+ // after one from today, and no way to make progress predictable.
392
+ list.sort((a, b) => String(a.modified || '').localeCompare(String(b.modified || '')));
393
+ this._listCache.set(url, { etag: res.headers.get('etag'), children: list });
394
+ return list;
395
+ }
396
+
397
+ /**
398
+ * The WebID profile advertises the actor as an account:
399
+ * <webId> foaf:account <actor> .
400
+ * <actor> a foaf:OnlineAccount, as:Person|as:Group ; foaf:accountName "@handle@host" .
401
+ * Read–check–write through rdflib. Returns false when the profile already
402
+ * says all of it. The parsed graph must mention the WebID before anything is
403
+ * written back — an empty or foreign body must never become the new profile.
404
+ */
405
+ async linkAccountInProfile({ actorUrl, accountName, kind = 'person' }) {
406
+ const docUrl = this.webId.split('#')[0];
407
+ const res = await this.fetch(docUrl, { headers: { accept: 'text/turtle' } });
408
+ if (res.status >= 400) throw new Error(`[${this.label}] GET ${docUrl} → ${res.status}`);
409
+ const g = $rdf.graph();
410
+ $rdf.parse(await res.text(), g, docUrl, 'text/turtle');
411
+ const doc = $rdf.sym(docUrl);
412
+ const me = $rdf.sym(this.webId);
413
+ if (!g.statementsMatching(me, null, null, doc).length) {
414
+ throw new Error(`profile at ${docUrl} does not mention ${this.webId} — not rewriting it`);
415
+ }
416
+ const actor = $rdf.sym(actorUrl);
417
+ const wanted = [
418
+ [me, FOAF('account'), actor],
419
+ [actor, RDF('type'), FOAF('OnlineAccount')],
420
+ [actor, RDF('type'), kind === 'group' ? AS('Group') : AS('Person')],
421
+ [actor, FOAF('accountName'), $rdf.literal(accountName)],
422
+ ];
423
+ const missing = wanted.filter(([s, p, o]) => !g.holds(s, p, o, doc));
424
+ // A handle change leaves the old accountName behind; ours is replaced.
425
+ const stale = g.statementsMatching(actor, FOAF('accountName'), null, doc)
426
+ .filter(st => st.object.value !== accountName);
427
+ if (!missing.length && !stale.length) return false;
428
+ // A patch touches these statements and nothing else. Rewriting the whole
429
+ // profile re-serialises statements that are not ours — the OIDC issuer
430
+ // among them — and a server is entitled to refuse a write that would.
431
+ const deletes = stale.map(st => [st.subject, st.predicate, st.object]);
432
+ if (await this.patchDocument(docUrl, missing, deletes)) return true;
433
+ for (const st of stale) g.remove(st);
434
+ for (const [s, p, o] of missing) g.add(s, p, o, doc);
435
+ await this.put(docUrl, $rdf.serialize(doc, g, docUrl, 'text/turtle'), 'text/turtle');
436
+ return true;
437
+ }
438
+
439
+ /**
440
+ * An N3 Patch of exactly these statements, or false when the pod will not
441
+ * take one and the caller should write the document instead.
442
+ *
443
+ * The statements are serialised by rdflib; only the wrapper naming what is
444
+ * being patched is assembled here, because N3's braces have no rdflib form.
445
+ */
446
+ n3Patch(docUrl, inserts, deletes) {
447
+ const block = (triples) => {
448
+ const g = $rdf.graph();
449
+ for (const [s, p, o] of triples) g.add(s, p, o);
450
+ return $rdf.serialize(null, g, docUrl, 'application/n-triples').trim();
451
+ };
452
+ const clauses = [];
453
+ if (deletes.length) clauses.push(` solid:deletes { ${block(deletes)} }`);
454
+ if (inserts.length) clauses.push(` solid:inserts { ${block(inserts)} }`);
455
+ return `@prefix solid: <http://www.w3.org/ns/solid/terms#>.\n`
456
+ + `<> a solid:InsertDeletePatch;\n${clauses.join(';\n')}.\n`;
457
+ }
458
+
459
+ async patchDocument(docUrl, inserts, deletes) {
460
+ let res;
461
+ try {
462
+ res = await this.fetch(docUrl, {
463
+ method: 'PATCH',
464
+ headers: { 'content-type': 'text/n3' },
465
+ body: this.n3Patch(docUrl, inserts, deletes),
466
+ });
467
+ } catch {
468
+ return false; // no PATCH on this transport at all
469
+ }
470
+ if (res.status < 300) return true;
471
+ // The pod cannot patch. Anything else — a 409 saying what we meant to
472
+ // remove is not there any more — is a real answer, and rewriting the whole
473
+ // document over the top of it would destroy whatever changed it.
474
+ if (res.status === 405 || res.status === 415 || res.status === 501) return false;
475
+ throw new Error(`[${this.label}] PATCH ${docUrl} → ${res.status}`);
476
+ }
477
+ }
@@ -0,0 +1,71 @@
1
+ // urls.mjs — where an ActivityPub actor's documents live on a Solid pod.
2
+ //
3
+ // Everything the actor owns nests under ONE top-level container (`root`), so
4
+ // the pod stays tidy and one pod could host several actors under different
5
+ // roots. WebFinger and host-meta are the exception: fediverse discovery only
6
+ // ever looks at the host root, so they sit at the pod base.
7
+ //
8
+ // `root` is REQUIRED. It used to default, and the default disagreed with what
9
+ // one of the two callers actually passed — which is the kind of bug that puts
10
+ // an actor's state in one container and its published documents in another.
11
+ // A library has no business having an opinion about which container an
12
+ // application uses; the application states it.
13
+ //
14
+ // When `publicBase` is given — an identity whose ADVERTISED ids live somewhere
15
+ // else, e.g. "https://front.example/u/me/" — the ids a remote server sees build
16
+ // on that instead, while the pod-side trees and every write target stay on the
17
+ // pod. `toPod`/`toPublic` map between the two spaces, and a transport applies
18
+ // `toPod` at its one request choke point, so callers keep passing advertised
19
+ // ids and the writes still land on the pod. With no `publicBase` the returned
20
+ // object is byte-identical to the unfronted form — an invariant worth keeping,
21
+ // because it is what lets the fronted case be tested against the plain one.
22
+
23
+ export function apUrls(remotePod, root, { publicBase = null } = {}) {
24
+ if (!root) throw new Error('apUrls: a container root is required — the caller states it, this library does not guess');
25
+ const base = remotePod.endsWith('/') ? remotePod : remotePod + '/';
26
+ const home = base + (root.endsWith('/') ? root : root + '/');
27
+ // The face a remote sees: the fronted home, or the pod home when unfronted.
28
+ const face = publicBase ? (publicBase.endsWith('/') ? publicBase : publicBase + '/') : home;
29
+ const urls = {
30
+ base, home,
31
+ webfinger: base + '.well-known/webfinger',
32
+ actor: face + 'ap/actor',
33
+ inbox: face + 'ap/inbox/',
34
+ outbox: face + 'ap/outbox',
35
+ followers: face + 'ap/followers',
36
+ following: face + 'ap/following',
37
+ notes: face + 'ap/notes/',
38
+ privateNotes: face + 'ap/private/',
39
+ featured: face + 'ap/featured',
40
+ // FEP-1b12: the moderator roster a recipient validates announced
41
+ // moderation against. Published only when moderators are configured.
42
+ moderators: face + 'ap/moderators',
43
+ // FEP-4ccd and FEP-c648: follows in limbo and the block list, as
44
+ // collections. They live in the private container so the owner-only ACL
45
+ // is inherited, not re-stated per document.
46
+ pendingFollowers: face + 'ap/private/pending-followers',
47
+ pendingFollowing: face + 'ap/private/pending-following',
48
+ blocked: face + 'ap/private/blocked',
49
+ profileHtml: face + 'ap/profile.html',
50
+ // Media stays on the pod even when fronted: attachment urls are not
51
+ // identity-checked by remotes, and proxying blobs would be pure cost.
52
+ media: home + 'ap/media/',
53
+ state: home + 'ap-state/',
54
+ };
55
+ if (publicBase) {
56
+ urls.podHome = home;
57
+ urls.publicHome = face;
58
+ urls.toPod = (u) => (typeof u === 'string' && u.startsWith(face) ? home + u.slice(face.length) : u);
59
+ urls.toPublic = (u) => (typeof u === 'string' && u.startsWith(home) ? face + u.slice(home.length) : u);
60
+ }
61
+ return urls;
62
+ }
63
+
64
+ // The host a handle would resolve through, or null when no handle can point
65
+ // here. @name@host is looked up at https://host/.well-known/webfinger, so only
66
+ // a pod that owns the root of its host can answer for one; a pod living at
67
+ // https://server/name/ may publish the document but nothing will ever ask.
68
+ export function webfingerHost(podUrl) {
69
+ const u = new URL(podUrl);
70
+ return u.pathname === '/' ? u.host : null;
71
+ }
package/lib/polls.mjs ADDED
@@ -0,0 +1,105 @@
1
+ // polls.mjs — the count behind a Question.
2
+ //
3
+ // A poll travels as a Question whose options live in `oneOf` (pick one) or
4
+ // `anyOf` (pick several). A vote is not a special activity: it arrives as an
5
+ // ordinary reply carrying the option's `name` and no content. So the fediverse
6
+ // carries the votes and the author's server keeps the count — which means when
7
+ // the poll is ours, the roster of who chose what is ours to hold.
8
+ //
9
+ // Everything here is pure. The roster is a plain object the caller stores and
10
+ // hands back; the tallies are always DERIVED from it rather than kept beside
11
+ // it, so a count cannot drift away from the votes it is meant to summarize.
12
+
13
+ import crypto from 'node:crypto';
14
+
15
+ /** Where the roster lives in agent state. */
16
+ export const VOTES_DOC = 'poll-votes.json';
17
+
18
+ // What a poll may be. These are Mastodon's own limits, and they are the ones
19
+ // clients read out of the instance document to draw their poll composer, so a
20
+ // number we chose differently would be a number the composer then lets someone
21
+ // exceed. They live here rather than beside the client API because every way
22
+ // in has to honour them: an option is matched BY NAME when the vote comes
23
+ // back, so a long title is one something else in the network may truncate and
24
+ // hand back unrecognizable.
25
+ export const MAX_OPTIONS = 4;
26
+ export const MAX_OPTION_CHARS = 50;
27
+ export const MIN_SECONDS = 5 * 60;
28
+ export const MAX_SECONDS = 2629746; // a month, as Mastodon counts one
29
+
30
+ // A voter is recorded as a hash of their actor id rather than the id itself.
31
+ // The roster is rewritten on every vote inside a document we serialize whole,
32
+ // and a poll that travels would otherwise grow a list of everyone who took
33
+ // part. The hash is stable, which is all a duplicate check needs.
34
+ export function voterKey(actor) {
35
+ return crypto.createHash('sha256').update(String(actor)).digest('hex').slice(0, 16);
36
+ }
37
+
38
+ /**
39
+ * Does this note have the shape of a vote? A name, and no content: that is the
40
+ * whole convention. Anything carrying prose is a reply somebody wrote, and
41
+ * swallowing it into a tally would lose it.
42
+ */
43
+ export function isVoteShape(note) {
44
+ if (!note?.name || typeof note.name !== 'string') return false;
45
+ const text = String(note.content ?? '').replace(/<[^>]*>/gu, '').trim();
46
+ return text === '';
47
+ }
48
+
49
+ /** Which option is this the name of? -1 when it names none of them. */
50
+ export function optionIndex(poll, name) {
51
+ const opts = poll?.options || [];
52
+ return opts.findIndex(o => o.title === String(name));
53
+ }
54
+
55
+ /** Closed outright, or past its end time. */
56
+ export function pollClosed(poll, now = Date.now()) {
57
+ if (!poll) return false;
58
+ if (poll.closed) return true;
59
+ return !!poll.expiresAt && Date.parse(poll.expiresAt) <= now;
60
+ }
61
+
62
+ /**
63
+ * Record one choice. Returns the roster to store and whether anything changed
64
+ * — an unchanged roster must not cost a rewrite of the Question, which is the
65
+ * one expense a busy poll can run up on the pod.
66
+ *
67
+ * A single-choice poll takes the first answer and ignores the rest, which is
68
+ * how a voter changing their mind is refused rather than counted twice. A
69
+ * multiple-choice poll unions the answers, because each choice arrives as its
70
+ * own reply.
71
+ */
72
+ export function addVote(roster, key, index, { multiple = false } = {}) {
73
+ const had = roster[key] || [];
74
+ if (had.length && !multiple) return { roster, changed: false };
75
+ if (had.includes(index)) return { roster, changed: false };
76
+ return { roster: { ...roster, [key]: [...had, index] }, changed: true };
77
+ }
78
+
79
+ /**
80
+ * The counts, derived. `votes` is answers given, `voters` is people who gave
81
+ * them — the same number until a poll takes several answers each.
82
+ */
83
+ export function tallyOf(roster, optionCount) {
84
+ const counts = new Array(optionCount).fill(0);
85
+ let voters = 0;
86
+ for (const picks of Object.values(roster || {})) {
87
+ let counted = false;
88
+ for (const i of picks) {
89
+ if (i >= 0 && i < optionCount) { counts[i]++; counted = true; }
90
+ }
91
+ if (counted) voters++;
92
+ }
93
+ return { counts, voters, votes: counts.reduce((n, c) => n + c, 0) };
94
+ }
95
+
96
+ /** The poll record with its counts brought up to date from the roster. */
97
+ export function withTally(poll, roster) {
98
+ const opts = poll?.options || [];
99
+ const { counts, voters } = tallyOf(roster, opts.length);
100
+ return {
101
+ ...poll,
102
+ options: opts.map((o, i) => ({ ...o, votes: counts[i] })),
103
+ votersCount: voters,
104
+ };
105
+ }