Skip to content

Commit 3f38348

Browse files
activitypub: owner-authed inbox collection, published stamping, threading
GET /ap/:user/inbox (owner Bearer; anon 401) pages the persisted inbox log newest-first as an AS2 OrderedCollection — the inbox is the owner's private mail, now readable by its owner (the mastodon/ shim consumes it for notifications). Activities are stamped published at ingest when the sender omitted one. Also fixed a real threading bug found on the way: the notes index dropped inReplyTo, losing reply chains whenever the outbox lists from the index. 20/20 tests, security regressions intact.
1 parent 2b49977 commit 3f38348

3 files changed

Lines changed: 177 additions & 8 deletions

File tree

activitypub/README.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@ longer passes `appPaths`. See **Findings**.
3737
|---|---|---|---|
3838
| `GET` | `/ap/<user>/actor` | ✅ done | AS2 `Person` + `publicKey.publicKeyPem` (RSA, per-actor, persisted) |
3939
| `GET` | `/ap/<user>/outbox` | ✅ done | `OrderedCollection`; `?page=true``OrderedCollectionPage` with `Create{Note}` items |
40-
| `POST` | `/ap/<user>/outbox` | ✅ done | owner-only (`api.auth.getAgent`); stores a Note in the pod via loopback PUT |
41-
| `POST` | `/ap/<user>/inbox` | ✅ done (store) | persists every activity (bounded `maxInbox`); `Follow` records the follower (deduped); **inbound signature verify: Phase 2** — unauthenticated-by-design, so outbound delivery is SSRF-gated |
40+
| `POST` | `/ap/<user>/outbox` | ✅ done | owner-only (`api.auth.getAgent`); stores a Note in the pod via loopback PUT; `inReplyTo` passes through onto the stored Note + returned `Create` (threading) |
41+
| `GET` | `/ap/<user>/inbox` | ✅ done | **owner-only** (same `getAgent` check as the outbox POST — the log holds strangers' activity, i.e. the owner's private mail; anonymous → 401). `OrderedCollection` summary; `?page=true``OrderedCollectionPage` of the raw stored activities, **newest first**, each carrying a `published` stamp (receipt time stamped at ingest when the sender omits it; backfilled at read for pre-stamping entries). The read surface mastodon/ builds timelines/notifications/threads on. Covered by the existing `/ap` reservation (GET was already in the reserved methods) |
42+
| `POST` | `/ap/<user>/inbox` | ✅ done (store) | persists every activity — `Like`/`Announce`/`Create` retained as-is, `Follow` records the follower (deduped), `Undo{Follow}` removes it — bounded `maxInbox`; **inbound signature verify: Phase 2** — unauthenticated-by-design, so outbound delivery is SSRF-gated |
4243
| `GET` | `/ap/<user>/followers` | ✅ done | `OrderedCollection` from persisted state |
4344
| `GET` | `/ap/<user>/following` | ✅ done | `OrderedCollection` (empty in Phase 1 — no outbound Follow yet) |
4445
|| 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 |
@@ -53,10 +54,14 @@ POST /ap/fedialice/outbox (owner Bearer) Note "hello fediverse" → 201 Create
5354
GET /ap/fedialice/outbox → OrderedCollection contains it
5455
POST /ap/fedialice/inbox Follow(bob) → 200, follower recorded
5556
GET /ap/fedialice/followers → contains bob
57+
GET /ap/fedialice/inbox (owner Bearer) → OrderedCollection; ?page=true
58+
pages the log newest-first,
59+
every item `published`-stamped
60+
GET /ap/fedialice/inbox (no Bearer) → 401 (owner's private mail)
5661
POST /ap/fedialice/outbox (no Bearer) → 401
5762
```
5863

59-
**15 tests, all green:**
64+
**20 tests, all green:**
6065

6166
```bash
6267
cd .../plugins && node --test --test-concurrency=1 activitypub/test.js
@@ -79,7 +84,12 @@ cd .../plugins && node --test --test-concurrency=1 activitypub/test.js
7984
container isn't world-readable; the container read is still attempted first
8085
and merges in anything written out-of-band.
8186
- **Inbox / followers / following** are persisted per-actor in
82-
`pluginDir/state/<user>.json` (`inbox` log, `followers`, `following`).
87+
`pluginDir/state/<user>.json` (`inbox` log, `followers`, `following`). Each
88+
inbox entry stores the raw activity plus a `receivedAt`; the activity gets a
89+
`published` stamp at ingest when the sender omitted one (and pre-stamping
90+
entries are backfilled from `receivedAt` at read time), so the owner-read
91+
`GET inbox` always serves sortable items. Followers/following stay public
92+
AP surface; the inbox **log** is owner-only.
8393

8494
## The HTTP-Signatures boundary
8595

activitypub/plugin.js

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@
1010
// actor (with a real RSA public key), post a Note to its outbox (stored in
1111
// the pod over loopback LDP, under real WAC), read the outbox back as an
1212
// OrderedCollection of Create{Note}, and receive Follow/Create/Like into the
13-
// inbox (persisted to pluginDir). Followers/following are OrderedCollections
14-
// from that persisted state. This is NOT a bundled-feature port: JSS core
13+
// inbox (persisted to pluginDir). The OWNER can read that inbox log back as
14+
// an OrderedCollection (newest first, owner-authed — it holds strangers'
15+
// activity, so it is private mail; mastodon/ builds timelines/notifications
16+
// on it). Followers/following are OrderedCollections from that persisted
17+
// state. This is NOT a bundled-feature port: JSS core
1518
// ships an ActivityPub feature under src/ap/ (which leans on the `microfed`
1619
// npm module and shares closures with server.js); this is a parallel
1720
// reimplementation on the PUBLIC plugin api only — src/ap/ was read for the
@@ -540,8 +543,13 @@ export async function activate(api) {
540543
if (!(put.ok || put.status === 204)) return err(reply, 500, `Pod storage rejected the Note (${put.status})`);
541544

542545
// Index it (authoritative for federation GETs that carry no creds).
546+
// inReplyTo rides along so threading survives even when the outbox is
547+
// listed from the index alone (pod container unreadable to the caller).
543548
const state = loadState(user);
544-
state.notes.push({ id: noteUri, url: noteUri, content, published, attributedTo: actorId(user) });
549+
state.notes.push({
550+
id: noteUri, url: noteUri, content, published, attributedTo: actorId(user),
551+
...(note.inReplyTo ? { inReplyTo: note.inReplyTo } : {}),
552+
});
545553
saveState(user, state);
546554

547555
// Deliver the Create to followers (signed, best-effort, non-blocking).
@@ -553,6 +561,47 @@ export async function activate(api) {
553561
return ap(reply, 201, { '@context': AS_CONTEXT, ...create });
554562
});
555563

564+
// ---- GET inbox (owner only): the persisted inbox log --------------------
565+
// Unlike the other collections this one is NOT public AP surface: the log
566+
// holds activity from strangers, i.e. the owner's private mail, so reading
567+
// it is gated on the SAME owner check as the outbox POST. Items are the raw
568+
// stored activities, newest first — mastodon/ builds home timeline,
569+
// notifications and threads from this. No new reservation needed: the /ap
570+
// subtree claim above already covers GET here.
571+
api.fastify.get(`${apRoot}/:user/inbox`, async (request, reply) => {
572+
const webid = await api.auth.getAgent(request);
573+
if (!webid) return err(reply, 401, 'Authentication required to read the inbox');
574+
const { username } = podFromWebid(webid);
575+
const user = request.params.user;
576+
if (username !== user) return err(reply, 403, 'Only the actor owner may read this inbox');
577+
578+
// Newest first (the log is append-ordered). Entries persisted before
579+
// ingest-time `published` stamping are backfilled from receivedAt here.
580+
const items = [...loadState(user).inbox].reverse().map(({ receivedAt, activity }) => (
581+
activity.published ? activity : { ...activity, published: receivedAt }
582+
));
583+
const inboxUrl = `${actorBase(user)}/inbox`;
584+
// ?page=true → the collection PAGE form (orderedItems inline).
585+
if (String(request.query?.page) === 'true') {
586+
return ap(reply, 200, {
587+
'@context': AS_CONTEXT,
588+
id: `${inboxUrl}?page=true`,
589+
type: 'OrderedCollectionPage',
590+
partOf: inboxUrl,
591+
totalItems: items.length,
592+
orderedItems: items,
593+
});
594+
}
595+
return ap(reply, 200, {
596+
'@context': AS_CONTEXT,
597+
id: inboxUrl,
598+
type: 'OrderedCollection',
599+
totalItems: items.length,
600+
first: `${inboxUrl}?page=true`,
601+
last: `${inboxUrl}?page=true`,
602+
});
603+
});
604+
556605
// ---- POST inbox: accept incoming activities ----------------------------
557606
api.fastify.post(`${apRoot}/:user/inbox`, async (request, reply) => {
558607
const user = request.params.user;
@@ -567,7 +616,13 @@ export async function activate(api) {
567616
// the mitigation for the abuse surface that opens. We persist every
568617
// activity to the inbox log, bounded to the most recent cfg.maxInbox.
569618
const state = loadState(user);
570-
state.inbox.push({ receivedAt: new Date().toISOString(), activity });
619+
// Stamp receipt time as `published` when the sender omitted it — done at
620+
// INGEST so the timestamp persists with the stored activity (GET inbox
621+
// additionally backfills at read time for entries stored before this
622+
// stamping existed). Timeline consumers (mastodon/) sort on it.
623+
const receivedAt = new Date().toISOString();
624+
if (!activity.published) activity.published = receivedAt;
625+
state.inbox.push({ receivedAt, activity });
571626
if (state.inbox.length > cfg.maxInbox) state.inbox = state.inbox.slice(-cfg.maxInbox);
572627

573628
if (activity.type === 'Follow') {

activitypub/test.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
// read outbox → GET /ap/<user>/outbox OrderedCollection contains it
77
// be followed → POST /ap/<user>/inbox a Follow activity
88
// list followers→ GET /ap/<user>/followers contains the follower
9+
// read inbox → GET /ap/<user>/inbox owner Bearer pages the log
10+
// (newest first, published
11+
// stamped); anon → 401
912
// auth boundary → POST /ap/<user>/outbox no Bearer → 401
1013
//
1114
// Same probe-port-then-boot dance as mastodon/: the plugin needs its origin
@@ -199,6 +202,107 @@ describe('activitypub plugin', () => {
199202
assert.strictEqual(res.status, 200);
200203
});
201204

205+
// ---- THE INBOX LOG (owner-read surface for mastodon/ timelines) -----------
206+
207+
it('GET /ap/<user>/inbox anonymous is 401 (the log is the owner\'s private mail)', async () => {
208+
const res = await fetch(`${base}/ap/${USER}/inbox`);
209+
assert.strictEqual(res.status, 401);
210+
const page = await fetch(`${base}/ap/${USER}/inbox?page=true`);
211+
assert.strictEqual(page.status, 401);
212+
});
213+
214+
it('GET /ap/<user>/inbox (owner Bearer) pages the log newest-first; the Follow carries published', async () => {
215+
const res = await fetch(`${base}/ap/${USER}/inbox`, {
216+
headers: { authorization: `Bearer ${token}`, accept: 'application/activity+json' },
217+
});
218+
assert.strictEqual(res.status, 200);
219+
const coll = await res.json();
220+
assert.strictEqual(coll.type, 'OrderedCollection');
221+
assert.ok(coll.totalItems >= 2, `expected the Follow + Create in the log, got ${coll.totalItems}`);
222+
223+
const page = await (await fetch(`${base}/ap/${USER}/inbox?page=true`, {
224+
headers: { authorization: `Bearer ${token}` },
225+
})).json();
226+
assert.strictEqual(page.type, 'OrderedCollectionPage');
227+
assert.ok(Array.isArray(page.orderedItems), 'no orderedItems');
228+
// The earlier Follow (POSTed with no published) is in the log, stamped at ingest.
229+
const follow = page.orderedItems.find((a) => a.type === 'Follow' && a.actor === REMOTE_FOLLOWER);
230+
assert.ok(follow, 'earlier Follow not in the inbox log');
231+
assert.ok(follow.published, 'stored Follow carries no published stamp');
232+
// Newest first: the Create was POSTed after the Follow.
233+
const iCreate = page.orderedItems.findIndex((a) => a.type === 'Create');
234+
const iFollow = page.orderedItems.findIndex((a) => a.type === 'Follow');
235+
assert.ok(iCreate < iFollow, `log not newest-first (Create at ${iCreate}, Follow at ${iFollow})`);
236+
});
237+
238+
it('a Like and an Announce POSTed to the inbox land in the log with type intact', async () => {
239+
for (const activity of [
240+
{ type: 'Like', id: `${REMOTE_FOLLOWER}#likes/1`, actor: REMOTE_FOLLOWER, object: `${base}/${USER}/public/statuses/x.jsonld` },
241+
{ type: 'Announce', id: `${REMOTE_FOLLOWER}#boosts/1`, actor: REMOTE_FOLLOWER, object: `${base}/${USER}/public/statuses/x.jsonld` },
242+
]) {
243+
const res = await fetch(`${base}/ap/${USER}/inbox`, {
244+
method: 'POST',
245+
headers: { 'content-type': 'application/activity+json' },
246+
body: JSON.stringify({ '@context': 'https://www.w3.org/ns/activitystreams', ...activity }),
247+
});
248+
assert.strictEqual(res.status, 200);
249+
}
250+
const page = await (await fetch(`${base}/ap/${USER}/inbox?page=true`, {
251+
headers: { authorization: `Bearer ${token}` },
252+
})).json();
253+
const like = page.orderedItems.find((a) => a.id === `${REMOTE_FOLLOWER}#likes/1`);
254+
const boost = page.orderedItems.find((a) => a.id === `${REMOTE_FOLLOWER}#boosts/1`);
255+
assert.ok(like, 'Like not retained in the inbox log');
256+
assert.strictEqual(like.type, 'Like');
257+
assert.ok(like.published, 'Like carries no published stamp');
258+
assert.ok(boost, 'Announce not retained in the inbox log');
259+
assert.strictEqual(boost.type, 'Announce');
260+
assert.ok(boost.published, 'Announce carries no published stamp');
261+
// Newest first: the Announce was POSTed last, so it heads the page.
262+
assert.strictEqual(page.orderedItems[0].id, `${REMOTE_FOLLOWER}#boosts/1`);
263+
});
264+
265+
it('read-time backfill: a legacy inbox entry without published is stamped from receivedAt', async () => {
266+
// Entries stored BEFORE ingest-time stamping existed have no published;
267+
// inject one straight into the state file (the format the plugin persists)
268+
// and assert the read path backfills it. statePath/readState are defined
269+
// with the security regressions below — same describe scope.
270+
const state = readState();
271+
state.inbox.push({
272+
receivedAt: '2020-01-01T00:00:00.000Z',
273+
activity: { type: 'Like', id: 'urn:legacy:unstamped', actor: REMOTE_FOLLOWER },
274+
});
275+
fs.writeFileSync(statePath(), JSON.stringify(state, null, 2));
276+
const page = await (await fetch(`${base}/ap/${USER}/inbox?page=true`, {
277+
headers: { authorization: `Bearer ${token}` },
278+
})).json();
279+
const legacy = page.orderedItems.find((a) => a.id === 'urn:legacy:unstamped');
280+
assert.ok(legacy, 'legacy entry not served');
281+
assert.strictEqual(legacy.published, '2020-01-01T00:00:00.000Z');
282+
});
283+
284+
it('inReplyTo survives outbox POST → pod resource → outbox collection (threading)', async () => {
285+
const parent = 'https://remote.example/users/bob/statuses/12345';
286+
const res = await fetch(`${base}/ap/${USER}/outbox`, {
287+
method: 'POST',
288+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/activity+json' },
289+
body: JSON.stringify({ type: 'Note', content: 'a threaded reply', inReplyTo: parent }),
290+
});
291+
assert.ok([200, 201].includes(res.status), `outbox POST: ${res.status}`);
292+
const create = await res.json();
293+
assert.strictEqual(create.object.inReplyTo, parent, 'returned Create lost inReplyTo');
294+
295+
// The stored pod resource carries it…
296+
const raw = await (await fetch(create.object.id, { headers: { authorization: `Bearer ${token}` } })).json();
297+
assert.strictEqual(raw.inReplyTo, parent, 'pod Note lost inReplyTo');
298+
299+
// …and so does the outbox collection item.
300+
const page = await (await fetch(`${base}/ap/${USER}/outbox?page=true`)).json();
301+
const item = page.orderedItems.find((it) => it.object?.content === 'a threaded reply');
302+
assert.ok(item, 'reply Note not in outbox');
303+
assert.strictEqual(item.object.inReplyTo, parent, 'outbox item lost inReplyTo');
304+
});
305+
202306
// ---- SECURITY REGRESSIONS -------------------------------------------------
203307

204308
// Path to the per-actor state file. No explicit `id` is passed for the entry;

0 commit comments

Comments
 (0)