Skip to content

Commit 7c61fe9

Browse files
federation-demo: two Solid servers federating over loopback, no cloud
A narrated demo (node federation-demo/demo.js) plus a 9-test permanent scenario suite: webfinger discovery across origins, a cross-origin Follow, a plugin-signed Accept and plugin-initiated Create delivery A->B (via activitypub's documented allowPrivateDelivery opt-in on A only), while B keeps the production SSRF default and demonstrably refuses loopback delivery — the asymmetry is the honest core. New findings: JSS's process-global DATA_ROOT (set at createServer, re-read per request) makes two live instances in one process impossible — each runs as a child process; api.serverInfo let webfinger bind each JRD to the right origin across two ports with zero config; micropub posts land in public/posts/ while the AP outbox reads public/statuses/, so the two don't federate — a layout seam.
1 parent 75bfdb3 commit 7c61fe9

5 files changed

Lines changed: 870 additions & 0 deletions

File tree

federation-demo/README.md

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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

Comments
 (0)