|
| 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