OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / test / router.test.js

706 lines · javascript ref:
import test from 'brittle'
import { handleRequest, keyBelongsToActor } from '../lib/router.js'
import { MemoryFollowerStore } from '../lib/followers.js'
import { MemoryInboundStore } from '../lib/inbound-store.js'
import { signRequest } from '../lib/http-signature.js'
import { TEST_PRIVATE_KEY, TEST_PUBLIC_KEY, OTHER_PUBLIC_KEY } from './fixtures/keys.js'

const REMOTE = 'https://mastodon.social/users/alice'
const REMOTE_KEY = `${REMOTE}#main-key`

// The whole point of splitting router from transport: a full inbox exchange, signature
// verification included, with no port bound and no remote server.
function makeCtx (over = {}) {
  const delivered = []
  const ctx = {
    config: { host: 'p2p.7rnx.net', username: 'p2p', maxSkewSeconds: 300 },
    publicKeyPem: TEST_PUBLIC_KEY,
    followers: new MemoryFollowerStore(),
    resolvePublicKey: async () => TEST_PUBLIC_KEY,
    deliver: async (d) => { delivered.push(d) },
    newId: () => 'test-id',
    log: () => {},
    ...over
  }
  ctx.delivered = delivered
  return ctx
}

// Sign as the REMOTE actor would (using our test key — the resolver returns the matching public key).
function signedFollow (ctx, { body = null, actorUri = REMOTE, kid = REMOTE_KEY } = {}) {
  const payload = body || JSON.stringify({
    '@context': 'https://www.w3.org/ns/activitystreams',
    id: `${REMOTE}/follows/1`,
    type: 'Follow',
    actor: actorUri,
    object: 'https://p2p.7rnx.net/ap/actor'
  })
  const added = signRequest({
    method: 'POST',
    url: 'https://p2p.7rnx.net/ap/inbox',
    body: payload,
    keyId: kid,
    privateKey: TEST_PRIVATE_KEY
  })
  return { method: 'POST', path: '/ap/inbox', headers: added, body: payload }
}

test('webfinger — serves our acct, 404s everything else', async (t) => {
  const ctx = makeCtx()
  const ok = await handleRequest({ method: 'GET', path: '/.well-known/webfinger', query: { resource: 'acct:p2p@p2p.7rnx.net' } }, ctx)
  t.is(ok.status, 200)
  t.ok(ok.headers['content-type'].startsWith('application/jrd+json'))
  t.is(JSON.parse(ok.body).subject, 'acct:p2p@p2p.7rnx.net')

  t.is((await handleRequest({ method: 'GET', path: '/.well-known/webfinger', query: { resource: 'acct:someone@elsewhere.net' } }, ctx)).status, 404)
  t.is((await handleRequest({ method: 'GET', path: '/.well-known/webfinger', query: {} }, ctx)).status, 400)
})

test('actor — served as activity+json with the public key', async (t) => {
  const res = await handleRequest({ method: 'GET', path: '/ap/actor' }, makeCtx())
  t.is(res.status, 200)
  t.ok(res.headers['content-type'].startsWith('application/activity+json'))
  const doc = JSON.parse(res.body)
  t.is(doc.id, 'https://p2p.7rnx.net/ap/actor')
  t.is(doc.inbox, 'https://p2p.7rnx.net/ap/inbox')
  t.ok(doc.publicKey.publicKeyPem.includes('BEGIN PUBLIC KEY'))
})

test('root — HTML for browsers, actor for content-negotiating fetchers', async (t) => {
  const ctx = makeCtx()
  const html = await handleRequest({ method: 'GET', path: '/', headers: { accept: 'text/html' } }, ctx)
  t.ok(html.headers['content-type'].startsWith('text/html'))

  const ap = await handleRequest({ method: 'GET', path: '/', headers: { accept: 'application/activity+json' } }, ctx)
  t.ok(ap.headers['content-type'].startsWith('application/activity+json'))
  t.is(JSON.parse(ap.body).type, 'Person')
})

test('★ a signed Follow is accepted, recorded, and Accepted', async (t) => {
  const ctx = makeCtx()
  const res = await handleRequest(signedFollow(ctx), ctx)

  t.is(res.status, 202, 'prompt ack — Accept goes out asynchronously')
  t.alike(await ctx.followers.list(), [REMOTE])

  await new Promise((r) => setImmediate(r)) // let the fire-and-forget delivery settle
  t.is(ctx.delivered.length, 1)
  t.is(ctx.delivered[0].to, REMOTE)
  t.is(ctx.delivered[0].activity.type, 'Accept')
  t.is(ctx.delivered[0].activity.actor, 'https://p2p.7rnx.net/ap/actor')
  t.is(ctx.delivered[0].activity.object.type, 'Follow', 'echo the whole Follow — Mastodon matches on it')
})

test('inbox — unsigned request is rejected before any activity handling', async (t) => {
  const ctx = makeCtx()
  const res = await handleRequest({
    method: 'POST', path: '/ap/inbox', headers: {}, body: JSON.stringify({ type: 'Follow', actor: REMOTE, id: 'x' })
  }, ctx)
  t.is(res.status, 401)
  t.is(await ctx.followers.count(), 0, 'nothing recorded from an unsigned request')
  t.is(ctx.delivered.length, 0)
})

