fedipod 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/bin/fedipod.mjs +3 -5
- package/cli.md +13 -0
- package/groups.md +11 -0
- package/gui.md +15 -1
- package/lib/admin.mjs +3 -3
- package/lib/deliver.mjs +6 -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 +335 -118
- package/lib/links.mjs +6 -32
- package/lib/mastoapi.mjs +372 -35
- 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/publisher.mjs +112 -183
- package/lib/remote.mjs +30 -395
- package/lib/social.mjs +18 -9
- package/lib/storage.mjs +2 -2
- package/lib/store.mjs +8 -1
- package/lib/tagfeed.mjs +19 -2
- package/lib/wire.mjs +48 -67
- package/package.json +9 -2
- package/run-agent.mjs +14 -39
- package/scripts/build-app.mjs +64 -0
- package/scripts/check-pod-calls.mjs +177 -0
- package/scripts/stage-site.mjs +144 -0
- 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
package/README.md
CHANGED
|
@@ -74,6 +74,20 @@ keyword filters, scheduled posts, pinned posts (visible from other servers),
|
|
|
74
74
|
blocking and muting from the client, custom emojis, and web-push
|
|
75
75
|
notifications that reach you while the client is closed.
|
|
76
76
|
|
|
77
|
+
<!-- CLAUDE 2026-09-09 — the browser build does not have three of the things
|
|
78
|
+
this paragraph promises, and it says so honestly now rather than
|
|
79
|
+
advertising them. Written as a first draft in your voice; rework or move
|
|
80
|
+
it wherever you think it belongs. Delete these markers when done. -->
|
|
81
|
+
Two of those are the installed agent only. **Scheduled posts** need something
|
|
82
|
+
running between now and the time you picked, and in the browser build nothing
|
|
83
|
+
is — so it refuses a scheduled post rather than accepting one it would silently
|
|
84
|
+
drop. **Web-push notifications** likewise: the browser build has no push
|
|
85
|
+
service behind it, so it does not advertise one and your client hides the
|
|
86
|
+
toggle instead of showing one that does nothing. Both work on the installed
|
|
87
|
+
agent. **Custom emojis** you receive are rendered everywhere; the picker for
|
|
88
|
+
sending your own is not built yet, on either.
|
|
89
|
+
<!-- /CLAUDE -->
|
|
90
|
+
|
|
77
91
|
Polls: up to four options, one answer or several, running from five minutes to
|
|
78
92
|
a month.
|
|
79
93
|
|
|
@@ -85,6 +99,12 @@ a month.
|
|
|
85
99
|
custom instance.
|
|
86
100
|
- **Streaming**: the agent serves the Mastodon streaming API
|
|
87
101
|
(`/api/v1/streaming`, WebSocket) so clients update live instead of polling.
|
|
102
|
+
<!-- CLAUDE 2026-09-09 — true of the installed agent; the browser build is a
|
|
103
|
+
service worker and cannot hold a socket. It advertises no streaming URL,
|
|
104
|
+
so clients fall back to polling on their own. Delete markers when done. -->
|
|
105
|
+
The browser build cannot: a service worker answers fetches, not sockets, so it
|
|
106
|
+
advertises no streaming URL and clients poll instead.
|
|
107
|
+
<!-- /CLAUDE -->
|
|
88
108
|
|
|
89
109
|
## Bluesky and ATProto
|
|
90
110
|
|
package/bin/fedipod.mjs
CHANGED
|
@@ -964,13 +964,12 @@ if (cmd === 'up') {
|
|
|
964
964
|
let copied;
|
|
965
965
|
try {
|
|
966
966
|
copied = await copyPrivateHalf({
|
|
967
|
-
from: { state: agent.privateStorage(cred, 'state')
|
|
968
|
-
to: { state: agent.privateStorage(destCred, 'state')
|
|
967
|
+
from: { state: agent.privateStorage(cred, 'state') },
|
|
968
|
+
to: { state: agent.privateStorage(destCred, 'state') },
|
|
969
969
|
log: (...a) => console.log('[state]', ...a),
|
|
970
970
|
});
|
|
971
971
|
} catch (e) { console.error(e.message); process.exit(1); }
|
|
972
972
|
console.log(`copied ${copied.docs} state document(s)`);
|
|
973
|
-
console.log(`copied ${copied.notes} note(s)`);
|
|
974
973
|
|
|
975
974
|
// An empty source produces an empty destination, and every check above
|
|
976
975
|
// passes: nothing failed to land because nothing was sent. The command then
|
|
@@ -2071,8 +2070,7 @@ WantedBy=default.target
|
|
|
2071
2070
|
if (res.status >= 400) { console.error(body.error || `HTTP ${res.status}`); process.exit(1); }
|
|
2072
2071
|
if (body.why) { console.error(body.why); process.exit(1); }
|
|
2073
2072
|
console.log(`the pod indexed ${body.indexed} post(s); recovered ${body.recovered}`
|
|
2074
|
-
+ `${body.reblogs ? `, and marked ${body.reblogs} of them boosted` : ''}`
|
|
2075
|
-
+ `${body.rdf ? ` (${body.rdf} written back to the RDF)` : ''}`);
|
|
2073
|
+
+ `${body.reblogs ? `, and marked ${body.reblogs} of them boosted` : ''}`);
|
|
2076
2074
|
if (body.dropped) console.log(`${body.dropped} fell past the 1000-status cap`);
|
|
2077
2075
|
if (!body.landed) { console.error('the state write did NOT land — nothing is saved'); process.exit(1); }
|
|
2078
2076
|
if (!body.recovered && !has('from-notes')) {
|
package/cli.md
CHANGED
|
@@ -70,6 +70,19 @@ fedipod revoke-credential --email EMAIL # cut this machine off from the pod acc
|
|
|
70
70
|
```
|
|
71
71
|
A new password takes effect when a running agent restarts.
|
|
72
72
|
|
|
73
|
+
<!-- CLAUDE 2026-09-09 — `passwd` stopped being optional for one case, and a
|
|
74
|
+
user who hits it will want to know why here. First draft in your voice;
|
|
75
|
+
delete these markers when done. -->
|
|
76
|
+
**A client hosted somewhere else now needs one.** A client running on its own
|
|
77
|
+
site (Elk, Phanpy on someone's own domain) asks the agent to send the sign-in
|
|
78
|
+
result back to that site. Without a password there is nothing to approve that
|
|
79
|
+
with — and the agent used to do it anyway, which meant any page you happened
|
|
80
|
+
to have open could ask for the same thing and be given a key to your account.
|
|
81
|
+
So: no password, no sending credentials off this machine. Clients that live on
|
|
82
|
+
the agent itself (the bundled one, a dist in `ui/`, anything on
|
|
83
|
+
`https://localhost:<port>`) are unaffected and need no password.
|
|
84
|
+
<!-- /CLAUDE -->
|
|
85
|
+
|
|
73
86
|
## Bluesky
|
|
74
87
|
|
|
75
88
|
```
|
package/groups.md
CHANGED
|
@@ -14,6 +14,17 @@ and pick group rather than person, or take the group path on a signup page.
|
|
|
14
14
|
You can turn on join review as you create it. The group's pod must be the root
|
|
15
15
|
of its own host, so its handle resolves.
|
|
16
16
|
|
|
17
|
+
<!-- CLAUDE 2026-09-09 — nothing anywhere said this, and a browser user who
|
|
18
|
+
went looking for the group option would just not have found it. First
|
|
19
|
+
draft in your voice; move or reword as you like. Delete markers when done. -->
|
|
20
|
+
**Running a group needs the installed agent.** The in-browser build makes
|
|
21
|
+
personal identities only — the sign-up wizard has no group option, and the
|
|
22
|
+
moderation surface (join review, members, muting, the moderation queue) is not
|
|
23
|
+
part of that build. You can *join* a group from the browser exactly as from
|
|
24
|
+
anywhere else: joining is following, and that works everywhere. It is hosting
|
|
25
|
+
one that needs an install.
|
|
26
|
+
<!-- /CLAUDE -->
|
|
27
|
+
|
|
17
28
|
Being in a group also connects you to the people in it: posts from fellow
|
|
18
29
|
members reach your timeline even when you do not follow them individually,
|
|
19
30
|
so a reply in the room is something you see rather than something filed
|
package/gui.md
CHANGED
|
@@ -91,7 +91,15 @@ is marked **sign in again** rather than quietly dropped.
|
|
|
91
91
|
## Handling follow requests
|
|
92
92
|
|
|
93
93
|
New followers appear under **Follow requests** with **Accept** and **Refuse**
|
|
94
|
-
beside them; nothing is accepted without you.
|
|
94
|
+
beside them; nothing is accepted without you.
|
|
95
|
+
<!-- CLAUDE 2026-09-09 — these were visible on this page and NOWHERE else
|
|
96
|
+
until today: the client API stubbed the list empty and offered no way to
|
|
97
|
+
answer one, so no Mastodon client could show them. Delete markers when
|
|
98
|
+
read. -->
|
|
99
|
+
They show in any Mastodon client as well, now that the client API serves the
|
|
100
|
+
queue and takes both answers — before this they were visible on this page
|
|
101
|
+
alone.
|
|
102
|
+
<!-- /CLAUDE --> Groups are different — joining
|
|
95
103
|
follows the group's own moderation settings; see [Groups](groups.md).
|
|
96
104
|
|
|
97
105
|
**Accept all** answers the whole queue at once, and the identity pane's
|
|
@@ -123,6 +131,12 @@ account*); your followers arrive by themselves. Removing an alias asks
|
|
|
123
131
|
twice — servers still processing the move check it while they retry. The CSV
|
|
124
132
|
files from the old server's export are imported with the CLI; see
|
|
125
133
|
[CLI admin](cli.md).
|
|
134
|
+
<!-- CLAUDE 2026-09-09 — the browser build runs the same importer now, so
|
|
135
|
+
"with the CLI" is no longer the whole story. Reword as you like; delete
|
|
136
|
+
these markers when done. -->
|
|
137
|
+
The in-browser build runs the same importer, so a CSV can be handed to it
|
|
138
|
+
there too.
|
|
139
|
+
<!-- /CLAUDE -->
|
|
126
140
|
|
|
127
141
|
## Protecting & recovering your data
|
|
128
142
|
|
package/lib/admin.mjs
CHANGED
|
@@ -1379,15 +1379,15 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
1379
1379
|
await agent.store.flush();
|
|
1380
1380
|
const destCred = { ...cred, privateRoot: target };
|
|
1381
1381
|
const copied = await copyPrivateHalf({
|
|
1382
|
-
from: { state: agent.privateStorage(cred, 'state')
|
|
1383
|
-
to: { state: agent.privateStorage(destCred, 'state')
|
|
1382
|
+
from: { state: agent.privateStorage(cred, 'state') },
|
|
1383
|
+
to: { state: agent.privateStorage(destCred, 'state') },
|
|
1384
1384
|
log,
|
|
1385
1385
|
});
|
|
1386
1386
|
if (target) cred.privateRoot = target; else delete cred.privateRoot;
|
|
1387
1387
|
if (isCurrent(cred)) cred.layout = CURRENT_LAYOUT; else delete cred.layout;
|
|
1388
1388
|
writeJsonAtomic(path.join(agent.home, 'credential.json'), cred);
|
|
1389
1389
|
// connect() rebuilds everything that pointed at the old location —
|
|
1390
|
-
// store,
|
|
1390
|
+
// store, publisher, intake — and restarts them.
|
|
1391
1391
|
agent.store.attach(agent.privateStorage(cred, 'state'));
|
|
1392
1392
|
agent.stateLoaded = false;
|
|
1393
1393
|
await agent.connect();
|
package/lib/deliver.mjs
CHANGED
|
@@ -107,6 +107,12 @@ export class Deliverer {
|
|
|
107
107
|
current = new URL(res.headers.get('location'), current).href;
|
|
108
108
|
continue;
|
|
109
109
|
}
|
|
110
|
+
// Where this ended up, which is not always where it was asked to go.
|
|
111
|
+
// Callers compare the document's own id against the URL they REQUESTED,
|
|
112
|
+
// so without this an open redirector on a victim's origin let an
|
|
113
|
+
// attacker's server answer for a victim's URL and be believed.
|
|
114
|
+
try { Object.defineProperty(res, 'finalUrl', { value: current, configurable: true }); }
|
|
115
|
+
catch { /* a frozen Response: the caller falls back to the requested URL */ }
|
|
110
116
|
return res;
|
|
111
117
|
}
|
|
112
118
|
throw new Error(`too many redirects from ${url}`);
|
package/lib/front-core.mjs
CHANGED
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
|
|
18
18
|
import crypto from 'node:crypto';
|
|
19
19
|
import { handleDelivery } from './gateway-core.mjs';
|
|
20
|
-
import { readCapped } from './safefetch.mjs';
|
|
21
|
-
import
|
|
20
|
+
import { readCapped, safeFetch, isLoopbackHost } from './safefetch.mjs';
|
|
21
|
+
import * as podRoot from './pod/root.mjs';
|
|
22
|
+
import * as podPolicy from './pod/policy.mjs';
|
|
22
23
|
|
|
23
24
|
// The one WebFinger document, spelled out here rather than imported from
|
|
24
25
|
// wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
|
|
@@ -58,25 +59,122 @@ const OWNER_LOOKUP_MS = 5_000;
|
|
|
58
59
|
* authority on that, so when it answers, its answer decides. A server that
|
|
59
60
|
* says nothing leaves where the WebID lives as the only evidence there is.
|
|
60
61
|
*/
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const res = await fetchImpl(podBase, {
|
|
64
|
-
method: 'HEAD', signal: AbortSignal.timeout(OWNER_LOOKUP_MS),
|
|
65
|
-
});
|
|
66
|
-
return linkTargets(res?.headers?.get?.('link'), REL.owner, podBase);
|
|
67
|
-
} catch { return []; }
|
|
68
|
-
}
|
|
62
|
+
const podOwners = (podBase, fetchImpl = fetch) =>
|
|
63
|
+
podRoot.readOwnerLinks(fetchImpl, podBase, { timeoutMs: OWNER_LOOKUP_MS });
|
|
69
64
|
|
|
65
|
+
// Whether a token's WebID vouches for this pod, on the evidence of where it
|
|
66
|
+
// lives. Weak on purpose, and never used alone any more — see provesPod.
|
|
70
67
|
function webidUnderPod(webid, podHome) {
|
|
71
68
|
try { return new URL(webid).origin === new URL(podHome).origin; } catch { return false; }
|
|
72
69
|
}
|
|
73
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Whether this WebID may speak for this pod.
|
|
73
|
+
*
|
|
74
|
+
* Sharing an origin was the whole test, which is right where an origin is one
|
|
75
|
+
* person (a subdomain pod) and wrong where it is not. On a path-based server
|
|
76
|
+
* every account is on the same origin, so any account holder could attach
|
|
77
|
+
* somebody else's pod under a name of their choosing — and, worse, re-attach a
|
|
78
|
+
* name that already existed and be handed its receipt secret (S7).
|
|
79
|
+
*
|
|
80
|
+
* The pod server is the authority on who owns the pod, and it will say so in a
|
|
81
|
+
* `solid:owner` link if asked. When it answers, its answer decides. Only when
|
|
82
|
+
* it says nothing does this fall back to where the WebID lives, which is the
|
|
83
|
+
* best a silent server leaves available. This is the rule /api/agent's opt-in
|
|
84
|
+
* has used all along; attach simply never asked.
|
|
85
|
+
*/
|
|
86
|
+
async function provesPod(webid, podHome, fetchImpl) {
|
|
87
|
+
const owners = await podOwners(podHome, fetchImpl || fetch);
|
|
88
|
+
if (owners.length) return owners.includes(webid);
|
|
89
|
+
return webidUnderPod(webid, podHome);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A pod this front will read from at all.
|
|
93
|
+
//
|
|
94
|
+
// The front reads `rec.podHome + <the rest of the request path>` and, on the
|
|
95
|
+
// CSS server component, that read goes straight into the resource store with
|
|
96
|
+
// no access control of its own — so a row whose podHome names the FRONT is a
|
|
97
|
+
// row that can read the server's own internals, `/.internal/fedipod/directory/`
|
|
98
|
+
// included, where every user's receipt secret is kept (S2). The front is not a
|
|
99
|
+
// pod and never was one; saying so outright is the fix, and it closes the
|
|
100
|
+
// self-recursion of S8 with the same line.
|
|
101
|
+
function podHomeProblem(podHome, frontOrigin) {
|
|
102
|
+
let u;
|
|
103
|
+
try { u = new URL(podHome); } catch { return 'podHome is not a URL'; }
|
|
104
|
+
if (frontOrigin) {
|
|
105
|
+
try {
|
|
106
|
+
if (u.origin === new URL(frontOrigin).origin) {
|
|
107
|
+
return 'a pod cannot live on this gateway — name the pod that holds your data';
|
|
108
|
+
}
|
|
109
|
+
} catch { /* no usable frontOrigin: the other checks still apply */ }
|
|
110
|
+
}
|
|
111
|
+
// Nothing may reach a server's private trees, whatever origin they are on.
|
|
112
|
+
if (/(^|\/)\.internal(\/|$)/u.test(u.pathname) || /(^|\/)\.\.(\/|$)/u.test(u.pathname)) {
|
|
113
|
+
return 'that is not a pod address';
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const RELAY_MAX_REQUESTS = 20;
|
|
119
|
+
const RELAY_MAX_BODY = 1024 * 1024;
|
|
120
|
+
const RELAY_TIMEOUT_MS = 8_000;
|
|
121
|
+
// The only headers a relayed request may carry to the remote server. Host
|
|
122
|
+
// comes from the URL; the user agent from safeFetch.
|
|
123
|
+
const RELAY_HEADERS = new Set(['date', 'digest', 'signature', 'content-type', 'accept']);
|
|
124
|
+
|
|
125
|
+
const keyIdOf = (signature) => (/keyId="([^"]+)"/.exec(signature || '') || [])[1] || null;
|
|
126
|
+
|
|
127
|
+
async function relayOne(item, rec, fetchImpl) {
|
|
128
|
+
const url = String(item?.url || '');
|
|
129
|
+
const method = String(item?.method || 'POST').toUpperCase();
|
|
130
|
+
if (method !== 'GET' && method !== 'POST') return { url, status: 0, error: 'method must be GET or POST' };
|
|
131
|
+
let u;
|
|
132
|
+
try { u = new URL(url); } catch { return { url, status: 0, error: 'not a URL' }; }
|
|
133
|
+
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && process.env.AP_ALLOW_PRIVATE_TARGETS === '1')) {
|
|
134
|
+
return { url, status: 0, error: 'https only' };
|
|
135
|
+
}
|
|
136
|
+
const headers = {};
|
|
137
|
+
for (const [k, v] of Object.entries(item?.headers || {})) {
|
|
138
|
+
const name = k.toLowerCase();
|
|
139
|
+
if (RELAY_HEADERS.has(name) && typeof v === 'string') headers[name] = v;
|
|
140
|
+
}
|
|
141
|
+
const body = method === 'POST' ? String(item?.body ?? '') : undefined;
|
|
142
|
+
if (body !== undefined && Buffer.byteLength(body) > RELAY_MAX_BODY) return { url, status: 0, error: 'body too large' };
|
|
143
|
+
const keyId = keyIdOf(headers.signature);
|
|
144
|
+
if (method === 'POST' && !keyId) return { url, status: 0, error: 'a delivery must be signed' };
|
|
145
|
+
if (keyId && !keyId.startsWith(rec.actorUrl + '#')) return { url, status: 0, error: "signed with a key that is not this account's" };
|
|
146
|
+
if (headers.digest) {
|
|
147
|
+
const want = 'SHA-256=' + crypto.createHash('sha256').update(body || '').digest('base64');
|
|
148
|
+
if (headers.digest !== want) return { url, status: 0, error: 'digest does not match the body' };
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const res = await safeFetch(url, { method, headers, body, signal: AbortSignal.timeout(RELAY_TIMEOUT_MS) }, fetchImpl);
|
|
152
|
+
const out = { url, status: res.status };
|
|
153
|
+
// The far server asking to be left alone has to reach the agent that will
|
|
154
|
+
// do the asking again. Without this the browser build could not honour a
|
|
155
|
+
// Retry-After at all — every delivery it makes goes through here — and fell
|
|
156
|
+
// back to its own ladder against a server that had already said how long.
|
|
157
|
+
const retryAfter = res.headers.get('retry-after');
|
|
158
|
+
if (retryAfter) out.retryAfter = retryAfter;
|
|
159
|
+
if (method === 'GET') {
|
|
160
|
+
out.contentType = res.headers.get('content-type') || null;
|
|
161
|
+
out.body = await readCapped(res, RELAY_MAX_BODY);
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
} catch (e) { return { url, status: 0, error: e.message }; }
|
|
165
|
+
}
|
|
166
|
+
|
|
74
167
|
const j = (status, obj, ct = 'application/json') =>
|
|
75
168
|
({ status, headers: { 'content-type': ct, 'cache-control': 'no-store' }, body: JSON.stringify(obj) });
|
|
76
169
|
const notFound = () => ({ status: 404, headers: { 'content-type': 'text/plain' }, body: 'not found\n' });
|
|
77
170
|
|
|
78
171
|
// A user's public base on the front, a 1:1 mirror of their pod home.
|
|
79
|
-
|
|
172
|
+
// The directory key is the FULL fediverse address — handle@host — never the
|
|
173
|
+
// bare handle, which is not unique: two different pods can each have a "me".
|
|
174
|
+
// A fronted identity answers at this front's host; a mail-door identity at its
|
|
175
|
+
// own pod's host. The `/u/` path segment carries that same key.
|
|
176
|
+
export const addressKey = (handle, host) => `${handle}@${host}`;
|
|
177
|
+
export const userBase = (frontOrigin, key) => `${frontOrigin}/u/${encodeURIComponent(key)}/`;
|
|
80
178
|
|
|
81
179
|
// Rewrite every occurrence of the pod home to the front base (or back). The
|
|
82
180
|
// bodies are JSON, so a whole-string swap rewrites ids wherever they appear —
|
|
@@ -90,7 +188,7 @@ function parseUserPath(pathname) {
|
|
|
90
188
|
}
|
|
91
189
|
|
|
92
190
|
// Top-level paths a handle may not take, so a name never shadows a route.
|
|
93
|
-
const RESERVED = new Set(['u', 'api', 'signup', 'new-account', 'run', 'admin', 'gateway',
|
|
191
|
+
const RESERVED = new Set(['u', 'api', 'signup', 'new-account', 'run', 'admin', 'roster', 'gateway',
|
|
94
192
|
'gw', 'well-known', 'inbox', 'outbox', 'actor', 'install']);
|
|
95
193
|
|
|
96
194
|
// Why a handle is unusable, or null when it is fine. Lowercase letters, digits
|
|
@@ -114,19 +212,12 @@ function handleProblem(h) {
|
|
|
114
212
|
const POLICY_TTL_MS = 5 * 60_000;
|
|
115
213
|
const policyCache = new Map(); // podHome -> { at, policy }
|
|
116
214
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const res = await fetchImpl(rec.podHome + 'ap/gateway-policy.json',
|
|
124
|
-
{ headers: { accept: 'application/json' } });
|
|
125
|
-
if (res && res.status < 400) policy = JSON.parse(await readCapped(res, 256 * 1024));
|
|
126
|
-
} catch { /* unpublished or unreachable: the row's own fields stand */ }
|
|
127
|
-
policyCache.set(key, { at: Date.now(), policy });
|
|
128
|
-
return policy;
|
|
129
|
-
}
|
|
215
|
+
// The cache is OURS, not the library's: on a warm serverless container one Map
|
|
216
|
+
// is shared across every user this front serves, so what it is keyed by is a
|
|
217
|
+
// correctness property. Keyed by podHome, it is one person's policy under one
|
|
218
|
+
// person's key.
|
|
219
|
+
const policyFor = (rec, fetchImpl = fetch) =>
|
|
220
|
+
podPolicy.read(fetchImpl, rec.podHome, { cache: policyCache, ttlMs: POLICY_TTL_MS });
|
|
130
221
|
|
|
131
222
|
// The verified-delivery identity for a directory record: what gateway-core
|
|
132
223
|
// needs to check what concerns the user and to append to their pod inbox.
|
|
@@ -152,8 +243,61 @@ function identFor(rec, policy = null) {
|
|
|
152
243
|
// listDirectory() -> { handle: record } every row, for the admin roster
|
|
153
244
|
// removeDirectory(handle) -> boolean drop a row; false when a seeded row remains
|
|
154
245
|
// podPut(url, body, ct) -> boolean append to a user's pod (per-user cred inside)
|
|
155
|
-
// podGet(url) -> Response
|
|
246
|
+
// podGet(url, { podHome }) -> Response read a user's pod (public reads; plain fetch
|
|
247
|
+
// is fine). `podHome` is the row's own pod, for
|
|
248
|
+
// an adapter that reads a store directly and so
|
|
249
|
+
// must confine the read itself.
|
|
156
250
|
export async function routeFront(request, ctx) {
|
|
251
|
+
const out = await route(request, ctx);
|
|
252
|
+
return { ...out, headers: withSecurityHeaders(out.headers, out.body) };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Every response this file makes, hardened in one place rather than in each of
|
|
256
|
+
// the dozen shapes below.
|
|
257
|
+
//
|
|
258
|
+
// `nosniff` matters most: the front serves user-supplied JSON straight from
|
|
259
|
+
// somebody's pod (the proxied actor and object documents), and without it a
|
|
260
|
+
// browser is free to decide for itself that a document is HTML and run what is
|
|
261
|
+
// inside it. The rest is the same posture the app already has — nothing may be
|
|
262
|
+
// framed, no base tag may be rewritten, no plugin content.
|
|
263
|
+
//
|
|
264
|
+
// A content-security-policy goes on the HTML only: it would mean nothing on a
|
|
265
|
+
// JSON document, and `frame-ancestors` has to be a header rather than a meta
|
|
266
|
+
// tag anyway.
|
|
267
|
+
//
|
|
268
|
+
// `script-src 'self'` is the one that matters, and it is only possible because
|
|
269
|
+
// none of these pages carries inline script any more — each has its own file and
|
|
270
|
+
// its own route above. A policy cannot tell an inline block the author wrote
|
|
271
|
+
// from one an attacker injected, so as long as any inline script has to run,
|
|
272
|
+
// every inline script may.
|
|
273
|
+
//
|
|
274
|
+
// `connect-src` allows https: because the pages sign in against the user's own
|
|
275
|
+
// pod, which is a different origin by definition and not one we can name here.
|
|
276
|
+
function withSecurityHeaders(headers = {}, body = null) {
|
|
277
|
+
const ct = String(headers['content-type'] || '');
|
|
278
|
+
const isHtml = ct.startsWith('text/html');
|
|
279
|
+
return {
|
|
280
|
+
...headers,
|
|
281
|
+
'x-content-type-options': 'nosniff',
|
|
282
|
+
'referrer-policy': 'same-origin',
|
|
283
|
+
'x-frame-options': 'SAMEORIGIN',
|
|
284
|
+
...(isHtml && body ? {
|
|
285
|
+
'content-security-policy': [
|
|
286
|
+
"default-src 'self'",
|
|
287
|
+
"script-src 'self'", // no inline script: see above
|
|
288
|
+
"style-src 'self' 'unsafe-inline'",
|
|
289
|
+
"img-src 'self' https: data:",
|
|
290
|
+
"connect-src 'self' https:", // sign-in goes to the user's own pod
|
|
291
|
+
"object-src 'none'", // no plugin content, ever
|
|
292
|
+
"base-uri 'none'", // no rewriting where relative URLs resolve
|
|
293
|
+
"frame-ancestors 'self'", // nobody else may frame these pages
|
|
294
|
+
"form-action 'self'", // a form here submits here
|
|
295
|
+
].join('; '),
|
|
296
|
+
} : {}),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function route(request, ctx) {
|
|
157
301
|
const url = new URL(request.url);
|
|
158
302
|
const { pathname } = url;
|
|
159
303
|
|
|
@@ -176,9 +320,10 @@ export async function routeFront(request, ctx) {
|
|
|
176
320
|
return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: ctx.runPage };
|
|
177
321
|
}
|
|
178
322
|
|
|
179
|
-
// The
|
|
323
|
+
// The roster page: the host reading who has accounts here. The page signs in
|
|
180
324
|
// and calls the roster API below; a deploy with no admin supplies no page.
|
|
181
|
-
|
|
325
|
+
// Off /admin, which is the owner's own record page on every agent.
|
|
326
|
+
if (pathname === '/roster') {
|
|
182
327
|
if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: {}, body: '' };
|
|
183
328
|
if (!ctx.adminPage) return notFound();
|
|
184
329
|
return { status: 200, headers: { 'content-type': 'text/html; charset=utf-8' }, body: ctx.adminPage };
|
|
@@ -197,9 +342,9 @@ export async function routeFront(request, ctx) {
|
|
|
197
342
|
.map((r) => ({
|
|
198
343
|
handle: r.handle, kind: r.kind || 'person', fronted: !r.inboxOnly,
|
|
199
344
|
podHome: r.podHome, webId: r.webId || null, actorUrl: r.actorUrl,
|
|
200
|
-
address: `@${r.handle}@${ctx.host}`,
|
|
345
|
+
address: r.address || `@${r.handle}@${ctx.host}`,
|
|
201
346
|
}))
|
|
202
|
-
.sort((a, b) => a.handle.localeCompare(b.handle));
|
|
347
|
+
.sort((a, b) => (a.address || a.handle).localeCompare(b.address || b.handle));
|
|
203
348
|
return j(200, { host: ctx.host, accounts });
|
|
204
349
|
}
|
|
205
350
|
|
|
@@ -213,11 +358,13 @@ export async function routeFront(request, ctx) {
|
|
|
213
358
|
if (webid !== ctx.adminWebId) return j(403, { error: 'that WebID is not the admin of this front' });
|
|
214
359
|
let body;
|
|
215
360
|
try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
return j(
|
|
220
|
-
|
|
361
|
+
// Revoke by full address (what the roster lists); a bare handle still works
|
|
362
|
+
// for any legacy row keyed that way.
|
|
363
|
+
const key = String(body.address || body.handle || '').replace(/^@/, '').toLowerCase();
|
|
364
|
+
if (!key || !(await ctx.lookup(key))) return j(404, { error: 'no such account' });
|
|
365
|
+
const removed = (await ctx.removeDirectory(key)) === true;
|
|
366
|
+
return j(200, removed ? { account: key, removed }
|
|
367
|
+
: { account: key, removed: false,
|
|
221
368
|
reason: 'this row is seeded in the deploy environment (FEDIPOD_DIRECTORY_JSON) — remove it there and redeploy' });
|
|
222
369
|
}
|
|
223
370
|
|
|
@@ -247,38 +394,94 @@ export async function routeFront(request, ctx) {
|
|
|
247
394
|
const podHome = String(body.podHome || '');
|
|
248
395
|
const problem = handleProblem(handle);
|
|
249
396
|
if (problem) return j(400, { error: problem });
|
|
250
|
-
|
|
251
|
-
if (
|
|
397
|
+
const podLoopback = (() => { try { return isLoopbackHost(new URL(podHome).hostname); } catch { return false; } })();
|
|
398
|
+
if (!(/^https:\/\/\S+\/$/.test(podHome) || (/^http:\/\/\S+\/$/.test(podHome) && podLoopback))) {
|
|
399
|
+
return j(400, { error: 'podHome must be an https URL ending in /' });
|
|
400
|
+
}
|
|
401
|
+
const badPod = podHomeProblem(podHome, ctx.frontOrigin);
|
|
402
|
+
if (badPod) return j(400, { error: badPod });
|
|
252
403
|
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
253
404
|
if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
|
|
254
|
-
if (!
|
|
405
|
+
if (!await provesPod(webid, podHome, ctx.fetchImpl)) {
|
|
255
406
|
return j(403, { error: 'the token proves a different pod than the one you listed' });
|
|
256
407
|
}
|
|
257
408
|
// The usual attachment keeps the user's identity on their pod and moves
|
|
258
409
|
// only the mail door here (@me@mypod). Fronted identity — actor ids on
|
|
259
410
|
// this domain — remains available to an explicit `fronted: true`.
|
|
260
411
|
const fronted = body.fronted === true;
|
|
261
|
-
|
|
262
|
-
|
|
412
|
+
// The full address is the key: a fronted name answers at this front's host,
|
|
413
|
+
// a mail-door name at its pod's host. The same handle on two pods is two
|
|
414
|
+
// distinct addresses, so it never collides.
|
|
415
|
+
let addressHost; try { addressHost = fronted ? ctx.host : new URL(podHome).host; } catch { return j(400, { error: 'bad podHome' }); }
|
|
416
|
+
// A fronted name answers at this front's host, where the handle alone is the
|
|
417
|
+
// whole address and is unique — so it keeps keying by bare handle (and its
|
|
418
|
+
// actor id stays /u/<handle>/, unchanged for existing accounts). A mail-door
|
|
419
|
+
// name answers at its own pod, where "me" is not unique, so it keys by the
|
|
420
|
+
// full address handle@podhost, which the /u/ segment then carries.
|
|
421
|
+
const key = fronted ? handle : addressKey(handle, addressHost);
|
|
422
|
+
const address = `@${handle}@${addressHost}`;
|
|
423
|
+
// "Taken" only when this exact address already belongs to a DIFFERENT pod.
|
|
424
|
+
// Re-attaching your own pod — a retry, a re-provision, a second browser — is
|
|
425
|
+
// idempotent: it updates the row in place and keeps the same door secret.
|
|
426
|
+
const prior = await ctx.lookup(key);
|
|
427
|
+
if (prior && prior.podHome !== podHome) return j(409, { error: 'that name is taken' });
|
|
428
|
+
// Re-attaching is idempotent — a retry, a re-provision, a second browser —
|
|
429
|
+
// and it hands back the EXISTING receipt secret. So it has to be the same
|
|
430
|
+
// person: matching podHome was enough on a subdomain server and, on a
|
|
431
|
+
// path-based one, meant any co-tenant could re-attach your row, flip its
|
|
432
|
+
// kind, and be given your secret (S7). A row that predates `webId` has
|
|
433
|
+
// nothing to compare and falls back to the pod check above.
|
|
434
|
+
if (prior?.webId && prior.webId !== webid) {
|
|
435
|
+
return j(409, { error: 'that name belongs to a different account on this pod' });
|
|
436
|
+
}
|
|
437
|
+
// The pod's actor lives under its AP root container, which the pod owner
|
|
438
|
+
// knows and the front does not — so take the caller's actorUrl when it is on
|
|
439
|
+
// this pod, and fall back only when none is given.
|
|
440
|
+
const claimedActor = typeof body.actorUrl === 'string' && body.actorUrl.startsWith(podHome) ? body.actorUrl : null;
|
|
441
|
+
const actorUrl = fronted ? `${userBase(ctx.frontOrigin, key)}ap/actor` : (claimedActor || podHome + 'ap/actor');
|
|
442
|
+
const hmacSecret = prior?.hmacSecret || crypto.randomBytes(32).toString('base64');
|
|
263
443
|
const record = {
|
|
264
|
-
handle, podHome, webId: webid, actorUrl,
|
|
444
|
+
handle, address, podHome, webId: webid, actorUrl,
|
|
265
445
|
kind: body.kind === 'group' ? 'group' : 'person',
|
|
266
446
|
gatewayWebId: ctx.gatewayWebId || null, hmacSecret,
|
|
267
447
|
...(fronted ? {} : { inboxOnly: true }),
|
|
268
448
|
};
|
|
269
|
-
await ctx.putDirectory(
|
|
449
|
+
await ctx.putDirectory(key, record);
|
|
270
450
|
// The secret is returned ONCE, with the one command the user runs to
|
|
271
451
|
// point their agent at this gateway.
|
|
272
|
-
const doorInbox = `${ctx.frontOrigin}
|
|
452
|
+
const doorInbox = `${userBase(ctx.frontOrigin, key)}ap/inbox/`;
|
|
273
453
|
if (fronted) {
|
|
274
|
-
return j(201, { ok: true, handle, actorUrl, frontActor: actorUrl, hmacSecret,
|
|
275
|
-
address: `@${handle}@${ctx.host}`,
|
|
454
|
+
return j(201, { ok: true, handle, address, actorUrl, frontActor: actorUrl, hmacSecret,
|
|
276
455
|
command: `fedipod gateway ${actorUrl} --secret ${hmacSecret}` });
|
|
277
456
|
}
|
|
278
|
-
return j(201, { ok: true, handle, doorInbox, hmacSecret,
|
|
457
|
+
return j(201, { ok: true, handle, address, doorInbox, hmacSecret,
|
|
279
458
|
command: `fedipod gateway ${doorInbox} --secret ${hmacSecret} --inbox-only` });
|
|
280
459
|
}
|
|
281
460
|
|
|
461
|
+
// The relay: the front sends requests a browser has already signed. A page
|
|
462
|
+
// may not set the Date or Host header, and both are inside an HTTP
|
|
463
|
+
// signature, so a browser-run agent signs and hands the request here; the
|
|
464
|
+
// front sends it with exactly the headers that were signed. It can forward
|
|
465
|
+
// a signature and cannot make one. Only the account's own key may sign,
|
|
466
|
+
// only the account's own pod may call, and nothing unsigned-for reaches
|
|
467
|
+
// the remote server.
|
|
468
|
+
if (pathname === '/api/relay' && request.method === 'POST') {
|
|
469
|
+
let body;
|
|
470
|
+
try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
|
|
471
|
+
const handle = String(body.handle || '').toLowerCase();
|
|
472
|
+
const rec = await ctx.lookup(handle);
|
|
473
|
+
if (!rec) return j(404, { error: 'no such account' });
|
|
474
|
+
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
475
|
+
if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
|
|
476
|
+
const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
|
|
477
|
+
if (!owner) return j(403, { error: "the token proves a different pod than this account's" });
|
|
478
|
+
const items = Array.isArray(body.requests) ? body.requests : [];
|
|
479
|
+
if (!items.length) return j(400, { error: 'requests must be a non-empty list' });
|
|
480
|
+
if (items.length > RELAY_MAX_REQUESTS) return j(400, { error: `at most ${RELAY_MAX_REQUESTS} requests per call` });
|
|
481
|
+
const results = await Promise.all(items.map((it) => relayOne(it, rec, ctx.fetchImpl || fetch)));
|
|
482
|
+
return j(200, { results });
|
|
483
|
+
}
|
|
484
|
+
|
|
282
485
|
// Runtime opt-in: a pod owner asks the server this front sits on to RUN
|
|
283
486
|
// their identity. Same proof as attach — a Solid-OIDC token — but the
|
|
284
487
|
// ownership check is stricter: the WebID must live UNDER the pod base, not
|
|
@@ -327,6 +530,20 @@ export async function routeFront(request, ctx) {
|
|
|
327
530
|
return j(400, { error: 'action must be opt-in or opt-out' });
|
|
328
531
|
}
|
|
329
532
|
|
|
533
|
+
// Each page's own script. They were inline until 2026-09-09; out here the
|
|
534
|
+
// pages can be served under `script-src 'self'`, which is the directive that
|
|
535
|
+
// tells running code from injected markup — and inline script defeats it by
|
|
536
|
+
// construction. Same mechanism the vendored library below has always used.
|
|
537
|
+
//
|
|
538
|
+
// The name is matched against a literal list, so nothing about the request
|
|
539
|
+
// chooses a file.
|
|
540
|
+
const mPageScript = /^\/(new-account|run|admin)\.js$/u.exec(pathname);
|
|
541
|
+
if (mPageScript) {
|
|
542
|
+
const body = ctx.pageScripts?.[`${mPageScript[1]}.js`];
|
|
543
|
+
if (!body) return notFound();
|
|
544
|
+
return { status: 200, headers: { 'content-type': 'text/javascript; charset=utf-8' }, body };
|
|
545
|
+
}
|
|
546
|
+
|
|
330
547
|
// The vendored Solid-OIDC browser library the /run and /admin pages load —
|
|
331
548
|
// served here because this function owns every path on the domain.
|
|
332
549
|
if (pathname === '/solid-oidc-client.js') {
|
|
@@ -373,9 +590,15 @@ export async function routeFront(request, ctx) {
|
|
|
373
590
|
// fixed to the front so a consumer cross-checks it consistently.
|
|
374
591
|
if (request.method !== 'GET' && request.method !== 'HEAD') return { status: 405, headers: {}, body: '' };
|
|
375
592
|
const podTarget = rec.podHome + up.rest;
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
593
|
+
// The pod this read belongs to travels with it: an adapter reading a store
|
|
594
|
+
// directly (the CSS server component) has no access control of its own and
|
|
595
|
+
// needs to be told what it may reach. See podHomeProblem above for the other
|
|
596
|
+
// half — a row that names something which is not a pod.
|
|
597
|
+
const got = await podRoot.readPublicDocument(
|
|
598
|
+
ctx.podGet || ((u) => fetch(u, { headers: { accept: AP_CT } })),
|
|
599
|
+
podTarget, { podHome: rec.podHome });
|
|
600
|
+
if (got.text === null) return { status: got.status, headers: {}, body: '' };
|
|
601
|
+
let text = got.text;
|
|
379
602
|
text = swap(text, rec.podHome, base);
|
|
380
603
|
if (up.rest === 'ap/actor') {
|
|
381
604
|
try {
|
package/lib/gateway-core.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import crypto from 'node:crypto';
|
|
16
16
|
import { verifyHttpSignature, makeSafeLoader, makeReceipt, signReceipt } from './httpsig.mjs';
|
|
17
|
+
import * as inbox from './pod/inbox.mjs';
|
|
17
18
|
|
|
18
19
|
const DEFAULT_MAX_BYTES = 512 * 1024; // mirror intake.mjs MAX_ITEM_BYTES
|
|
19
20
|
|
|
@@ -100,17 +101,12 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
|
|
|
100
101
|
? signReceipt(makeReceipt(v, { gateway: ident.gatewayWebId }), ident.hmacSecret)
|
|
101
102
|
: null;
|
|
102
103
|
const hash = sha256hex(raw);
|
|
103
|
-
const okA = await podPut
|
|
104
|
+
const okA = await inbox.appendVerifiedDelivery(podPut, ident.inboxUrl, hash, raw);
|
|
104
105
|
// A pod-write failure returns 5xx so the ORIGIN retries over its own ladder —
|
|
105
106
|
// that is what preserves the pod's buffer property without the gateway
|
|
106
107
|
// holding any state.
|
|
107
108
|
if (!okA) return { status: 502, reason: 'pod inbox write failed' };
|
|
108
|
-
|
|
109
|
-
// receipt did not is simply unverified, never lost.
|
|
110
|
-
if (receipt) {
|
|
111
|
-
await podPut(ident.inboxUrl + hash + '.receipt.json', JSON.stringify(receipt), 'application/json')
|
|
112
|
-
.catch(() => {});
|
|
113
|
-
}
|
|
109
|
+
if (receipt) await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
|
|
114
110
|
return { status: 202, reason: v.verified ? 'verified' : 'buffered-unverified' };
|
|
115
111
|
}
|
|
116
112
|
|