Skip to content

Commit f2f1e0a

Browse files
activitypub plugin (#51/#164): federate a pod as an AP actor
AS2 Person actor with a real RSA-2048 keypair (pluginDir/keys), owner-only outbox (Notes stored in the pod via loopback under real WAC), inbox (persists activities, records Follow), followers/following collections. Stretch: outbound HTTP Signature signing (draft-cavage RSA-SHA256) — signed Accept to followers + Create fan-out. Discovery composes with webfinger/nip05. Reimplements the AS2 surface on the public api, standing beside core's src/ap without importing it. Findings: (1) FOURTH reserved-path/appPaths confirmation (after mastodon /api+/oauth, bluesky /xrpc) — sharpened: the natural AP layout wants paths INTERLEAVED with the pod's own /<user>/ namespace, which no single mount prefix can carve out; operator passes appPaths:['/ap']. (2) HTTP Signatures are NOT an api gap — outbound signing is in-plugin, inbound verify just needs a fetch of the sender key (Phase-2). 12/12.
1 parent 2a359cc commit f2f1e0a

3 files changed

Lines changed: 763 additions & 0 deletions

File tree

activitypub/README.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# activitypub — a pod as a W3C ActivityPub actor
2+
3+
Out-of-tree take on JSS issues **#51 / #164** ("federate a pod as an AP
4+
actor"), built straight onto the [#206 plugin loader](https://jss.live/docs/features/plugins).
5+
Phase 1: a single pod stood up as a **personal ActivityPub actor** — fetch the
6+
actor (with a real RSA public key), post a Note to its outbox (stored in the
7+
pod over loopback LDP, under real WAC), read the outbox back as an
8+
`OrderedCollection` of `Create{Note}`, and receive `Follow`/`Create`/`Like`
9+
into the inbox.
10+
11+
This is **not a port**. JSS core ships an ActivityPub feature under `src/ap/`,
12+
but it leans on the `microfed` npm module and shares closures with
13+
`server.js`. Per the repo rule this plugin imports **no** `src/...``src/ap/`
14+
was read for the AS2 shapes and never imported. Everything (keypair, HTTP
15+
Signature signing, collections) is reimplemented on the public plugin api +
16+
`node:crypto` alone.
17+
18+
```js
19+
plugins: [{
20+
module: 'activitypub/plugin.js',
21+
config: {
22+
baseUrl: 'http://localhost:3000', // public origin (mint absolute URIs)
23+
loopbackUrl: 'http://127.0.0.1:3000', // where WE reach the pod (default: baseUrl)
24+
apRoot: '/ap', // single root for all AP paths (default /ap)
25+
},
26+
}]
27+
```
28+
29+
Because the AP paths are fixed and live outside the plugin's mount prefix, the
30+
operator must widen WAC: `createServer({ appPaths: ['/ap'], … })`. See
31+
**Findings**.
32+
33+
## Endpoints
34+
35+
| Method | Path | Status | Notes |
36+
|---|---|---|---|
37+
| `GET` | `/ap/<user>/actor` | ✅ done | AS2 `Person` + `publicKey.publicKeyPem` (RSA, per-actor, persisted) |
38+
| `GET` | `/ap/<user>/outbox` | ✅ done | `OrderedCollection`; `?page=true``OrderedCollectionPage` with `Create{Note}` items |
39+
| `POST` | `/ap/<user>/outbox` | ✅ done | owner-only (`api.auth.getAgent`); stores a Note in the pod via loopback PUT |
40+
| `POST` | `/ap/<user>/inbox` | ✅ done (store) | persists every activity; `Follow` records the follower; **inbound signature verify: Phase 2** |
41+
| `GET` | `/ap/<user>/followers` | ✅ done | `OrderedCollection` from persisted state |
42+
| `GET` | `/ap/<user>/following` | ✅ done | `OrderedCollection` (empty in Phase 1 — no outbound Follow yet) |
43+
|| outbound delivery signing | ✅ done (stretch) | `signAndDeliver` signs POSTs with the actor RSA key (draft-cavage); `Accept` to a follower, `Create` to followers on post — best-effort, non-blocking |
44+
|| inbound HTTP Signature **verify** | 📋 Phase 2 | needs fetching the sender's actor key then verifying |
45+
|| multi-actor / real cross-server timelines | 📋 Phase 2 | Phase 1 is single/personal actor |
46+
47+
### The vertical slice
48+
49+
```
50+
GET /ap/fedialice/actor → Person, publicKeyPem present
51+
POST /ap/fedialice/outbox (owner Bearer) Note "hello fediverse" → 201 Create{Note}
52+
GET /ap/fedialice/outbox → OrderedCollection contains it
53+
POST /ap/fedialice/inbox Follow(bob) → 200, follower recorded
54+
GET /ap/fedialice/followers → contains bob
55+
POST /ap/fedialice/outbox (no Bearer) → 401
56+
```
57+
58+
**12 tests, all green:**
59+
60+
```bash
61+
cd .../plugins && node --test --test-concurrency=1 activitypub/test.js
62+
```
63+
64+
## The actor / keypair model
65+
66+
- **One RSA-2048 keypair per actor**, generated with
67+
`crypto.generateKeyPairSync` on first `GET .../actor` and persisted to
68+
`pluginDir/keys/<user>.json` (SPKI public PEM + PKCS8 private PEM). The
69+
public half is published in the actor doc's `publicKey.publicKeyPem` (the
70+
key remote servers use to verify our HTTP Signatures); the private half
71+
never leaves the plugin dir and signs outbound deliveries.
72+
- **Objects live in the pod, not the plugin.** A posted Note is written by
73+
loopback LDP `PUT /<user>/public/statuses/<id>.jsonld` carrying the owner's
74+
own Bearer — so **real WAC**, not this shim, decides the write. (Same
75+
container mastodon/ uses, so the two plugins see each other's posts.) The
76+
plugin keeps a small index in `pluginDir/state/<user>.json` so an
77+
unauthenticated federation `GET outbox` can list Notes even when the pod
78+
container isn't world-readable; the container read is still attempted first
79+
and merges in anything written out-of-band.
80+
- **Inbox / followers / following** are persisted per-actor in
81+
`pluginDir/state/<user>.json` (`inbox` log, `followers`, `following`).
82+
83+
## The HTTP-Signatures boundary
84+
85+
Real federation authenticates every server-to-server POST with an HTTP
86+
Signature.
87+
88+
- **Outbound signing — done, in-plugin.** `signAndDeliver` builds the
89+
standard draft-cavage signing string
90+
(`(request-target) host date digest content-type`), signs it with
91+
`crypto.createSign('RSA-SHA256')` using the actor's private key, and sends
92+
the `Signature` + `Digest` headers. Used to deliver a signed `Accept` to a
93+
new follower and to fan a `Create` out to followers on post. Best-effort and
94+
non-blocking (a delivery to an unreachable inbox never stalls the request),
95+
so it needs no live remote in the tests.
96+
- **Inbound verification — Phase-2 boundary.** Verifying an incoming signature
97+
means: parse the `Signature` header, extract `keyId`, **fetch the sender's
98+
actor document** for its `publicKeyPem`, reconstruct the signing string, and
99+
`crypto.verify`. Every piece is doable on the public api (plain `fetch` +
100+
`node:crypto`) — core's own inbox even logs-but-doesn't-reject today — but
101+
it's deferred here: Phase 1 **stores** inbound activities unconditionally
102+
and documents the gap. No core seam is required to close it; it's scope, not
103+
a wall.
104+
105+
## Findings
106+
107+
**Headline: a plugin can stand up a functional ActivityPub actor on the public
108+
api** — RSA keypair in `pluginDir`, objects in the pod via loopback LDP under
109+
real WAC, actor discovery composable with the webfinger/nip05 pattern — with
110+
**no** `src/...` import, standing beside core's own `src/ap/` rather than
111+
replacing it. Two things it hits:
112+
113+
1. **The fixed AP paths re-hit the reserved-path / `appPaths` seam
114+
(Nth confirmation).** ActivityPub endpoints are conventionally
115+
actor-rooted absolute paths (`/<user>/inbox`, `/<user>/outbox`, …) that
116+
*collide with the pod's own LDP namespace* (`/<user>/…` **is** the pod).
117+
Even mounted under one configurable root (`/ap`, chosen precisely to keep
118+
it to **one** extra path — the bluesky/ move), the loader still WAC-exempts
119+
only the plugin's single `prefix`; the AP root is not it, so **every
120+
federation request 401s until the operator hand-passes
121+
`appPaths: ['/ap']`**. This is the **same seam** mastodon/ (`/api` +
122+
`/oauth`) and bluesky/ (`/xrpc`) hit — now a **fourth** independent
123+
API-shim confirming it. The AP case sharpens the reason the seam is
124+
*reservePath*, not *more prefixes*: the natural AP layout wants paths
125+
**interleaved with** pod paths under the same `/<user>/` root, which no
126+
single mount prefix can carve out. The seam NOTES.md already names —
127+
`api.reservePath('/ap')` (or `paths: […]` in the plugin entry): the loader
128+
exempts *and* claims each declared path and reports collisions — would let
129+
this plugin own its surface and choose the canonical `/<user>/inbox` layout
130+
without the operator editing `createServer`. Cross-ref NOTES §5 and
131+
`mastodon/` + `bluesky/` "Findings".
132+
133+
2. **Real federation needs HTTP Signature sign/verify — signing is in-plugin,
134+
verify needs a remote key fetch (both doable, no core seam).** Outbound
135+
signing is implemented here with `node:crypto` (above). Inbound
136+
verification needs fetching the remote actor's public key — a plain
137+
`fetch` — then `crypto.verify`; nothing in it requires reaching into core.
138+
So unlike the reserved-path seam, the signature story is **not** a
139+
plugin-api gap: it's Phase-2 scope. (The one adjacent convenience a core
140+
seam *would* help with is `api.serverInfo` — this plugin, like mastodon/,
141+
notifications/, webdav/, must be handed its own `baseUrl` in config to mint
142+
absolute actor URIs and reach the pod over loopback; NOTES §3.)
143+
144+
Cross-refs: NOTES.md §3 (`api.serverInfo`) and §5 (reserved-path /
145+
`api.reservePath`); sibling shims `mastodon/` and `bluesky/`.

0 commit comments

Comments
 (0)