A runnable, narrated demo scenario (not a plugin — there is no
plugin.js here): TWO independent JavaScript Solid Server instances on one
machine, each composed from webfinger/ (actor discovery) + activitypub/
(the actor itself), walking real ActivityPub federation mechanics over
loopback HTTP. Server A hosts alice, server B hosts bob; every
actor URL, webfinger JRD, Follow, Accept and Create in the flow is a real
cross-origin HTTP exchange between the two live servers.
node federation-demo/demo.js # the narrated show
node --test --test-concurrency=1 federation-demo/test.js # the assertions (9 tests)
Loopback only — no external network, no flags, exit 0 on success.
| step | mechanic | who does the work |
|---|---|---|
| discovery | acct:alice@127.0.0.1:<portA> → A’s /.well-known/webfinger → JRD → actor doc |
webfinger + activitypub plugins |
| 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) |
| accept | A fetches bob’s actor doc from B, resolves his inbox, delivers a signed Accept to B | plugin-initiated (A runs allowPrivateDelivery: true) |
| publish | alice posts a Note via her outbox (owner Bearer); stored in her pod over loopback LDP under real WAC | activitypub plugin |
| delivery A→B | the Create fans out to bob’s inbox on B, HTTP-signed | plugin-initiated (same opt-in) |
| 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 |
| 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 |
The two directions are deliberately asymmetric. Server A uses the
activitypub plugin’s documented opt-in for private-network/test
deployments (allowPrivateDelivery: true) — without it a loopback-only
demo could never see server-initiated delivery, because the plugin’s SSRF
gate refuses loopback/private targets by default. Server B keeps
that production default, so the same demo also shows the gate holding: the
gate’s logic is not weakened, bypassed or reimplemented anywhere — one
server opts in via config, the other demonstrates the refusal.
── Step 3: DISCOVERY — B's side resolves alice via webfinger on A
GET http://127.0.0.1:43235/.well-known/webfinger?resource=acct%3Aalice%40127.0.0.1%3A43235
JRD:
{ "subject": "acct:alice@127.0.0.1:43235",
"links": [ …, { "rel": "self", "type": "application/activity+json",
"href": "http://127.0.0.1:43235/ap/alice/actor" } ] }
…
── Step 6: DELIVERY — A fans the Create out to bob's inbox on B (plugin-initiated)
arrived in bob's inbox log on B:
{ "receivedAt": "2026-07-12T13:14:55.211Z",
"activity": { "type": "Create",
"actor": "http://127.0.0.1:43235/ap/alice/actor",
"object": { "content": "Hello from server A — federation with no cloud!" } } }
no driver involved: A's activitypub plugin signed and POSTed this to
http://127.0.0.1:44835/ap/bob/inbox on its own, at publish time.
…
── Step 7: REVERSE — alice follows bob on B, which runs the PRODUCTION default
B recorded the follower BUT refused to fetch alice's actor URL:
follower record on B: { actor: alice@A, inbox: null }
that is the SSRF gate doing its job — 127.0.0.1 is a private target
and B never opted into allowPrivateDelivery. No Accept was sent.
demo.js — the narrated standalone script (node federation-demo/demo.js).test.js — the same flow as 9 node:test assertions (picked up by the
repo’s npm test glob).harness.js — shared driver helpers (spawn an instance, register a user,
post activities, poll the activitypub plugin’s persisted state).instance.js — the child-process entry that boots ONE federated JSS
(webfinger + activitypub) and prints READY {json}. See the first
finding for why this file must exist.Publishing goes through the ActivityPub outbox, not micropub/:
micropub stores h-entries under public/posts/… while the AP outbox reads
public/statuses/…, so a micropub post would never appear in the federated
outbox — the AP-native path is the cleaner story and micropub adds nothing
here.
What a TWO-instance topology exercises that no single-instance suite can — and the walls it hit.
Two live JSS instances cannot share one process — the module-global
DATA_ROOT footgun is a hard wall for multi-server topologies, not just
a test-ordering hazard. createServer writes the storage root into
process-global state (src/server.js:203 sets process.env.DATA_ROOT)
and the IdP + storage layers re-read it lazily per request
(src/idp/accounts.js getAccountsDir(), src/idp/keys.js,
src/utils/url.js getDataRoot(), src/handlers/pay.js, …). AGENT.md
documents this for sequential boots (“order the failing boot first”);
for concurrent servers there is no ordering trick: the second boot
repoints the global and the first server’s account lookups, token
verification and pod reads silently hit the second server’s data root.
This demo therefore spawns each instance as a child process
(instance.js), which works cleanly — but it means “run two JSS servers
in one Node process” is off the table until the root is carried per
instance rather than per process.
Plugin-initiated outbound federation genuinely works on loopback — but
only through allowPrivateDelivery, and the demo makes the SSRF/loopback
tension visible in both directions. With the documented opt-in on
server A, the activitypub plugin did everything itself: fetched bob’s
actor document cross-origin from B, resolved his inbox, delivered an
HTTP-signed Accept, and fanned a Create out to B at publish time — real
server-to-server delivery, observed in B’s persisted inbox log. Server B,
on the default config (the one three security regression tests in
activitypub/test.js protect), correctly refused to resolve or deliver
to a loopback peer (inbox: null in its follower record, no Accept, no
Create), so the reverse direction had to be couriered by the driver
(read B’s outbox, POST to A’s inbox) — recorded honestly in the
narration. The tension is structural: any LAN/air-gapped “federation with
no cloud” deployment is exactly the case the anti-SSRF default refuses,
and the per-instance boolean is the entire policy surface. A finer
delivery policy (e.g. an allowlist of peer origins rather than
all-private-or-nothing) would let a private-network deployment federate
without opening delivery to every private address; that’s a plugin
improvement, not a core seam.
Delivery only exists where the plugin owns the write path — the
missing seams are api.events (#603) and an outbound-Follow client, and
the driver’s courier role names them. The plugin delivers at exactly
two moments it controls: Accept-on-Follow and Create-on-outbox-POST. A
Note PUT straight into the pod via LDP (or by another plugin) federates
to nobody, because nothing tells the plugin the pod changed — that’s the
api.events.onResourceChange gap. And nothing in Phase 1 initiates a
Follow (the following collection is empty by design), which is why the
demo’s Follows are couriered by the driver even though the Accepts and
Creates are not. Retries/queues for failed deliveries would sit on the
same missing event/queue seam.
api.serverInfo (#601) pays off across origins: webfinger bound each
JRD to the right host with zero config. Two instances on the same
machine differ only by port; webfinger (given no baseUrl) minted
hrefs on each server’s own live origin via api.serverInfo(), so the
same plugin config produced correct, distinct JRDs on A and B. The
activitypub plugin predates the seam and still requires a hand-fed
config.baseUrl per instance — visible here as the one per-server
config line the demo has to compute (NOTES §3).
Cross-origin actor URLs are the thing single-instance tests fake — and
the composed layout held up. activitypub/test.js uses a fictitious
https://remote.example/... follower it never dereferences; here bob’s
actor id is a live URL on another server that A actually fetches, and
alice’s followers collection ends up holding a B-hosted URL (and vice
versa). That also exercised the composed discovery chain across origins:
webfinger’s actorPathTemplate: '/ap/<user>/actor' gluing the JRD to the
activitypub plugin’s api.reservePath-claimed /ap root (#602) worked
unchanged on both instances — and B accepting A’s signed deliveries
without verification is the plugin’s documented Phase-2 boundary
(inbound HTTP-Signature verify), unchanged by this demo.
Seams named: module-global DATA_ROOT (finding 1, new emphasis: concurrent
instances), api.events.onResourceChange #603 + delivery policy (findings
2–3), api.serverInfo #601 (consumed by webfinger, still missing from
activitypub’s config surface), api.reservePath #602 (consumed, held up
across two origins).