fedipod 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/architecture.svg +89 -0
- package/bin/fedipod.mjs +2303 -0
- package/cli.md +243 -0
- package/groups.md +85 -0
- package/gui.md +99 -0
- package/lib/account.mjs +87 -0
- package/lib/admin.mjs +1606 -0
- package/lib/atproto.mjs +310 -0
- package/lib/bskyfeed.mjs +166 -0
- package/lib/bskygroup.mjs +162 -0
- package/lib/c2s.mjs +305 -0
- package/lib/certs.mjs +265 -0
- package/lib/deliver.mjs +249 -0
- package/lib/directory.mjs +90 -0
- package/lib/embed.mjs +214 -0
- package/lib/export-collections.mjs +161 -0
- package/lib/front-core.mjs +322 -0
- package/lib/gateway-core.mjs +115 -0
- package/lib/guard.mjs +213 -0
- package/lib/home.mjs +161 -0
- package/lib/httpsig.mjs +109 -0
- package/lib/import.mjs +357 -0
- package/lib/intake.mjs +1710 -0
- package/lib/keys.mjs +139 -0
- package/lib/lease.mjs +190 -0
- package/lib/localapi.mjs +87 -0
- package/lib/mastoapi.mjs +1588 -0
- package/lib/migrate.mjs +127 -0
- package/lib/oidc-auth.mjs +53 -0
- package/lib/podrdf.mjs +132 -0
- package/lib/ports.mjs +27 -0
- package/lib/publisher.mjs +1003 -0
- package/lib/remote.mjs +303 -0
- package/lib/safefetch.mjs +235 -0
- package/lib/setup.mjs +262 -0
- package/lib/social.mjs +505 -0
- package/lib/storage.mjs +170 -0
- package/lib/store.mjs +470 -0
- package/lib/streaming.mjs +154 -0
- package/lib/tagfeed.mjs +158 -0
- package/lib/ua.mjs +29 -0
- package/lib/update.mjs +91 -0
- package/lib/webpush.mjs +98 -0
- package/lib/wire.mjs +762 -0
- package/package.json +60 -0
- package/phanpy/dist/404.html +40 -0
- package/phanpy/dist/_headers +2 -0
- package/phanpy/dist/apple-touch-icon.png +0 -0
- package/phanpy/dist/assets/ICONS-DIPJeU0C.js +2 -0
- package/phanpy/dist/assets/ICONS-DIPJeU0C.js.map +1 -0
- package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js +183 -0
- package/phanpy/dist/assets/ScheduledAtField-D-Abfo4J.js.map +1 -0
- package/phanpy/dist/assets/Temml-CCjoacWt.woff2 +0 -0
- package/phanpy/dist/assets/__vite-browser-external-C7iut881.js +2 -0
- package/phanpy/dist/assets/__vite-browser-external-C7iut881.js.map +1 -0
- package/phanpy/dist/assets/boosts-carousel-BiOaNdMT.jpg +0 -0
- package/phanpy/dist/assets/catch-up-O5q5C75Z.png +0 -0
- package/phanpy/dist/assets/chunk-aKtaBQYM.js +1 -0
- package/phanpy/dist/assets/compose-CFEx7wb4.js +2 -0
- package/phanpy/dist/assets/compose-CFEx7wb4.js.map +1 -0
- package/phanpy/dist/assets/compose-ZJN5xSb4.js +35 -0
- package/phanpy/dist/assets/compose-ZJN5xSb4.js.map +1 -0
- package/phanpy/dist/assets/debug.module-YFQgwnIr.js +52 -0
- package/phanpy/dist/assets/debug.module-YFQgwnIr.js.map +1 -0
- package/phanpy/dist/assets/dom-p1YcJ5Rw.js +2 -0
- package/phanpy/dist/assets/dom-p1YcJ5Rw.js.map +1 -0
- package/phanpy/dist/assets/home-mobile-dark@2x-Dc-J8-SM.png +0 -0
- package/phanpy/dist/assets/home-mobile-light@2x-Bd3fod8m.png +0 -0
- package/phanpy/dist/assets/home-tablet-dark@2x-BgcNI8le.png +0 -0
- package/phanpy/dist/assets/home-tablet-light@2x-BW2RV9T6.png +0 -0
- package/phanpy/dist/assets/icons/add-circle-line-WD0mwORD.js +1 -0
- package/phanpy/dist/assets/icons/alert-line-CpFUMf9s.js +1 -0
- package/phanpy/dist/assets/icons/android-2-line-Bzxnrd3S.js +1 -0
- package/phanpy/dist/assets/icons/announcement-line-5nwaJFJm.js +1 -0
- package/phanpy/dist/assets/icons/arrow-down-circle-line-a7Id9-Iv.js +1 -0
- package/phanpy/dist/assets/icons/arrow-down-line-B8uzXzq6.js +1 -0
- package/phanpy/dist/assets/icons/arrow-left-line-CGrGT9C-.js +1 -0
- package/phanpy/dist/assets/icons/arrow-right-line-CE-HERfX.js +1 -0
- package/phanpy/dist/assets/icons/arrow-to-up-line-CvdQjmcy.js +1 -0
- package/phanpy/dist/assets/icons/arrow-up-circle-line-DEparSNF.js +1 -0
- package/phanpy/dist/assets/icons/arrow-up-line-C7yBeugc.js +1 -0
- package/phanpy/dist/assets/icons/arrows-right-line-BtbXmJGP.js +1 -0
- package/phanpy/dist/assets/icons/at-line-CF66Bznk.js +1 -0
- package/phanpy/dist/assets/icons/attachment-line-BY8It-l9.js +1 -0
- package/phanpy/dist/assets/icons/blockquote-line-BS6pU_hm.js +1 -0
- package/phanpy/dist/assets/icons/board-line-C0UWXyA6.js +1 -0
- package/phanpy/dist/assets/icons/bookmark-line-1CtX5JLF.js +1 -0
- package/phanpy/dist/assets/icons/building-5-line-Dd59MzbO.js +1 -0
- package/phanpy/dist/assets/icons/bus-2-line-6_dvA0GG.js +1 -0
- package/phanpy/dist/assets/icons/calendar-day-line-Crpl_fmu.js +1 -0
- package/phanpy/dist/assets/icons/calendar-month-line-vommts2C.js +1 -0
- package/phanpy/dist/assets/icons/calendar-time-add-line-BU_DTlQk.js +1 -0
- package/phanpy/dist/assets/icons/camera-line-Cv69MEei.js +1 -0
- package/phanpy/dist/assets/icons/celebrate-line-B8DvIoi0.js +1 -0
- package/phanpy/dist/assets/icons/chart-bar-line-FK-QOmtF.js +1 -0
- package/phanpy/dist/assets/icons/chart-line-line-BGcJAFWw.js +1 -0
- package/phanpy/dist/assets/icons/chat-3-line-YFK2-hwz.js +1 -0
- package/phanpy/dist/assets/icons/check-circle-line-DsAxFr5-.js +1 -0
- package/phanpy/dist/assets/icons/clipboard-line-BBp364gJ.js +1 -0
- package/phanpy/dist/assets/icons/close-circle-line-CICdiuGz.js +1 -0
- package/phanpy/dist/assets/icons/close-line-DYrFWcjv.js +1 -0
- package/phanpy/dist/assets/icons/cloud-line-tiQAbah8.js +1 -0
- package/phanpy/dist/assets/icons/code-line-fAl8iiV3.js +1 -0
- package/phanpy/dist/assets/icons/comment-2-line-CR3g7WeB.js +1 -0
- package/phanpy/dist/assets/icons/copy-2-line-CLW69yF5.js +1 -0
- package/phanpy/dist/assets/icons/delete-2-line-B8xmbr4n.js +1 -0
- package/phanpy/dist/assets/icons/document-3-line-DBGNhAg2.js +1 -0
- package/phanpy/dist/assets/icons/document-line-C7mb9sb7.js +1 -0
- package/phanpy/dist/assets/icons/down-line-BGqSSThd.js +1 -0
- package/phanpy/dist/assets/icons/edit-4-line-DcgGaEhx.js +1 -0
- package/phanpy/dist/assets/icons/emoji-2-line-D8YgnM-y.js +1 -0
- package/phanpy/dist/assets/icons/exit-line-Dc07BS-x.js +1 -0
- package/phanpy/dist/assets/icons/external-link-line-BvpSkmjO.js +1 -0
- package/phanpy/dist/assets/icons/eye-2-line-CiWXx-f_.js +1 -0
- package/phanpy/dist/assets/icons/eye-close-line-BgTaSJ4P.js +1 -0
- package/phanpy/dist/assets/icons/filter-2-line-B_mR2usy.js +1 -0
- package/phanpy/dist/assets/icons/filter-line-C_nm-3cU.js +1 -0
- package/phanpy/dist/assets/icons/flag-1-line-C2HB05I0.js +1 -0
- package/phanpy/dist/assets/icons/forbid-circle-line-D3LTT9yd.js +1 -0
- package/phanpy/dist/assets/icons/formula-line-DLrt2d6j.js +1 -0
- package/phanpy/dist/assets/icons/grid-line-CgUmEikf.js +1 -0
- package/phanpy/dist/assets/icons/group-line-C5fnX0wU.js +1 -0
- package/phanpy/dist/assets/icons/hand-finger-2-line-BGgxbuL9.js +1 -0
- package/phanpy/dist/assets/icons/hashtag-line-CyTEEXBb.js +1 -0
- package/phanpy/dist/assets/icons/heart-crack-line-BZddbO2C.js +1 -0
- package/phanpy/dist/assets/icons/heart-line-CJ7K47J4.js +1 -0
- package/phanpy/dist/assets/icons/history-2-line-kWWvFzqv.js +1 -0
- package/phanpy/dist/assets/icons/history-line-bGQiwbTH.js +1 -0
- package/phanpy/dist/assets/icons/home-3-line-fRU1zeLG.js +1 -0
- package/phanpy/dist/assets/icons/information-line-iNk6-sOY.js +1 -0
- package/phanpy/dist/assets/icons/keyboard-line-DsCyuQNz.js +1 -0
- package/phanpy/dist/assets/icons/layout-4-line-CZkOX52Z.js +1 -0
- package/phanpy/dist/assets/icons/layout-5-line-B_znt5wO.js +1 -0
- package/phanpy/dist/assets/icons/left-line-eUw0tya4.js +1 -0
- package/phanpy/dist/assets/icons/lightning-line-DaKoDdix.js +1 -0
- package/phanpy/dist/assets/icons/link-2-line-DCqLmMA7.js +1 -0
- package/phanpy/dist/assets/icons/list-check-line-BAxosBh9.js +1 -0
- package/phanpy/dist/assets/icons/lock-line-CPoaXeUW.js +1 -0
- package/phanpy/dist/assets/icons/mail-line-B0P8aa1L.js +1 -0
- package/phanpy/dist/assets/icons/moon-line-CEqNAToP.js +1 -0
- package/phanpy/dist/assets/icons/more-1-fill-Dw2e5xab.js +1 -0
- package/phanpy/dist/assets/icons/more-3-line-BIiVjYTz.js +1 -0
- package/phanpy/dist/assets/icons/notification-line-BZZq2Gtu.js +1 -0
- package/phanpy/dist/assets/icons/pencil-line-CKh8-A1v.js +1 -0
- package/phanpy/dist/assets/icons/photo-album-line-Bnfg2u40.js +1 -0
- package/phanpy/dist/assets/icons/pin-line-DpTolyhs.js +1 -0
- package/phanpy/dist/assets/icons/play-fill-B0-G_8lX.js +1 -0
- package/phanpy/dist/assets/icons/qrcode-2-line-B6SPCaHn.js +1 -0
- package/phanpy/dist/assets/icons/quill-pen-line-B6LUrnCP.js +1 -0
- package/phanpy/dist/assets/icons/quote-left-fill-hQZ1cj_6.js +1 -0
- package/phanpy/dist/assets/icons/radar-line-CKyroTth.js +1 -0
- package/phanpy/dist/assets/icons/react-line-CoIKhhtK.js +1 -0
- package/phanpy/dist/assets/icons/refresh-2-line-D0mTF97S.js +1 -0
- package/phanpy/dist/assets/icons/right-line-B3KWJaqc.js +1 -0
- package/phanpy/dist/assets/icons/rocket-line-4KZl537D.js +1 -0
- package/phanpy/dist/assets/icons/round-fill-C69NTrZ0.js +1 -0
- package/phanpy/dist/assets/icons/round-line-B9k_1uo-.js +1 -0
- package/phanpy/dist/assets/icons/route-line-B28ReHcE.js +1 -0
- package/phanpy/dist/assets/icons/rows-4-line-CrBniagM.js +1 -0
- package/phanpy/dist/assets/icons/scan-line-DSf74YdP.js +1 -0
- package/phanpy/dist/assets/icons/search-2-line-B6geIU1z.js +1 -0
- package/phanpy/dist/assets/icons/settings-3-line-azJwK47w.js +1 -0
- package/phanpy/dist/assets/icons/settings-6-line-C_5zc5YR.js +1 -0
- package/phanpy/dist/assets/icons/share-2-line-D5MxWjrm.js +1 -0
- package/phanpy/dist/assets/icons/share-forward-line-Cp61fZzZ.js +1 -0
- package/phanpy/dist/assets/icons/sparkles-2-line-DYYyXTaW.js +1 -0
- package/phanpy/dist/assets/icons/sparkles-line-C2hY1s6I.js +1 -0
- package/phanpy/dist/assets/icons/time-line-BF3eet1v.js +1 -0
- package/phanpy/dist/assets/icons/transfer-4-line-oisfvTBw.js +1 -0
- package/phanpy/dist/assets/icons/translate-line-BFWol2Ce.js +1 -0
- package/phanpy/dist/assets/icons/unlock-line-X_x5vwv3.js +1 -0
- package/phanpy/dist/assets/icons/upload-3-line-DgzwUJeW.js +1 -0
- package/phanpy/dist/assets/icons/user-4-line-BVSynZCp.js +1 -0
- package/phanpy/dist/assets/icons/user-add-2-line-BlGS5Q8q.js +1 -0
- package/phanpy/dist/assets/icons/user-add-line-BbXdHG4z.js +1 -0
- package/phanpy/dist/assets/icons/user-edit-line-BFAHOV0X.js +1 -0
- package/phanpy/dist/assets/icons/user-follow-line-BXux0wSd.js +1 -0
- package/phanpy/dist/assets/icons/user-star-line-DdYlpHLU.js +1 -0
- package/phanpy/dist/assets/icons/user-warning-line-Cj43j0_E.js +1 -0
- package/phanpy/dist/assets/icons/user-x-line-DIDgNlPH.js +1 -0
- package/phanpy/dist/assets/icons/volume-line-Bs390KQt.js +1 -0
- package/phanpy/dist/assets/icons/volume-mute-line-DzlzIqFZ.js +1 -0
- package/phanpy/dist/assets/icons/walk-line-yNXfB-md.js +1 -0
- package/phanpy/dist/assets/icons/world-2-line-CenkGIwj.js +1 -0
- package/phanpy/dist/assets/icons/zoom-in-line-CaENUphf.js +1 -0
- package/phanpy/dist/assets/icons/zoom-out-line-BDNk0EWo.js +1 -0
- package/phanpy/dist/assets/instances-BNPptnn-.json +333 -0
- package/phanpy/dist/assets/locales/ar-SA-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/ca-ES-DHRW86Tz.js +1 -0
- package/phanpy/dist/assets/locales/cs-CZ-CKoqEuA1.js +1 -0
- package/phanpy/dist/assets/locales/de-DE-ByRPMRFy.js +1 -0
- package/phanpy/dist/assets/locales/eo-UY-CPC7wLCS.js +1 -0
- package/phanpy/dist/assets/locales/es-ES-dJa0Fd9t.js +1 -0
- package/phanpy/dist/assets/locales/eu-ES-C3djlPR2.js +1 -0
- package/phanpy/dist/assets/locales/fa-IR-DTT490Ck.js +1 -0
- package/phanpy/dist/assets/locales/fi-FI-B23MCPuz.js +1 -0
- package/phanpy/dist/assets/locales/fr-FR-BcaQsO5C.js +1 -0
- package/phanpy/dist/assets/locales/gl-ES-BR-CVKBN.js +1 -0
- package/phanpy/dist/assets/locales/he-IL-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/hu-HU-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/it-IT-C1136UFV.js +1 -0
- package/phanpy/dist/assets/locales/ja-JP-CRZmRAhD.js +1 -0
- package/phanpy/dist/assets/locales/kab-Cz8xH_TT.js +1 -0
- package/phanpy/dist/assets/locales/ko-KR-CG9UiY4o.js +1 -0
- package/phanpy/dist/assets/locales/lt-LT-CJcI6sHZ.js +1 -0
- package/phanpy/dist/assets/locales/nb-NO-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/nl-NL-DxN4Yc1t.js +1 -0
- package/phanpy/dist/assets/locales/oc-FR-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/pl-PL-DkwGXf6P.js +1 -0
- package/phanpy/dist/assets/locales/pseudo-LOCALE-CQ4XxkQ0.js +1 -0
- package/phanpy/dist/assets/locales/pt-BR-nGL0HdFS.js +1 -0
- package/phanpy/dist/assets/locales/pt-PT-Cp17aj2z.js +1 -0
- package/phanpy/dist/assets/locales/ru-RU-0Y8cIoaK.js +1 -0
- package/phanpy/dist/assets/locales/th-TH-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/tok-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/tr-TR-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/locales/uk-UA-C5E3dFBf.js +1 -0
- package/phanpy/dist/assets/locales/zh-CN-BHJi7_et.js +1 -0
- package/phanpy/dist/assets/locales/zh-TW-CfheRbCu.js +1 -0
- package/phanpy/dist/assets/main-BdqNbG-a.js +26 -0
- package/phanpy/dist/assets/main-BdqNbG-a.js.map +1 -0
- package/phanpy/dist/assets/mock-home-DGhR__He.js +2 -0
- package/phanpy/dist/assets/mock-home-DGhR__He.js.map +1 -0
- package/phanpy/dist/assets/multi-column-ROck0NVt.jpg +0 -0
- package/phanpy/dist/assets/multi-hashtag-timeline-DH5INVHi.jpg +0 -0
- package/phanpy/dist/assets/nested-comments-thread-hv59kZLc.jpg +0 -0
- package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js +2 -0
- package/phanpy/dist/assets/polyfill-force-H8sUkLGp.js.map +1 -0
- package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js +20 -0
- package/phanpy/dist/assets/pwa-viewport-ogKbF51K.js.map +1 -0
- package/phanpy/dist/assets/style-B1iLPTPR.css +2 -0
- package/phanpy/dist/assets/temml-DuyIQMei.js +97 -0
- package/phanpy/dist/assets/temml-DuyIQMei.js.map +1 -0
- package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js +2 -0
- package/phanpy/dist/assets/tinyld.light.node-DtbBhLYC.js.map +1 -0
- package/phanpy/dist/assets/year-in-posts-B2lywqdx.png +0 -0
- package/phanpy/dist/assets/year-in-posts-DJv_star.js +3 -0
- package/phanpy/dist/assets/year-in-posts-DJv_star.js.map +1 -0
- package/phanpy/dist/compose/index.html +20 -0
- package/phanpy/dist/favicon.ico +0 -0
- package/phanpy/dist/index.html +73 -0
- package/phanpy/dist/logo-192.png +0 -0
- package/phanpy/dist/logo-512.png +0 -0
- package/phanpy/dist/logo-badge-72.png +0 -0
- package/phanpy/dist/logo-maskable-512.png +0 -0
- package/phanpy/dist/logo-monochrome-512.png +0 -0
- package/phanpy/dist/logo-monochrome-maskable-512.png +0 -0
- package/phanpy/dist/manifest.webmanifest +1 -0
- package/phanpy/dist/og-image-2.jpg +0 -0
- package/phanpy/dist/og-image.png +0 -0
- package/phanpy/dist/robots.txt +2 -0
- package/phanpy/dist/sw.js +25 -0
- package/phanpy/dist/version.json +1 -0
- package/run-agent.mjs +805 -0
- package/scripts/build-dist.mjs +37 -0
- package/scripts/verify-ui.mjs +60 -0
- package/ui/README.md +23 -0
- package/vendor/gate.cjs +132 -0
- package/vendor/idp-grant.cjs +394 -0
- package/vendor/jfetch.cjs +16 -0
- package/web/admin/admin.js +958 -0
- package/web/admin/bar.css +64 -0
- package/web/admin/bar.js +61 -0
- package/web/admin/client/client.js +66 -0
- package/web/admin/client/index.html +42 -0
- package/web/admin/index.html +493 -0
- package/web/admin/setup/index.html +172 -0
- package/web/admin/setup/setup.js +268 -0
- package/web/admin/window.css +55 -0
- package/web/admin/window.js +136 -0
- package/web/front/install.sh +69 -0
- package/web/front/new-account.html +424 -0
- package/web/front/run.html +138 -0
- package/web/front/solid-client-authn.bundle.js +2 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// build-dist.mjs — produce a self-contained, run-anywhere tarball:
|
|
2
|
+
// code + vendored auth helpers + Phanpy UI + pruned production deps.
|
|
3
|
+
// The only thing a user needs besides the tarball is Node ≥ 20.
|
|
4
|
+
//
|
|
5
|
+
// node scripts/build-dist.mjs → dist/fedipod-<version>.tar.gz
|
|
6
|
+
//
|
|
7
|
+
// Unpack-and-go: tar xzf fedipod-*.tar.gz && cd fedipod
|
|
8
|
+
// bin/fedipod.mjs setup (asks two questions, then
|
|
9
|
+
// finishes in the browser)
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
18
|
+
const stage = path.join(root, 'dist', 'stage', 'fedipod');
|
|
19
|
+
const out = path.join(root, 'dist', `fedipod-${pkg.version}.tar.gz`);
|
|
20
|
+
|
|
21
|
+
fs.rmSync(path.join(root, 'dist', 'stage'), { recursive: true, force: true });
|
|
22
|
+
fs.mkdirSync(stage, { recursive: true });
|
|
23
|
+
|
|
24
|
+
for (const item of ['bin', 'lib', 'vendor', 'phanpy', 'ui', 'web', 'run-agent.mjs', 'package.json', 'package-lock.json',
|
|
25
|
+
'README.md', 'gui.md', 'cli.md', 'groups.md', 'architecture.svg']) {
|
|
26
|
+
fs.cpSync(path.join(root, item), path.join(stage, item), { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log('installing production deps into the stage…');
|
|
30
|
+
execFileSync('npm', ['ci', '--omit=dev', '--no-audit', '--no-fund'], { cwd: stage, stdio: 'inherit' });
|
|
31
|
+
|
|
32
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
33
|
+
execFileSync('tar', ['czf', out, '-C', path.dirname(stage), 'fedipod']);
|
|
34
|
+
fs.rmSync(path.join(root, 'dist', 'stage'), { recursive: true, force: true });
|
|
35
|
+
|
|
36
|
+
const mb = (fs.statSync(out).size / 1024 / 1024).toFixed(1);
|
|
37
|
+
console.log(`built ${out} (${mb} MB)`);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// verify-ui.mjs — integrity check for the vendored client bundles.
|
|
2
|
+
//
|
|
3
|
+
// phanpy/dist is third-party code we hand-patched; nothing else in the
|
|
4
|
+
// project would notice if a file in it changed. This records a SHA-256 per
|
|
5
|
+
// file and re-checks them, so tampering (or an accidental edit) is visible.
|
|
6
|
+
//
|
|
7
|
+
// node scripts/verify-ui.mjs # verify against phanpy/SHA256SUMS
|
|
8
|
+
// node scripts/verify-ui.mjs --write # (re)record after a deliberate patch
|
|
9
|
+
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
|
+
const dir = path.join(root, 'phanpy/dist');
|
|
17
|
+
const sumsFile = path.join(root, 'phanpy/SHA256SUMS');
|
|
18
|
+
|
|
19
|
+
function walk(d, base = d, out = []) {
|
|
20
|
+
for (const name of fs.readdirSync(d).sort()) {
|
|
21
|
+
const p = path.join(d, name);
|
|
22
|
+
if (fs.statSync(p).isDirectory()) walk(p, base, out);
|
|
23
|
+
else out.push(path.relative(base, p));
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const current = new Map(walk(dir).map(rel => [
|
|
29
|
+
rel, crypto.createHash('sha256').update(fs.readFileSync(path.join(dir, rel))).digest('hex'),
|
|
30
|
+
]));
|
|
31
|
+
|
|
32
|
+
if (process.argv.includes('--write')) {
|
|
33
|
+
const body = [...current].map(([rel, hash]) => `${hash} ${rel}`).join('\n') + '\n';
|
|
34
|
+
fs.writeFileSync(sumsFile, body);
|
|
35
|
+
console.log(`recorded ${current.size} file hashes → ${path.relative(root, sumsFile)}`);
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let recorded;
|
|
40
|
+
try {
|
|
41
|
+
recorded = new Map(fs.readFileSync(sumsFile, 'utf8').trim().split('\n')
|
|
42
|
+
.map(l => { const [h, ...r] = l.split(/\s+/); return [r.join(' '), h]; }));
|
|
43
|
+
} catch {
|
|
44
|
+
console.error(`no ${path.relative(root, sumsFile)} — run with --write to record`);
|
|
45
|
+
process.exit(2);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const problems = [];
|
|
49
|
+
for (const [rel, hash] of current) {
|
|
50
|
+
if (!recorded.has(rel)) problems.push(`ADDED ${rel}`);
|
|
51
|
+
else if (recorded.get(rel) !== hash) problems.push(`CHANGED ${rel}`);
|
|
52
|
+
}
|
|
53
|
+
for (const rel of recorded.keys()) if (!current.has(rel)) problems.push(`REMOVED ${rel}`);
|
|
54
|
+
|
|
55
|
+
if (problems.length) {
|
|
56
|
+
console.error(`vendored UI does not match ${path.relative(root, sumsFile)}:`);
|
|
57
|
+
for (const p of problems) console.error(' ' + p);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
console.log(`vendored UI verified: ${current.size} files match`);
|
package/ui/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# ui/ — additional web clients
|
|
2
|
+
|
|
3
|
+
Drop any static Mastodon web client dist here as `ui/<name>/` and the agent
|
|
4
|
+
serves it at `https://<handle>.localhost:8030/<name>/`, same-origin with the API —
|
|
5
|
+
no CORS, no mixed content. Phanpy stays at `/`.
|
|
6
|
+
|
|
7
|
+
`/admin/` is taken: that is the agent's own surface — the record at `/admin/`,
|
|
8
|
+
first-run setup at `/admin/setup/`, the framed client view at
|
|
9
|
+
`/admin/client/` — served from `web/` rather than from here,
|
|
10
|
+
so a dist dropped in as `ui/admin/` would never be reached.
|
|
11
|
+
|
|
12
|
+
Known-good candidates: Enafore (Pinafore successor), Soapbox — anything
|
|
13
|
+
that ships as a plain static `dist/` folder. (Elk does not fit: it is
|
|
14
|
+
server-rendered, not static.)
|
|
15
|
+
|
|
16
|
+
One patch is usually needed: most clients hardcode `https://${instance}`
|
|
17
|
+
when building API URLs. Replace those construction sites with
|
|
18
|
+
`${location.protocol}//${instance}` so the client works over whichever of
|
|
19
|
+
the agent's origins it is opened on.
|
|
20
|
+
|
|
21
|
+
After adding a client, watch `~/.fedipod/profiles/<name>/agent.log` while using it:
|
|
22
|
+
every API endpoint it needs that the facade lacks is logged as an
|
|
23
|
+
`unhandled` line — that list is the to-do for full support.
|
package/vendor/gate.cjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Shared request gate for dk's bundled local servers (pivot CSS + CORS proxy).
|
|
2
|
+
// Plain Node — no electron imports — the spawned server processes require it.
|
|
3
|
+
//
|
|
4
|
+
// The servers are no-auth and loopback-bound, so the remaining attacker is a
|
|
5
|
+
// web page in some browser on this machine. The gate shuts that out with a
|
|
6
|
+
// per-install secret (DK_GATE_TOKEN, generated by servers.cjs). A request is
|
|
7
|
+
// authorized when it carries the secret:
|
|
8
|
+
// 1. header `x-dk-token` — the Electron shell injects this on app traffic, or
|
|
9
|
+
// 2. cookie `dk-token` — a browser "blessed" via the token URL, or
|
|
10
|
+
// 3. query `?dk-token=<secret>` — answered with a SameSite=Strict cookie and
|
|
11
|
+
// a redirect to the same URL minus the param (the blessing flow), or
|
|
12
|
+
// 4. (opt-in, proxy only) an Origin/Referer on the allowOrigins list — app
|
|
13
|
+
// pages in a blessed browser call the proxy with plain fetch(), which
|
|
14
|
+
// doesn't attach cookies cross-port; a hostile page can't forge Origin.
|
|
15
|
+
// Anything else gets a bare 401 with no CORS headers. No token → gate off
|
|
16
|
+
// (standalone dev runs of the servers stay open, as before the gate existed).
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const crypto = require('node:crypto');
|
|
21
|
+
|
|
22
|
+
const HEADER = 'x-dk-token';
|
|
23
|
+
const COOKIE = 'dk-token';
|
|
24
|
+
|
|
25
|
+
// A short-lived "bless" nonce so the DURABLE token never has to appear in a URL
|
|
26
|
+
// (which would leak it into the external browser's history / address bar). Main
|
|
27
|
+
// hands the browser `?dk-bless=<ts>.<hmac>`; the gate recomputes the HMAC from the
|
|
28
|
+
// token it already holds and checks the timestamp window — no shared state, and
|
|
29
|
+
// the nonce reveals nothing about the token and expires.
|
|
30
|
+
const BLESS_TTL_MS = 120_000;
|
|
31
|
+
function blessNonce(token, now = Date.now()) {
|
|
32
|
+
const ts = String(now);
|
|
33
|
+
const mac = crypto.createHmac('sha256', token).update(ts).digest('base64url');
|
|
34
|
+
return `${ts}.${mac}`;
|
|
35
|
+
}
|
|
36
|
+
function validBless(token, nonce, now = Date.now()) {
|
|
37
|
+
if (!token || !nonce) return false;
|
|
38
|
+
const i = String(nonce).indexOf('.');
|
|
39
|
+
if (i < 0) return false;
|
|
40
|
+
const ts = nonce.slice(0, i);
|
|
41
|
+
const t = Number(ts);
|
|
42
|
+
if (!Number.isFinite(t) || Math.abs(now - t) > BLESS_TTL_MS) return false;
|
|
43
|
+
const expect = crypto.createHmac('sha256', token).update(ts).digest('base64url');
|
|
44
|
+
try { return crypto.timingSafeEqual(Buffer.from(nonce.slice(i + 1)), Buffer.from(expect)); }
|
|
45
|
+
catch { return false; } // length mismatch etc.
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Endpoints that are PUBLIC by Solid/OIDC design and must pass the gate
|
|
49
|
+
// un-authenticated, otherwise third-party login is impossible:
|
|
50
|
+
// - a WebID profile document is publicly dereferenceable (it's the user's
|
|
51
|
+
// public identity; CSS's own server-side ownership check fetches it too),
|
|
52
|
+
// - the OIDC discovery + provider endpoints are how any external Solid app
|
|
53
|
+
// authenticates against this server.
|
|
54
|
+
// Everything else (the rest of the pod) stays gated.
|
|
55
|
+
function isPublicEndpoint(req) {
|
|
56
|
+
let p;
|
|
57
|
+
try { p = new URL(req.url, 'http://localhost').pathname; } catch { return false; }
|
|
58
|
+
if (p === '/.well-known/openid-configuration') return true;
|
|
59
|
+
if (p.startsWith('/.oidc/')) return true;
|
|
60
|
+
if (req.method === 'GET' && /\/profile\/card$/.test(p)) return true; // any pod's public WebID doc
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Fixed-width digest compare: neither the token's value nor its length leaks
|
|
65
|
+
// through timing. (Ported from data-kitchen's copy of this file.)
|
|
66
|
+
function tokenOk(given, token) {
|
|
67
|
+
if (typeof given !== 'string' || given === '') return false;
|
|
68
|
+
const a = crypto.createHash('sha256').update(given).digest();
|
|
69
|
+
const b = crypto.createHash('sha256').update(String(token)).digest();
|
|
70
|
+
return crypto.timingSafeEqual(a, b);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function cookieValue(header, name) {
|
|
74
|
+
for (const part of String(header || '').split(';')) {
|
|
75
|
+
const i = part.indexOf('=');
|
|
76
|
+
if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// `publicEndpoints` opts back into the exemptions above. They are CSS's rules,
|
|
82
|
+
// vendored with this file: this agent serves no WebID document and no OIDC
|
|
83
|
+
// provider, so on it they exempt three paths that only ever 404 — and would
|
|
84
|
+
// silently un-gate any future route underneath them. An agent exposed by
|
|
85
|
+
// AP_ALLOWED_HOSTS has nothing but this token, so the gate has to be total.
|
|
86
|
+
// `token` may be a function, resolved per request: an identity's secret can
|
|
87
|
+
// rotate while the server runs, and the very next request sees the new one.
|
|
88
|
+
function makeGate(token, { allowOrigins = [], publicEndpoints = false, secureCookie = false } = {}) {
|
|
89
|
+
const tokenNow = () => (typeof token === 'function' ? token() : token);
|
|
90
|
+
// gate(req, res) → true when the gate handled the response (caller stops).
|
|
91
|
+
function gate(req, res) {
|
|
92
|
+
const t = tokenNow();
|
|
93
|
+
if (!t) return false;
|
|
94
|
+
if (tokenOk(req.headers[HEADER], t)) return false;
|
|
95
|
+
if (tokenOk(cookieValue(req.headers.cookie, COOKIE), t)) return false;
|
|
96
|
+
|
|
97
|
+
const url = new URL(req.url, 'http://localhost');
|
|
98
|
+
// Bless a browser via ?dk-token=<secret> (legacy) or the leak-free
|
|
99
|
+
// ?dk-bless=<nonce> — either way, answer with the cookie and strip the param.
|
|
100
|
+
const blessParam = url.searchParams.get('dk-bless');
|
|
101
|
+
if (tokenOk(url.searchParams.get(COOKIE), t) || validBless(t, blessParam)) {
|
|
102
|
+
url.searchParams.delete(COOKIE);
|
|
103
|
+
url.searchParams.delete('dk-bless');
|
|
104
|
+
res.writeHead(302, {
|
|
105
|
+
'set-cookie': `${COOKIE}=${t}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000`
|
|
106
|
+
+ (secureCookie ? '; Secure' : ''),
|
|
107
|
+
'location': url.pathname + url.search,
|
|
108
|
+
});
|
|
109
|
+
res.end();
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const from = req.headers.origin || req.headers.referer || '';
|
|
114
|
+
if (allowOrigins.some((o) => from === o || from.startsWith(o + '/'))) return false;
|
|
115
|
+
|
|
116
|
+
if (publicEndpoints && isPublicEndpoint(req)) return false; // public WebID / OIDC endpoints
|
|
117
|
+
|
|
118
|
+
res.writeHead(401, { 'content-type': 'text/plain' });
|
|
119
|
+
res.end('fedipod: missing or bad token\n');
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Websocket upgrades have no res to answer with — just allowed or not.
|
|
124
|
+
gate.upgradeOk = (req) => {
|
|
125
|
+
const t = tokenNow();
|
|
126
|
+
return !t || tokenOk(req.headers[HEADER], t) || tokenOk(cookieValue(req.headers.cookie, COOKIE), t);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
return gate;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { makeGate, blessNonce, validBless };
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// idp-grant.cjs — mint a CSS client-credential and run the headless DPoP
|
|
2
|
+
// `client_credentials` grant that turns it into authenticated fetches.
|
|
3
|
+
//
|
|
4
|
+
// This is the durable, no-popup, no-Authorize path of "remember this IdP":
|
|
5
|
+
// - mintCredential(): drives the CSS account API (login → controls →
|
|
6
|
+
// create-client-credential) to obtain a long-lived, revocable {id, secret}.
|
|
7
|
+
// The account password is used transiently here and never persisted (the
|
|
8
|
+
// vault stores only the resulting credential — see idp-vault.cjs).
|
|
9
|
+
// - createGrantSession(): given a stored credential, manages a DPoP key pair +
|
|
10
|
+
// access-token lifecycle and exposes .fetch(url, init) — a Solid-OIDC
|
|
11
|
+
// DPoP-bound fetch identical in effect to an interactive session's fetch,
|
|
12
|
+
// but obtained with zero user interaction.
|
|
13
|
+
//
|
|
14
|
+
// All of this runs in the Electron MAIN process. The access token and DPoP
|
|
15
|
+
// private key never leave it; the renderer only ever gets a proxied fetch.
|
|
16
|
+
//
|
|
17
|
+
// The DPoP plumbing (proof JWTs, the use_dpop_nonce retry dance) is hand-built
|
|
18
|
+
// because @inrupt/solid-client-authn-node is not installed — jose (already in
|
|
19
|
+
// the tree) provides the signing primitives.
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const crypto = require('node:crypto');
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const { generateKeyPair, exportJWK, SignJWT } = require('jose');
|
|
26
|
+
const { jfetch } = require('./jfetch.cjs');
|
|
27
|
+
|
|
28
|
+
// The HTTP URI for a DPoP proof's `htu` is the request URL without query/fragment.
|
|
29
|
+
function htuOf(url) { const u = new URL(url); return u.origin + u.pathname; }
|
|
30
|
+
|
|
31
|
+
// Build a DPoP proof JWT bound to (htm, htu), optionally carrying a server nonce
|
|
32
|
+
// and the access-token hash (`ath`, required on resource requests).
|
|
33
|
+
async function dpopProof({ keyPair, htm, htu, nonce, accessToken }) {
|
|
34
|
+
const jwk = await exportJWK(keyPair.publicKey);
|
|
35
|
+
const payload = { htu, htm, jti: crypto.randomUUID() };
|
|
36
|
+
if (nonce) payload.nonce = nonce;
|
|
37
|
+
if (accessToken) payload.ath = crypto.createHash('sha256').update(accessToken).digest('base64url');
|
|
38
|
+
return new SignJWT(payload)
|
|
39
|
+
.setProtectedHeader({ alg: 'ES256', typ: 'dpop+jwt', jwk })
|
|
40
|
+
.setIssuedAt()
|
|
41
|
+
.sign(keyPair.privateKey);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Mint a durable client-credential for an issuer via its CSS account API.
|
|
46
|
+
* The password is used only here and discarded by the caller.
|
|
47
|
+
* @param {object} o
|
|
48
|
+
* @param {string} o.origin issuer origin, e.g. "https://solidcommunity.net"
|
|
49
|
+
* @param {string} o.email account email
|
|
50
|
+
* @param {string} o.password account password (transient — never stored)
|
|
51
|
+
* @param {string} [o.webId] WebID to bind; discovered from the account if absent
|
|
52
|
+
* @param {string} [o.podUrl] the pod this credential is for — picks the right
|
|
53
|
+
* WebID when the account owns more than one
|
|
54
|
+
* @param {string} [o.gateToken] x-dk-token, ONLY for the local gated origin
|
|
55
|
+
* @param {string} [o.name] human label for the credential
|
|
56
|
+
* @returns {{clientId, secret, webId, tokenEndpoint, issuerOrigin}}
|
|
57
|
+
*/
|
|
58
|
+
async function mintCredential({ origin, email, password, webId, podUrl, gateToken, name = 'data-kitchen' }) {
|
|
59
|
+
const accountRoot = `${origin}/.account/`;
|
|
60
|
+
|
|
61
|
+
const pre = (await jfetch(accountRoot, { gateToken })).json;
|
|
62
|
+
const loginUrl = pre?.controls?.password?.login || `${accountRoot}login/password/`;
|
|
63
|
+
const login = await jfetch(loginUrl, { method: 'POST', gateToken, body: { email, password } });
|
|
64
|
+
if (login.status >= 400 || !login.json?.authorization) {
|
|
65
|
+
throw new Error(`account login failed (HTTP ${login.status})`);
|
|
66
|
+
}
|
|
67
|
+
const cookie = `css-account=${login.json.authorization}`;
|
|
68
|
+
|
|
69
|
+
const controls = (await jfetch(accountRoot, { gateToken, cookie })).json?.controls;
|
|
70
|
+
const ccUrl = controls?.account?.clientCredentials;
|
|
71
|
+
if (!ccUrl) throw new Error('clientCredentials control missing — issuer is not a CSS account API');
|
|
72
|
+
|
|
73
|
+
let wid = webId;
|
|
74
|
+
if (!wid) {
|
|
75
|
+
const linkCtl = controls?.account?.webId;
|
|
76
|
+
const links = linkCtl ? (await jfetch(linkCtl, { gateToken, cookie })).json?.webIdLinks : null;
|
|
77
|
+
const all = links ? Object.keys(links) : [];
|
|
78
|
+
// An account may own several pods, each with its own WebID. Taking the
|
|
79
|
+
// first is right only by luck: bound to another pod's WebID the credential
|
|
80
|
+
// authenticates fine and then 403s every write, which reads as a broken
|
|
81
|
+
// server rather than a mis-bound token.
|
|
82
|
+
let origins = [];
|
|
83
|
+
try { origins = podUrl ? all.filter(w => new URL(w).origin === new URL(podUrl).origin) : []; } catch {}
|
|
84
|
+
wid = origins[0] || all[0];
|
|
85
|
+
if (!wid) throw new Error('no WebID is linked to this account');
|
|
86
|
+
if (podUrl && !origins.length && all.length > 1) {
|
|
87
|
+
throw new Error(`this account has ${all.length} WebIDs and none of them is on ${new URL(podUrl).origin} `
|
|
88
|
+
+ '— the pod and the credential would not match');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const made = await jfetch(ccUrl, { method: 'POST', gateToken, cookie, body: { name, webId: wid } });
|
|
93
|
+
if (made.status >= 400 || !made.json?.secret) {
|
|
94
|
+
throw new Error(`mint failed (HTTP ${made.status}): ${made.json?.message || ''}`);
|
|
95
|
+
}
|
|
96
|
+
const tokenEndpoint = await discoverTokenEndpoint(origin, gateToken);
|
|
97
|
+
return { clientId: made.json.id, secret: made.json.secret, webId: wid, tokenEndpoint, resource: made.json.resource, issuerOrigin: origin };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Discover an issuer's OIDC token endpoint. gateToken only for the local origin. */
|
|
101
|
+
async function discoverTokenEndpoint(origin, gateToken) {
|
|
102
|
+
const headers = { accept: 'application/json' };
|
|
103
|
+
if (gateToken) headers['x-dk-token'] = gateToken;
|
|
104
|
+
const res = await fetch(`${origin}/.well-known/openid-configuration`, { headers });
|
|
105
|
+
const cfg = await res.json().catch(() => ({}));
|
|
106
|
+
if (!cfg.token_endpoint) throw new Error('no token_endpoint in OIDC configuration');
|
|
107
|
+
return cfg.token_endpoint;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Revoke a credential server-side (best-effort), so forgetting truly unlinks it.
|
|
112
|
+
* Needs the account password to re-authenticate, so this is only possible where we
|
|
113
|
+
* still hold it (the local pod). The credential's `resource` URL is returned by
|
|
114
|
+
* mintCredential and kept in the vault for exactly this.
|
|
115
|
+
*/
|
|
116
|
+
async function revokeCredentialViaAccount({ origin, email, password, gateToken, resource }) {
|
|
117
|
+
if (!resource) return false;
|
|
118
|
+
const accountRoot = `${origin}/.account/`;
|
|
119
|
+
const pre = (await jfetch(accountRoot, { gateToken })).json;
|
|
120
|
+
const loginUrl = pre?.controls?.password?.login || `${accountRoot}login/password/`;
|
|
121
|
+
const login = await jfetch(loginUrl, { method: 'POST', gateToken, body: { email, password } });
|
|
122
|
+
if (!login.json?.authorization) return false;
|
|
123
|
+
const cookie = `css-account=${login.json.authorization}`;
|
|
124
|
+
const res = await fetch(resource, { method: 'DELETE', headers: { cookie, ...(gateToken ? { 'x-dk-token': gateToken } : {}) } });
|
|
125
|
+
return res.ok;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Build a headless authenticated session from a stored credential. Manages one
|
|
130
|
+
* DPoP key pair and a cached access token (re-granted on expiry/401), and
|
|
131
|
+
* handles the use_dpop_nonce challenge on both the token and resource servers.
|
|
132
|
+
* @param {{clientId, secret, webId, tokenEndpoint, issuerOrigin}} rec
|
|
133
|
+
* @param {object} [o]
|
|
134
|
+
* @param {string} [o.gateToken] x-dk-token value
|
|
135
|
+
* @param {string} [o.gatedOrigin] the origin x-dk-token applies to (local pod only)
|
|
136
|
+
* @returns {{webId, issuer, fetch}}
|
|
137
|
+
*/
|
|
138
|
+
// Every token request costs the issuer an OIDC replay-detection write, and that
|
|
139
|
+
// write takes a lock — so retrying one without backoff compounds the contention
|
|
140
|
+
// that slowed it down. solidcommunity.net's operators measured ~30 of our token
|
|
141
|
+
// requests in 5 seconds during their 2026-07-29 outage: one forced grant per 401
|
|
142
|
+
// across a sweep, with nothing coalescing them. Hence one grant in flight at a
|
|
143
|
+
// time, a breaker shut for a jittered exponential interval after each failure
|
|
144
|
+
// (or for Retry-After when offered), and a floor on how often a 401 may force a
|
|
145
|
+
// fresh grant.
|
|
146
|
+
const TOKEN_BACKOFF_MIN_MS = 1_000;
|
|
147
|
+
const TOKEN_BACKOFF_MAX_MS = 60_000;
|
|
148
|
+
const FORCE_COOLDOWN_MS = 10_000;
|
|
149
|
+
|
|
150
|
+
// Identify ourselves. An operator reading their access log should be able to
|
|
151
|
+
// tell who we are and where to complain: solidcommunity.net's incident report
|
|
152
|
+
// had to infer us from client-credential names because every request said only
|
|
153
|
+
// "node", which is undici's default.
|
|
154
|
+
const { version: AGENT_VERSION } = require('../package.json');
|
|
155
|
+
// This is the one on the token grants, so it is the one scn actually logged.
|
|
156
|
+
// Renamed 2026-07-31 in lockstep with lib/ua.mjs — changing only that leaves
|
|
157
|
+
// the grants still announcing the old name.
|
|
158
|
+
const USER_AGENT = `fedipod/${AGENT_VERSION} (+https://github.com/jeff-zucker/FediPod)`;
|
|
159
|
+
|
|
160
|
+
// A ceiling no timer, retry or future bug can exceed. Steady state is ~3
|
|
161
|
+
// requests/minute, so the default sits 20x above normal use and only engages
|
|
162
|
+
// when something has gone wrong.
|
|
163
|
+
const DEFAULT_MAX_PER_MIN = 60;
|
|
164
|
+
|
|
165
|
+
// A crash loop would otherwise reset the breaker on every boot and force a
|
|
166
|
+
// grant immediately — systemd restarts us on failure, so backoff has to outlive
|
|
167
|
+
// the process. One small file, best-effort: an unreadable or unwritable one just
|
|
168
|
+
// means we behave as before.
|
|
169
|
+
// --- token reuse across restarts -------------------------------------------
|
|
170
|
+
function readSavedToken(file) {
|
|
171
|
+
if (!file) return null;
|
|
172
|
+
try {
|
|
173
|
+
const d = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
174
|
+
if (!d.accessToken || !d.privateJwk) return null;
|
|
175
|
+
if (!(Number(d.expiresAt) > Date.now() + 60_000)) return null; // too near expiry to bother
|
|
176
|
+
return d;
|
|
177
|
+
} catch { return null; }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function importSavedKey(saved) {
|
|
181
|
+
const { importJWK } = require('jose');
|
|
182
|
+
const privateKey = await importJWK(saved.privateJwk, 'ES256');
|
|
183
|
+
const publicKey = await importJWK(saved.publicJwk, 'ES256');
|
|
184
|
+
return { privateKey, publicKey };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function saveToken(file, keyPair, accessToken, expiresAt) {
|
|
188
|
+
if (!file) return;
|
|
189
|
+
try {
|
|
190
|
+
const { exportJWK } = require('jose');
|
|
191
|
+
fs.writeFileSync(file, JSON.stringify({
|
|
192
|
+
accessToken, expiresAt,
|
|
193
|
+
privateJwk: await exportJWK(keyPair.privateKey),
|
|
194
|
+
publicJwk: await exportJWK(keyPair.publicKey),
|
|
195
|
+
}) + '\n', { mode: 0o600 });
|
|
196
|
+
} catch { /* best-effort: a fresh grant next start is the only cost */ }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function readBackoff(file) {
|
|
200
|
+
if (!file) return { breakerUntil: 0, failures: 0 };
|
|
201
|
+
try {
|
|
202
|
+
const d = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
203
|
+
return { breakerUntil: Number(d.breakerUntil) || 0, failures: Number(d.failures) || 0 };
|
|
204
|
+
} catch { return { breakerUntil: 0, failures: 0 }; }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function writeBackoff(file, breakerUntil, failures) {
|
|
208
|
+
if (!file) return;
|
|
209
|
+
try { fs.writeFileSync(file, JSON.stringify({ breakerUntil, failures }) + '\n', { mode: 0o600 }); }
|
|
210
|
+
catch { /* best-effort */ }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function createGrantSession(rec, { gateToken, gatedOrigin, backoffFile = null, tokenFile = null } = {}) {
|
|
214
|
+
const { clientId, secret, webId, tokenEndpoint, issuerOrigin } = rec;
|
|
215
|
+
// A DPoP token is bound to the key that requested it, so reusing one across a
|
|
216
|
+
// restart means reusing the key too. Both go in one 0600 file beside the
|
|
217
|
+
// credential that could mint them anyway. Any problem reading it falls back
|
|
218
|
+
// to a fresh key and a fresh grant.
|
|
219
|
+
const saved = readSavedToken(tokenFile);
|
|
220
|
+
const keyPairP = saved
|
|
221
|
+
// `extractable: true` because the DPoP key is exported two ways: as the
|
|
222
|
+
// public JWK that rides in every proof header, and to token.json so a
|
|
223
|
+
// restart resumes the same session instead of minting a fresh grant. jose 6
|
|
224
|
+
// makes generated keys non-extractable by default, which turns both of
|
|
225
|
+
// those into "non-extractable CryptoKey cannot be exported as a JWK".
|
|
226
|
+
? importSavedKey(saved).catch(() => generateKeyPair('ES256', { extractable: true }))
|
|
227
|
+
: generateKeyPair('ES256', { extractable: true });
|
|
228
|
+
let accessToken = saved?.accessToken || null;
|
|
229
|
+
let expiresAt = saved?.expiresAt || 0;
|
|
230
|
+
let rsNonce = null;
|
|
231
|
+
let inFlight = null; // single-flight: N callers, one grant
|
|
232
|
+
const carried = readBackoff(backoffFile);
|
|
233
|
+
let failures = carried.failures, breakerUntil = carried.breakerUntil, lastForced = 0;
|
|
234
|
+
const maxPerMin = Number(process.env.AP_MAX_REQUESTS_PER_MIN) || DEFAULT_MAX_PER_MIN;
|
|
235
|
+
let allowance = maxPerMin, lastRefill = Date.now();
|
|
236
|
+
// Token bucket over every request this session makes — resource and token
|
|
237
|
+
// endpoint alike, since both cost the issuer a lock.
|
|
238
|
+
// Accounting, so "are we the problem?" is a question the agent can answer
|
|
239
|
+
// rather than one that takes an evening of reading. Counts every request this
|
|
240
|
+
// session makes, by outcome, plus a rolling one-minute rate.
|
|
241
|
+
const counts = { requests: 0, tokenGrants: 0, ok: 0, client4xx: 0, server5xx: 0, refused: 0, failed: 0 };
|
|
242
|
+
const recent = []; // request timestamps, last 60s
|
|
243
|
+
const note = (bucket) => { counts[bucket]++; };
|
|
244
|
+
const rate = () => {
|
|
245
|
+
const cutoff = Date.now() - 60_000;
|
|
246
|
+
while (recent.length && recent[0] < cutoff) recent.shift();
|
|
247
|
+
return recent.length;
|
|
248
|
+
};
|
|
249
|
+
const takeSlot = () => {
|
|
250
|
+
const now = Date.now();
|
|
251
|
+
allowance = Math.min(maxPerMin, allowance + ((now - lastRefill) * maxPerMin) / 60_000);
|
|
252
|
+
lastRefill = now;
|
|
253
|
+
if (allowance < 1) return false;
|
|
254
|
+
allowance -= 1;
|
|
255
|
+
counts.requests++;
|
|
256
|
+
recent.push(now);
|
|
257
|
+
return true;
|
|
258
|
+
};
|
|
259
|
+
// The ceiling is OUR politeness, not the server's refusal, so hitting it must
|
|
260
|
+
// WAIT rather than fail: a first-run setup legitimately needs ~60 writes, and
|
|
261
|
+
// throwing there left an account, a pod and a half-published actor behind.
|
|
262
|
+
// Refusing outright is also not the polite option — it just moves the retry
|
|
263
|
+
// somewhere with less information. The cap is a backstop so a genuinely
|
|
264
|
+
// wedged bucket still surfaces instead of hanging for ever.
|
|
265
|
+
const SLOT_WAIT_MAX_MS = Number(process.env.AP_SLOT_WAIT_MAX_MS) || 120_000;
|
|
266
|
+
const waitForSlot = async (what) => {
|
|
267
|
+
if (takeSlot()) return;
|
|
268
|
+
const deadline = Date.now() + SLOT_WAIT_MAX_MS;
|
|
269
|
+
const step = Math.max(50, Math.ceil(60_000 / maxPerMin));
|
|
270
|
+
for (;;) {
|
|
271
|
+
await new Promise(r => setTimeout(r, step));
|
|
272
|
+
if (takeSlot()) return;
|
|
273
|
+
if (Date.now() >= deadline) {
|
|
274
|
+
note('refused');
|
|
275
|
+
throw new Error(`local ceiling of ${maxPerMin} requests/min: no slot after `
|
|
276
|
+
+ `${Math.round(SLOT_WAIT_MAX_MS / 1000)}s — ${what}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const stats = () => ({
|
|
282
|
+
...counts,
|
|
283
|
+
perMinuteNow: rate(),
|
|
284
|
+
ceilingPerMinute: maxPerMin,
|
|
285
|
+
backingOffFor: Math.max(0, Math.round((breakerUntil - Date.now()) / 1000)),
|
|
286
|
+
});
|
|
287
|
+
const gateFor = (url) => (gatedOrigin && new URL(url).origin === gatedOrigin) ? gateToken : undefined;
|
|
288
|
+
|
|
289
|
+
async function requestToken(keyPair, nonce) {
|
|
290
|
+
const proof = await dpopProof({ keyPair, htm: 'POST', htu: htuOf(tokenEndpoint), nonce });
|
|
291
|
+
const headers = {
|
|
292
|
+
authorization: 'Basic ' + Buffer.from(`${encodeURIComponent(clientId)}:${encodeURIComponent(secret)}`).toString('base64'),
|
|
293
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
294
|
+
'user-agent': USER_AGENT,
|
|
295
|
+
dpop: proof,
|
|
296
|
+
};
|
|
297
|
+
const gate = gateFor(tokenEndpoint); if (gate) headers['x-dk-token'] = gate;
|
|
298
|
+
// Deliberately shorter than the 60s nginx in front of a typical CSS: if a
|
|
299
|
+
// grant has not landed in 30s the server is in trouble, and holding its
|
|
300
|
+
// worker to the wire makes that worse. We back off and try later instead.
|
|
301
|
+
// AP_HTTP_TIMEOUT_MS raises it for a knowingly slow issuer.
|
|
302
|
+
await waitForSlot('token request');
|
|
303
|
+
const res = await fetch(tokenEndpoint, {
|
|
304
|
+
method: 'POST', headers, body: 'grant_type=client_credentials&scope=webid',
|
|
305
|
+
signal: AbortSignal.timeout(Number(process.env.AP_HTTP_TIMEOUT_MS) || 30_000),
|
|
306
|
+
});
|
|
307
|
+
if ((res.status === 400 || res.status === 401) && !nonce) {
|
|
308
|
+
const n = res.headers.get('dpop-nonce');
|
|
309
|
+
if (n) return requestToken(keyPair, n);
|
|
310
|
+
}
|
|
311
|
+
// Truncated: an ailing issuer answers with a whole HTML page (a
|
|
312
|
+
// Cloudflare 504, say) and this message goes into the log.
|
|
313
|
+
if (!res.ok) {
|
|
314
|
+
const body = (await res.text().catch(() => '')).replace(/\s+/g, ' ').slice(0, 200);
|
|
315
|
+
note(res.status >= 500 ? 'server5xx' : 'client4xx');
|
|
316
|
+
const err = new Error(`token request failed (HTTP ${res.status}): ${body}`);
|
|
317
|
+
const ra = Number(res.headers.get('retry-after'));
|
|
318
|
+
if (Number.isFinite(ra) && ra > 0) err.retryAfterMs = Math.min(ra * 1000, TOKEN_BACKOFF_MAX_MS);
|
|
319
|
+
throw err;
|
|
320
|
+
}
|
|
321
|
+
counts.tokenGrants++;
|
|
322
|
+
note('ok');
|
|
323
|
+
return res.json();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function ensureToken(force) {
|
|
327
|
+
const keyPair = await keyPairP;
|
|
328
|
+
if (!force && accessToken && Date.now() < expiresAt - 30_000) return keyPair;
|
|
329
|
+
if (inFlight) { await inFlight; return keyPair; } // someone else is already asking
|
|
330
|
+
const shut = breakerUntil - Date.now();
|
|
331
|
+
if (shut > 0) throw new Error(`token endpoint backing off — ${Math.ceil(shut / 1000)}s left`);
|
|
332
|
+
inFlight = (async () => {
|
|
333
|
+
try {
|
|
334
|
+
const tok = await requestToken(keyPair, null);
|
|
335
|
+
accessToken = tok.access_token;
|
|
336
|
+
expiresAt = Date.now() + (Number(tok.expires_in) || 300) * 1000;
|
|
337
|
+
failures = 0;
|
|
338
|
+
writeBackoff(backoffFile, 0, 0);
|
|
339
|
+
await saveToken(tokenFile, keyPair, accessToken, expiresAt);
|
|
340
|
+
} catch (e) {
|
|
341
|
+
failures++;
|
|
342
|
+
const capped = Math.min(TOKEN_BACKOFF_MIN_MS * 2 ** (failures - 1), TOKEN_BACKOFF_MAX_MS);
|
|
343
|
+
breakerUntil = Date.now() + (e.retryAfterMs ?? Math.round(capped * (0.8 + Math.random() * 0.4)));
|
|
344
|
+
writeBackoff(backoffFile, breakerUntil, failures);
|
|
345
|
+
throw e;
|
|
346
|
+
} finally { inFlight = null; }
|
|
347
|
+
})();
|
|
348
|
+
await inFlight;
|
|
349
|
+
return keyPair;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function doFetch(url, init = {}, retried = false) {
|
|
353
|
+
const keyPair = await ensureToken(false);
|
|
354
|
+
const method = (init.method || 'GET').toUpperCase();
|
|
355
|
+
const proof = await dpopProof({ keyPair, htm: method, htu: htuOf(url), accessToken, nonce: rsNonce });
|
|
356
|
+
const headers = {
|
|
357
|
+
'user-agent': USER_AGENT,
|
|
358
|
+
...(init.headers || {}),
|
|
359
|
+
authorization: `DPoP ${accessToken}`, dpop: proof,
|
|
360
|
+
};
|
|
361
|
+
const gate = gateFor(url); if (gate) headers['x-dk-token'] = gate;
|
|
362
|
+
await waitForSlot(`${method} ${url}`);
|
|
363
|
+
const res = await fetch(url, {
|
|
364
|
+
signal: AbortSignal.timeout(Number(process.env.AP_HTTP_TIMEOUT_MS) || 20_000), ...init, headers,
|
|
365
|
+
});
|
|
366
|
+
if (res.status >= 500) note('server5xx');
|
|
367
|
+
else if (res.status >= 400) note('client4xx');
|
|
368
|
+
else note('ok');
|
|
369
|
+
if (res.status === 401 && !retried) {
|
|
370
|
+
const n = res.headers.get('dpop-nonce');
|
|
371
|
+
if (n) { rsNonce = n; return doFetch(url, init, true); } // server wants a nonce
|
|
372
|
+
// Or the token expired — but a sweep whose every request 401s must not
|
|
373
|
+
// mint a token per request, so force at most one grant per window and
|
|
374
|
+
// otherwise retry with what we already hold.
|
|
375
|
+
if (Date.now() - lastForced > FORCE_COOLDOWN_MS) {
|
|
376
|
+
lastForced = Date.now();
|
|
377
|
+
await ensureToken(true);
|
|
378
|
+
}
|
|
379
|
+
return doFetch(url, init, true);
|
|
380
|
+
}
|
|
381
|
+
return res;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Confirms the credential works before the session is used — but a token we
|
|
385
|
+
// already hold IS that confirmation, and forcing a grant here was defeating
|
|
386
|
+
// the persisted one entirely: every restart paid the issuer for a token it
|
|
387
|
+
// already had. Cold start still mints (and so still fails loudly on a revoked
|
|
388
|
+
// credential); a warm one costs nothing.
|
|
389
|
+
async function warmup() { await ensureToken(false); return webId; }
|
|
390
|
+
|
|
391
|
+
return { webId, issuer: issuerOrigin, fetch: doFetch, warmup, stats };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
module.exports = { mintCredential, discoverTokenEndpoint, revokeCredentialViaAccount, createGrantSession };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// JSON fetch helper for the CSS account API, shared by seed-account.cjs (owner
|
|
4
|
+
// account provisioning) and idp-grant.cjs (the durable client-credentials grant).
|
|
5
|
+
// Returns { status, json } with json null on a non-JSON body.
|
|
6
|
+
async function jfetch(url, { method = 'GET', body, gateToken, cookie } = {}) {
|
|
7
|
+
const headers = { accept: 'application/json' };
|
|
8
|
+
if (body !== undefined) headers['content-type'] = 'application/json';
|
|
9
|
+
if (gateToken) headers['x-dk-token'] = gateToken;
|
|
10
|
+
if (cookie) headers.cookie = cookie;
|
|
11
|
+
const res = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
12
|
+
let json = null; try { json = await res.json(); } catch { /* non-JSON */ }
|
|
13
|
+
return { status: res.status, json };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = { jfetch };
|