test('inbox — a validly-signed request from the WRONG key is rejected', async (t) => {
  const ctx = makeCtx({ resolvePublicKey: async () => OTHER_PUBLIC_KEY })
  const res = await handleRequest(signedFollow(ctx), ctx)
  t.is(res.status, 401)
  t.is(await ctx.followers.count(), 0)
})

// Without this check, a valid signature from ANY actor would authenticate an activity
// attributed to ANYONE — the impersonation hole.
test('★ inbox — rejects a signature whose key does not belong to the claimed actor', async (t) => {
  const ctx = makeCtx()
  const req = signedFollow(ctx, { actorUri: 'https://mastodon.social/users/victim', kid: REMOTE_KEY })
  const res = await handleRequest(req, ctx)
  t.is(res.status, 403)
  t.is(await ctx.followers.count(), 0)
  t.is(ctx.delivered.length, 0)
})

test('keyBelongsToActor', (t) => {
  t.ok(keyBelongsToActor('https://a.net/users/x#main-key', 'https://a.net/users/x'))
  t.ok(keyBelongsToActor('https://a.net/users/x', 'https://a.net/users/x'))
  t.ok(keyBelongsToActor('https://a.net/users/x/key#k', 'https://a.net/users/x'), 'key on a sibling path is allowed')
  t.absent(keyBelongsToActor('https://evil.net/users/x#main-key', 'https://a.net/users/x'))
  t.absent(keyBelongsToActor('https://a.net/users/other#main-key', 'https://a.net/users/x'))
  t.absent(keyBelongsToActor(null, 'https://a.net/users/x'))
  t.absent(keyBelongsToActor('not a url', 'https://a.net/users/x'))
})

test('inbox — Undo{Follow} removes the follower', async (t) => {
  const ctx = makeCtx()
  await handleRequest(signedFollow(ctx), ctx)
  t.is(await ctx.followers.count(), 1)

  const undo = JSON.stringify({
    '@context': 'https://www.w3.org/ns/activitystreams',
    id: `${REMOTE}/undo/1`, type: 'Undo', actor: REMOTE,
    object: { type: 'Follow', actor: REMOTE, object: 'https://p2p.7rnx.net/ap/actor' }
  })
  const res = await handleRequest(signedFollow(ctx, { body: undo }), ctx)
  t.is(res.status, 202)
  t.is(await ctx.followers.count(), 0)
})

// Returning an error for an unrecognised type makes remote servers retry it forever.
test('inbox — unknown activity types are acked, not errored', async (t) => {
  const ctx = makeCtx()
  const body = JSON.stringify({ '@context': 'https://www.w3.org/ns/activitystreams', id: 'x', type: 'Like', actor: REMOTE, object: 'y' })
  const res = await handleRequest(signedFollow(ctx, { body }), ctx)
  t.is(res.status, 202)
  t.is(await ctx.followers.count(), 0)
})

test('followers collection reflects the store', async (t) => {
  const ctx = makeCtx({ followers: new MemoryFollowerStore([REMOTE, 'https://b.net/users/c']) })
  const res = await handleRequest({ method: 'GET', path: '/ap/followers' }, ctx)
  const doc = JSON.parse(res.body)
  t.is(doc.type, 'OrderedCollection')
  t.is(doc.totalItems, 2)
  t.ok(doc.orderedItems.includes(REMOTE))
})

test('inbox rejects GET; unknown paths 404; healthz is plain', async (t) => {
  const ctx = makeCtx()
  t.is((await handleRequest({ method: 'GET', path: '/ap/inbox' }, ctx)).status, 405)
  t.is((await handleRequest({ method: 'GET', path: '/nope' }, ctx)).status, 404)
  t.is((await handleRequest({ method: 'GET', path: '/healthz' }, ctx)).status, 200)
})

// ★ The outbox is a PROJECTION over records read keylessly from the peer — not stored AS2.
// A fake source with the same three methods is enough to test the whole path offline.
function fakeRecords (notes) {
  return {
    async listNotes ({ limit = 20 } = {}) { return { notes: notes.slice(0, limit), cursor: null } },
    async getNote (rkey) { return notes.find((n) => n.id.endsWith('/' + rkey)) || null }
  }
}

const NOTE = {
  id: 'https://p2p.7rnx.net/ap/objects/3lab2c',
  type: 'Note',
  content: '<p>hello</p>',
  'atproto:cid': 'bafyreib2rxk3ryb'
}

test('★ outbox projects records, carrying the dedup CID', async (t) => {
  const ctx = makeCtx({ records: fakeRecords([NOTE]) })
  const res = await handleRequest({ method: 'GET', path: '/ap/outbox', query: {} }, ctx)
  const doc = JSON.parse(res.body)
  t.is(doc.type, 'OrderedCollection')
  t.is(doc.totalItems, 1)
  t.is(doc.orderedItems[0].id, NOTE.id)
  t.is(doc.orderedItems[0]['atproto:cid'], 'bafyreib2rxk3ryb', 'the dedup key survives the projection')
})

