|
| 1 | +# federation-demo — two Solid servers federating over loopback, no cloud |
| 2 | + |
| 3 | +A runnable, narrated **demo scenario** (not a plugin — there is no |
| 4 | +`plugin.js` here): TWO independent JavaScript Solid Server instances on one |
| 5 | +machine, each composed from `webfinger/` (actor discovery) + `activitypub/` |
| 6 | +(the actor itself), walking real ActivityPub federation mechanics over |
| 7 | +loopback HTTP. Server A hosts **alice**, server B hosts **bob**; every |
| 8 | +actor URL, webfinger JRD, Follow, Accept and Create in the flow is a real |
| 9 | +cross-origin HTTP exchange between the two live servers. |
| 10 | + |
| 11 | +```bash |
| 12 | +node federation-demo/demo.js # the narrated show |
| 13 | +node --test --test-concurrency=1 federation-demo/test.js # the assertions (9 tests) |
| 14 | +``` |
| 15 | + |
| 16 | +Loopback only — no external network, no flags, exit 0 on success. |
| 17 | + |
| 18 | +## What it walks |
| 19 | + |
| 20 | +| step | mechanic | who does the work | |
| 21 | +|---|---|---| |
| 22 | +| discovery | `acct:alice@127.0.0.1:<portA>` → A's `/.well-known/webfinger` → JRD → actor doc | webfinger + activitypub plugins | |
| 23 | +| follow | bob's Follow (his **real B-hosted actor URL**) into alice's inbox on A | driver couriers the Follow (Phase-1 plugin has no outbound-Follow client) | |
| 24 | +| accept | A fetches bob's actor doc **from B**, resolves his inbox, delivers a **signed Accept** to B | **plugin-initiated** (A runs `allowPrivateDelivery: true`) | |
| 25 | +| publish | alice posts a Note via her outbox (owner Bearer); stored in her pod over loopback LDP under real WAC | activitypub plugin | |
| 26 | +| delivery A→B | the Create fans out to bob's inbox on B, HTTP-signed | **plugin-initiated** (same opt-in) | |
| 27 | +| reverse follow | alice follows bob on **default-config** B | B records the follower but its SSRF gate refuses to resolve the loopback actor URL — `inbox: null`, no Accept | |
| 28 | +| reverse delivery B→A | bob posts; B cannot deliver to a loopback peer | **driver couriers it** (read bob's outbox, POST to alice's inbox) and the narration says so | |
| 29 | + |
| 30 | +The two directions are deliberately asymmetric. Server **A** uses the |
| 31 | +activitypub plugin's **documented** opt-in for private-network/test |
| 32 | +deployments (`allowPrivateDelivery: true`) — without it a loopback-only |
| 33 | +demo could never see server-initiated delivery, because the plugin's SSRF |
| 34 | +gate refuses loopback/private targets **by default**. Server **B** keeps |
| 35 | +that production default, so the same demo also shows the gate holding: the |
| 36 | +gate's logic is not weakened, bypassed or reimplemented anywhere — one |
| 37 | +server opts in via config, the other demonstrates the refusal. |
| 38 | + |
| 39 | +## Transcript excerpt |
| 40 | + |
| 41 | +``` |
| 42 | +── Step 3: DISCOVERY — B's side resolves alice via webfinger on A |
| 43 | + GET http://127.0.0.1:43235/.well-known/webfinger?resource=acct%3Aalice%40127.0.0.1%3A43235 |
| 44 | + JRD: |
| 45 | + { "subject": "acct:alice@127.0.0.1:43235", |
| 46 | + "links": [ …, { "rel": "self", "type": "application/activity+json", |
| 47 | + "href": "http://127.0.0.1:43235/ap/alice/actor" } ] } |
| 48 | +… |
| 49 | +── Step 6: DELIVERY — A fans the Create out to bob's inbox on B (plugin-initiated) |
| 50 | + arrived in bob's inbox log on B: |
| 51 | + { "receivedAt": "2026-07-12T13:14:55.211Z", |
| 52 | + "activity": { "type": "Create", |
| 53 | + "actor": "http://127.0.0.1:43235/ap/alice/actor", |
| 54 | + "object": { "content": "Hello from server A — federation with no cloud!" } } } |
| 55 | + no driver involved: A's activitypub plugin signed and POSTed this to |
| 56 | + http://127.0.0.1:44835/ap/bob/inbox on its own, at publish time. |
| 57 | +… |
| 58 | +── Step 7: REVERSE — alice follows bob on B, which runs the PRODUCTION default |
| 59 | + B recorded the follower BUT refused to fetch alice's actor URL: |
| 60 | + follower record on B: { actor: alice@A, inbox: null } |
| 61 | + that is the SSRF gate doing its job — 127.0.0.1 is a private target |
| 62 | + and B never opted into allowPrivateDelivery. No Accept was sent. |
| 63 | +``` |
| 64 | + |
| 65 | +## The files |
| 66 | + |
| 67 | +- `demo.js` — the narrated standalone script (`node federation-demo/demo.js`). |
| 68 | +- `test.js` — the same flow as 9 `node:test` assertions (picked up by the |
| 69 | + repo's `npm test` glob). |
| 70 | +- `harness.js` — shared driver helpers (spawn an instance, register a user, |
| 71 | + post activities, poll the activitypub plugin's persisted state). |
| 72 | +- `instance.js` — the child-process entry that boots ONE federated JSS |
| 73 | + (webfinger + activitypub) and prints `READY {json}`. See the first |
| 74 | + finding for why this file must exist. |
| 75 | + |
| 76 | +Publishing goes through the ActivityPub **outbox**, not `micropub/`: |
| 77 | +micropub stores h-entries under `public/posts/…` while the AP outbox reads |
| 78 | +`public/statuses/…`, so a micropub post would never appear in the federated |
| 79 | +outbox — the AP-native path is the cleaner story and micropub adds nothing |
| 80 | +here. |
| 81 | + |
| 82 | +## Findings |
| 83 | + |
| 84 | +What a TWO-instance topology exercises that no single-instance suite can — |
| 85 | +and the walls it hit. |
| 86 | + |
| 87 | +1. **Two live JSS instances cannot share one process — the module-global |
| 88 | + `DATA_ROOT` footgun is a hard wall for multi-server topologies, not just |
| 89 | + a test-ordering hazard.** `createServer` writes the storage root into |
| 90 | + process-global state (`src/server.js:203` sets `process.env.DATA_ROOT`) |
| 91 | + and the IdP + storage layers re-read it lazily **per request** |
| 92 | + (`src/idp/accounts.js` `getAccountsDir()`, `src/idp/keys.js`, |
| 93 | + `src/utils/url.js` `getDataRoot()`, `src/handlers/pay.js`, …). AGENT.md |
| 94 | + documents this for *sequential* boots ("order the failing boot first"); |
| 95 | + for **concurrent** servers there is no ordering trick: the second boot |
| 96 | + repoints the global and the first server's account lookups, token |
| 97 | + verification and pod reads silently hit the second server's data root. |
| 98 | + This demo therefore spawns each instance as a **child process** |
| 99 | + (`instance.js`), which works cleanly — but it means "run two JSS servers |
| 100 | + in one Node process" is off the table until the root is carried per |
| 101 | + instance rather than per process. |
| 102 | + |
| 103 | +2. **Plugin-initiated outbound federation genuinely works on loopback — but |
| 104 | + only through `allowPrivateDelivery`, and the demo makes the SSRF/loopback |
| 105 | + tension visible in both directions.** With the documented opt-in on |
| 106 | + server A, the activitypub plugin did everything itself: fetched bob's |
| 107 | + actor document cross-origin from B, resolved his inbox, delivered an |
| 108 | + HTTP-signed Accept, and fanned a Create out to B at publish time — real |
| 109 | + server-to-server delivery, observed in B's persisted inbox log. Server B, |
| 110 | + on the default config (the one three security regression tests in |
| 111 | + `activitypub/test.js` protect), correctly refused to resolve or deliver |
| 112 | + to a loopback peer (`inbox: null` in its follower record, no Accept, no |
| 113 | + Create), so the reverse direction had to be **couriered by the driver** |
| 114 | + (read B's outbox, POST to A's inbox) — recorded honestly in the |
| 115 | + narration. The tension is structural: any LAN/air-gapped "federation with |
| 116 | + no cloud" deployment is exactly the case the anti-SSRF default refuses, |
| 117 | + and the per-instance boolean is the entire policy surface. A finer |
| 118 | + delivery policy (e.g. an allowlist of peer origins rather than |
| 119 | + all-private-or-nothing) would let a private-network deployment federate |
| 120 | + without opening delivery to *every* private address; that's a plugin |
| 121 | + improvement, not a core seam. |
| 122 | + |
| 123 | +3. **Delivery only exists where the plugin owns the write path — the |
| 124 | + missing seams are `api.events` (#603) and an outbound-Follow client, and |
| 125 | + the driver's courier role names them.** The plugin delivers at exactly |
| 126 | + two moments it controls: Accept-on-Follow and Create-on-outbox-POST. A |
| 127 | + Note PUT straight into the pod via LDP (or by another plugin) federates |
| 128 | + to nobody, because nothing tells the plugin the pod changed — that's the |
| 129 | + `api.events.onResourceChange` gap. And nothing in Phase 1 *initiates* a |
| 130 | + Follow (the `following` collection is empty by design), which is why the |
| 131 | + demo's Follows are couriered by the driver even though the Accepts and |
| 132 | + Creates are not. Retries/queues for failed deliveries would sit on the |
| 133 | + same missing event/queue seam. |
| 134 | + |
| 135 | +4. **`api.serverInfo` (#601) pays off across origins: webfinger bound each |
| 136 | + JRD to the right host with zero config.** Two instances on the same |
| 137 | + machine differ only by port; webfinger (given no `baseUrl`) minted |
| 138 | + `href`s on each server's own live origin via `api.serverInfo()`, so the |
| 139 | + same plugin config produced correct, distinct JRDs on A and B. The |
| 140 | + activitypub plugin predates the seam and still requires a hand-fed |
| 141 | + `config.baseUrl` per instance — visible here as the one per-server |
| 142 | + config line the demo has to compute (NOTES §3). |
| 143 | + |
| 144 | +5. **Cross-origin actor URLs are the thing single-instance tests fake — and |
| 145 | + the composed layout held up.** `activitypub/test.js` uses a fictitious |
| 146 | + `https://remote.example/...` follower it never dereferences; here bob's |
| 147 | + actor id is a live URL on another server that A actually fetches, and |
| 148 | + alice's followers collection ends up holding a B-hosted URL (and vice |
| 149 | + versa). That also exercised the composed discovery chain across origins: |
| 150 | + webfinger's `actorPathTemplate: '/ap/<user>/actor'` gluing the JRD to the |
| 151 | + activitypub plugin's `api.reservePath`-claimed `/ap` root (#602) worked |
| 152 | + unchanged on both instances — and B accepting A's signed deliveries |
| 153 | + without verification is the plugin's documented Phase-2 boundary |
| 154 | + (inbound HTTP-Signature verify), unchanged by this demo. |
| 155 | + |
| 156 | +Seams named: module-global `DATA_ROOT` (finding 1, new emphasis: concurrent |
| 157 | +instances), `api.events.onResourceChange` #603 + delivery policy (findings |
| 158 | +2–3), `api.serverInfo` #601 (consumed by webfinger, still missing from |
| 159 | +activitypub's config surface), `api.reservePath` #602 (consumed, held up |
| 160 | +across two origins). |
0 commit comments