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/deliver.mjs
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// deliver.mjs — signed outbound delivery + retry queue. Fedify's signRequest
|
|
2
|
+
// does draft-cavage HTTP Signatures (what Mastodon verifies). Failures go to
|
|
3
|
+
// a JSON-file queue with exponential backoff; peers that stay down are
|
|
4
|
+
// dropped after MAX_ATTEMPTS (~3 days).
|
|
5
|
+
|
|
6
|
+
import { signRequest } from '@fedify/fedify/sig';
|
|
7
|
+
import { pinnedFor, retryAfterMs, HTTP_TIMEOUT_MS } from './safefetch.mjs';
|
|
8
|
+
import { USER_AGENT } from './ua.mjs';
|
|
9
|
+
|
|
10
|
+
const MAX_REDIRECTS = 3;
|
|
11
|
+
|
|
12
|
+
const MAX_ATTEMPTS = 12; // 2^12 min ≈ 68h of backoff
|
|
13
|
+
const TICK_MS = 60_000;
|
|
14
|
+
const MAX_QUEUE = 2000; // beyond it, overflow dead-letters
|
|
15
|
+
|
|
16
|
+
// Is this failure about the SERVER, or about this one recipient? A 410 Gone,
|
|
17
|
+
// a 403, a 401 from an authorized-fetch instance are answers about one inbox;
|
|
18
|
+
// cooling the whole host for them starves every other recipient on it — which
|
|
19
|
+
// on a big instance is most of your followers. Only a refusal that is about
|
|
20
|
+
// the server, or no answer at all, is grounds to leave it alone.
|
|
21
|
+
const hostOf = (inbox) => { try { return new URL(inbox).host; } catch { return inbox; } };
|
|
22
|
+
const aboutTheHost = (e) => !e.status || e.status >= 500 || e.status === 429;
|
|
23
|
+
|
|
24
|
+
export class Deliverer {
|
|
25
|
+
// passive: signing-only (viewer-mode agents) — no queue drain timer, so a
|
|
26
|
+
// read-only agent never mutates shared delivery state. startQueue() flips
|
|
27
|
+
// it live when a viewer is promoted to active.
|
|
28
|
+
constructor({ store, keyId, rsaPrivate, log = console.log, passive = false }) {
|
|
29
|
+
this.store = store;
|
|
30
|
+
this.keyId = keyId;
|
|
31
|
+
this.rsaPrivate = rsaPrivate;
|
|
32
|
+
this.log = log;
|
|
33
|
+
if (!passive) this.startQueue();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
startQueue() {
|
|
37
|
+
if (this.timer) return;
|
|
38
|
+
this.timer = setInterval(() => this.drainQueue().catch(e => this.log(`queue: ${e.message}`)), TICK_MS);
|
|
39
|
+
this.timer.unref?.();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Signed fetch — also used for GETs so authorized-fetch instances answer
|
|
43
|
+
// us. Targets come from attacker-supplied documents, so every hop is
|
|
44
|
+
// address-checked and the connection is pinned to the checked address.
|
|
45
|
+
// Redirects are followed by hand because each hop needs its own signature
|
|
46
|
+
// (the signature covers the request target).
|
|
47
|
+
async signedFetch(url, init = {}) {
|
|
48
|
+
let current = url;
|
|
49
|
+
// Named on every hop: a remote operator seeing our deliveries or our
|
|
50
|
+
// verify-by-dereference GETs should know what we are.
|
|
51
|
+
//
|
|
52
|
+
// And bounded. This was the one remote-facing path in the project without a
|
|
53
|
+
// deadline — publisher, intake and the token grant all set one — so a peer
|
|
54
|
+
// that accepted the connection and then stalled held us for undici's 300s
|
|
55
|
+
// default. Against a 60s queue tick that is five overlapping drains
|
|
56
|
+
// re-POSTing the same activity at a server already in trouble. One signal,
|
|
57
|
+
// built once, so it covers all four redirect hops together rather than
|
|
58
|
+
// giving each hop a fresh deadline.
|
|
59
|
+
const withUa = {
|
|
60
|
+
...init,
|
|
61
|
+
signal: init.signal || AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
62
|
+
headers: { 'user-agent': USER_AGENT, ...(init.headers || {}) },
|
|
63
|
+
};
|
|
64
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
65
|
+
const dispatcher = await pinnedFor(current);
|
|
66
|
+
const req = new Request(current, withUa);
|
|
67
|
+
const signed = await signRequest(req, this.rsaPrivate, new URL(this.keyId));
|
|
68
|
+
const res = await fetch(signed, { ...(dispatcher ? { dispatcher } : {}), redirect: 'manual' });
|
|
69
|
+
if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
|
|
70
|
+
current = new URL(res.headers.get('location'), current).href;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
return res;
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`too many redirects from ${url}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async deliverNow(inbox, activity) {
|
|
79
|
+
const res = await this.signedFetch(inbox, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'content-type': 'application/activity+json' },
|
|
82
|
+
body: JSON.stringify(activity),
|
|
83
|
+
});
|
|
84
|
+
if (res.status >= 400) {
|
|
85
|
+
// Carry what the server told us instead of flattening it to a string.
|
|
86
|
+
// The Response used to be discarded here, headers and all, which is why
|
|
87
|
+
// a `Retry-After: 3600` was answered with four more POSTs inside the
|
|
88
|
+
// hour it asked for.
|
|
89
|
+
const err = new Error(`POST ${inbox} → ${res.status}`);
|
|
90
|
+
err.status = res.status;
|
|
91
|
+
// Only when the header is ACTUALLY there. Defaulting made every 503
|
|
92
|
+
// without one — which is most of them — collapse the exponential ladder
|
|
93
|
+
// to a flat 60s, the opposite of the intent. A remote's own instruction
|
|
94
|
+
// is honoured up to a day rather than the pod path's 30-minute ceiling:
|
|
95
|
+
// MAX_ATTEMPTS bounds the wait anyway.
|
|
96
|
+
const ra = retryAfterMs(res, 24 * 60 * 60_000);
|
|
97
|
+
if (ra != null) err.retryAfterMs = ra;
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
return res;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Deliver, queueing on failure.
|
|
104
|
+
async deliver(inbox, activity) {
|
|
105
|
+
// A host we already know is refusing: queue without asking again. This is
|
|
106
|
+
// the path a FRESH activity takes, so without it a fan-out to a struggling
|
|
107
|
+
// server opened one socket per follower before any of this applied.
|
|
108
|
+
const host = hostOf(inbox);
|
|
109
|
+
const until = this._cooling?.get(host);
|
|
110
|
+
if (until && until > Date.now()) {
|
|
111
|
+
this.log(`${host} is cooling — queueing ${activity.type} rather than asking again`);
|
|
112
|
+
this._enqueue({ inbox, activity, attempts: 1, nextAt: until });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
await this.deliverNow(inbox, activity);
|
|
117
|
+
this.log(`delivered ${activity.type} → ${inbox}`);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
this.log(`delivery failed (${e.message}) — queued`);
|
|
120
|
+
const wait = e.retryAfterMs || 60_000;
|
|
121
|
+
if (aboutTheHost(e)) {
|
|
122
|
+
this._cooling ||= new Map();
|
|
123
|
+
this._cooling.set(host, Date.now() + wait);
|
|
124
|
+
}
|
|
125
|
+
this._enqueue({ inbox, activity, attempts: 1, nextAt: Date.now() + wait });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The queue has a ceiling: a fan-out into a sea of down servers must not grow
|
|
130
|
+
// queue.json without bound. Overflow keeps the evidence as a dead letter.
|
|
131
|
+
_enqueue(item) {
|
|
132
|
+
const q = this.store.getQueue();
|
|
133
|
+
if (q.length >= MAX_QUEUE) {
|
|
134
|
+
this.log(`delivery queue is full (${MAX_QUEUE}) — dead-lettering ${item.activity?.type} → ${item.inbox}`);
|
|
135
|
+
this.store.addDeadLetter({ inboxUrl: item.inbox, reason: 'delivery queue full', activity: item.activity });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
q.push(item);
|
|
139
|
+
this.store.setQueue(q);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async deliverToAll(inboxes, activity) {
|
|
143
|
+
// Shared inboxes deduplicate fan-out to the same server.
|
|
144
|
+
for (const inbox of [...new Set(inboxes)]) await this.deliver(inbox, activity);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Serialized, for the same reason Intake.drain is: the tick is 60s and a
|
|
148
|
+
// drain over slow peers outlasts it, so a second run started on top of the
|
|
149
|
+
// first. Both read their own clone of queue.json, so both re-POSTed every due
|
|
150
|
+
// activity — duplicate deliveries at a server that is already failing — and
|
|
151
|
+
// both then wrote `keep` back from a stale snapshot, losing the attempt
|
|
152
|
+
// counts that are supposed to make the backoff converge.
|
|
153
|
+
async drainQueue() {
|
|
154
|
+
if (this._draining) { this._drainAgain = true; return this._draining; }
|
|
155
|
+
this._draining = this._drainQueueOnce().finally(() => {
|
|
156
|
+
this._draining = null;
|
|
157
|
+
if (this._drainAgain) { this._drainAgain = false; this.drainQueue().catch(e => this.log(`queue: ${e.message}`)); }
|
|
158
|
+
});
|
|
159
|
+
return this._draining;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The queue is walked per HOST, not per item.
|
|
163
|
+
//
|
|
164
|
+
// It used to be a flat sequential loop, which had two costs against a server
|
|
165
|
+
// that was down. Every due item for that host was POSTed — the first failure
|
|
166
|
+
// told us nothing the code could act on — and because each request carries a
|
|
167
|
+
// 15s deadline, K items for one dead host held the drain for up to 15K
|
|
168
|
+
// seconds while every healthy peer's activity waited behind them. Compounding
|
|
169
|
+
// requests at a struggling server, and head-of-line blocking of everyone
|
|
170
|
+
// else: the shape of the incident this project has a postmortem for.
|
|
171
|
+
//
|
|
172
|
+
// One failure now cools the whole host for the rest of this drain, and a
|
|
173
|
+
// Retry-After it sent decides for how long. The breaker is in memory; what
|
|
174
|
+
// persists is nextAt, which the queue already carried.
|
|
175
|
+
async _drainQueueOnce() {
|
|
176
|
+
const q = this.store.getQueue();
|
|
177
|
+
if (!q.length) return;
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
const keep = [];
|
|
180
|
+
// Whether anything actually moved. The only early return above is on an
|
|
181
|
+
// EMPTY queue, so one item with a far-future nextAt used to rewrite
|
|
182
|
+
// queue.json on all 1440 ticks of a day with byte-identical content —
|
|
183
|
+
// PodStore.write has no equality check, and the ordinary ladder reaches
|
|
184
|
+
// hours-long waits by attempt 6, so any peer that fails a few times got
|
|
185
|
+
// there. A length compare is not enough: getQueue() hands back a clone, so
|
|
186
|
+
// attempts/nextAt are mutated in place and the count stays the same.
|
|
187
|
+
let changed = false;
|
|
188
|
+
// Outlives the drain: a Retry-After only reached items that happened to be
|
|
189
|
+
// due in the same pass, so a sibling due a minute later ignored it entirely.
|
|
190
|
+
this._cooling ||= new Map(); // host → until
|
|
191
|
+
for (const [h, until] of this._cooling) if (until <= now) this._cooling.delete(h);
|
|
192
|
+
|
|
193
|
+
const ladderFor = (item) => {
|
|
194
|
+
const base = Math.min(2 ** item.attempts, 2 ** MAX_ATTEMPTS) * 60_000;
|
|
195
|
+
return Math.round(base * (0.85 + Math.random() * 0.3)); // jittered, not lockstep
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
for (const item of q) {
|
|
199
|
+
// Due first, THEN cooling: an item that is not yet due must not have its
|
|
200
|
+
// ladder advanced on every 60s tick of a 15-minute cooldown. Once
|
|
201
|
+
// deferred its nextAt IS the cooldown, so it advances exactly once per
|
|
202
|
+
// cooldown — the same rate as the item that opened the socket.
|
|
203
|
+
if (item.nextAt > now) { keep.push(item); continue; }
|
|
204
|
+
const host = hostOf(item.inbox);
|
|
205
|
+
const until = this._cooling.get(host);
|
|
206
|
+
if (until && until > now) {
|
|
207
|
+
// A sibling already failed against this host. Defer without opening a
|
|
208
|
+
// socket — but ADVANCE THE LADDER, or only the one item that actually
|
|
209
|
+
// made a request ever climbs it and a queue of K items takes K times
|
|
210
|
+
// as long to give up as the module promises.
|
|
211
|
+
item.attempts += 1;
|
|
212
|
+
changed = true;
|
|
213
|
+
if (item.attempts > MAX_ATTEMPTS) {
|
|
214
|
+
this.log(`giving up on ${item.inbox} after ${MAX_ATTEMPTS} attempts`);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
item.nextAt = Math.max(item.nextAt, until);
|
|
218
|
+
keep.push(item);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
await this.deliverNow(item.inbox, item.activity);
|
|
223
|
+
changed = true;
|
|
224
|
+
this.log(`retry ok: ${item.activity.type} → ${item.inbox}`);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
item.attempts += 1;
|
|
227
|
+
changed = true;
|
|
228
|
+
const wait = e.retryAfterMs || ladderFor(item);
|
|
229
|
+
// Cool the host BEFORE the give-up test: a failure is evidence about
|
|
230
|
+
// the server whether or not the item that produced it survives, and
|
|
231
|
+
// skipping this left the flat behaviour intact for exactly the backlog
|
|
232
|
+
// that has just aged out.
|
|
233
|
+
if (aboutTheHost(e)) {
|
|
234
|
+
this._cooling.set(host, now + wait);
|
|
235
|
+
if (e.retryAfterMs) this.log(`${host} asked for ${Math.round(wait / 1000)}s — honouring it`);
|
|
236
|
+
}
|
|
237
|
+
if (item.attempts > MAX_ATTEMPTS) {
|
|
238
|
+
this.log(`giving up on ${item.inbox} after ${MAX_ATTEMPTS} attempts`);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
item.nextAt = now + wait;
|
|
242
|
+
keep.push(item);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (changed) this.store.setQueue(keep);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
stop() { clearInterval(this.timer); this.timer = null; }
|
|
249
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// directory.mjs — the well-known door. Whichever running agent holds port
|
|
2
|
+
// 8030 answers there with a redirect to its own record page, so opening
|
|
3
|
+
// https://localhost:8030/ always reaches a page that lists every identity on
|
|
4
|
+
// this machine, whatever ports they actually run on. Loopback only, https
|
|
5
|
+
// only — like every listener this project starts. Held only by a CONFIGURED agent: an
|
|
6
|
+
// unconfigured one's record page is a setup form with no identity list,
|
|
7
|
+
// which strands whoever the door sends there.
|
|
8
|
+
//
|
|
9
|
+
// The door is held politely: it identifies itself at GET /__directory, and a
|
|
10
|
+
// real owner of the port — an identity that recorded 8030 as its own — asks it
|
|
11
|
+
// to step aside with POST /__directory/yield before binding. The holder frees
|
|
12
|
+
// the port at once and stays off it for a minute. AP_DIRECTORY=0 disables the
|
|
13
|
+
// whole mechanism (the test suite sets it, so throwaway agents do not fight
|
|
14
|
+
// over a real machine's door).
|
|
15
|
+
import https from 'node:https';
|
|
16
|
+
import { localFetch } from './localapi.mjs';
|
|
17
|
+
|
|
18
|
+
export const DIRECTORY_PORT = 8030;
|
|
19
|
+
|
|
20
|
+
// Answer for a running agent. No-op when this agent already IS the door, or
|
|
21
|
+
// when the mechanism is switched off. `eligible` is asked before every claim
|
|
22
|
+
// attempt — the 15 s retry means an agent that configures later takes the
|
|
23
|
+
// door soon after, and one that never does never holds it.
|
|
24
|
+
export function claimDirectory({ port, origin, log = () => {}, eligible = () => true, tls = null }) {
|
|
25
|
+
if (process.env.AP_DIRECTORY === '0' || port === DIRECTORY_PORT) {
|
|
26
|
+
return { stop: () => {} };
|
|
27
|
+
}
|
|
28
|
+
const servers = [];
|
|
29
|
+
let held = false;
|
|
30
|
+
let pausedUntil = 0;
|
|
31
|
+
const handler = (req, res) => {
|
|
32
|
+
const p = req.url || '/';
|
|
33
|
+
if (p === '/__directory') {
|
|
34
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
35
|
+
res.end(JSON.stringify({ directory: true, agent: origin() }) + '\n');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (req.method === 'POST' && p === '/__directory/yield') {
|
|
39
|
+
pausedUntil = Date.now() + 60_000;
|
|
40
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
41
|
+
res.end('{"ok":true}\n');
|
|
42
|
+
release();
|
|
43
|
+
log(`directory: yielded port ${DIRECTORY_PORT}`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
res.writeHead(302, { location: origin().replace(/\/$/, '') + p });
|
|
47
|
+
res.end();
|
|
48
|
+
};
|
|
49
|
+
const release = () => {
|
|
50
|
+
for (const s of servers.splice(0)) { try { s.close(); } catch { /* already down */ } }
|
|
51
|
+
held = false;
|
|
52
|
+
};
|
|
53
|
+
const tryClaim = () => {
|
|
54
|
+
if (held || !eligible() || Date.now() < pausedUntil) return;
|
|
55
|
+
// Loopback both ways, like the agent's own listener: "localhost" resolves
|
|
56
|
+
// to ::1 first on many systems. IPv4 is the one that decides `held`.
|
|
57
|
+
if (!tls) return; // no certificate, no door: this project serves https only
|
|
58
|
+
const tlsOpts = { key: tls.key, cert: tls.cert };
|
|
59
|
+
const s4 = https.createServer(tlsOpts, handler);
|
|
60
|
+
s4.on('error', () => { /* someone else holds the door — retry later */ });
|
|
61
|
+
s4.listen(DIRECTORY_PORT, '127.0.0.1', () => {
|
|
62
|
+
held = true;
|
|
63
|
+
servers.push(s4);
|
|
64
|
+
const s6 = https.createServer(tlsOpts, handler);
|
|
65
|
+
s6.on('error', () => { /* no IPv6 loopback here — IPv4 covers it */ });
|
|
66
|
+
s6.listen(DIRECTORY_PORT, '::1', () => servers.push(s6));
|
|
67
|
+
log(`directory: https://localhost:${DIRECTORY_PORT}/ now reaches ${origin()}`);
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
tryClaim();
|
|
71
|
+
const timer = setInterval(tryClaim, 15_000);
|
|
72
|
+
timer.unref();
|
|
73
|
+
return { stop: () => { clearInterval(timer); release(); } };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Free `port` if what holds it is the directory door. Returns true when the
|
|
77
|
+
// port is free afterwards. Quick and quiet on every other kind of holder.
|
|
78
|
+
export async function yieldDirectory(port, { portFree, home = null } = {}) {
|
|
79
|
+
const probe = await localFetch(home, port, '/__directory', { timeout: 1000 })
|
|
80
|
+
.then(r => (r.ok ? r.json() : null)).catch(() => null);
|
|
81
|
+
if (!probe?.directory) return false;
|
|
82
|
+
await localFetch(home, port, '/__directory/yield', { method: 'POST', timeout: 1000 })
|
|
83
|
+
.catch(() => {});
|
|
84
|
+
if (!portFree) return true;
|
|
85
|
+
for (let i = 0; i < 10; i++) {
|
|
86
|
+
if (await portFree(port)) return true;
|
|
87
|
+
await new Promise(r => setTimeout(r, 100));
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
package/lib/embed.mjs
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// embed.mjs — the agent, running inside the pod server that holds its pod.
|
|
2
|
+
//
|
|
3
|
+
// Nothing here reimplements the agent: it is the same Agent class, the same
|
|
4
|
+
// intake, deliverer, publisher and lease. What changes is the transport. The
|
|
5
|
+
// caller hands in a session that reaches the pod through the server's own
|
|
6
|
+
// store, and RemotePod takes it instead of minting a credential — so an
|
|
7
|
+
// identity acts on its pod with no token, no socket, and no second process.
|
|
8
|
+
//
|
|
9
|
+
// The one thing a pod server can do that a laptop cannot is notice a write the
|
|
10
|
+
// moment it happens, so the notification socket is replaced by the store's own
|
|
11
|
+
// change events.
|
|
12
|
+
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
|
|
19
|
+
import { Agent } from '../run-agent.mjs';
|
|
20
|
+
import { RemotePod } from './remote.mjs';
|
|
21
|
+
import { apUrls } from './wire.mjs';
|
|
22
|
+
import { writeJsonAtomic } from './home.mjs';
|
|
23
|
+
import { buildAdminSurface } from './admin.mjs';
|
|
24
|
+
import { FixedAuthorities } from './guard.mjs';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const { makeGate } = require('../vendor/gate.cjs');
|
|
28
|
+
|
|
29
|
+
// How long to gather store events before draining, so a delivery of several
|
|
30
|
+
// items costs one sweep rather than one each.
|
|
31
|
+
const DRAIN_COALESCE_MS = 250;
|
|
32
|
+
|
|
33
|
+
// How every secret in this project is minted (the attach flow's recipe).
|
|
34
|
+
const mintSecret = () => crypto.randomBytes(32).toString('base64');
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The secret guarding one identity's owner door, kept beside its signing key.
|
|
38
|
+
* Minted when absent; `rotate` re-mints over an existing one — which is how a
|
|
39
|
+
* lost secret is recovered, by proving pod control again. Never logged.
|
|
40
|
+
*/
|
|
41
|
+
export function ensureDoorSecret(dataDir, handle, { rotate = false } = {}) {
|
|
42
|
+
const home = path.join(dataDir, handle);
|
|
43
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
44
|
+
const file = path.join(home, 'door-secret.json');
|
|
45
|
+
if (!rotate) {
|
|
46
|
+
try {
|
|
47
|
+
const rec = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
48
|
+
if (rec?.secret) return { secret: rec.secret, path: file, rotated: false };
|
|
49
|
+
} catch { /* absent or unreadable: mint below */ }
|
|
50
|
+
}
|
|
51
|
+
const rec = { secret: mintSecret(), mintedAt: new Date().toISOString() };
|
|
52
|
+
writeJsonAtomic(file, rec, { mode: 0o600 });
|
|
53
|
+
return { secret: rec.secret, path: file, rotated: rotate };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The identity's name: a subdomain pod is its label, a path pod its last segment. */
|
|
57
|
+
export function handleFor(podBase) {
|
|
58
|
+
const u = new URL(podBase);
|
|
59
|
+
const segments = u.pathname.split('/').filter(Boolean);
|
|
60
|
+
return segments.length ? segments[segments.length - 1] : u.hostname.split('.')[0];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The credential file an embedded identity runs on. It names the pod and
|
|
65
|
+
* nothing else: there is no client id and no secret, because there is nobody
|
|
66
|
+
* to authenticate to. Absent `privateRoot`, so the state tree stays on the pod.
|
|
67
|
+
*/
|
|
68
|
+
function ensureCredential(home, { podBase, webId }) {
|
|
69
|
+
const file = path.join(home, 'credential.json');
|
|
70
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
71
|
+
catch { /* first run for this identity */ }
|
|
72
|
+
const rec = {
|
|
73
|
+
webId,
|
|
74
|
+
remotePod: podBase.endsWith('/') ? podBase : podBase + '/',
|
|
75
|
+
root: 'activitypods-js/',
|
|
76
|
+
};
|
|
77
|
+
writeJsonAtomic(file, rec, { mode: 0o600 });
|
|
78
|
+
return rec;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Bring one identity up inside the server.
|
|
83
|
+
*
|
|
84
|
+
* `session` is the store-backed transport ({ fetch, warmup, stats }).
|
|
85
|
+
* `resourceStore` is optional and used only to watch for inbox writes.
|
|
86
|
+
* Returns the running agent and a stop() that leaves the pod tidy.
|
|
87
|
+
*/
|
|
88
|
+
export async function startEmbeddedAgent({
|
|
89
|
+
podBase,
|
|
90
|
+
dataDir,
|
|
91
|
+
session,
|
|
92
|
+
resourceStore = null,
|
|
93
|
+
webIdSuffix = 'profile/card#me',
|
|
94
|
+
log = () => {},
|
|
95
|
+
pollSeconds = null,
|
|
96
|
+
autoAcceptFollows = true,
|
|
97
|
+
gateToken = null,
|
|
98
|
+
uiPath = '/fedipod/',
|
|
99
|
+
}) {
|
|
100
|
+
const base = podBase.endsWith('/') ? podBase : podBase + '/';
|
|
101
|
+
const handle = handleFor(base);
|
|
102
|
+
const home = path.join(dataDir, handle);
|
|
103
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
104
|
+
|
|
105
|
+
const webId = base + webIdSuffix.replace(/^\//u, '');
|
|
106
|
+
const cred = ensureCredential(home, { podBase: base, webId });
|
|
107
|
+
|
|
108
|
+
const agent = new Agent({ home, log });
|
|
109
|
+
agent.log = log;
|
|
110
|
+
agent.store.log = log;
|
|
111
|
+
// Read by connect() when it builds Intake: in-process, the pod's own change
|
|
112
|
+
// events are the push channel.
|
|
113
|
+
agent.embedded = true;
|
|
114
|
+
if (pollSeconds) agent.pollSeconds = pollSeconds;
|
|
115
|
+
agent.remote = new RemotePod(cred, { log, session });
|
|
116
|
+
await agent.remote.warmup();
|
|
117
|
+
|
|
118
|
+
// Whether this pod already carries an identity is the question connect()
|
|
119
|
+
// assumes an answer to, so settle it first.
|
|
120
|
+
const urls = apUrls(cred.remotePod, cred.root);
|
|
121
|
+
agent.store.attach(agent.privateStorage(cred, 'state', urls));
|
|
122
|
+
|
|
123
|
+
// A state container that is NOT THERE means a pod nobody has set up yet. A
|
|
124
|
+
// state container we could not READ means a pod we could not ask, which is a
|
|
125
|
+
// different answer: treating it as empty would provision a second identity
|
|
126
|
+
// over the top of a working one.
|
|
127
|
+
const state = await agent.remote.fetch(urls.state, { headers: { accept: 'text/turtle' } });
|
|
128
|
+
const unprovisioned = state.status === 404 || state.status === 410;
|
|
129
|
+
if (!unprovisioned && state.status >= 400) {
|
|
130
|
+
throw new Error(`state at ${urls.state} → ${state.status} — cannot tell whether this pod is set up`);
|
|
131
|
+
}
|
|
132
|
+
if (!unprovisioned) {
|
|
133
|
+
await agent.store.load();
|
|
134
|
+
agent.stateLoaded = true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!agent.store.getConfig()) {
|
|
138
|
+
// The pod itself must exist first. A server creates its seeded pods after
|
|
139
|
+
// it runs its initializers, so an agent that provisioned eagerly would
|
|
140
|
+
// leave a container tree exactly where a pod is about to be created.
|
|
141
|
+
const profile = await agent.remote.fetch(webId.split('#')[0], { headers: { accept: 'text/turtle' } });
|
|
142
|
+
if (profile.status === 404 || profile.status === 410) {
|
|
143
|
+
throw new Error(`no pod at ${base} yet — its owner profile is not there`);
|
|
144
|
+
}
|
|
145
|
+
log(`no identity on ${base} yet — provisioning @${handle}`);
|
|
146
|
+
await agent.bootstrap({ handle, name: handle, kind: 'person' });
|
|
147
|
+
if (autoAcceptFollows) {
|
|
148
|
+
agent.store.setConfig({ ...agent.store.getConfig(), autoAcceptFollows: true });
|
|
149
|
+
await agent.store.flush();
|
|
150
|
+
}
|
|
151
|
+
agent.stateLoaded = true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await agent.connect();
|
|
155
|
+
|
|
156
|
+
// The client surfaces, on the pod's own origin: the Mastodon API a phone app
|
|
157
|
+
// speaks, the write API, nodeinfo, and behind the door the admin routes and
|
|
158
|
+
// the web client. Same code the standalone agent serves, minus the routes
|
|
159
|
+
// that only mean something to a process of one's own.
|
|
160
|
+
const authorities = new FixedAuthorities(base);
|
|
161
|
+
agent.authorities = authorities;
|
|
162
|
+
const surface = buildAdminSurface({
|
|
163
|
+
agent,
|
|
164
|
+
log,
|
|
165
|
+
gate: makeGate(gateToken, { secureCookie: authorities.secure }),
|
|
166
|
+
allowed: authorities,
|
|
167
|
+
embedded: true,
|
|
168
|
+
basePath: uiPath,
|
|
169
|
+
publicOrigin: base,
|
|
170
|
+
scheme: new URL(base).protocol,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// A delivery landing in the inbox — through the door, or from any other
|
|
174
|
+
// writer — wakes the drain at once. Only additions, and only in the inbox:
|
|
175
|
+
// the drain's own DELETEs must not call it back.
|
|
176
|
+
let coalesce = null;
|
|
177
|
+
let onChanged = null;
|
|
178
|
+
if (typeof resourceStore?.on === 'function') {
|
|
179
|
+
const inbox = urls.inbox;
|
|
180
|
+
onChanged = (identifier, activity) => {
|
|
181
|
+
const kind = String(activity?.value ?? activity ?? '');
|
|
182
|
+
if (!identifier?.path?.startsWith(inbox)) return;
|
|
183
|
+
if (!kind.endsWith('Create') && !kind.endsWith('Add')) return;
|
|
184
|
+
if (coalesce) return;
|
|
185
|
+
coalesce = setTimeout(() => {
|
|
186
|
+
coalesce = null;
|
|
187
|
+
if (!agent.viewer) agent.intake?.drain().catch((e) => log(`drain: ${e.message}`));
|
|
188
|
+
}, DRAIN_COALESCE_MS);
|
|
189
|
+
coalesce.unref?.();
|
|
190
|
+
};
|
|
191
|
+
resourceStore.on('changed', onChanged);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const stop = async () => {
|
|
195
|
+
if (onChanged) resourceStore.off('changed', onChanged);
|
|
196
|
+
surface.streaming?.stop?.();
|
|
197
|
+
clearTimeout(coalesce);
|
|
198
|
+
agent.intake?.stop();
|
|
199
|
+
agent.tagfeed?.stop();
|
|
200
|
+
agent.bskyfeed?.stop();
|
|
201
|
+
agent.deliverer?.stop();
|
|
202
|
+
agent.importer?.stop();
|
|
203
|
+
clearInterval(agent.schedTimer);
|
|
204
|
+
clearInterval(agent.refreshTimer);
|
|
205
|
+
// Same order the standalone agent's shutdown uses: write what is pending,
|
|
206
|
+
// then let go of the lease so the next agent need not wait out the TTL.
|
|
207
|
+
await Promise.allSettled([
|
|
208
|
+
agent.store.flush(),
|
|
209
|
+
agent.viewer ? Promise.resolve() : agent.lease?.release(),
|
|
210
|
+
]);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
return { agent, handle, home, surface, host: authorities.host, stop };
|
|
214
|
+
}
|