// A bridge that cannot reach its peer should look QUIET, not broken — a 500 here would make
// remote servers treat the actor as failing rather than as having posted nothing.
test('outbox degrades to empty when the record source is absent or throws', async (t) => {
  t.is(JSON.parse((await handleRequest({ method: 'GET', path: '/ap/outbox', query: {} }, makeCtx())).body).totalItems, 0)

  const broken = makeCtx({ records: { async listNotes () { throw new Error('peer unreachable') } } })
  const res = await handleRequest({ method: 'GET', path: '/ap/outbox', query: {} }, broken)
  t.is(res.status, 200)
  t.is(JSON.parse(res.body).totalItems, 0)
})

test('individual objects are fetchable so receivers can verify a Create', async (t) => {
  const ctx = makeCtx({ records: fakeRecords([NOTE]) })
  const ok = await handleRequest({ method: 'GET', path: '/ap/objects/3lab2c' }, ctx)
  t.is(ok.status, 200)
  t.is(JSON.parse(ok.body).id, NOTE.id)

  t.is((await handleRequest({ method: 'GET', path: '/ap/objects/nope' }, ctx)).status, 404)
  t.is((await handleRequest({ method: 'GET', path: '/ap/objects/a/b' }, ctx)).status, 404, 'no path traversal')
  t.is((await handleRequest({ method: 'GET', path: '/ap/objects/x' }, makeCtx())).status, 404, '404 with no source')
})

// ── inbound: what the fediverse sends us ────────────────────────────────────────────────────────
//
// ⛔ ALL OF THIS USED TO BE DISCARDED. The switch had Follow, Undo and a `default: 202`, so a reply,
// a mention, a like or a boost was signature-verified, logged "accepted", acknowledged — and
// dropped. The sender considered it delivered. Meanwhile the atproto side subscribes to
// `app.bsky.feed.post:reply.parent.uri` for exactly this data, so the same product listened on one
// network and not the other.
//
// A full signed exchange through the real router, no port bound, no remote server.

const OUR_POST = 'https://p2p.7rnx.net/ap/objects/3mtecpdhk5c22'

function signedActivity (activity) {
  return signedActivityTo('/ap/inbox', activity)
}

// The same, to any inbox path — a registered user's inbox is /ap/users/<name>/inbox.
function signedActivityTo (path, activity) {
  const payload = JSON.stringify(activity)
  const added = signRequest({
    method: 'POST', url: 'https://p2p.7rnx.net' + path, body: payload,
    keyId: REMOTE_KEY, privateKey: TEST_PRIVATE_KEY
  })
  return { method: 'POST', path, headers: added, body: payload }
}

const replyTo = (id, inReplyTo = OUR_POST) => ({
  '@context': 'https://www.w3.org/ns/activitystreams',
  id: `${REMOTE}/activities/${id}`,
  type: 'Create',
  actor: REMOTE,
  object: { id: `${REMOTE}/notes/${id}`, type: 'Note', content: '<p>nice post</p>', inReplyTo, published: '2026-08-18T20:00:00Z', url: `${REMOTE}/@fleeky/${id}` }
})

test('★ a REPLY from the fediverse is kept, not dropped', async (t) => {
  const inbound = new MemoryInboundStore()
  const ctx = makeCtx({ inbound })
  const res = await handleRequest(signedActivity(replyTo('r1')), ctx)
  t.is(res.status, 202, 'still acked promptly — a slow or failing inbox makes senders back off')

  const kept = await inbound.list({})
  t.is(kept.length, 1, 'the reply exists afterwards')
  t.is(kept[0].type, 'Create')
  t.is(kept[0].actor, REMOTE)
  t.is(kept[0].inReplyTo, OUR_POST, 'and we can tell WHICH of our posts it answers')
  t.is(kept[0].content, '<p>nice post</p>')
})

test('★ a Like and an Announce of our post are kept', async (t) => {
  const inbound = new MemoryInboundStore()
  const ctx = makeCtx({ inbound })
  await handleRequest(signedActivity({ '@context': 'x', id: `${REMOTE}/act/l1`, type: 'Like', actor: REMOTE, object: OUR_POST }), ctx)
  await handleRequest(signedActivity({ '@context': 'x', id: `${REMOTE}/act/b1`, type: 'Announce', actor: REMOTE, object: OUR_POST }), ctx)
  const kept = await inbound.list({})
  t.is(kept.length, 2)
  t.alike(kept.map((k) => k.type).sort(), ['Announce', 'Like'])
  t.is(kept[0].objectId, OUR_POST, 'the object is recorded, so it can be attributed to a post')
})

test('★ a retry is not a second event', async (t) => {
  // Remote servers retry on any hiccup. Without dedup one reply becomes N notifications.
  const inbound = new MemoryInboundStore()
  const ctx = makeCtx({ inbound })
  const req = signedActivity(replyTo('r2'))
  await handleRequest(req, ctx)
  await handleRequest(req, ctx)
  t.is((await inbound.list({})).length, 1)
})

test('★ Delete removes what it names', async (t) => {
  const inbound = new MemoryInboundStore()
  const ctx = makeCtx({ inbound })
  await handleRequest(signedActivity(replyTo('r3')), ctx)
  t.is((await inbound.list({})).length, 1)

  await handleRequest(signedActivity({ '@context': 'x', id: `${REMOTE}/act/d1`, type: 'Delete', actor: REMOTE, object: `${REMOTE}/notes/r3` }), ctx)
  t.is((await inbound.list({})).length, 0, 'gone at the origin, gone here')
})

