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,99 @@
|
|
|
1
|
+
// keystore.mjs — the signing keys, made in the browser and locked under the
|
|
2
|
+
// account password, so the copy that lives on the pod is ciphertext.
|
|
3
|
+
//
|
|
4
|
+
// The Node agent keeps keys.json (RSA + Ed25519 PEMs) on disk, 0600. A browser
|
|
5
|
+
// has no disk it can carry to the next machine, so the durable copy lives on
|
|
6
|
+
// the pod, wrapped here first: the pod's owner-only ACL keeps strangers out,
|
|
7
|
+
// and the password keeps the pod's HOST out — which the ACL cannot do, and
|
|
8
|
+
// which matters more for a key than for any other document, because whoever
|
|
9
|
+
// holds it is you to every server in the fediverse. The shape inside is exactly
|
|
10
|
+
// the agent's keys.json, so once unwrapped it is used unchanged.
|
|
11
|
+
//
|
|
12
|
+
// Each browser opens it once and keeps the opened copy in its own IndexedDB
|
|
13
|
+
// (see keys-browser.mjs). That is what lets the service worker boot itself
|
|
14
|
+
// after an idle kill with nobody present to type anything, and it is why a NEW
|
|
15
|
+
// browser asks for the password and a returning one does not.
|
|
16
|
+
//
|
|
17
|
+
// Until 2026-09-09 the wrap existed here but nothing called it: signup PUT the
|
|
18
|
+
// bare record and the loader read it plain, while this header, web/app/README.md
|
|
19
|
+
// and claude/plans/browser-agent.md all said otherwise. Now they agree.
|
|
20
|
+
|
|
21
|
+
const PEM = (der, label) => {
|
|
22
|
+
const b64 = btoa(String.fromCharCode(...new Uint8Array(der)));
|
|
23
|
+
const lines = b64.match(/.{1,64}/g).join('\n');
|
|
24
|
+
return `-----BEGIN ${label}-----\n${lines}\n-----END ${label}-----\n`;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** RSA (what Mastodon verifies) and, where the browser supports it, Ed25519
|
|
28
|
+
* (FEP-8b32 proofs). A missing Ed25519 half is not fatal — the agent mints one
|
|
29
|
+
* the same way it does for a pre-proofs keys.json, since nothing has published it. */
|
|
30
|
+
export async function generateKeys() {
|
|
31
|
+
const rsa = await crypto.subtle.generateKey(
|
|
32
|
+
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
|
33
|
+
true, ['sign', 'verify']);
|
|
34
|
+
const rec = {
|
|
35
|
+
rsa: {
|
|
36
|
+
publicPem: PEM(await crypto.subtle.exportKey('spki', rsa.publicKey), 'PUBLIC KEY'),
|
|
37
|
+
privatePem: PEM(await crypto.subtle.exportKey('pkcs8', rsa.privateKey), 'PRIVATE KEY'),
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
try {
|
|
41
|
+
const ed = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']);
|
|
42
|
+
rec.ed25519 = {
|
|
43
|
+
publicPem: PEM(await crypto.subtle.exportKey('spki', ed.publicKey), 'PUBLIC KEY'),
|
|
44
|
+
privatePem: PEM(await crypto.subtle.exportKey('pkcs8', ed.privateKey), 'PRIVATE KEY'),
|
|
45
|
+
};
|
|
46
|
+
} catch { /* no Ed25519 in this browser — the agent mints it on first run */ }
|
|
47
|
+
return rec;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const PBKDF2_ITERATIONS = 310_000; // OWASP 2023 floor for PBKDF2-HMAC-SHA256
|
|
51
|
+
|
|
52
|
+
async function deriveAesKey(password, salt, iterations) {
|
|
53
|
+
const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']);
|
|
54
|
+
return crypto.subtle.deriveKey(
|
|
55
|
+
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
|
|
56
|
+
base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const toB64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
|
|
60
|
+
const fromB64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
|
61
|
+
|
|
62
|
+
/** Whether a document read off the pod is a wrapped envelope rather than a bare
|
|
63
|
+
* keys record. Installs made before wrapping have the bare record there, and
|
|
64
|
+
* those still have to boot — the shape is what tells them apart. */
|
|
65
|
+
export function isKeyEnvelope(doc) {
|
|
66
|
+
return !!doc && doc.v === 1 && typeof doc.ct === 'string' && typeof doc.salt === 'string';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Thrown when the pod holds a wrapped key and nothing in this browser can open
|
|
70
|
+
* it. The page catches it by `code` and asks for the account password; the
|
|
71
|
+
* worker cannot ask anyone anything. */
|
|
72
|
+
export class KeyPasswordNeeded extends Error {
|
|
73
|
+
constructor() { super('this browser needs your account password to unlock the signing key'); }
|
|
74
|
+
code = 'key-password-needed';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Wrap the keys record under the password. The envelope names its own KDF and
|
|
78
|
+
* parameters, so unwrap needs only the password and this document. */
|
|
79
|
+
export async function wrapKeys(keysRecord, password) {
|
|
80
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
81
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
82
|
+
const aes = await deriveAesKey(password, salt, PBKDF2_ITERATIONS);
|
|
83
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(keysRecord));
|
|
84
|
+
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aes, plaintext);
|
|
85
|
+
return { v: 1, kdf: 'PBKDF2-SHA256', iterations: PBKDF2_ITERATIONS,
|
|
86
|
+
salt: toB64(salt), iv: toB64(iv), ct: toB64(ct) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Unwrap an envelope wrapKeys made. Throws on a wrong password (AES-GCM auth
|
|
90
|
+
* failure) — which is how a new browser tells a typo from a real key. */
|
|
91
|
+
export async function unwrapKeys(envelope, password) {
|
|
92
|
+
if (!envelope || envelope.v !== 1) throw new Error('not a key envelope');
|
|
93
|
+
const aes = await deriveAesKey(password, fromB64(envelope.salt), envelope.iterations);
|
|
94
|
+
let plaintext;
|
|
95
|
+
try {
|
|
96
|
+
plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: fromB64(envelope.iv) }, aes, fromB64(envelope.ct));
|
|
97
|
+
} catch { throw new Error('wrong password'); }
|
|
98
|
+
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
99
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// oidc-session.mjs — Solid-OIDC login for the browser agent, with a secure,
|
|
2
|
+
// durable, worker-visible session.
|
|
3
|
+
//
|
|
4
|
+
// The redirect handshake (authorization-code + PKCE) runs in the page. The
|
|
5
|
+
// session it yields — a DPoP keypair whose private half is NON-EXTRACTABLE, plus
|
|
6
|
+
// the refresh token, the token endpoint and the WebID — is kept in IndexedDB.
|
|
7
|
+
// IndexedDB survives a browser restart and is readable by the service worker
|
|
8
|
+
// where the agent runs, and a non-extractable key can sign but never be read
|
|
9
|
+
// out, even by injected script. That is the security this buys over uvdsl's
|
|
10
|
+
// sessionStorage (which must hold an extractable key as a string, per-tab).
|
|
11
|
+
//
|
|
12
|
+
// Page: const { authorizationUrl } = await beginLogin({ issuer, redirectUri });
|
|
13
|
+
// location.href = authorizationUrl; // ... redirect ...
|
|
14
|
+
// await completeLogin({ currentUrl: location.href }); // on return
|
|
15
|
+
// Anywhere (page or worker):
|
|
16
|
+
// const session = await getSession(); // { webId, fetch, signOut } | null
|
|
17
|
+
|
|
18
|
+
const DB = 'fedipod-oidc';
|
|
19
|
+
const STORE = 'session';
|
|
20
|
+
const b64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
21
|
+
const enc = (o) => b64u(new TextEncoder().encode(JSON.stringify(o)));
|
|
22
|
+
const sha256 = (s) => crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
|
|
23
|
+
const rand = (n = 32) => b64u(crypto.getRandomValues(new Uint8Array(n)));
|
|
24
|
+
|
|
25
|
+
function idb() {
|
|
26
|
+
return new Promise((res, rej) => {
|
|
27
|
+
const r = indexedDB.open(DB, 1);
|
|
28
|
+
r.onupgradeneeded = () => r.result.createObjectStore(STORE);
|
|
29
|
+
r.onsuccess = () => res(r.result);
|
|
30
|
+
r.onerror = () => rej(r.error);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
async function idbGet(key) {
|
|
34
|
+
const db = await idb();
|
|
35
|
+
return new Promise((res, rej) => { const t = db.transaction(STORE).objectStore(STORE).get(key); t.onsuccess = () => res(t.result); t.onerror = () => rej(t.error); });
|
|
36
|
+
}
|
|
37
|
+
async function idbPut(key, val) {
|
|
38
|
+
const db = await idb();
|
|
39
|
+
return new Promise((res, rej) => { const t = db.transaction(STORE, 'readwrite').objectStore(STORE).put(val, key); t.onsuccess = () => res(); t.onerror = () => rej(t.error); });
|
|
40
|
+
}
|
|
41
|
+
async function idbDel(key) {
|
|
42
|
+
const db = await idb();
|
|
43
|
+
return new Promise((res, rej) => { const t = db.transaction(STORE, 'readwrite').objectStore(STORE).delete(key); t.onsuccess = () => res(); t.onerror = () => rej(t.error); });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function dpopKey() { return crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']); }
|
|
47
|
+
async function publicJwk(pair) { const j = await crypto.subtle.exportKey('jwk', pair.publicKey); return { kty: j.kty, crv: j.crv, x: j.x, y: j.y }; }
|
|
48
|
+
async function dpopProof(pair, jwk, htm, htu, ath) {
|
|
49
|
+
const header = { typ: 'dpop+jwt', alg: 'ES256', jwk };
|
|
50
|
+
const payload = { htm, htu: htu.split('#')[0], jti: crypto.randomUUID(), iat: Math.floor(Date.now() / 1000), ...(ath ? { ath } : {}) };
|
|
51
|
+
const data = `${enc(header)}.${enc(payload)}`;
|
|
52
|
+
const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, pair.privateKey, new TextEncoder().encode(data));
|
|
53
|
+
return `${data}.${b64u(sig)}`;
|
|
54
|
+
}
|
|
55
|
+
const jwtPayload = (jwt) => { try { return JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)))); } catch { return {}; } };
|
|
56
|
+
|
|
57
|
+
async function discover(issuer) {
|
|
58
|
+
const res = await fetch(`${issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`, { headers: { accept: 'application/json' } });
|
|
59
|
+
if (!res.ok) throw new Error(`OIDC discovery failed at ${issuer} (HTTP ${res.status})`);
|
|
60
|
+
return res.json();
|
|
61
|
+
}
|
|
62
|
+
async function registerClient(cfg, redirectUri, clientName) {
|
|
63
|
+
const res = await fetch(cfg.registration_endpoint, {
|
|
64
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
65
|
+
body: JSON.stringify({ client_name: clientName, redirect_uris: [redirectUri], token_endpoint_auth_method: 'none',
|
|
66
|
+
grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], application_type: 'web', scope: 'openid webid offline_access' }),
|
|
67
|
+
});
|
|
68
|
+
if (!res.ok) throw new Error(`client registration failed (HTTP ${res.status})`);
|
|
69
|
+
return (await res.json()).client_id;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Begin login: prepare PKCE + a non-extractable DPoP key, stash the pending
|
|
73
|
+
* state, and return the authorization URL for the caller to navigate to. */
|
|
74
|
+
export async function beginLogin({ issuer, redirectUri, clientName = 'FediPod' }) {
|
|
75
|
+
const cfg = await discover(issuer);
|
|
76
|
+
const client_id = await registerClient(cfg, redirectUri, clientName);
|
|
77
|
+
const pair = await dpopKey();
|
|
78
|
+
const verifier = rand(48);
|
|
79
|
+
const challenge = b64u(await sha256(verifier));
|
|
80
|
+
const state = rand(16);
|
|
81
|
+
await idbPut('pending', { issuer, client_id, redirectUri, verifier, state, pair,
|
|
82
|
+
tokenEndpoint: cfg.token_endpoint, authorizationEndpoint: cfg.authorization_endpoint,
|
|
83
|
+
revocationEndpoint: cfg.revocation_endpoint || null });
|
|
84
|
+
const url = new URL(cfg.authorization_endpoint);
|
|
85
|
+
for (const [k, v] of Object.entries({ client_id, redirect_uri: redirectUri, response_type: 'code',
|
|
86
|
+
scope: 'openid webid offline_access', code_challenge: challenge, code_challenge_method: 'S256', state, prompt: 'consent' })) url.searchParams.set(k, v);
|
|
87
|
+
return { authorizationUrl: url.href };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Complete login from the redirect back. Exchanges the code (DPoP + PKCE),
|
|
91
|
+
* stores the durable session, and returns it. Returns null if there is no code. */
|
|
92
|
+
export async function completeLogin({ currentUrl }) {
|
|
93
|
+
const u = new URL(currentUrl);
|
|
94
|
+
const code = u.searchParams.get('code');
|
|
95
|
+
const state = u.searchParams.get('state');
|
|
96
|
+
if (!code) return null;
|
|
97
|
+
const p = await idbGet('pending');
|
|
98
|
+
if (!p || p.state !== state) throw new Error('login state mismatch');
|
|
99
|
+
const jwk = await publicJwk(p.pair);
|
|
100
|
+
const res = await fetch(p.tokenEndpoint, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', dpop: await dpopProof(p.pair, jwk, 'POST', p.tokenEndpoint) },
|
|
103
|
+
body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: p.redirectUri, client_id: p.client_id, code_verifier: p.verifier }),
|
|
104
|
+
});
|
|
105
|
+
const tok = await res.json().catch(() => ({}));
|
|
106
|
+
if (!res.ok || !tok.access_token) throw new Error(`token exchange failed (HTTP ${res.status}): ${tok.error || ''}`);
|
|
107
|
+
const webId = jwtPayload(tok.access_token).webid || jwtPayload(tok.id_token || '').webid || null;
|
|
108
|
+
const session = { issuer: p.issuer, client_id: p.client_id, tokenEndpoint: p.tokenEndpoint, pair: p.pair,
|
|
109
|
+
// Where to hand the refresh token back at sign-out. Kept on the session
|
|
110
|
+
// because discovery is a network round trip and sign-out should not need
|
|
111
|
+
// one — see signOut().
|
|
112
|
+
revocationEndpoint: p.revocationEndpoint || null,
|
|
113
|
+
refreshToken: tok.refresh_token || null, accessToken: tok.access_token,
|
|
114
|
+
expiresAt: Date.now() + Math.max(30, (tok.expires_in || 300)) * 1000, webId };
|
|
115
|
+
await idbPut('session', session);
|
|
116
|
+
await idbDel('pending');
|
|
117
|
+
return sessionHandle(session);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Restore the session from IndexedDB — works in the page and the worker. */
|
|
121
|
+
export async function getSession() {
|
|
122
|
+
const s = await idbGet('session');
|
|
123
|
+
return s ? sessionHandle(s) : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Sign out, and tell the issuer.
|
|
128
|
+
*
|
|
129
|
+
* Dropping the row locally left the REFRESH TOKEN alive at the pod for its full
|
|
130
|
+
* life — signing out of a shared or borrowed browser cleared the screen and
|
|
131
|
+
* nothing else, and anyone who had copied the token could go on minting access
|
|
132
|
+
* tokens with it. RFC 7009: hand it back.
|
|
133
|
+
*
|
|
134
|
+
* Best effort, and the local teardown happens either way: an issuer that is
|
|
135
|
+
* unreachable, or that publishes no revocation endpoint, must not be able to
|
|
136
|
+
* keep somebody signed in.
|
|
137
|
+
*/
|
|
138
|
+
export async function signOut() {
|
|
139
|
+
const s = await idbGet('session').catch(() => null);
|
|
140
|
+
if (s?.revocationEndpoint && s.refreshToken) {
|
|
141
|
+
try {
|
|
142
|
+
await fetch(s.revocationEndpoint, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
145
|
+
body: new URLSearchParams({
|
|
146
|
+
token: s.refreshToken, token_type_hint: 'refresh_token', client_id: s.client_id,
|
|
147
|
+
}),
|
|
148
|
+
});
|
|
149
|
+
} catch { /* told if we could; leaving is not conditional on being heard */ }
|
|
150
|
+
}
|
|
151
|
+
await idbDel('session');
|
|
152
|
+
await idbDel('pending');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function sessionHandle(s) {
|
|
156
|
+
let { accessToken, expiresAt, refreshToken } = s;
|
|
157
|
+
const jwkP = publicJwk(s.pair);
|
|
158
|
+
const refresh = async () => {
|
|
159
|
+
if (!refreshToken) throw new Error('session expired and there is no refresh token — sign in again');
|
|
160
|
+
const jwk = await jwkP;
|
|
161
|
+
const res = await fetch(s.tokenEndpoint, {
|
|
162
|
+
method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', dpop: await dpopProof(s.pair, jwk, 'POST', s.tokenEndpoint) },
|
|
163
|
+
body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: s.client_id, scope: 'openid webid offline_access' }),
|
|
164
|
+
});
|
|
165
|
+
const tok = await res.json().catch(() => ({}));
|
|
166
|
+
if (!res.ok || !tok.access_token) { await idbDel('session'); throw new Error('refresh failed — sign in again'); }
|
|
167
|
+
accessToken = tok.access_token;
|
|
168
|
+
expiresAt = Date.now() + Math.max(30, (tok.expires_in || 300)) * 1000;
|
|
169
|
+
if (tok.refresh_token) refreshToken = tok.refresh_token;
|
|
170
|
+
// Re-read before writing back. There is ONE 'session' row, and this handle
|
|
171
|
+
// closed over the session it was made from — so a refresh that was already
|
|
172
|
+
// in flight when the user signed in as somebody else would put the OLD
|
|
173
|
+
// identity back, silently, over the new one. Only write if the row is still
|
|
174
|
+
// the session this handle belongs to.
|
|
175
|
+
const now = await idbGet('session');
|
|
176
|
+
if (now && now.webId !== s.webId) {
|
|
177
|
+
throw new Error('signed in as somebody else while this session was refreshing');
|
|
178
|
+
}
|
|
179
|
+
await idbPut('session', { ...s, accessToken, expiresAt, refreshToken });
|
|
180
|
+
};
|
|
181
|
+
const authFetch = async (url, init = {}) => {
|
|
182
|
+
if (Date.now() > expiresAt - 30_000) await refresh();
|
|
183
|
+
const jwk = await jwkP;
|
|
184
|
+
const ath = b64u(await sha256(accessToken));
|
|
185
|
+
const headers = { ...(init.headers || {}), authorization: `DPoP ${accessToken}`, dpop: await dpopProof(s.pair, jwk, (init.method || 'GET'), url, ath) };
|
|
186
|
+
return fetch(url, { ...init, headers });
|
|
187
|
+
};
|
|
188
|
+
return { webId: s.webId, fetch: authFetch, refresh, signOut };
|
|
189
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// pod-auth.mjs — the pod side of sign-in, done in the browser with no server
|
|
2
|
+
// in between. It is the browser-native twin of lib/account.mjs and
|
|
3
|
+
// vendor/idp-grant.cjs: create a Community Solid Server account and pod, mint a
|
|
4
|
+
// client credential, and turn that credential into a DPoP-bound session whose
|
|
5
|
+
// fetch writes to the pod. Everything a Node agent did against a CSS account
|
|
6
|
+
// API, a page does here — proven cross-origin against solidcommunity.net on
|
|
7
|
+
// 2026-09-06 (claude/validation/scn-cross-origin-spike.html).
|
|
8
|
+
//
|
|
9
|
+
// No dependency on lib/: lib is Node (node:crypto, undici, @fedify). This uses
|
|
10
|
+
// only WebCrypto and fetch, so it runs in a tab and in a service worker.
|
|
11
|
+
|
|
12
|
+
const b64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)))
|
|
13
|
+
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
14
|
+
const enc = (o) => b64u(new TextEncoder().encode(JSON.stringify(o)));
|
|
15
|
+
const sha256 = async (str) => crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
|
|
16
|
+
|
|
17
|
+
// A CSS account-API call: JSON in, JSON out, the account token carried the way
|
|
18
|
+
// CSS wants it (a bearer with its own scheme, not a cookie, in v7).
|
|
19
|
+
async function accountFetch(url, { method = 'GET', body, token } = {}) {
|
|
20
|
+
const headers = { accept: 'application/json' };
|
|
21
|
+
if (body !== undefined) headers['content-type'] = 'application/json';
|
|
22
|
+
if (token) headers.authorization = `CSS-Account-Token ${token}`;
|
|
23
|
+
const res = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
24
|
+
let json = null;
|
|
25
|
+
try { json = await res.json(); } catch { /* non-JSON error bodies exist */ }
|
|
26
|
+
return { status: res.status, json };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Is this origin a CSS v7 account API at all? Cheap pre-flight for the form. */
|
|
30
|
+
export async function accountApiControls(issuer) {
|
|
31
|
+
const origin = issuer.replace(/\/+$/, '');
|
|
32
|
+
const { status, json } = await accountFetch(`${origin}/.account/`);
|
|
33
|
+
if (status >= 400 || !json?.controls) return null;
|
|
34
|
+
return json.controls;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create an account and a pod named `podName`. Idempotent on re-run: an
|
|
39
|
+
* existing account logs in, an existing pod of the same name is reused. Refuses
|
|
40
|
+
* to make a second pod on an account that already has one, which would give it
|
|
41
|
+
* a second WebID a single credential could not match. Mirrors
|
|
42
|
+
* lib/account.mjs's guards exactly, because the failure it prevents (a
|
|
43
|
+
* credential bound to the wrong WebID that 403s every write) is the same here.
|
|
44
|
+
*/
|
|
45
|
+
export async function createAccountWithPod({ issuer, email, password, podName }) {
|
|
46
|
+
const origin = issuer.replace(/\/+$/, '');
|
|
47
|
+
const accountRoot = `${origin}/.account/`;
|
|
48
|
+
|
|
49
|
+
let token;
|
|
50
|
+
const login = await accountFetch(`${accountRoot}login/password/`, { method: 'POST', body: { email, password } });
|
|
51
|
+
if (login.status < 400 && login.json?.authorization) {
|
|
52
|
+
token = login.json.authorization;
|
|
53
|
+
} else {
|
|
54
|
+
const create = await accountFetch(`${accountRoot}account/`, { method: 'POST' });
|
|
55
|
+
if (!create.json?.authorization) {
|
|
56
|
+
throw new Error(`could not create an account at ${origin} (HTTP ${create.status})`
|
|
57
|
+
+ ` — is sign-up open there? ${create.json?.message || ''}`);
|
|
58
|
+
}
|
|
59
|
+
token = create.json.authorization;
|
|
60
|
+
const pwCreate = (await accountFetch(accountRoot, { token })).json?.controls?.password?.create;
|
|
61
|
+
if (!pwCreate) throw new Error('this server is not a CSS v7 account API (no password control)');
|
|
62
|
+
const pw = await accountFetch(pwCreate, { method: 'POST', token, body: { email, password } });
|
|
63
|
+
if (pw.status >= 400) throw new Error(`could not set the password (HTTP ${pw.status}): ${pw.json?.message || ''}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const podCreate = (await accountFetch(accountRoot, { token })).json?.controls?.account?.pod;
|
|
67
|
+
if (!podCreate) throw new Error('this server does not offer pod creation through its account API');
|
|
68
|
+
|
|
69
|
+
const findOwn = async () => {
|
|
70
|
+
const pods = (await accountFetch(podCreate, { token })).json?.pods || {};
|
|
71
|
+
return Object.entries(pods).find(([url]) => {
|
|
72
|
+
try {
|
|
73
|
+
const u = new URL(url);
|
|
74
|
+
return u.hostname === podName || u.hostname.startsWith(`${podName}.`)
|
|
75
|
+
|| u.pathname.split('/').filter(Boolean).includes(podName);
|
|
76
|
+
} catch { return false; }
|
|
77
|
+
}) || null;
|
|
78
|
+
};
|
|
79
|
+
const owned = Object.keys((await accountFetch(podCreate, { token })).json?.pods || {});
|
|
80
|
+
if (owned.length && !(await findOwn())) {
|
|
81
|
+
throw new Error(`${email} already has a pod on ${new URL(origin).host} — one account, one pod.`
|
|
82
|
+
+ ' A second identity needs its own account. Nothing was created.');
|
|
83
|
+
}
|
|
84
|
+
const made = await accountFetch(podCreate, { method: 'POST', token, body: { name: podName } });
|
|
85
|
+
let pod = made.json?.pod || made.json?.podBaseUrl || null;
|
|
86
|
+
let webId = made.json?.webId || null;
|
|
87
|
+
if (made.status >= 400 || !pod) {
|
|
88
|
+
const own = await findOwn();
|
|
89
|
+
if (own) { pod = own[0]; webId = webId || own[1]?.webId || null; }
|
|
90
|
+
else if (made.status >= 400) throw new Error(`pod creation failed (HTTP ${made.status}): ${made.json?.message || ''}`);
|
|
91
|
+
}
|
|
92
|
+
if (!pod) throw new Error('the server did not report a pod URL');
|
|
93
|
+
return { pod: pod.endsWith('/') ? pod : pod + '/', webId, accountToken: token };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Mint a client credential against the account. `accountToken` reuses a login
|
|
98
|
+
* from createAccountWithPod; otherwise email+password logs in. Returns the
|
|
99
|
+
* credential record the agent expects, minus remotePod (the caller adds it).
|
|
100
|
+
*/
|
|
101
|
+
export async function mintCredential({ issuer, email, password, webId, podUrl, accountToken, name = 'fedipod' }) {
|
|
102
|
+
const origin = issuer.replace(/\/+$/, '');
|
|
103
|
+
const accountRoot = `${origin}/.account/`;
|
|
104
|
+
|
|
105
|
+
let token = accountToken;
|
|
106
|
+
if (!token) {
|
|
107
|
+
// Sign in with the identifier the form gave (an email). If that account does
|
|
108
|
+
// not own the target pod — or the sign-in fails outright — fall back to the
|
|
109
|
+
// pod's subdomain as a username: older solidcommunity.net accounts sign in by
|
|
110
|
+
// username, not email. Done behind the scenes so the form only asks for an email.
|
|
111
|
+
const tryLogin = async (id) => {
|
|
112
|
+
const r = await accountFetch(`${accountRoot}login/password/`, { method: 'POST', body: { email: id, password } });
|
|
113
|
+
return (r.status < 400 && r.json?.authorization) ? r.json.authorization : null;
|
|
114
|
+
};
|
|
115
|
+
const ownsPod = async (tok) => {
|
|
116
|
+
if (!podUrl) return true;
|
|
117
|
+
try {
|
|
118
|
+
const ctl = (await accountFetch(accountRoot, { token: tok })).json?.controls?.account?.webId;
|
|
119
|
+
const links = ctl ? (await accountFetch(ctl, { token: tok })).json?.webIdLinks : null;
|
|
120
|
+
return Object.keys(links || {}).some((w) => new URL(w).origin === new URL(podUrl).origin);
|
|
121
|
+
} catch { return false; }
|
|
122
|
+
};
|
|
123
|
+
token = await tryLogin(email);
|
|
124
|
+
let sub = null; try { sub = podUrl ? new URL(podUrl).host.split('.')[0] : null; } catch { /* podUrl not a URL */ }
|
|
125
|
+
if (sub && sub !== email && (!token || !(await ownsPod(token)))) {
|
|
126
|
+
const alt = await tryLogin(sub);
|
|
127
|
+
if (alt) token = alt;
|
|
128
|
+
}
|
|
129
|
+
if (!token) throw new Error('account login failed — check the email or username and the password');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const controls = (await accountFetch(accountRoot, { token })).json?.controls;
|
|
133
|
+
const ccUrl = controls?.account?.clientCredentials;
|
|
134
|
+
if (!ccUrl) throw new Error('this server is not a CSS account API (no clientCredentials control)');
|
|
135
|
+
|
|
136
|
+
let wid = webId;
|
|
137
|
+
if (!wid) {
|
|
138
|
+
const linkCtl = controls?.account?.webId;
|
|
139
|
+
const links = linkCtl ? (await accountFetch(linkCtl, { token })).json?.webIdLinks : null;
|
|
140
|
+
const all = links ? Object.keys(links) : [];
|
|
141
|
+
if (!all.length) throw new Error('no WebID is linked to this account');
|
|
142
|
+
if (podUrl) {
|
|
143
|
+
// The credential MUST be for a WebID that owns the target pod, or every
|
|
144
|
+
// write to it is rejected. Never fall back to an unrelated WebID: a
|
|
145
|
+
// credential bound to the wrong identity 401s/403s on the pod, which is
|
|
146
|
+
// the opaque "could not store the key" people then see.
|
|
147
|
+
let origins = [];
|
|
148
|
+
try { origins = all.filter((w) => new URL(w).origin === new URL(podUrl).origin); } catch { /* podUrl not a URL */ }
|
|
149
|
+
if (!origins.length) {
|
|
150
|
+
throw new Error(`the account you signed in with does not own ${new URL(podUrl).origin}. `
|
|
151
|
+
+ `It is linked to ${all.length} WebID${all.length > 1 ? 's' : ''} — on `
|
|
152
|
+
+ `${all.map((w) => new URL(w).host).join(', ')} — none of them this pod. `
|
|
153
|
+
+ 'Sign in with the account that owns this pod, or let FediPod make you a new one.');
|
|
154
|
+
}
|
|
155
|
+
wid = origins[0];
|
|
156
|
+
} else {
|
|
157
|
+
wid = all[0];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const made = await accountFetch(ccUrl, { method: 'POST', token, body: { name, webId: wid } });
|
|
162
|
+
if (made.status >= 400 || !made.json?.secret) throw new Error(`mint failed (HTTP ${made.status}): ${made.json?.message || ''}`);
|
|
163
|
+
const tokenEndpoint = await discoverTokenEndpoint(origin);
|
|
164
|
+
return { clientId: made.json.id, secret: made.json.secret, webId: wid, tokenEndpoint,
|
|
165
|
+
resource: made.json.resource, issuerOrigin: origin,
|
|
166
|
+
// Carried so sign-up can hand it straight back to revokeCredential. It is a
|
|
167
|
+
// session token for the ACCOUNT — never stored, never written to the pod,
|
|
168
|
+
// and gone with the tab.
|
|
169
|
+
accountToken: token };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Delete a client credential.
|
|
174
|
+
*
|
|
175
|
+
* The one sign-up mints is full, permanent access to the pod, and it is needed
|
|
176
|
+
* only while sign-up is running — the agent itself runs on the Solid-OIDC
|
|
177
|
+
* session. Leaving it behind meant every account carried an orphaned key that
|
|
178
|
+
* nothing held and nobody would think to revoke, while the page told the user
|
|
179
|
+
* it was "stored in this browser", which it never was.
|
|
180
|
+
*
|
|
181
|
+
* Best effort by design: a server that will not delete it leaves a credential
|
|
182
|
+
* the owner can still revoke from their pod's own account page, and saying so
|
|
183
|
+
* is better than failing a sign-up that otherwise worked.
|
|
184
|
+
*/
|
|
185
|
+
export async function revokeCredential({ resource, accountToken }) {
|
|
186
|
+
if (!resource || !accountToken) return false;
|
|
187
|
+
try {
|
|
188
|
+
const r = await accountFetch(resource, { method: 'DELETE', token: accountToken });
|
|
189
|
+
return r.status < 400;
|
|
190
|
+
} catch { return false; }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The OIDC token endpoint, from the issuer's discovery document. */
|
|
194
|
+
export async function discoverTokenEndpoint(issuer) {
|
|
195
|
+
const origin = issuer.replace(/\/+$/, '');
|
|
196
|
+
try {
|
|
197
|
+
const res = await fetch(`${origin}/.well-known/openid-configuration`, { headers: { accept: 'application/json' } });
|
|
198
|
+
if (res.ok) {
|
|
199
|
+
const doc = await res.json();
|
|
200
|
+
if (doc.token_endpoint) return doc.token_endpoint;
|
|
201
|
+
}
|
|
202
|
+
} catch { /* fall through to the CSS default */ }
|
|
203
|
+
return `${origin}/.oidc/token`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A DPoP-bound session: one ES256 proof key, a client-credentials access token
|
|
208
|
+
* bound to it, and a `fetch` that signs every request. The same key must sign
|
|
209
|
+
* the token request and every resource request, or the token's binding fails,
|
|
210
|
+
* so the key is made once and kept.
|
|
211
|
+
*/
|
|
212
|
+
export async function makeDpopSession({ clientId, secret, tokenEndpoint }) {
|
|
213
|
+
const proofKey = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']);
|
|
214
|
+
const jwk = await crypto.subtle.exportKey('jwk', proofKey.publicKey);
|
|
215
|
+
const publicJwk = { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
|
|
216
|
+
|
|
217
|
+
const proof = async (htm, htu, ath) => {
|
|
218
|
+
const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: publicJwk };
|
|
219
|
+
const payload = { htm, htu: htu.split('#')[0], jti: crypto.randomUUID(), iat: Math.floor(Date.now() / 1000), ...(ath ? { ath } : {}) };
|
|
220
|
+
const data = `${enc(header)}.${enc(payload)}`;
|
|
221
|
+
const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, proofKey.privateKey, new TextEncoder().encode(data));
|
|
222
|
+
return `${data}.${b64u(sig)}`;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
let token = null; let expiresAt = 0;
|
|
226
|
+
const refresh = async () => {
|
|
227
|
+
const basic = btoa(`${encodeURIComponent(clientId)}:${encodeURIComponent(secret)}`);
|
|
228
|
+
const res = await fetch(tokenEndpoint, {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
headers: { authorization: `Basic ${basic}`, 'content-type': 'application/x-www-form-urlencoded', dpop: await proof('POST', tokenEndpoint) },
|
|
231
|
+
body: 'grant_type=client_credentials&scope=webid',
|
|
232
|
+
});
|
|
233
|
+
const tok = await res.json().catch(() => ({}));
|
|
234
|
+
if (!tok.access_token) throw new Error(`token request failed (HTTP ${res.status}): ${tok.error || ''}`);
|
|
235
|
+
token = tok.access_token;
|
|
236
|
+
expiresAt = Date.now() + Math.max(30, (tok.expires_in || 300) - 30) * 1000;
|
|
237
|
+
return token;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const authFetch = async (url, init = {}) => {
|
|
241
|
+
if (!token || Date.now() > expiresAt) await refresh();
|
|
242
|
+
const ath = b64u(await sha256(token));
|
|
243
|
+
const headers = { ...(init.headers || {}), authorization: `DPoP ${token}`, dpop: await proof((init.method || 'GET'), url, ath) };
|
|
244
|
+
return fetch(url, { ...init, headers });
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
return { fetch: authFetch, refresh };
|
|
248
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// pod-remote.mjs — the browser's pod transport, over a DPoP/OIDC session.
|
|
2
|
+
//
|
|
3
|
+
// The conversation with the pod — verbs, ACL documents, the deletion deny-list,
|
|
4
|
+
// the Retry-After cooldown, container listings — is lib/pod/transport.mjs, the
|
|
5
|
+
// same code the Node agent runs. This used to be a hand-kept fork of that file,
|
|
6
|
+
// and the fork had drifted: no cooldown at all for a 429 without a Retry-After
|
|
7
|
+
// header, no byte cap on a listing from a public-Append inbox, a swallowed
|
|
8
|
+
// parse failure, and probes that were neither counted nor held back. Those are
|
|
9
|
+
// gone with the fork.
|
|
10
|
+
//
|
|
11
|
+
// What genuinely differs, and all that is left here: this side retries a
|
|
12
|
+
// dropped connection, and waits a throttle out instead of failing fast.
|
|
13
|
+
|
|
14
|
+
import { PodTransport } from '../../lib/pod/transport.mjs';
|
|
15
|
+
|
|
16
|
+
export { protectedFromDeletion } from '../../lib/pod/transport.mjs';
|
|
17
|
+
|
|
18
|
+
// Retry transient edge throttling (dropped connections, 429/503) with backoff.
|
|
19
|
+
const RETRY_MAX = 5;
|
|
20
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
21
|
+
const backoff = (n) => Math.min(5000, 400 * 2 ** n) + Math.floor(Math.random() * 250);
|
|
22
|
+
|
|
23
|
+
// A tighter ceiling than the Node side's thirty minutes: a tab is a person
|
|
24
|
+
// waiting, and a hostile or confused header must not park them for half an hour.
|
|
25
|
+
const COOLDOWN_MAX_MS = 5 * 60_000;
|
|
26
|
+
|
|
27
|
+
export class BrowserRemotePod extends PodTransport {
|
|
28
|
+
constructor(session, { webId, log = () => {}, role = 'agent' } = {}) {
|
|
29
|
+
super(session, {
|
|
30
|
+
webId,
|
|
31
|
+
log,
|
|
32
|
+
role,
|
|
33
|
+
runtime: 'browser',
|
|
34
|
+
// Wait the throttle out rather than refusing: there is a person here, and
|
|
35
|
+
// an operation that resumes in four seconds beats one that fails and asks
|
|
36
|
+
// them to try again.
|
|
37
|
+
cooldownMode: 'wait',
|
|
38
|
+
maxCooldownMs: COOLDOWN_MAX_MS,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async warmup() { /* the DPoP session refreshes lazily */ }
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* solidcommunity.net sits behind an edge (Cloudflare) that throttles a burst
|
|
46
|
+
* of requests: during first-boot provisioning the agent makes ~15 rapid
|
|
47
|
+
* calls, and one comes back as a dropped connection ("Failed to fetch") or a
|
|
48
|
+
* 429/503. A single dropped write used to abort the whole boot. Retry those
|
|
49
|
+
* with escalating backoff — the throttle window clears in under a few seconds.
|
|
50
|
+
* Safe to retry: a thrown request never reached the server, and a 429/503 was
|
|
51
|
+
* refused, not applied.
|
|
52
|
+
*
|
|
53
|
+
* This overrides `_send`, not `fetch`, so it cannot skip the url map, the
|
|
54
|
+
* cooldown accounting or the deletion deny-list — all of which sit in `fetch`
|
|
55
|
+
* above it. The pod-wide pause is armed there too, so a retry here waits for
|
|
56
|
+
* the whole pod rather than each call climbing its own ladder.
|
|
57
|
+
*/
|
|
58
|
+
async _send(url, init) {
|
|
59
|
+
let attempt = 0;
|
|
60
|
+
for (;;) {
|
|
61
|
+
try {
|
|
62
|
+
const res = await this.session.fetch(url, init);
|
|
63
|
+
if ((res.status === 429 || res.status === 503) && attempt < RETRY_MAX) {
|
|
64
|
+
// Arm the pod-wide pause from THIS answer before retrying. The retry
|
|
65
|
+
// happens inside `fetch`, so waiting for the outer `_observe` would
|
|
66
|
+
// mean climbing the whole ladder while ignoring the Retry-After the
|
|
67
|
+
// server just gave us. `_cooldownGate` then sleeps whatever was
|
|
68
|
+
// armed; when the server named no delay, nothing is armed and the
|
|
69
|
+
// backoff below paces it instead.
|
|
70
|
+
this._observe(res);
|
|
71
|
+
if (this.pausedUntil > Date.now()) await this._cooldownGate();
|
|
72
|
+
else await sleep(backoff(attempt));
|
|
73
|
+
attempt++;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
return res;
|
|
77
|
+
} catch (e) {
|
|
78
|
+
if (attempt >= RETRY_MAX) throw e;
|
|
79
|
+
this.log(`[${this.label}] ${init?.method || 'GET'} ${url} failed (${e.message}); retry ${attempt + 1}/${RETRY_MAX}`);
|
|
80
|
+
await sleep(backoff(attempt++));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|