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.
- package/dist/claims.js +1 -1
- package/dist/handler.d.ts +1 -1
- package/dist/handler.js +35 -2
- package/dist/handler.jsonld +1 -1
- package/lib/admin.mjs +3 -3
- package/lib/c2s.mjs +27 -2
- package/lib/deliver.mjs +6 -0
- package/lib/embed.mjs +1 -0
- package/lib/export-collections.mjs +1 -1
- package/lib/front-core.mjs +272 -49
- package/lib/gateway-core.mjs +3 -7
- package/lib/httpsig.mjs +33 -2
- package/lib/import.mjs +1 -1
- package/lib/intake.mjs +357 -118
- package/lib/links.mjs +6 -32
- package/lib/mastoapi.mjs +449 -38
- package/lib/migrate.mjs +4 -18
- package/lib/pod/README.md +61 -0
- package/lib/pod/actor.mjs +96 -0
- package/lib/pod/collection.mjs +77 -0
- package/lib/pod/containers.mjs +136 -0
- package/lib/pod/discovery.mjs +46 -0
- package/lib/pod/featured.mjs +18 -0
- package/lib/pod/followers.mjs +24 -0
- package/lib/pod/following.mjs +10 -0
- package/lib/pod/http.mjs +74 -0
- package/lib/pod/inbox.mjs +144 -0
- package/lib/pod/links.mjs +35 -0
- package/lib/pod/media.mjs +27 -0
- package/lib/pod/notes.mjs +80 -0
- package/lib/pod/notifications.mjs +89 -0
- package/lib/pod/outbox.mjs +18 -0
- package/lib/pod/package.json +19 -0
- package/lib/pod/policy.mjs +58 -0
- package/lib/pod/private.mjs +19 -0
- package/lib/pod/root.mjs +63 -0
- package/lib/pod/state.mjs +39 -0
- package/lib/pod/transport.mjs +477 -0
- package/lib/pod/urls.mjs +71 -0
- package/lib/polls.mjs +105 -0
- package/lib/publisher.mjs +357 -188
- package/lib/remote.mjs +30 -395
- package/lib/social.mjs +20 -9
- package/lib/storage.mjs +2 -2
- package/lib/store.mjs +8 -1
- package/lib/tagfeed.mjs +19 -2
- package/lib/wire.mjs +87 -67
- package/package.json +1 -1
- package/run-agent.mjs +20 -39
- package/web/admin/admin.js +141 -74
- package/web/admin/bar.css +9 -13
- package/web/admin/bar.js +2 -2
- package/web/admin/client/client.js +1 -1
- package/web/admin/client/index.html +5 -6
- package/web/admin/index.html +53 -65
- package/web/admin/setup/index.html +2 -1
- package/web/admin/setup/setup.js +1 -1
- package/web/admin/tokens.css +56 -0
- package/web/app/README.md +77 -0
- package/web/app/admin-facade.mjs +549 -0
- package/web/app/agent.mjs +490 -0
- package/web/app/atproto-browser.mjs +86 -0
- package/web/app/boot.mjs +340 -0
- package/web/app/deliver-relay.mjs +72 -0
- package/web/app/dist/boot.js +34067 -0
- package/web/app/dist/boot.js.map +7 -0
- package/web/app/dist/sw.js +53745 -0
- package/web/app/dist/sw.js.map +7 -0
- package/web/app/fediacct-browser.mjs +146 -0
- package/web/app/idb-kv.mjs +35 -0
- package/web/app/index.html +203 -0
- package/web/app/index.html~ +152 -0
- package/web/app/keys-browser.mjs +46 -0
- package/web/app/keystore.mjs +99 -0
- package/web/app/oidc-session.mjs +189 -0
- package/web/app/pod-auth.mjs +248 -0
- package/web/app/pod-remote.mjs +84 -0
- package/web/app/shims/_globals.mjs +2 -0
- package/web/app/shims/fedify-sig.mjs +77 -0
- package/web/app/shims/node-crypto.mjs +113 -0
- package/web/app/shims/node-fs.mjs +17 -0
- package/web/app/shims/node-path.mjs +19 -0
- package/web/app/shims/node-url.mjs +7 -0
- package/web/app/shims/prelude.js +58 -0
- package/web/app/shims/safefetch.mjs +58 -0
- package/web/app/shims/web-push.mjs +14 -0
- package/web/app/signup.mjs +248 -0
- package/web/app/site/_headers +9 -0
- package/web/app/site/_redirects +21 -0
- package/web/app/site/admin/admin.js +1181 -0
- package/web/app/site/admin/bar.css +60 -0
- package/web/app/site/admin/bar.js +61 -0
- package/web/app/site/admin/client/client.js +66 -0
- package/web/app/site/admin/client/index.html +43 -0
- package/web/app/site/admin/index.html +556 -0
- package/web/app/site/admin/setup/index.html +191 -0
- package/web/app/site/admin/setup/setup.js +308 -0
- package/web/app/site/admin/tokens.css +56 -0
- package/web/app/site/admin/window.css +55 -0
- package/web/app/site/admin/window.js +136 -0
- package/web/app/site/app/404.html +40 -0
- package/web/app/site/app/_headers +2 -0
- package/web/app/site/app/apple-touch-icon.png +0 -0
- package/web/app/site/app/assets/ICONS-DIPJeU0C.js +2 -0
- package/web/app/site/app/assets/ICONS-DIPJeU0C.js.map +1 -0
- package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js +183 -0
- package/web/app/site/app/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
- package/web/app/site/app/assets/Temml-CCjoacWt.woff2 +0 -0
- package/web/app/site/app/assets/__vite-browser-external-C7iut881.js +2 -0
- package/web/app/site/app/assets/__vite-browser-external-C7iut881.js.map +1 -0
- package/web/app/site/app/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
- package/web/app/site/app/assets/catch-up-O5q5C75Z.png +0 -0
- package/web/app/site/app/assets/chunk-aKtaBQYM.js +1 -0
- package/web/app/site/app/assets/compose-CFEx7wb4.js +2 -0
- package/web/app/site/app/assets/compose-CFEx7wb4.js.map +1 -0
- package/web/app/site/app/assets/compose-ZJN5xSb4.js +35 -0
- package/web/app/site/app/assets/compose-ZJN5xSb4.js.map +1 -0
- package/web/app/site/app/assets/debug.module-YFQgwnIr.js +52 -0
- package/web/app/site/app/assets/debug.module-YFQgwnIr.js.map +1 -0
- package/web/app/site/app/assets/dom-p1YcJ5Rw.js +2 -0
- package/web/app/site/app/assets/dom-p1YcJ5Rw.js.map +1 -0
- package/web/app/site/app/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
- package/web/app/site/app/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
- package/web/app/site/app/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
- package/web/app/site/app/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
- package/web/app/site/app/assets/icons/add-circle-line-WD0mwORD.js +1 -0
- package/web/app/site/app/assets/icons/alert-line-CpFUMf9s.js +1 -0
- package/web/app/site/app/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
- package/web/app/site/app/assets/icons/announcement-line-5nwaJFJm.js +1 -0
- package/web/app/site/app/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
- package/web/app/site/app/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
- package/web/app/site/app/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
- package/web/app/site/app/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
- package/web/app/site/app/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
- package/web/app/site/app/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
- package/web/app/site/app/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
- package/web/app/site/app/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
- package/web/app/site/app/assets/icons/at-line-CF66Bznk.js +1 -0
- package/web/app/site/app/assets/icons/attachment-line-BY8It-l9.js +1 -0
- package/web/app/site/app/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
- package/web/app/site/app/assets/icons/board-line-C0UWXyA6.js +1 -0
- package/web/app/site/app/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
- package/web/app/site/app/assets/icons/building-5-line-Dd59MzbO.js +1 -0
- package/web/app/site/app/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
- package/web/app/site/app/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
- package/web/app/site/app/assets/icons/calendar-month-line-vommts2C.js +1 -0
- package/web/app/site/app/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
- package/web/app/site/app/assets/icons/camera-line-Cv69MEei.js +1 -0
- package/web/app/site/app/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
- package/web/app/site/app/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
- package/web/app/site/app/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
- package/web/app/site/app/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
- package/web/app/site/app/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
- package/web/app/site/app/assets/icons/clipboard-line-BBp364gJ.js +1 -0
- package/web/app/site/app/assets/icons/close-circle-line-CICdiuGz.js +1 -0
- package/web/app/site/app/assets/icons/close-line-DYrFWcjv.js +1 -0
- package/web/app/site/app/assets/icons/cloud-line-tiQAbah8.js +1 -0
- package/web/app/site/app/assets/icons/code-line-fAl8iiV3.js +1 -0
- package/web/app/site/app/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
- package/web/app/site/app/assets/icons/copy-2-line-CLW69yF5.js +1 -0
- package/web/app/site/app/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
- package/web/app/site/app/assets/icons/document-3-line-DBGNhAg2.js +1 -0
- package/web/app/site/app/assets/icons/document-line-C7mb9sb7.js +1 -0
- package/web/app/site/app/assets/icons/down-line-BGqSSThd.js +1 -0
- package/web/app/site/app/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
- package/web/app/site/app/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
- package/web/app/site/app/assets/icons/exit-line-Dc07BS-x.js +1 -0
- package/web/app/site/app/assets/icons/external-link-line-BvpSkmjO.js +1 -0
- package/web/app/site/app/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
- package/web/app/site/app/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
- package/web/app/site/app/assets/icons/filter-2-line-B_mR2usy.js +1 -0
- package/web/app/site/app/assets/icons/filter-line-C_nm-3cU.js +1 -0
- package/web/app/site/app/assets/icons/flag-1-line-C2HB05I0.js +1 -0
- package/web/app/site/app/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
- package/web/app/site/app/assets/icons/formula-line-DLrt2d6j.js +1 -0
- package/web/app/site/app/assets/icons/grid-line-CgUmEikf.js +1 -0
- package/web/app/site/app/assets/icons/group-line-C5fnX0wU.js +1 -0
- package/web/app/site/app/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
- package/web/app/site/app/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
- package/web/app/site/app/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
- package/web/app/site/app/assets/icons/heart-line-CJ7K47J4.js +1 -0
- package/web/app/site/app/assets/icons/history-2-line-kWWvFzqv.js +1 -0
- package/web/app/site/app/assets/icons/history-line-bGQiwbTH.js +1 -0
- package/web/app/site/app/assets/icons/home-3-line-fRU1zeLG.js +1 -0
- package/web/app/site/app/assets/icons/information-line-iNk6-sOY.js +1 -0
- package/web/app/site/app/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
- package/web/app/site/app/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
- package/web/app/site/app/assets/icons/layout-5-line-B_znt5wO.js +1 -0
- package/web/app/site/app/assets/icons/left-line-eUw0tya4.js +1 -0
- package/web/app/site/app/assets/icons/lightning-line-DaKoDdix.js +1 -0
- package/web/app/site/app/assets/icons/link-2-line-DCqLmMA7.js +1 -0
- package/web/app/site/app/assets/icons/list-check-line-BAxosBh9.js +1 -0
- package/web/app/site/app/assets/icons/lock-line-CPoaXeUW.js +1 -0
- package/web/app/site/app/assets/icons/mail-line-B0P8aa1L.js +1 -0
- package/web/app/site/app/assets/icons/moon-line-CEqNAToP.js +1 -0
- package/web/app/site/app/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
- package/web/app/site/app/assets/icons/more-3-line-BIiVjYTz.js +1 -0
- package/web/app/site/app/assets/icons/notification-line-BZZq2Gtu.js +1 -0
- package/web/app/site/app/assets/icons/pencil-line-CKh8-A1v.js +1 -0
- package/web/app/site/app/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
- package/web/app/site/app/assets/icons/pin-line-DpTolyhs.js +1 -0
- package/web/app/site/app/assets/icons/play-fill-B0-G_8lX.js +1 -0
- package/web/app/site/app/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
- package/web/app/site/app/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
- package/web/app/site/app/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
- package/web/app/site/app/assets/icons/radar-line-CKyroTth.js +1 -0
- package/web/app/site/app/assets/icons/react-line-CoIKhhtK.js +1 -0
- package/web/app/site/app/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
- package/web/app/site/app/assets/icons/right-line-B3KWJaqc.js +1 -0
- package/web/app/site/app/assets/icons/rocket-line-4KZl537D.js +1 -0
- package/web/app/site/app/assets/icons/round-fill-C69NTrZ0.js +1 -0
- package/web/app/site/app/assets/icons/round-line-B9k_1uo-.js +1 -0
- package/web/app/site/app/assets/icons/route-line-B28ReHcE.js +1 -0
- package/web/app/site/app/assets/icons/rows-4-line-CrBniagM.js +1 -0
- package/web/app/site/app/assets/icons/scan-line-DSf74YdP.js +1 -0
- package/web/app/site/app/assets/icons/search-2-line-B6geIU1z.js +1 -0
- package/web/app/site/app/assets/icons/settings-3-line-azJwK47w.js +1 -0
- package/web/app/site/app/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
- package/web/app/site/app/assets/icons/share-2-line-D5MxWjrm.js +1 -0
- package/web/app/site/app/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
- package/web/app/site/app/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
- package/web/app/site/app/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
- package/web/app/site/app/assets/icons/time-line-BF3eet1v.js +1 -0
- package/web/app/site/app/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
- package/web/app/site/app/assets/icons/translate-line-BFWol2Ce.js +1 -0
- package/web/app/site/app/assets/icons/unlock-line-X_x5vwv3.js +1 -0
- package/web/app/site/app/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
- package/web/app/site/app/assets/icons/user-4-line-BVSynZCp.js +1 -0
- package/web/app/site/app/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
- package/web/app/site/app/assets/icons/user-add-line-BbXdHG4z.js +1 -0
- package/web/app/site/app/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
- package/web/app/site/app/assets/icons/user-follow-line-BXux0wSd.js +1 -0
- package/web/app/site/app/assets/icons/user-star-line-DdYlpHLU.js +1 -0
- package/web/app/site/app/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
- package/web/app/site/app/assets/icons/user-x-line-DIDgNlPH.js +1 -0
- package/web/app/site/app/assets/icons/volume-line-Bs390KQt.js +1 -0
- package/web/app/site/app/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
- package/web/app/site/app/assets/icons/walk-line-yNXfB-md.js +1 -0
- package/web/app/site/app/assets/icons/world-2-line-CenkGIwj.js +1 -0
- package/web/app/site/app/assets/icons/zoom-in-line-CaENUphf.js +1 -0
- package/web/app/site/app/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
- package/web/app/site/app/assets/instances-BNPptnn-.json +333 -0
- package/web/app/site/app/assets/locales/ar-SA-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/ca-ES-DHRW86Tz.js +1 -0
- package/web/app/site/app/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
- package/web/app/site/app/assets/locales/de-DE-ByRPMRFy.js +1 -0
- package/web/app/site/app/assets/locales/eo-UY-CPC7wLCS.js +1 -0
- package/web/app/site/app/assets/locales/es-ES-dJa0Fd9t.js +1 -0
- package/web/app/site/app/assets/locales/eu-ES-C3djlPR2.js +1 -0
- package/web/app/site/app/assets/locales/fa-IR-DTT490Ck.js +1 -0
- package/web/app/site/app/assets/locales/fi-FI-B23MCPuz.js +1 -0
- package/web/app/site/app/assets/locales/fr-FR-BcaQsO5C.js +1 -0
- package/web/app/site/app/assets/locales/gl-ES-BR-CVKBN.js +1 -0
- package/web/app/site/app/assets/locales/he-IL-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/hu-HU-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/it-IT-C1136UFV.js +1 -0
- package/web/app/site/app/assets/locales/ja-JP-CRZmRAhD.js +1 -0
- package/web/app/site/app/assets/locales/kab-Cz8xH_TT.js +1 -0
- package/web/app/site/app/assets/locales/ko-KR-CG9UiY4o.js +1 -0
- package/web/app/site/app/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
- package/web/app/site/app/assets/locales/nb-NO-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
- package/web/app/site/app/assets/locales/oc-FR-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/pl-PL-DkwGXf6P.js +1 -0
- package/web/app/site/app/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
- package/web/app/site/app/assets/locales/pt-BR-nGL0HdFS.js +1 -0
- package/web/app/site/app/assets/locales/pt-PT-Cp17aj2z.js +1 -0
- package/web/app/site/app/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
- package/web/app/site/app/assets/locales/th-TH-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/tok-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/tr-TR-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/locales/uk-UA-C5E3dFBf.js +1 -0
- package/web/app/site/app/assets/locales/zh-CN-BHJi7_et.js +1 -0
- package/web/app/site/app/assets/locales/zh-TW-CfheRbCu.js +1 -0
- package/web/app/site/app/assets/main-BdqNbG-a.js +26 -0
- package/web/app/site/app/assets/main-BdqNbG-a.js.map +1 -0
- package/web/app/site/app/assets/mock-home-DGhR__He.js +2 -0
- package/web/app/site/app/assets/mock-home-DGhR__He.js.map +1 -0
- package/web/app/site/app/assets/multi-column-ROck0NVt.jpg +0 -0
- package/web/app/site/app/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
- package/web/app/site/app/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
- package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js +2 -0
- package/web/app/site/app/assets/polyfill-force-H8sUkLGp.js.map +1 -0
- package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js +20 -0
- package/web/app/site/app/assets/pwa-viewport-ogKbF51K.js.map +1 -0
- package/web/app/site/app/assets/style-B1iLPTPR.css +2 -0
- package/web/app/site/app/assets/temml-DuyIQMei.js +97 -0
- package/web/app/site/app/assets/temml-DuyIQMei.js.map +1 -0
- package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js +2 -0
- package/web/app/site/app/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
- package/web/app/site/app/assets/year-in-posts-B2lywqdx.png +0 -0
- package/web/app/site/app/assets/year-in-posts-DJv_star.js +3 -0
- package/web/app/site/app/assets/year-in-posts-DJv_star.js.map +1 -0
- package/web/app/site/app/compose/index.html +20 -0
- package/web/app/site/app/favicon.ico +0 -0
- package/web/app/site/app/index.html +73 -0
- package/web/app/site/app/logo-192.png +0 -0
- package/web/app/site/app/logo-512.png +0 -0
- package/web/app/site/app/logo-badge-72.png +0 -0
- package/web/app/site/app/logo-maskable-512.png +0 -0
- package/web/app/site/app/logo-monochrome-512.png +0 -0
- package/web/app/site/app/logo-monochrome-maskable-512.png +0 -0
- package/web/app/site/app/manifest.webmanifest +1 -0
- package/web/app/site/app/og-image-2.jpg +0 -0
- package/web/app/site/app/og-image.png +0 -0
- package/web/app/site/app/robots.txt +2 -0
- package/web/app/site/app/version.json +1 -0
- package/web/app/site/boot.js +34067 -0
- package/web/app/site/index.html +203 -0
- package/web/app/site/sw.js +53745 -0
- package/web/app/sw-src.mjs +205 -0
- package/web/front/#new-account.html# +43 -0
- package/web/front/admin.html +1 -81
- package/web/front/admin.js +85 -0
- package/web/front/new-account.html +1 -9
- package/web/front/new-account.js +13 -0
- package/web/front/run.html +1 -92
- package/web/front/run.js +107 -0
- 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
|
+
}
|
package/lib/pod/urls.mjs
ADDED
|
@@ -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
|
+
}
|