test('a Delete for something we never held is a normal 202', async (t) => {
  // Mastodon broadcasts deletes widely, including for objects that never touched us.
  const inbound = new MemoryInboundStore()
  const ctx = makeCtx({ inbound })
  const res = await handleRequest(signedActivity({ '@context': 'x', id: `${REMOTE}/act/d2`, type: 'Delete', actor: REMOTE, object: 'https://elsewhere.example/notes/999' }), ctx)
  t.is(res.status, 202)
})

test('★ an UNSIGNED reply is rejected before it can be stored', async (t) => {
  const inbound = new MemoryInboundStore()
  const ctx = makeCtx({ inbound })
  const res = await handleRequest({ method: 'POST', path: '/ap/inbox', headers: {}, body: JSON.stringify(replyTo('r4')) }, ctx)
  t.is(res.status, 401)
  t.is((await inbound.list({})).length, 0, 'nothing unauthenticated reaches the store')
})

test('★ a storage failure still returns 202 — we owe the ack regardless', async (t) => {
  // A 5xx here makes the sender retry forever and eventually stop delivering to us at all. Our
  // storage problem must not become our silence.
  const ctx = makeCtx({ inbound: { async add () { throw new Error('disk full') }, async removeObject () { return 0 }, async list () { return [] } } })
  const res = await handleRequest(signedActivity(replyTo('r5')), ctx)
  t.is(res.status, 202)
})

test('no store injected behaves exactly as before', async (t) => {
  const ctx = makeCtx()
  const res = await handleRequest(signedActivity(replyTo('r6')), ctx)
  t.is(res.status, 202)
})

// ── many actors, one process ──────────────────────────────────────────────────────────────────

test('★★ the primary keeps the flat paths and a second user gets their own', async (t) => {
  const { Users } = await import('../lib/users.js')
  const users = new Users([{ username: 'p2p', primary: true }, { username: 'ashatron' }], 'p2p.7rnx.net')
  const ctx = makeCtx({ users })

  const a = await handleRequest({ method: 'GET', path: '/ap/actor', headers: { accept: 'application/activity+json' } }, ctx)
  t.is(a.status, 200)
  t.is(JSON.parse(a.body).id, 'https://p2p.7rnx.net/ap/actor', 'the incumbent id is untouched')
  t.is(JSON.parse(a.body).preferredUsername, 'p2p')

  const b = await handleRequest({ method: 'GET', path: '/ap/users/ashatron/actor', headers: { accept: 'application/activity+json' } }, ctx)
  t.is(b.status, 200)
  t.is(JSON.parse(b.body).id, 'https://p2p.7rnx.net/ap/users/ashatron/actor')
  t.is(JSON.parse(b.body).preferredUsername, 'ashatron')
  // ⛔ Every collection a user's actor names must be theirs, not the primary's — an inbox shared by
  // accident is somebody else's mail.
  t.is(JSON.parse(b.body).inbox, 'https://p2p.7rnx.net/ap/users/ashatron/inbox')
  t.is(JSON.parse(b.body).followers, 'https://p2p.7rnx.net/ap/users/ashatron/followers')
})

// ⛔ "An inbox shared by accident is somebody else's mail" — the test above proves the PATHS are
// separate. The STORE was not: one InboundStore serves the whole process, and nothing on a kept item
// said who it had been delivered to. Harmless while only the operator read it; a leak the moment a
// peer can ask the bridge for its own inbound, which is the whole point of keeping it.
//
// The recipient is known at arrival — onInbound runs under withUser — so this is about persisting
// what the route already knows, not about discovering it.
test('★★ a kept item records WHICH user it was delivered to', async (t) => {
  const { Users } = await import('../lib/users.js')
  const users = new Users([{ username: 'p2p', primary: true }, { username: 'ashatron' }], 'p2p.7rnx.net')
  const inbound = new MemoryInboundStore()
  const ctx = makeCtx({ users, inbound })

  await handleRequest(signedActivityTo('/ap/users/ashatron/inbox', replyTo('u1')), ctx)
  await handleRequest(signedActivityTo('/ap/inbox', replyTo('u2')), ctx)

  const kept = await inbound.list({})
  t.is(kept.length, 2, 'both were kept')
  const byId = Object.fromEntries(kept.map((i) => [i.objectId, i]))
  t.is(byId[`${REMOTE}/notes/u1`].recipient, 'ashatron', "the registered user's mail is theirs")
  t.is(byId[`${REMOTE}/notes/u2`].recipient, 'p2p', 'and the primary keeps the flat path')
})

test('⛔ a read scoped to one user excludes items that cannot say who they were for', async (t) => {
  const inbound = new MemoryInboundStore()
  // An item stored before the field existed. Its addressing is not recoverable — normalize drops
  // to/cc and the object is only refetchable while the remote server still serves it.
  await inbound.add({ id: 'old', type: 'Create', actor: REMOTE, objectId: 'x', public: true, receivedAt: 1 })
  await inbound.add({ id: 'new', type: 'Create', actor: REMOTE, objectId: 'y', public: true, receivedAt: 2, recipient: 'ashatron' })

  const all = await inbound.list({})
  t.is(all.length, 2, 'both are still held — nothing is destroyed by the new field')
  const mine = await inbound.list({ recipient: 'ashatron' })
  t.is(mine.length, 1, 'only the attributable one is served')
  t.is(mine[0].id, 'new')
  const theirs = await inbound.list({ recipient: 'p2p' })
  t.is(theirs.length, 0, "an unattributable item is nobody's, rather than everybody's")
})

