Skip to content

Commit b4d83cd

Browse files
activitypub: self-reserve the /ap root via api.reservePath (#602)
The literal-root half of the reserved-path finding is consumed as of JSS 0.0.219 — no more hand-passed appPaths. Widened to POST only (inbox by design, outbox enforces its own owner Bearer), nothing wider. The sharper half stays open: AP's natural layout wants paths interleaved with the pod's /<user>/ namespace; /ap is still the workaround root. All 15 tests green including the three security regressions.
1 parent d5416b6 commit b4d83cd

3 files changed

Lines changed: 57 additions & 39 deletions

File tree

activitypub/README.md

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ plugins: [{
2626
}]
2727
```
2828

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**.
29+
The AP paths are fixed and live outside the plugin's mount prefix. Since JSS
30+
0.0.219 the plugin claims + WAC-exempts the `/ap` root itself at activate time
31+
via `api.reservePath('/ap', { methods: [...] })` (#602) — the operator no
32+
longer passes `appPaths`. See **Findings**.
3233

3334
## Endpoints
3435

@@ -55,7 +56,7 @@ GET /ap/fedialice/followers → contains bob
5556
POST /ap/fedialice/outbox (no Bearer) → 401
5657
```
5758

58-
**12 tests, all green:**
59+
**15 tests, all green:**
5960

6061
```bash
6162
cd .../plugins && node --test --test-concurrency=1 activitypub/test.js
@@ -141,24 +142,29 @@ real WAC, actor discovery composable with the webfinger/nip05 pattern — with
141142
replacing it. Two things it hits:
142143

143144
1. **The fixed AP paths re-hit the reserved-path / `appPaths` seam
144-
(Nth confirmation).** ActivityPub endpoints are conventionally
145-
actor-rooted absolute paths (`/<user>/inbox`, `/<user>/outbox`, …) that
146-
*collide with the pod's own LDP namespace* (`/<user>/…` **is** the pod).
147-
Even mounted under one configurable root (`/ap`, chosen precisely to keep
148-
it to **one** extra path — the bluesky/ move), the loader still WAC-exempts
149-
only the plugin's single `prefix`; the AP root is not it, so **every
150-
federation request 401s until the operator hand-passes
151-
`appPaths: ['/ap']`**. This is the **same seam** mastodon/ (`/api` +
152-
`/oauth`) and bluesky/ (`/xrpc`) hit — now a **fourth** independent
153-
API-shim confirming it. The AP case sharpens the reason the seam is
154-
*reservePath*, not *more prefixes*: the natural AP layout wants paths
155-
**interleaved with** pod paths under the same `/<user>/` root, which no
156-
single mount prefix can carve out. The seam NOTES.md already names —
157-
`api.reservePath('/ap')` (or `paths: […]` in the plugin entry): the loader
158-
exempts *and* claims each declared path and reports collisions — would let
159-
this plugin own its surface and choose the canonical `/<user>/inbox` layout
160-
without the operator editing `createServer`. Cross-ref NOTES §5 and
161-
`mastodon/` + `bluesky/` "Findings".
145+
(Nth confirmation) — the literal-root half is now CLOSED.** ActivityPub
146+
endpoints are conventionally actor-rooted absolute paths (`/<user>/inbox`,
147+
`/<user>/outbox`, …) that *collide with the pod's own LDP namespace*
148+
(`/<user>/…` **is** the pod). Even mounted under one configurable root
149+
(`/ap`, chosen precisely to keep it to **one** extra path — the bluesky/
150+
move), the loader WAC-exempts only the plugin's single `prefix`; the AP
151+
root is not it, so **every federation request 401'd until the operator
152+
hand-passed `appPaths: ['/ap']`**. This was the **same seam** mastodon/
153+
(`/api` + `/oauth`) and bluesky/ (`/xrpc`) hit — a **fourth** independent
154+
API-shim confirming it. **Consumed as of JSS 0.0.219**: the seam shipped
155+
as `api.reservePath` (#602) and this plugin now claims + WAC-exempts `/ap`
156+
itself at activate time (methods widened to exactly the implemented verbs
157+
— POST for the inbox/outbox, nothing wider, since an exemption on an
158+
unimplemented write verb would fall through to LDP as an unauthenticated
159+
write; collisions with another claimant fail the boot loudly). **The
160+
SHARPER half remains open**: the AP case is why the seam is *reservePath*,
161+
not *more prefixes* — the natural AP layout wants paths **interleaved
162+
with** pod paths under the same `/<user>/` root, i.e. the parameterized
163+
case, which the literal-subtree claim doesn't give a plugin a way to own
164+
for a *whole namespace* (`/:user/inbox` for every user alongside the pod's
165+
own `/:user/…` documents). `/ap` is still the workaround root; the
166+
canonical `/<user>/inbox` layout is still out of reach. Cross-ref NOTES §5
167+
and `mastodon/` + `bluesky/` "Findings".
162168

163169
2. **Real federation needs HTTP Signature sign/verify — signing is in-plugin,
164170
verify needs a remote key fetch (both doable, no core seam).** Outbound

activitypub/plugin.js

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,14 @@
2424
// the pod's own LDP namespace (`/<user>/...` IS the pod). Like mastodon/
2525
// (fixed `/api` + `/oauth`) and bluesky/ (`/xrpc`), a plugin gets ONE mount
2626
// `prefix` and the loader WAC-exempts only that one prefix. To keep the AP
27-
// surface to a SINGLE extra root the operator must exempt, everything here
28-
// lives under one configurable base — `/ap/<user>/actor`, `/ap/<user>/outbox`,
29-
// … (default `apRoot: '/ap'`). This deviates from the AP convention of
30-
// actor-rooted absolute paths; the deviation, and the reserved-path/appPaths
31-
// seam it re-hits, are the README "Findings" (Nth confirmation of #582's
32-
// `api.reservePath`).
27+
// surface to a SINGLE extra root, everything here lives under one
28+
// configurable base — `/ap/<user>/actor`, `/ap/<user>/outbox`, … (default
29+
// `apRoot: '/ap'`). Since JSS 0.0.219 the plugin claims + WAC-exempts that
30+
// root ITSELF via `api.reservePath` (#602) — no more hand-passed
31+
// `appPaths: ['/ap']`. The deviation from AP's actor-rooted absolute paths
32+
// remains: the natural layout wants paths interleaved with the pod's
33+
// `/<user>/` namespace, and /ap is still the workaround root — the open
34+
// half of the README "Findings".
3335
//
3436
// -------------------------------------------------------- HTTP Signatures
3537
//
@@ -229,9 +231,18 @@ export async function activate(api) {
229231
}
230232
const loopback = (api.config.loopbackUrl || baseUrl).replace(/\/$/, '');
231233
// ONE extra root (default /ap): every AP path lives under it, so the
232-
// operator exempts a single path via appPaths (see README findings).
234+
// plugin claims a single path (see README findings).
233235
const apRoot = (api.config.apRoot || '/ap').replace(/\/$/, '');
234236

237+
// Claim + WAC-exempt the AP root (api.reservePath, #602, JSS 0.0.219):
238+
// a literal path exempts the whole subtree, and a second plugin claiming
239+
// it fails the boot loudly. Reservations are READ-ONLY by default
240+
// (GET/HEAD/OPTIONS); POST is widened because the routes below implement
241+
// it (inbox, and the owner-authed outbox). Nothing wider — exempting a
242+
// write verb with no route would fall through to LDP's wildcards as an
243+
// unauthenticated storage write.
244+
api.reservePath(apRoot, { methods: ['GET', 'HEAD', 'OPTIONS', 'POST'] });
245+
235246
const dir = api.storage.pluginDir();
236247
const keysDir = path.join(dir, 'keys');
237248
const stateDir = path.join(dir, 'state');
@@ -605,7 +616,5 @@ export async function activate(api) {
605616
return ap(reply, 200, collection(user, 'following', loadState(user).following.map((f) => (typeof f === 'string' ? f : f.actor))));
606617
});
607618

608-
api.log.info(`activitypub: AP actor surface at ${apRoot}/<user>/{actor,outbox,inbox,followers,following} → pods via ${loopback} (issues #51/#164 Phase 1)`);
609-
api.log.warn(`activitypub: ${apRoot} must be in appPaths or WAC will 401 every `
610-
+ 'federation request — the one-prefix plugin model cannot self-exempt fixed AP paths (see README findings)');
619+
api.log.info(`activitypub: AP actor surface at ${apRoot}/<user>/{actor,outbox,inbox,followers,following} → pods via ${loopback} (issues #51/#164 Phase 1; root reserved via api.reservePath)`);
611620
}

activitypub/test.js

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@
99
// auth boundary → POST /ap/<user>/outbox no Bearer → 401
1010
//
1111
// Same probe-port-then-boot dance as mastodon/: the plugin needs its origin
12-
// in config before listen (finding: api.serverInfo), idp:true gives the
13-
// /idp/register + /idp/credentials the owner Bearer rides on, and appPaths
14-
// widens WAC past the plugin's single prefix to the fixed AP root.
12+
// in config before listen (finding: api.serverInfo), and idp:true gives the
13+
// /idp/register + /idp/credentials the owner Bearer rides on. No appPaths:
14+
// since JSS 0.0.219 the plugin reserves the fixed /ap root itself
15+
// (api.reservePath, #602).
1516

1617
import { describe, it, before, after } from 'node:test';
1718
import assert from 'node:assert';
@@ -48,10 +49,12 @@ describe('activitypub plugin', () => {
4849
jss = await startJss({
4950
port,
5051
idp: true,
51-
// The finding in action: the fixed AP paths live OUTSIDE the plugin's
52-
// single prefix, and the loader WAC-exempts only that prefix. Keeping
53-
// everything under one /ap root means the operator exempts ONE path.
54-
appPaths: ['/ap'],
52+
// No appPaths: the fixed AP paths live OUTSIDE the plugin's single
53+
// prefix, and as of JSS 0.0.219 the plugin claims + WAC-exempts the
54+
// one /ap root itself via api.reservePath (#602) — the literal-root
55+
// half of the finding, consumed. The SHARPER half stays open: the
56+
// natural AP layout wants paths interleaved with the pod's /<user>/
57+
// namespace (the parameterized case), and /ap is still the workaround.
5558
// NOTE: allowPrivateDelivery is deliberately NOT set — the instance runs
5659
// the DEFAULT (closed) SSRF policy, so the loopback-actor test below
5760
// exercises the real production default. maxInbox is lowered only so the

0 commit comments

Comments
 (0)