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
package/lib/keys.mjs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// keys.mjs — actor signing keys. RSA (Mastodon's draft-cavage HTTP Signatures
|
|
2
|
+
// require RSA-SHA256) + Ed25519 (stored for future FEP-8b32 use, not yet in
|
|
3
|
+
// the actor doc). PEM at rest (0600), CryptoKey in memory for Fedify's
|
|
4
|
+
// signRequest.
|
|
5
|
+
//
|
|
6
|
+
// The key lives on THIS MACHINE by default (AP_HOME/keys.json): the pod host
|
|
7
|
+
// then never holds it. `setup --keys pod` puts it in pod state instead,
|
|
8
|
+
// which is what lets several devices sign as the same actor without copying
|
|
9
|
+
// anything — see resolveKeys() for the guard that keeps the two modes from
|
|
10
|
+
// silently breaking each other.
|
|
11
|
+
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { writeJsonAtomic } from './home.mjs';
|
|
16
|
+
|
|
17
|
+
const RSA_ALG = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
|
|
18
|
+
|
|
19
|
+
const localPath = (dir) => path.join(dir, 'keys.json');
|
|
20
|
+
const readLocal = (dir) => {
|
|
21
|
+
try { return JSON.parse(fs.readFileSync(localPath(dir), 'utf8')); } catch { return null; }
|
|
22
|
+
};
|
|
23
|
+
// Atomic: this is the private key remote servers have cached, and a truncated
|
|
24
|
+
// one cannot be recovered from anywhere — the pod does not hold it.
|
|
25
|
+
const writeLocal = (dir, rec) => {
|
|
26
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
27
|
+
writeJsonAtomic(localPath(dir), rec);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function generate() {
|
|
31
|
+
const rsa = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
32
|
+
const ed = crypto.generateKeyPairSync('ed25519');
|
|
33
|
+
return {
|
|
34
|
+
rsa: {
|
|
35
|
+
publicPem: rsa.publicKey.export({ type: 'spki', format: 'pem' }),
|
|
36
|
+
privatePem: rsa.privateKey.export({ type: 'pkcs8', format: 'pem' }),
|
|
37
|
+
},
|
|
38
|
+
ed25519: {
|
|
39
|
+
publicPem: ed.publicKey.export({ type: 'spki', format: 'pem' }),
|
|
40
|
+
privatePem: ed.privateKey.export({ type: 'pkcs8', format: 'pem' }),
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Find or create the key record.
|
|
47
|
+
*
|
|
48
|
+
* The dangerous case is minting a NEW key when the actor already advertises
|
|
49
|
+
* one: remote servers cache the public key, so a fresh keypair silently
|
|
50
|
+
* invalidates every signature until the actor document is republished — and
|
|
51
|
+
* then breaks whichever other device holds the old key. So when key material
|
|
52
|
+
* is missing but the actor doc already has a key, we refuse and explain,
|
|
53
|
+
* unless the caller passes rotate:true deliberately.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} store pod-backed state store
|
|
56
|
+
* @param {object} o
|
|
57
|
+
* @param {string|null} o.localDir AP_HOME when keys are local (the default)
|
|
58
|
+
* @param {boolean} o.rotate mint a replacement on purpose
|
|
59
|
+
* @param {function} [o.actorHasKey] async () => boolean, consulted only when
|
|
60
|
+
* no key material exists anywhere
|
|
61
|
+
* @param {function} [o.log]
|
|
62
|
+
*/
|
|
63
|
+
export async function resolveKeys(store, { localDir = null, rotate = false, actorHasKey = null, actorId = null, log = () => {} } = {}) {
|
|
64
|
+
let rec = localDir ? readLocal(localDir) : null;
|
|
65
|
+
|
|
66
|
+
// A key belongs to ONE actor. Adopting whatever keys.json happens to be in
|
|
67
|
+
// the directory is right within an identity and wrong across two: it is how
|
|
68
|
+
// a second setup in the same AP_HOME ended up signing as a different actor
|
|
69
|
+
// with the first one's key, which links the two publicly for anyone who
|
|
70
|
+
// compares the actor documents. A stamped key that names another actor is
|
|
71
|
+
// treated as absent, so the mint path (and its actorHasKey guard) applies.
|
|
72
|
+
if (rec && actorId && rec.mintedFor && rec.mintedFor !== actorId) {
|
|
73
|
+
log(`local keys.json belongs to ${rec.mintedFor} — not reusing it for ${actorId}`);
|
|
74
|
+
rec = null;
|
|
75
|
+
} else if (rec && actorId && !rec.mintedFor) {
|
|
76
|
+
// Written before keys were stamped: keep working, but say so, because it
|
|
77
|
+
// may be another actor's key.
|
|
78
|
+
log('keys.json predates per-identity stamping — if this actor was set up in a '
|
|
79
|
+
+ 'home that already held a key, `setup --rotate-key` mints one of its own');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!rec) {
|
|
83
|
+
const podRec = store.read('keys.json', null);
|
|
84
|
+
if (podRec && localDir) {
|
|
85
|
+
// Migrating an existing actor to local keys: adopt the pod's key so
|
|
86
|
+
// the identity is preserved, then remove the pod's copy — leaving it
|
|
87
|
+
// would defeat the point of going local.
|
|
88
|
+
writeLocal(localDir, podRec);
|
|
89
|
+
rec = podRec;
|
|
90
|
+
const gone = await store.remove('keys.json');
|
|
91
|
+
log(gone
|
|
92
|
+
? `signing key moved to ${localPath(localDir)} and deleted from pod state`
|
|
93
|
+
: `signing key copied to ${localPath(localDir)} — could NOT delete the pod copy, remove it by hand`);
|
|
94
|
+
} else {
|
|
95
|
+
rec = podRec;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!rec && !rotate && actorHasKey && await actorHasKey()) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
'this actor already publishes a signing key, but no key material is present here.\n'
|
|
102
|
+
+ ` Copy keys.json from your other device into ${localDir || 'pod state'}, or\n`
|
|
103
|
+
+ ' re-run setup with --keys pod to share the key through the pod, or\n'
|
|
104
|
+
// --force, because rotate-key connects before it rotates and would meet
|
|
105
|
+
// this same refusal: advice has to name a command that can actually run.
|
|
106
|
+
+ ' run `fedipod rotate-key --force` to replace the published key (breaks other devices).');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!rec || rotate) {
|
|
110
|
+
rec = { ...generate(), ...(actorId ? { mintedFor: actorId } : {}) };
|
|
111
|
+
if (localDir) writeLocal(localDir, rec); else store.write('keys.json', rec);
|
|
112
|
+
log(rotate ? 'minted a REPLACEMENT signing key — republish the profile' : 'minted a signing key');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const der = crypto.createPrivateKey(rec.rsa.privatePem).export({ type: 'pkcs8', format: 'der' });
|
|
116
|
+
const rsaPrivate = await crypto.subtle.importKey('pkcs8', der, RSA_ALG, true, ['sign']);
|
|
117
|
+
return { rsaPrivate, rsaPublicPem: rec.rsa.publicPem };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Move a local key into pod state — the arrangement that lets several
|
|
121
|
+
// devices sign as one actor once the state store is a pod they all reach.
|
|
122
|
+
// The pod write is forced and checked BEFORE the local file goes: a key
|
|
123
|
+
// remote servers have cached cannot be recovered from anywhere else.
|
|
124
|
+
export async function moveKeysToPod(store, { localDir, actorId = null, log = () => {} }) {
|
|
125
|
+
const rec = readLocal(localDir);
|
|
126
|
+
if (!rec) throw new Error(`no local key at ${localPath(localDir)} — nothing to move`);
|
|
127
|
+
if (actorId && rec.mintedFor && rec.mintedFor !== actorId) {
|
|
128
|
+
throw new Error(`local keys.json belongs to ${rec.mintedFor}, not ${actorId} — refusing to move it`);
|
|
129
|
+
}
|
|
130
|
+
store.write('keys.json', rec);
|
|
131
|
+
const landed = store.commit ? await store.commit() : await store.flush?.();
|
|
132
|
+
if (landed === false) throw new Error('the pod write did not land — the local key is untouched');
|
|
133
|
+
fs.rmSync(localPath(localDir), { force: true });
|
|
134
|
+
log('signing key moved to pod state and removed from this machine');
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Back-compat alias for callers that just want "the keys, wherever they are".
|
|
139
|
+
export const ensureKeys = (store, opts) => resolveKeys(store, opts);
|
package/lib/lease.mjs
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// lease.mjs — single-active-agent coordination. The inbox drain is a
|
|
2
|
+
// destructive read and the state store is write-through-cached, so exactly
|
|
3
|
+
// one agent may ACT on a pod at a time; later arrivals run as read-only
|
|
4
|
+
// viewers. The lease is one JSON doc in ap-state, always read and written
|
|
5
|
+
// with FRESH fetches (never through the cached store), renewed while held.
|
|
6
|
+
// Best-effort by design: a lost renewal just means the other agent takes
|
|
7
|
+
// over at the next expiry.
|
|
8
|
+
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
// Renewal is the one thing that writes even when nothing is happening: at 30s
|
|
12
|
+
// it was 120 PUTs an hour per agent, each taking a write lock on the pod. 90s
|
|
13
|
+
// against a 5-minute TTL leaves three missed renewals of headroom and cuts that
|
|
14
|
+
// load by two thirds. The cost is automatic promotion after a crash waiting up
|
|
15
|
+
// to the TTL — a user acting on a second device does not wait, since takeover()
|
|
16
|
+
// claims the lease outright.
|
|
17
|
+
const TTL_MS = 300_000;
|
|
18
|
+
const RENEW_MS = 90_000;
|
|
19
|
+
const JITTER = () => 0.85 + Math.random() * 0.3;
|
|
20
|
+
// Distinct from null: null is "nobody holds it", this is "we could not ask".
|
|
21
|
+
const UNREADABLE = Symbol('lease-unreadable');
|
|
22
|
+
|
|
23
|
+
export class Lease {
|
|
24
|
+
constructor({ url, fetchImpl, log = console.log }) {
|
|
25
|
+
this.url = url;
|
|
26
|
+
this.fetchImpl = fetchImpl;
|
|
27
|
+
this.log = log;
|
|
28
|
+
this.id = crypto.randomUUID();
|
|
29
|
+
this.stopped = false;
|
|
30
|
+
this.timer = null;
|
|
31
|
+
this.heldUntil = 0; // wall-clock end of the lease we last wrote
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// null means the lease document is NOT THERE — nobody holds it. UNREADABLE
|
|
35
|
+
// means we could not ask, which is a different answer and must not be read as
|
|
36
|
+
// "free": acquire() treated both as free, so a pod hiccup during acquire
|
|
37
|
+
// could hand a second agent a lease the first still holds, and both would
|
|
38
|
+
// drain the same inbox destructively.
|
|
39
|
+
async readFresh() {
|
|
40
|
+
let res, body;
|
|
41
|
+
try {
|
|
42
|
+
res = await this.fetchImpl(this.url, { headers: { accept: 'application/json' } });
|
|
43
|
+
if (res.status === 404 || res.status === 410) return null;
|
|
44
|
+
if (res.status >= 400) return UNREADABLE;
|
|
45
|
+
body = await res.text();
|
|
46
|
+
} catch { return UNREADABLE; }
|
|
47
|
+
this.etag = res.headers?.get?.('etag') || null;
|
|
48
|
+
// A 200 carrying garbage — a half-written PUT, a proxy error page served
|
|
49
|
+
// as 200 — is not a lease anybody holds, and overwriting it is the repair.
|
|
50
|
+
// Calling that UNREADABLE would leave the agent a viewer forever, because
|
|
51
|
+
// acquire() refuses to write over what it could not read.
|
|
52
|
+
try { return JSON.parse(body); }
|
|
53
|
+
catch { this.log('lease document is unparsable — treating it as unheld'); return null; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Returns false only for a refused If-Match — that is not an error, it is the
|
|
57
|
+
// answer. Anything else throws.
|
|
58
|
+
async write(doc, { ifMatch = null } = {}) {
|
|
59
|
+
const res = await this.fetchImpl(this.url, {
|
|
60
|
+
method: 'PUT',
|
|
61
|
+
headers: { 'content-type': 'application/json', ...(ifMatch ? { 'if-match': ifMatch } : {}) },
|
|
62
|
+
body: JSON.stringify(doc),
|
|
63
|
+
});
|
|
64
|
+
if (ifMatch && res.status === 412) return false;
|
|
65
|
+
if (res.status >= 400) throw new Error(`lease PUT → ${res.status}`);
|
|
66
|
+
this.etag = res.headers?.get?.('etag') || null;
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// True when this agent now holds the lease. Write-then-confirm narrows the
|
|
71
|
+
// last-writer-wins race to a small window — acceptable for a human-scale
|
|
72
|
+
// "laptop vs phone" situation.
|
|
73
|
+
async acquire() {
|
|
74
|
+
const cur = await this.readFresh();
|
|
75
|
+
// Cannot read it: stay a viewer. A viewer that should have been active is
|
|
76
|
+
// an inconvenience; two agents both draining is the destructive one.
|
|
77
|
+
if (cur === UNREADABLE) { this.log('lease unreadable — staying a viewer'); return false; }
|
|
78
|
+
if (cur && cur.holder !== this.id && Date.now() < cur.expiresAt) return false;
|
|
79
|
+
const doc = { holder: this.id, expiresAt: Date.now() + TTL_MS };
|
|
80
|
+
if (!await this.write(doc)) return false;
|
|
81
|
+
const confirm = await this.readFresh();
|
|
82
|
+
if (confirm === UNREADABLE || confirm?.holder !== this.id) return false;
|
|
83
|
+
// From the document we WROTE, and only once the confirm agrees. Recomputing
|
|
84
|
+
// it here would claim a lease that outlives what the pod's copy grants, by
|
|
85
|
+
// however long the PUT and the confirming GET took.
|
|
86
|
+
this.heldUntil = doc.expiresAt;
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Renewal checks ownership first: a takeover by another agent must win,
|
|
91
|
+
// not be clobbered by a blind rewrite. Losing the lease fires onLost once
|
|
92
|
+
// and stops renewing.
|
|
93
|
+
async renewOnce() {
|
|
94
|
+
// The lease we hold has a wall-clock end. If renewals keep failing — the
|
|
95
|
+
// pod unreachable, refusing, or slow — the TTL passes, another agent is
|
|
96
|
+
// entitled to take over, and this one went on draining as though nothing
|
|
97
|
+
// had happened. Two agents on one destructive inbox read is exactly what
|
|
98
|
+
// the lease exists to prevent, so an expiry we could not extend is a loss.
|
|
99
|
+
if (this.heldUntil && Date.now() > this.heldUntil) {
|
|
100
|
+
this.stopRenewal();
|
|
101
|
+
this.etag = null;
|
|
102
|
+
this.log('lease expired and could not be renewed — standing down');
|
|
103
|
+
this.onLost?.();
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
const doc = { holder: this.id, expiresAt: Date.now() + TTL_MS };
|
|
107
|
+
// The steady path is a single conditional PUT. A 412 means somebody else
|
|
108
|
+
// wrote the lease — the only thing the preceding GET was ever there to
|
|
109
|
+
// detect — so the read is paid for exactly when it matters instead of every
|
|
110
|
+
// 90 seconds forever. No etag (a server that does not return one) falls
|
|
111
|
+
// back to read-then-write.
|
|
112
|
+
if (this.etag && await this.write(doc, { ifMatch: this.etag })) { this.heldUntil = doc.expiresAt; return true; }
|
|
113
|
+
const cur = await this.readFresh();
|
|
114
|
+
// Before the ownership test, or the sentinel answers it: a Symbol is truthy
|
|
115
|
+
// and its .holder is undefined, so "could not ask" read as "somebody else
|
|
116
|
+
// holds it" and stood a perfectly healthy agent down on one 500.
|
|
117
|
+
if (cur === UNREADABLE) return true; // keep it; the next tick retries
|
|
118
|
+
if (cur && cur.holder !== this.id) {
|
|
119
|
+
// clearTimeout alone was a no-op here — the timer being cleared is the one
|
|
120
|
+
// whose own callback is running, and the chain re-armed in `finally`
|
|
121
|
+
// regardless. Stopping has to outlive this call, so it is a flag.
|
|
122
|
+
this.stopRenewal();
|
|
123
|
+
// readFresh just cached the NEW holder's ETag. Keeping it would let the
|
|
124
|
+
// next conditional PUT succeed and take the lease back, silently, from an
|
|
125
|
+
// agent that has already stood down.
|
|
126
|
+
this.etag = null;
|
|
127
|
+
this.log('lease taken over by another agent');
|
|
128
|
+
this.onLost?.();
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
await this.write(doc);
|
|
132
|
+
this.heldUntil = doc.expiresAt;
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Self-scheduling rather than setInterval, so each agent's renewals drift
|
|
137
|
+
// apart instead of several beating in lockstep against one pod.
|
|
138
|
+
// Idempotent, like Deliverer.startQueue: startActive reaches this twice on the
|
|
139
|
+
// parked path, and two chains against one pod is the doubling this whole file
|
|
140
|
+
// is written to avoid.
|
|
141
|
+
startRenewal() {
|
|
142
|
+
if (this.timer) return;
|
|
143
|
+
this.stopped = false; // restartable across demote/takeover
|
|
144
|
+
const tick = () => {
|
|
145
|
+
if (this.stopped) return;
|
|
146
|
+
this.timer = setTimeout(() => {
|
|
147
|
+
this.renewOnce()
|
|
148
|
+
.catch(e => this.log(`lease renewal: ${e.message}`))
|
|
149
|
+
.finally(() => { if (!this.stopped) tick(); });
|
|
150
|
+
}, Math.round(RENEW_MS * JITTER()));
|
|
151
|
+
this.timer.unref?.();
|
|
152
|
+
};
|
|
153
|
+
tick();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Stop renewing WITHOUT giving the lease up — a demoted agent stands down, it
|
|
157
|
+
// does not release. Unstopped, the chain outlives demotion for the life of the
|
|
158
|
+
// process: ~80 requests an hour from an agent that is no longer acting.
|
|
159
|
+
stopRenewal() {
|
|
160
|
+
this.stopped = true;
|
|
161
|
+
clearTimeout(this.timer);
|
|
162
|
+
this.timer = null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Forcibly claim the lease (a user acting on a viewer device outranks the
|
|
166
|
+
// idle active agent). Write-then-confirm with one retry against the small
|
|
167
|
+
// window where the current holder's renewal lands in between.
|
|
168
|
+
async takeover() {
|
|
169
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
170
|
+
const doc = { holder: this.id, expiresAt: Date.now() + TTL_MS };
|
|
171
|
+
await this.write(doc);
|
|
172
|
+
const confirm = await this.readFresh();
|
|
173
|
+
if (confirm !== UNREADABLE && confirm?.holder === this.id) {
|
|
174
|
+
this.heldUntil = doc.expiresAt;
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Asked by the destructive path before it acts. The stand-down otherwise
|
|
182
|
+
// waits for the next renewal tick, which is up to ~117s away, and the whole
|
|
183
|
+
// point of the TTL is that after it another agent may start draining.
|
|
184
|
+
stillHeld() { return !this.stopped && (!this.heldUntil || Date.now() < this.heldUntil); }
|
|
185
|
+
|
|
186
|
+
async release() {
|
|
187
|
+
this.stopRenewal();
|
|
188
|
+
await this.write({ holder: this.id, expiresAt: 0 }).catch(() => {});
|
|
189
|
+
}
|
|
190
|
+
}
|
package/lib/localapi.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// localapi.mjs — talking to an agent on this machine.
|
|
2
|
+
//
|
|
3
|
+
// The agent serves https and nothing else, on the one port it was given. Its
|
|
4
|
+
// certificate is signed by this install's own certificate authority, which no
|
|
5
|
+
// public trust store knows about, so a plain fetch() would refuse it. Every
|
|
6
|
+
// caller here is on loopback reaching a listener this install started, so the
|
|
7
|
+
// answer is to trust that one authority — never to skip verification.
|
|
8
|
+
//
|
|
9
|
+
// Returns a small fetch-shaped result ({ status, ok, text(), json() }) so call
|
|
10
|
+
// sites read the way they did when this was cleartext http.
|
|
11
|
+
|
|
12
|
+
import https from 'node:https';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { rootOf, apRoot } from './home.mjs';
|
|
16
|
+
import { certPaths } from './certs.mjs';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The authorities worth offering for a loopback call: this install's, and the
|
|
20
|
+
* machine's default install's. The well-known door on 8030 is held by
|
|
21
|
+
* whichever agent got there first, which may belong to a different root than
|
|
22
|
+
* the one this command is acting in — so both are candidates. An empty list
|
|
23
|
+
* means no certificate has been minted anywhere, and the call will fail the
|
|
24
|
+
* way any unreachable agent does.
|
|
25
|
+
*/
|
|
26
|
+
export function localCa(home) {
|
|
27
|
+
const read = (root) => {
|
|
28
|
+
try { return fs.readFileSync(certPaths(path.join(root, 'certs')).caCert); } catch { return null; }
|
|
29
|
+
};
|
|
30
|
+
// This install's own authority decides, and alone: every install names its
|
|
31
|
+
// authority the same thing, so offering two lets the verifier match by name
|
|
32
|
+
// and then fail on the signature. Only an install with no authority of its
|
|
33
|
+
// own falls back to the machine's default — that is the case of a command
|
|
34
|
+
// run outside any install reaching the well-known door.
|
|
35
|
+
const mine = home ? read(rootOf(home)) : null;
|
|
36
|
+
if (mine) return [mine];
|
|
37
|
+
try { const fallback = read(apRoot()); if (fallback) return [fallback]; } catch { /* no home dir */ }
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One request to an agent on this machine. `home` names any identity under the
|
|
43
|
+
* install, which is how the CA is found; `port` is the agent's own port.
|
|
44
|
+
*/
|
|
45
|
+
export async function localFetch(home, port, pathname, opts = {}) {
|
|
46
|
+
const res = await once(home, port, pathname, opts);
|
|
47
|
+
// The well-known door answers with a redirect to whichever agent holds it,
|
|
48
|
+
// so a probe that lands there has to follow it the way fetch() would.
|
|
49
|
+
if (opts.redirect === 'manual' || res.status < 300 || res.status >= 400) return res;
|
|
50
|
+
const to = res.headers?.location;
|
|
51
|
+
if (!to) return res;
|
|
52
|
+
const target = new URL(to, `https://localhost:${port}`);
|
|
53
|
+
return once(home, Number(target.port) || 443, target.pathname + target.search, opts);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function once(home, port, pathname, { method = 'GET', headers = {}, body, timeout = 0 } = {}) {
|
|
57
|
+
const ca = localCa(home);
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const req = https.request({
|
|
60
|
+
host: 'localhost', port, path: pathname, method,
|
|
61
|
+
// One-shot calls: a kept-alive socket holds the agent's listener open, so
|
|
62
|
+
// a shutdown asked for over this channel would never finish.
|
|
63
|
+
headers: { connection: 'close', ...headers },
|
|
64
|
+
...(ca ? { ca } : {}),
|
|
65
|
+
// The certificate carries localhost and *.localhost; on some systems
|
|
66
|
+
// "localhost" resolves to ::1 first, which the name check still covers.
|
|
67
|
+
servername: 'localhost',
|
|
68
|
+
}, (res) => {
|
|
69
|
+
const chunks = [];
|
|
70
|
+
res.on('data', (c) => chunks.push(c));
|
|
71
|
+
res.on('end', () => {
|
|
72
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
73
|
+
resolve({
|
|
74
|
+
status: res.statusCode,
|
|
75
|
+
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
76
|
+
headers: res.headers,
|
|
77
|
+
text: async () => text,
|
|
78
|
+
json: async () => JSON.parse(text),
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
req.on('error', reject);
|
|
83
|
+
if (timeout) req.setTimeout(timeout, () => req.destroy(new Error('timed out')));
|
|
84
|
+
if (body !== undefined && body !== null) req.write(body);
|
|
85
|
+
req.end();
|
|
86
|
+
});
|
|
87
|
+
}
|