test('⛔ an unknown user is 404, never the primary', async (t) => {
  const { Users } = await import('../lib/users.js')
  const ctx = makeCtx({ users: new Users([{ username: 'p2p', primary: true }], 'p2p.7rnx.net') })
  const r = await handleRequest({ method: 'GET', path: '/ap/users/nobody/actor', headers: { accept: 'application/activity+json' } }, ctx)
  t.is(r.status, 404, 'answering here would serve one person under another name')
})

test('⚠ a node with NO registry behaves exactly as before', async (t) => {
  // The compatibility story, through the real dispatcher: the live actor runs this path.
  const ctx = makeCtx()
  const a = await handleRequest({ method: 'GET', path: '/ap/actor', headers: { accept: 'application/activity+json' } }, ctx)
  t.is(a.status, 200)
  t.is(JSON.parse(a.body).id, 'https://p2p.7rnx.net/ap/actor')
})

// ── the public feed ───────────────────────────────────────────────────────────────────────────────

test('★★ /timeline serves one feed: our own posts and the interactions sent to us', async (t) => {
  const inbound = new MemoryInboundStore()
  const ctx = makeCtx({
    inbound,
    records: {
      async listNotes () {
        return { notes: [{ id: 'https://p2p.7rnx.net/ap/objects/1', type: 'Note', content: '<p>my post</p>', published: '2026-09-15T09:00:00Z' }] }
      }
    }
  })
  await inbound.add({ id: 'l1', type: 'Like', actor: REMOTE, objectId: 'https://p2p.7rnx.net/ap/objects/1', public: true, recipient: 'p2p', published: '2026-09-15T10:00:00Z' })
  await inbound.add({ id: 'dm', type: 'Create', actor: REMOTE, content: '<p>secret</p>', public: false, recipient: 'p2p', published: '2026-09-15T11:00:00Z' })

  const res = await handleRequest({ method: 'GET', path: '/timeline', headers: {} }, ctx)
  t.is(res.status, 200)
  t.ok(/my post/.test(res.body), 'our own post is on our own feed')
  // ⚠ The like acted on a post that IS on this page, so it renders as that post's like count with the
  // liker named inside it — not as a standalone "liked your post" row. Both are the interaction being
  // visible; this is the threaded form.
  t.ok(/♥ 1/.test(res.body), 'the interaction shows as a count on the post it acted on')
  t.ok(/@alice@mastodon.social/.test(res.body), 'and opening it names who — a count nobody can check is not evidence')
  t.absent(/secret/.test(res.body), 'a DM arrived at the same inbox and is not published')
})

test('⛔ a peer\'s feed shows their mail and not another peer\'s', async (t) => {
  const { Users } = await import('../lib/users.js')
  const users = new Users([{ username: 'p2p', primary: true }, { username: 'ashatron' }], 'p2p.7rnx.net')
  const inbound = new MemoryInboundStore()
  const ctx = makeCtx({ users, inbound })
  await inbound.add({ id: 'a', type: 'Like', actor: REMOTE, objectId: 'o', public: true, recipient: 'ashatron', published: '2026-09-15T10:00:00Z' })
  await inbound.add({ id: 'p', type: 'Announce', actor: REMOTE, objectId: 'o', public: true, recipient: 'p2p', published: '2026-09-15T10:01:00Z' })

  const mine = await handleRequest({ method: 'GET', path: '/ap/users/ashatron/timeline', headers: {} }, ctx)
  t.is(mine.status, 200)
  t.ok(/liked your post/.test(mine.body), "ashatron's like is on ashatron's page")
  t.absent(/boosted your post/.test(mine.body), "and p2p's boost is not")
  t.ok(/@ashatron@p2p.7rnx.net/.test(mine.body), 'the page says whose it is')
})

test('★★ the feed shows BOTH directions — a like we sent and a like we got', async (t) => {
  const inbound = new MemoryInboundStore()
  const ctx = makeCtx({
    inbound,
    records: {
      async listNotes () { return { notes: [] } },
      async listInteractions () {
        return {
          interactions: [{ kind: 'like', rkey: 'x1', key: 'like:x1', at: '2026-09-15T12:00:00Z', activity: { type: 'Like', object: 'https://m.example/notes/9' } }],
          unaddressable: 2
        }
      }
    }
  })
  await inbound.add({ id: 'l1', type: 'Like', actor: REMOTE, objectId: 'https://p2p.7rnx.net/ap/objects/1', public: true, recipient: 'p2p', published: '2026-09-15T10:00:00Z' })

  const res = await handleRequest({ method: 'GET', path: '/timeline', headers: {} }, ctx)
  t.is(res.status, 200)
  t.ok(/you liked a post/.test(res.body), 'outbound — the half that proves a like we sent exists')
  t.ok(/liked your post/.test(res.body), 'inbound')
  t.ok(/2 of our interaction\(s\) could not be projected to ActivityPub/.test(res.body),
    'and the ones that cannot cross say so — they are shown here, not silently absent')
})

