OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / e498b68

inbox → the user's observation feed (I1 S2, hyper-social #82)
A registered user's inbox now writes into the observation feed the bridge owns for them: like / boost / follow (keyed by activity id), public reply / mention with plain text (keyed by OBJECT id, so an Update overwrites and a Delete removes), Undo / Delete remove. Subject kept as observed AND inverted to the user's own record. Public only (the feed replicates in the clear). Primary untouched. Feeds share FeedHolder's corestore/overlay/swarm, opened at admission. Test-first (red on missing observe calls); 384/384, favela-gateway 80/80. Unreleased. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e498b6812a2b5ab4adbd11ee4058a151673ac1a0
by plankatron <fleeky@gmail.com> · 2026-10-14T19:13:37.000Z

Changed files (8) +281 -8

lib/bridge.js +31 -0
……@@ -130,6 +130,19 @@ export async function startApBridge (config, { log = defaultLog, bootstrap = nul
130130 // machine, so the bridge holds their feed read-only by key — the same thing any follower does —
131131 // rather than reaching for a unix socket that only exists when the peer is co-located.
132132 const holder = registrations ? await new FeedHolder({ storage: join(dataDir, 'held'), bootstrap, log }).ready() : null
133 // ⛔ WHAT THE FEDIVERSE DOES TO A REGISTERED USER goes into an observation feed this bridge owns for them, which
134 // their nodes hold (hyper-social #82, I1). On FeedHolder's corestore, overlay and SWARM — one store, one swarm,
135 // no new published key. Non-fatal: a bridge that federates is worth more than one that refuses to boot.
136 let obsFeeds = null
137 if (holder) {
138 try {
139 const { ObservationFeeds } = await import('./observation-feeds.js')
140 obsFeeds = await new ObservationFeeds({ corestore: holder.corestore, storage: join(dataDir, 'held'), overlay: holder.overlay, swarm: holder.swarm, log }).ready()
141 log({ msg: 'observation feeds ready', observerRoot: obsFeeds.root })
142 } catch (err) {
143 log({ msg: 'observation feeds unavailable', error: (err && err.message) || String(err) })
144 }
145 }
133146 // ⛔ ITS OWN KEYPAIR PER USER. Mastodon indexes actors by publicKey.id, and sharing the primary's
134147 // key made a registered user resolve AS the primary — a duplicate, not a new account. Distinct
135148 // ids fix resolution; distinct key material is what makes the separation real, since a signature
……@@ -157,6 +170,16 @@ export async function startApBridge (config, { log = defaultLog, bootstrap = nul
157170 newId: () => `${Date.now().toString(36)}-${(seq++).toString(36)}`,
158171 log
159172 }
173 // The router's observation-feed sink (router.js observe/unobserve). Keyed by the REGISTRATION generation, so a
174 // released and re-claimed name writes to a new feed, never its predecessor's.
175 if (obsFeeds && registrations) {
176 const genOf = async (user) => { const reg = await registrations.get(user.username); return reg ? reg.generation : null }
177 ctx.observe = {
178 note: async (user, obs) => { const g = await genOf(user); if (g !== null) await obsFeeds.note(user.username, g, obs) },
179 remove: async (user, activity) => { const g = await genOf(user); if (g !== null) await obsFeeds.remove(user.username, g, activity) }
180 }
181 ctx.observationFeeds = obsFeeds
182 }
160183
161184 // ★ THE KEYLESS READ SIDE. Front the DID exactly as atproto-bridge and favela do: replicate the
162185 // peer's cores, read records, project to AS2 on demand. No key involved — atproto-did-peer
……@@ -376,6 +399,13 @@ export async function startApBridge (config, { log = defaultLog, bootstrap = nul
376399 const keys = userKeys ? await userKeys.forUser(username) : null
377400 const user = users.add({ username, root, feedKey, peer, keys })
378401 log({ msg: 'admitted', username, held: !!peer, keyed: !!keys })
402 // Their observation feed exists from admission on, so a node can hold it before the first observation.
403 if (obsFeeds && registrations) {
404 registrations.get(username)
405 .then((reg) => (reg ? obsFeeds.open(username, reg.generation) : null))
406 .then((e) => { if (e) log({ msg: 'observation feed', username, key: e.key }) })
407 .catch((err) => log({ msg: 'observation feed open failed', username, error: err.message }))
408 }
379409 // ⛔ AND START PUBLISHING. Without this a registered user's posts reach the OUTBOX and nobody
380410 // else: the outbox is a pull surface and no fediverse server backfills one. Every post
381411 // ashatron made was visible at /outbox and delivered to zero followers, because the per-user
……@@ -702,6 +732,7 @@ export async function startApBridge (config, { log = defaultLog, bootstrap = nul
702732 ['admin-server', () => (open.adminServer ? closeServer(open.adminServer) : null)],
703733 ['admin-control', () => (open.adminControl ? open.adminControl.close() : null)],
704734 ['registration', () => (open.reg ? open.reg.close() : null)],
735 ['observation feeds', () => (obsFeeds ? obsFeeds.close() : null)],
705736 ['holder', () => (holder ? holder.close() : null)],
706737 ['did-peer', () => (open.didPeer ? open.didPeer.close() : null)],
707738 ['did-swarm', () => (open.didSwarm ? open.didSwarm.destroy() : null)],
lib/html-text.js +35 -0
……@@ -0,0 +1,35 @@
1// html-text.js — a remote Note's HTML → plain text, for an observation's body (hyper-social #82, I1 S2).
2//
3// ⛔ PLAIN TEXT, NEVER MARKUP. The result lands in a favela record body that the surface renders and escapes; a
4// remote server's HTML must never become markup on our side. Block boundaries survive as newlines, entities are
5// decoded ONCE (so `&amp;amp;` stays `&amp;`), script/style contents are not text, and the result is bounded.
6
7const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
8
9function decodeEntities (s) {
10 return s.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (m, e) => {
11 if (e[0] === '#') {
12 const code = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10)
13 return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : m
14 }
15 const v = ENTITIES[e.toLowerCase()]
16 return v === undefined ? m : v
17 })
18}
19
20export function htmlToText (html, { max = 4000 } = {}) {
21 if (typeof html !== 'string' || !html) return ''
22 const text = html
23 .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '')
24 .replace(/<br\s*\/?>/gi, '\n')
25 .replace(/<\/p\s*>\s*<p\b[^>]*>/gi, '\n\n')
26 .replace(/<\/?(p|div|blockquote|li|ul|ol|h[1-6])\b[^>]*>/gi, '\n')
27 .replace(/<[^>]*>/g, '')
28 return decodeEntities(text)
29 .replace(/[ \t]+\n/g, '\n')
30 .replace(/\n{3,}/g, '\n\n')
31 .trim()
32 .slice(0, max)
33}
34
35export default { htmlToText }
lib/observation-feeds.js +11 -1
……@@ -45,12 +45,16 @@ export function observationName (activityId) {
4545}
4646
4747export class ObservationFeeds {
48 constructor ({ corestore, storage, overlay = null, log = () => {} } = {}) {
48 constructor ({ corestore, storage, overlay = null, swarm = null, log = () => {} } = {}) {
4949 if (!corestore) throw new Error('ObservationFeeds: a corestore is required')
5050 if (!storage) throw new Error('ObservationFeeds: a storage directory is required')
5151 this.corestore = corestore
5252 this.storage = storage
5353 this.overlay = overlay
54 // ⛔ THE SAME SWARM as FeedHolder (the bridge's persistent keypair), never a second one: every connection
55 // replicates every observation feed, exactly as FeedHolder replicates every held feed.
56 this.swarm = swarm
57 if (swarm) swarm.on('connection', (socket) => this.replicate(socket))
5458 this._log = log
5559 this._dir = join(storage, 'observer')
5660 this._mapPath = join(this._dir, 'feeds.json')
……@@ -130,6 +134,12 @@ export class ObservationFeeds {
130134 if (!known) { map[id] = key; this._saveMap(map) }
131135 // Bytes written before a restart must be servable again (favela-peer node.js _seedOverlay).
132136 store.reregisterLocal().catch(() => {})
137 // Announce the feed and replicate it to whoever is already connected — a feed opened after boot would
138 // otherwise wait for a new connection that may never come (FeedHolder.hold, the same rule).
139 if (this.swarm) {
140 this.swarm.join(feed.discoveryKey, { server: true, client: true })
141 for (const socket of this.swarm.connections) { try { feed.base.replicate(socket) } catch {} }
142 }
133143 const entry = { key, feed }
134144 this._feeds.set(id, entry)
135145 return entry
lib/router.js +70 -4
……@@ -15,6 +15,7 @@ import {
1515import { verifyRequest } from './http-signature.js'
1616import { normalize } from './inbound-store.js'
1717import { KIND as OBS } from './observations.js'
18import { htmlToText } from './html-text.js'
1819
1920const json = (status, doc, type = 'application/json') => ({
2021 status,
……@@ -548,6 +549,7 @@ async function onFollow (activity, actorUri, ctx) {
548549 if (!activity.id) return json(400, { error: 'Follow has no id' })
549550
550551 const added = await ctx.followers.add(actorUri)
552 observe(ctx, { kind: 'follow', actor: actorUri, subject: null, activity: activity.id, at: Date.now() })
551553
552554 // ⛔ AND IT IS AN OBSERVATION. A Follow is dispatched HERE, never through onInbound, so noting it
553555 // there alone recorded likes and boosts and silently lost every follow — caught by the test that
……@@ -628,8 +630,10 @@ async function onInbound (activity, actorUri, ctx) {
628630// Delete{Note} — gone at the origin, so gone here. Mastodon broadcasts deletes widely, including for
629631// objects we never held, so "removed 0" is the normal case and not worth logging.
630632async function onDelete (activity, actorUri, ctx) {
631 if (!ctx.inbound) return { status: 202, headers: {}, body: '' }
632633 const target = typeof activity.object === 'string' ? activity.object : activity.object?.id
634 // A deleted reply or mention leaves the user's observation feed (it is keyed by the object id).
635 unobserve(ctx, target)
636 if (!ctx.inbound) return { status: 202, headers: {}, body: '' }
633637 try {
634638 const n = await ctx.inbound.removeObject(target)
635639 if (n) ctx.log?.({ msg: 'inbox: deleted', count: n, actor: actorUri })
……@@ -639,24 +643,83 @@ async function onDelete (activity, actorUri, ctx) {
639643 return { status: 202, headers: {}, body: '' }
640644}
641645
646// ── the user's OBSERVATION FEED (hyper-social #82, I1) ─────────────────────────────────────────────────────
647//
648// ⛔ THE ROUTER DECIDES, THE FEED STORES. A registered user (one with a favela feed) gets what their inbox
649// receives written into the observation feed the gateway owns for them; their nodes hold it. `ctx.observe` is
650// injected by bridge.js ({ note(user, obs), remove(user, id) }); without it, or for the primary (DID path, no
651// favela feed), nothing happens here. NEVER awaited by the inbox: we owe the sender a prompt 202.
652//
653// ⚠ The subject is kept as observed (interaction.md §3.2) AND inverted to the user's own record when it is one
654// of theirs — the object id this bridge minted for it is `<ids.objects>/<record name>` (users.js ids()).
655function observeTarget (subject, ctx) {
656 const objects = ctx.ids && ctx.ids.objects
657 const feed = ctx.user && ctx.user.feedKey
658 if (!objects || !feed || typeof subject !== 'string') return null
659 const prefix = objects + '/'
660 if (!subject.startsWith(prefix)) return null
661 const name = subject.slice(prefix.length)
662 return name && !name.includes('/') ? { feed, collection: 'post', name } : null
663}
664
665function observe (ctx, obs) {
666 if (!ctx.observe || !ctx.user || !ctx.user.feedKey) return
667 const user = ctx.user
668 const target = obs.subject ? observeTarget(obs.subject, ctx) : null
669 Promise.resolve()
670 .then(() => ctx.observe.note(user, { ...obs, target }))
671 .catch((err) => ctx.log?.({ msg: 'inbox: observation feed write failed', kind: obs.kind, actor: obs.actor, error: err.message }))
672}
673
674function unobserve (ctx, activity) {
675 if (!ctx.observe || !ctx.user || !ctx.user.feedKey || typeof activity !== 'string' || !activity) return
676 const user = ctx.user
677 Promise.resolve()
678 .then(() => ctx.observe.remove(user, activity))
679 .catch((err) => ctx.log?.({ msg: 'inbox: observation feed removal failed', error: err.message }))
680}
681
642682// One inbound activity → one observation, or none.
643683//
644684// ⛔ ONLY WHAT WE HAVE WORDS FOR. An activity type with no kind is not recorded as the nearest
645685// familiar one — a row read later as data has to mean what it says.
686//
687// ⛔ PUBLIC ONLY INTO THE FEED. The feed replicates in the clear (#82 D2): a boost, a reply or a mention reaches
688// it only when the activity was public (item.public = isShowable, fail-closed). A Like has no audience.
646689function noteObservation (activity, actorUri, recipient, item, ctx) {
647 if (!ctx.observations || !recipient || !actorUri) return
690 if (!actorUri) return
648691 const type = activity && activity.type
692 const at = Date.parse(item.published || '') || Date.now()
693 const isNote = type === 'Create' || type === 'Update'
694
695 // The user's observation feed.
696 if (type === 'Like') observe(ctx, { kind: 'like', actor: actorUri, subject: item.objectId || null, activity: item.id, at })
697 else if (type === 'Announce' && item.public) observe(ctx, { kind: 'repost', actor: actorUri, subject: item.objectId || null, activity: item.id, at })
698 else if (isNote && item.public && item.objectId) {
699 // Keyed by the OBJECT id: an Update overwrites it, a Delete (which names only the object) removes it.
700 observe(ctx, {
701 kind: item.inReplyTo ? 'reply' : 'mention',
702 actor: actorUri,
703 subject: item.inReplyTo || null,
704 activity: item.objectId,
705 at,
706 body: htmlToText(item.content || '')
707 })
708 }
709
710 // The bridge's own observation buffer (what `collect` serves) — unchanged until the feed path is proven.
711 if (!ctx.observations || !recipient) return
649712 let kind = null
650713 let subject = null
651714
652715 if (type === 'Like') { kind = OBS.LIKE; subject = item.objectId }
653716 else if (type === 'Announce') { kind = OBS.REPOST; subject = item.objectId }
654717 else if (type === 'Follow') { kind = OBS.FOLLOW; subject = null }
655 else if ((type === 'Create' || type === 'Update') && item.inReplyTo) { kind = OBS.REPLY; subject = item.inReplyTo }
718 else if (isNote && item.inReplyTo) { kind = OBS.REPLY; subject = item.inReplyTo }
656719 if (kind === null) return
657720
658721 try {
659 ctx.observations.note({ recipient, subject: subject || null, actor: actorUri, kind, at: Date.parse(item.published || '') || Date.now(), id: item.id })
722 ctx.observations.note({ recipient, subject: subject || null, actor: actorUri, kind, at, id: item.id })
660723 } catch (err) {
661724 ctx.log?.({ msg: 'inbox: observation not recorded', type, actor: actorUri, error: err.message })
662725 }
……@@ -669,6 +732,9 @@ async function onUndo (activity, actorUri, ctx) {
669732 await ctx.followers.remove(actorUri)
670733 ctx.log?.({ msg: 'inbox: unfollowed', actor: actorUri })
671734 }
735 // The undone like / boost / follow leaves the user's observation feed — it was keyed by the activity id the
736 // Undo names (as an embedded object, or as a bare id).
737 unobserve(ctx, typeof inner === 'string' ? inner : (inner && inner.id) || null)
672738 // ⛔ A RETRACTION IS POSITIVE EVIDENCE, never an absence. interaction.md §4.3: absence is ambiguous
673739 // in a replicated log, so "they un-liked" must not be indistinguishable from "we never saw it".
674740 // The reader decides what a later UNDO means; the store does not silently forget the earlier row.
package-lock.json +2 -2
……@@ -1,12 +1,12 @@
11{
22 "name": "ap-bridge",
3 "version": "0.10.17",
3 "version": "0.10.18",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "ap-bridge",
9 "version": "0.10.17",
9 "version": "0.10.18",
1010 "license": "MIT",
1111 "dependencies": {
1212 "atproto-did-peer": "file:../atproto-did-peer",
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "ap-bridge",
3 "version": "0.10.17",
3 "version": "0.10.18",
44 "description": "ActivityPub membrane \u2014 a second projection of the self-custody atproto peer",
55 "type": "module",
66 "main": "index.js",
test/html-text.test.js +26 -0
……@@ -0,0 +1,26 @@
1// html-text.test.js — a remote Note's HTML → the plain text an observation carries (hyper-social #82, I1 S2).
2//
3// ⛔ PLAIN TEXT, NEVER MARKUP. The text lands in a favela record body that the surface renders; a remote
4// server's HTML must not become markup on our side. Paragraphs and line breaks survive as newlines,
5// entities are decoded once, every tag is dropped.
6
7import test from 'brittle'
8import { htmlToText } from '../lib/html-text.js'
9
10test('paragraphs and breaks become newlines; tags are dropped', (t) => {
11 t.is(htmlToText('<p>hello <a href="https://x/@a" class="u-url mention">@<span>a</span></a></p><p>second<br>line</p>'), 'hello @a\n\nsecond\nline')
12})
13
14test('entities are decoded once', (t) => {
15 t.is(htmlToText('<p>a &amp; b &lt;3 &quot;q&quot; &#39;s&#x27; &amp;amp;</p>'), 'a & b <3 "q" \'s\' &amp;')
16})
17
18test('script and style content is not text', (t) => {
19 t.is(htmlToText('<p>ok</p><script>alert(1)</script><style>p{}</style>'), 'ok')
20})
21
22test('bounded and tolerant of junk', (t) => {
23 t.is(htmlToText(null), '')
24 t.is(htmlToText(42), '')
25 t.is(htmlToText('<p>' + 'x'.repeat(5000) + '</p>', { max: 100 }).length, 100)
26})
test/router.test.js +105 -0
……@@ -598,3 +598,108 @@ test('★★★ a peer whose source never answers is skipped, and the others sti
598598 // that is the failure shape this whole area keeps producing.
599599 t.ok(/did not answer/.test(res.body), 'and the one that did not answer is named')
600600})
601
602// ── observations go to the user's OBSERVATION FEED (hyper-social #82, I1 S2) ──────────────────────────────────
603//
604// ⛔ THE ROUTER DECIDES, THE FEED STORES. What a registered user's inbox receives becomes a call on ctx.observe:
605// kind, actor, subject, the subject inverted to the user's OWN record, the reply text, and the identity it can be
606// undone by. Public material only — a feed replicates in the clear (#82 D2). The primary (on the DID path, no
607// feed) is untouched.
608const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public'
609const FK = 'ab'.repeat(32)
610const FLEEKY_POST = 'https://p2p.7rnx.net/ap/users/fleeky/objects/01k2post'
611
612async function observeCtx () {
613 const { Users } = await import('../lib/users.js')
614 const users = new Users([{ username: 'p2p', primary: true }, { username: 'fleeky', feedKey: FK }], 'p2p.7rnx.net')
615 const calls = []
616 const observe = {
617 note: async (user, obs) => { calls.push({ op: 'note', user: user.username, ...obs }) },
618 remove: async (user, activity) => { calls.push({ op: 'remove', user: user.username, activity }) }
619 }
620 return { ctx: makeCtx({ users, inbound: new MemoryInboundStore(), observe }), calls }
621}
622const toFleeky = (activity) => signedActivityTo('/ap/users/fleeky/inbox', activity)
623const settle = () => new Promise((resolve) => setTimeout(resolve, 20))
624
625test('★★★ a Like, an Announce and a Follow reach the user\'s observation feed, the subject mapped to their own post', async (t) => {
626 const { ctx, calls } = await observeCtx()
627 const act = (type, over = {}) => ({ '@context': 'https://www.w3.org/ns/activitystreams', id: `${REMOTE}/a/${type}`, type, actor: REMOTE, object: FLEEKY_POST, ...over })
628 await handleRequest(toFleeky(act('Like')), ctx)
629 await handleRequest(toFleeky(act('Announce', { to: [PUBLIC] })), ctx)
630 await handleRequest(toFleeky(act('Follow', { object: 'https://p2p.7rnx.net/ap/users/fleeky/actor' })), ctx)
631 await settle()
632 const notes = calls.filter((c) => c.op === 'note')
633 t.alike(notes.map((c) => c.kind), ['like', 'repost', 'follow'])
634 t.ok(notes.every((c) => c.user === 'fleeky' && c.actor === REMOTE), 'attributed, and scoped to the recipient')
635 t.alike(notes[0].target, { feed: FK, collection: 'post', name: '01k2post' }, 'the subject inverted to the user\'s own record')
636 t.is(notes[0].subject, FLEEKY_POST, 'and the origin URI kept as observed')
637 t.is(notes[0].activity, `${REMOTE}/a/Like`, 'a like is identified by its ACTIVITY id (its Undo names it)')
638 t.is(notes[2].target, null, 'a follow acts on you, not on a post')
639})
640
641test('★★ a PUBLIC reply carries its text and is identified by its OBJECT id; an Update overwrites it', async (t) => {
642 const { ctx, calls } = await observeCtx()
643 const reply = (id, content, actId = id) => ({
644 '@context': 'https://www.w3.org/ns/activitystreams', id: `${REMOTE}/activities/${actId}`, type: 'Create', actor: REMOTE, to: [PUBLIC],
645 object: { id: `${REMOTE}/notes/${id}`, type: 'Note', content, inReplyTo: FLEEKY_POST, to: [PUBLIC], published: '2026-10-14T20:00:00Z' }
646 })
647 await handleRequest(toFleeky(reply('r1', '<p>nice <b>post</b></p>')), ctx)
648 await handleRequest(toFleeky({ ...reply('r1', '<p>nicer post</p>', 'r1-edit'), type: 'Update' }), ctx)
649 await settle()
650 const notes = calls.filter((c) => c.op === 'note')
651 t.is(notes.length, 2)
652 t.is(notes[0].kind, 'reply')
653 t.is(notes[0].body, 'nice post', 'the reply text, as plain text')
654 t.is(notes[0].activity, `${REMOTE}/notes/r1`, 'keyed by the OBJECT id …')
655 t.is(notes[1].activity, `${REMOTE}/notes/r1`, '… so the Update lands on the same observation')
656 t.is(notes[1].body, 'nicer post')
657 t.alike(notes[0].target, { feed: FK, collection: 'post', name: '01k2post' })
658})
659
660test('⛔ a followers-only reply and a DM never reach the feed (it replicates in the clear)', async (t) => {
661 const { ctx, calls } = await observeCtx()
662 const followersOnly = {
663 '@context': 'https://www.w3.org/ns/activitystreams', id: `${REMOTE}/activities/fo`, type: 'Create', actor: REMOTE, to: [`${REMOTE}/followers`],
664 object: { id: `${REMOTE}/notes/fo`, type: 'Note', content: '<p>secret</p>', inReplyTo: FLEEKY_POST, to: [`${REMOTE}/followers`] }
665 }
666 const dm = {
667 '@context': 'https://www.w3.org/ns/activitystreams', id: `${REMOTE}/activities/dm`, type: 'Create', actor: REMOTE, to: ['https://p2p.7rnx.net/ap/users/fleeky/actor'],
668 object: { id: `${REMOTE}/notes/dm`, type: 'Note', content: '<p>psst</p>', to: ['https://p2p.7rnx.net/ap/users/fleeky/actor'] }
669 }
670 await handleRequest(toFleeky(followersOnly), ctx)
671 await handleRequest(toFleeky(dm), ctx)
672 await settle()
673 t.is(calls.length, 0, 'nothing private is written to a replicating feed')
674})
675
676test('★ a PUBLIC mention (no inReplyTo) is observed as a mention', async (t) => {
677 const { ctx, calls } = await observeCtx()
678 await handleRequest(toFleeky({
679 '@context': 'https://www.w3.org/ns/activitystreams', id: `${REMOTE}/activities/m1`, type: 'Create', actor: REMOTE, to: [PUBLIC],
680 object: { id: `${REMOTE}/notes/m1`, type: 'Note', content: '<p>hey @fleeky</p>', to: [PUBLIC] }
681 }), ctx)
682 await settle()
683 t.is(calls.length, 1)
684 t.alike({ kind: calls[0].kind, body: calls[0].body, activity: calls[0].activity, target: calls[0].target },
685 { kind: 'mention', body: 'hey @fleeky', activity: `${REMOTE}/notes/m1`, target: null })
686})
687
688test('★ an Undo removes what it undoes; a Delete removes a reply', async (t) => {
689 const { ctx, calls } = await observeCtx()
690 const like = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${REMOTE}/a/L`, type: 'Like', actor: REMOTE, object: FLEEKY_POST }
691 await handleRequest(toFleeky(like), ctx)
692 await handleRequest(toFleeky({ '@context': 'https://www.w3.org/ns/activitystreams', id: `${REMOTE}/a/U`, type: 'Undo', actor: REMOTE, object: like }), ctx)
693 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)
694 await settle()
695 const removes = calls.filter((c) => c.op === 'remove')
696 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')
697 t.ok(removes.every((c) => c.user === 'fleeky'))
698})
699
700test('the primary (no favela feed) never calls observe', async (t) => {
701 const { ctx, calls } = await observeCtx()
702 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)
703 await settle()
704 t.is(calls.length, 0)
705})