test('★★ /timeline is the COMBINED view — every peer this bridge serves, in one stream', async (t) => {
  const { Users } = await import('../lib/users.js')
  const users = new Users([{ username: 'p2p', primary: true }, { username: 'fleeky' }], 'p2p.7rnx.net')
  const inbound = new MemoryInboundStore()
  const notesFor = {
    p2p: [{ id: 'https://p2p.7rnx.net/ap/objects/1', type: 'Note', content: '<p>from p2p</p>', published: '2026-09-15T09:00:00Z' }],
    fleeky: [{ id: 'https://p2p.7rnx.net/ap/users/fleeky/objects/2', type: 'Note', content: '<p>from fleeky</p>', published: '2026-09-15T10:00:00Z' }]
  }
  const ctx = makeCtx({
    users,
    inbound,
    recordsFor: (u) => ({ async listNotes () { return { notes: notesFor[u.username] || [] } } }),
    records: { async listNotes () { return { notes: notesFor.p2p } } }
  })
  await inbound.add({ id: 'l', type: 'Like', actor: REMOTE, objectId: 'https://p2p.7rnx.net/ap/users/fleeky/objects/2', public: true, recipient: 'fleeky', published: '2026-09-15T11:00:00Z' })

  const all = await handleRequest({ method: 'GET', path: '/timeline', headers: {} }, ctx)
  t.is(all.status, 200)
  t.ok(/from p2p/.test(all.body), "the primary's posts")
  t.ok(/from fleeky/.test(all.body), "and a registered peer's — one bridge, one stream")
  t.ok(/♥ 1/.test(all.body), "and an interaction lands on ITS OWN peer's post, not the primary's")

  // ⛔ THE PER-PEER PAGES STAY SCOPED. A combined front page must not make one peer's page show
  // another's, or the isolation the recipient field exists for is decorative.
  const mine = await handleRequest({ method: 'GET', path: '/ap/users/p2p/timeline', headers: {} }, ctx)
  t.ok(/from p2p/.test(mine.body))
  t.absent(/from fleeky/.test(mine.body), "p2p's page is p2p's alone")
})

// ── inbound interactions become observations ──────────────────────────────────────────────────────
//
// ⛔ The inbound store is a CAPPED timeline for this bridge's own pages. An observation is what a PEER
// collects and keeps. Same arrival, two consumers with different lifetimes — and the durable half is
// the one that was missing, which is why a like from a stranger was invisible on a handset.

test('★★★ a Like, an Announce and a Follow each land as an observation, scoped to the recipient', async (t) => {
  const { ObservationStore, KIND } = await import('../lib/observations.js')
  const fs = await import('bare-fs')
  const os = await import('bare-os')
  const { join } = await import('bare-path')
  const dir = fs.mkdtempSync(join(os.tmpdir(), 'obs-router-'))
  t.teardown(() => { try { fs.rmSync(dir, { recursive: true, force: true }) } catch {} })

  const observations = new ObservationStore({ path: join(dir, 'o.jsonl') })
  const ctx = makeCtx({ inbound: new MemoryInboundStore(), observations })

  const act = (type, over = {}) => ({
    '@context': 'https://www.w3.org/ns/activitystreams',
    id: `${REMOTE}/a/${type}`, type, actor: REMOTE,
    object: 'https://p2p.7rnx.net/ap/objects/1', ...over
  })
  await handleRequest(signedActivity(act('Like')), ctx)
  await handleRequest(signedActivity(act('Announce', { id: `${REMOTE}/a/Announce2` })), ctx)
  await handleRequest(signedActivity(act('Follow', { id: `${REMOTE}/a/Follow2` })), ctx)

  const rows = observations.list({ recipient: 'p2p' }).rows
  t.alike(rows.map((r) => r.kind), [KIND.LIKE, KIND.REPOST, KIND.FOLLOW], 'all three, in words we have')
  t.is(rows[0].actor, REMOTE, 'attributed to who did it')
  t.is(rows[2].subject, null, 'a follow acts on you, not on a post')
  // ⛔ And nobody else's page can read them.
  t.is(observations.list({ recipient: 'someone-else' }).rows.length, 0)
})

test('⛔ an activity type we have no kind for is NOT recorded as the nearest one', async (t) => {
  const { ObservationStore } = await import('../lib/observations.js')
  const fs = await import('bare-fs'); const os = await import('bare-os'); const { join } = await import('bare-path')
  const dir = fs.mkdtempSync(join(os.tmpdir(), 'obs-router2-'))
  t.teardown(() => { try { fs.rmSync(dir, { recursive: true, force: true }) } catch {} })

  const observations = new ObservationStore({ path: join(dir, 'o.jsonl') })
  const ctx = makeCtx({ inbound: new MemoryInboundStore(), observations })
  await handleRequest(signedActivity({
    '@context': 'https://www.w3.org/ns/activitystreams',
    id: `${REMOTE}/a/flag`, type: 'Flag', actor: REMOTE, object: 'https://p2p.7rnx.net/ap/objects/1'
  }), ctx)

  // A row read later as data has to mean what it says.
  t.is(observations.count({}), 0, 'dropped rather than guessed at')
})

test('⚠ a bridge with no observation store behaves exactly as before', async (t) => {
  const inbound = new MemoryInboundStore()
  const ctx = makeCtx({ inbound })
  const res = await handleRequest(signedActivity({
    '@context': 'https://www.w3.org/ns/activitystreams',
    id: `${REMOTE}/a/like-noobs`, type: 'Like', actor: REMOTE, object: 'https://p2p.7rnx.net/ap/objects/1'
  }), ctx)
  t.is(res.status, 202, 'still acked promptly')
  t.is((await inbound.list({})).length, 1, 'and still kept in the inbound timeline')
})

// ── one unreachable peer must not take the page down ──────────────────────────────────────────────
//
// ⛔ MEASURED IN PRODUCTION 2026-09-15, minutes after deploying 0.10.9 and wrongly suspected of being
// it. /timeline returned nothing while /healthz, /ap/actor and /ap/outbox answered in milliseconds:
//
//     p2p      outbox 200 in 0.008s
//     fleeky   outbox 000 in 8.002s      ← a handset, held over the swarm, not answering
//     ashatron outbox 200 in 0.083s
//
// The combined page reads EVERY peer, so one silent feed hung all three. gatherFor already says "a
// record source that is down must not blank the page" — and guards it with try/catch, which catches a
// throw and waits for ever on a hang. A page that renders two peers and names the third is strictly
// better than a page that renders nobody.

test('★★★ a peer whose source never answers is skipped, and the others still render', async (t) => {
  const { Users } = await import('../lib/users.js')
  const users = new Users([{ username: 'p2p', primary: true }, { username: 'slow' }], 'p2p.7rnx.net')
  const notes = { p2p: [{ id: 'https://p2p.7rnx.net/ap/objects/1', type: 'Note', content: '<p>from p2p</p>', published: '2026-09-15T09:00:00Z' }] }

  const ctx = makeCtx({
    users,
    inbound: new MemoryInboundStore(),
    records: { async listNotes () { return { notes: notes.p2p } } },
    // The handset's shape: it accepts the call and never comes back.
    recordsFor: (u) => (u.username === 'slow'
      ? { async listNotes () { return new Promise(() => {}) } }
      : { async listNotes () { return { notes: notes.p2p } } })
  })

  const started = Date.now()
  const res = await handleRequest({ method: 'GET', path: '/timeline', headers: {} }, ctx)
  const took = Date.now() - started

  t.is(res.status, 200, 'the page is served at all — this is the whole point')
  t.ok(took < 8000, 'and it does not wait for ever (' + took + 'ms)')
  t.ok(/from p2p/.test(res.body), 'the readable peer is rendered')
  // ⚠ SAID, not silently absent. A quiet peer and an unreachable one look identical otherwise, and
  // that is the failure shape this whole area keeps producing.
  t.ok(/did not answer/.test(res.body), 'and the one that did not answer is named')
})

// ── observations go to the user's OBSERVATION FEED (hyper-social #82, I1 S2) ──────────────────────────────────
//
// ⛔ THE ROUTER DECIDES, THE FEED STORES. What a registered user's inbox receives becomes a call on ctx.observe:
// kind, actor, subject, the subject inverted to the user's OWN record, the reply text, and the identity it can be
// undone by. Public material only — a feed replicates in the clear (#82 D2). The primary (on the DID path, no
// feed) is untouched.
const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public'
const FK = 'ab'.repeat(32)
const FLEEKY_POST = 'https://p2p.7rnx.net/ap/users/fleeky/objects/01k2post'

async function observeCtx () {
  const { Users } = await import('../lib/users.js')
  const users = new Users([{ username: 'p2p', primary: true }, { username: 'fleeky', feedKey: FK }], 'p2p.7rnx.net')
  const calls = []
  const observe = {
    note: async (user, obs) => { calls.push({ op: 'note', user: user.username, ...obs }) },
    remove: async (user, activity) => { calls.push({ op: 'remove', user: user.username, activity }) }
  }
  return { ctx: makeCtx({ users, inbound: new MemoryInboundStore(), observe }), calls }
}
const toFleeky = (activity) => signedActivityTo('/ap/users/fleeky/inbox', activity)
const settle = () => new Promise((resolve) => setTimeout(resolve, 20))

test('★★★ a Like, an Announce and a Follow reach the user\'s observation feed, the subject mapped to their own post', async (t) => {
  const { ctx, calls } = await observeCtx()
  const act = (type, over = {}) => ({ '@context': 'https://www.w3.org/ns/activitystreams', id: `${REMOTE}/a/${type}`, type, actor: REMOTE, object: FLEEKY_POST, ...over })
  await handleRequest(toFleeky(act('Like')), ctx)
  await handleRequest(toFleeky(act('Announce', { to: [PUBLIC] })), ctx)
  await handleRequest(toFleeky(act('Follow', { object: 'https://p2p.7rnx.net/ap/users/fleeky/actor' })), ctx)
  await settle()
  const notes = calls.filter((c) => c.op === 'note')
  t.alike(notes.map((c) => c.kind), ['like', 'repost', 'follow'])
  t.ok(notes.every((c) => c.user === 'fleeky' && c.actor === REMOTE), 'attributed, and scoped to the recipient')
  t.alike(notes[0].target, { feed: FK, collection: 'post', name: '01k2post' }, 'the subject inverted to the user\'s own record')
  t.is(notes[0].subject, FLEEKY_POST, 'and the origin URI kept as observed')
  t.is(notes[0].activity, `${REMOTE}/a/Like`, 'a like is identified by its ACTIVITY id (its Undo names it)')
  t.is(notes[2].target, null, 'a follow acts on you, not on a post')
})

test('★★ a PUBLIC reply carries its text and is identified by its OBJECT id; an Update overwrites it', async (t) => {
  const { ctx, calls } = await observeCtx()
  const reply = (id, content, actId = id) => ({
    '@context': 'https://www.w3.org/ns/activitystreams', id: `${REMOTE}/activities/${actId}`, type: 'Create', actor: REMOTE, to: [PUBLIC],
    object: { id: `${REMOTE}/notes/${id}`, type: 'Note', content, inReplyTo: FLEEKY_POST, to: [PUBLIC], published: '2026-10-14T20:00:00Z' }
  })
  await handleRequest(toFleeky(reply('r1', '<p>nice <b>post</b></p>')), ctx)
  await handleRequest(toFleeky({ ...reply('r1', '<p>nicer post</p>', 'r1-edit'), type: 'Update' }), ctx)
  await settle()
  const notes = calls.filter((c) => c.op === 'note')
  t.is(notes.length, 2)
  t.is(notes[0].kind, 'reply')
  t.is(notes[0].body, 'nice post', 'the reply text, as plain text')
  t.is(notes[0].activity, `${REMOTE}/notes/r1`, 'keyed by the OBJECT id …')
  t.is(notes[1].activity, `${REMOTE}/notes/r1`, '… so the Update lands on the same observation')
  t.is(notes[1].body, 'nicer post')
  t.alike(notes[0].target, { feed: FK, collection: 'post', name: '01k2post' })
})

test('⛔ a followers-only reply and a DM never reach the feed (it replicates in the clear)', async (t) => {
  const { ctx, calls } = await observeCtx()
  const followersOnly = {
    '@context': 'https://www.w3.org/ns/activitystreams', id: `${REMOTE}/activities/fo`, type: 'Create', actor: REMOTE, to: [`${REMOTE}/followers`],
    object: { id: `${REMOTE}/notes/fo`, type: 'Note', content: '<p>secret</p>', inReplyTo: FLEEKY_POST, to: [`${REMOTE}/followers`] }
  }
  const dm = {
    '@context': 'https://www.w3.org/ns/activitystreams', id: `${REMOTE}/activities/dm`, type: 'Create', actor: REMOTE, to: ['https://p2p.7rnx.net/ap/users/fleeky/actor'],
    object: { id: `${REMOTE}/notes/dm`, type: 'Note', content: '<p>psst</p>', to: ['https://p2p.7rnx.net/ap/users/fleeky/actor'] }
  }
  await handleRequest(toFleeky(followersOnly), ctx)
  await handleRequest(toFleeky(dm), ctx)
  await settle()
  t.is(calls.length, 0, 'nothing private is written to a replicating feed')
})

test('★ a PUBLIC mention (no inReplyTo) is observed as a mention', async (t) => {
  const { ctx, calls } = await observeCtx()
  await handleRequest(toFleeky({
    '@context': 'https://www.w3.org/ns/activitystreams', id: `${REMOTE}/activities/m1`, type: 'Create', actor: REMOTE, to: [PUBLIC],
    object: { id: `${REMOTE}/notes/m1`, type: 'Note', content: '<p>hey @fleeky</p>', to: [PUBLIC] }
  }), ctx)
  await settle()
  t.is(calls.length, 1)
  t.alike({ kind: calls[0].kind, body: calls[0].body, activity: calls[0].activity, target: calls[0].target },
    { kind: 'mention', body: 'hey @fleeky', activity: `${REMOTE}/notes/m1`, target: null })
})

test('★ an Undo removes what it undoes; a Delete removes a reply', async (t) => {
  const { ctx, calls } = await observeCtx()
  const like = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${REMOTE}/a/L`, type: 'Like', actor: REMOTE, object: FLEEKY_POST }
  await handleRequest(toFleeky(like), ctx)
  await handleRequest(toFleeky({ '@context': 'https://www.w3.org/ns/activitystreams', id: `${REMOTE}/a/U`, type: 'Undo', actor: REMOTE, object: like }), ctx)
  await handleRequest(toFleeky({ '@context': 'https://www.w3.org/ns/activitystreams', id: `${REMOTE}/a/D`, type: 'Delete', actor: REMOTE, object: { id: `${REMOTE}/notes/r9`, type: 'Tombstone' } }), ctx)
  await settle()
  const removes = calls.filter((c) => c.op === 'remove')
  t.alike(removes.map((c) => c.activity), [`${REMOTE}/a/L`, `${REMOTE}/notes/r9`], 'the like by its activity id, the reply by its object id')
  t.ok(removes.every((c) => c.user === 'fleeky'))
})

test('the primary (no favela feed) never calls observe', async (t) => {
  const { ctx, calls } = await observeCtx()
  await handleRequest(signedActivity({ '@context': 'https://www.w3.org/ns/activitystreams', id: `${REMOTE}/a/Lp`, type: 'Like', actor: REMOTE, object: 'https://p2p.7rnx.net/ap/objects/1' }), ctx)
  await settle()
  t.is(calls.length, 0)
})