| … | … | @@ -130,6 +130,19 @@ export async function startApBridge (config, { log = defaultLog, bootstrap = nul |
| 130 | 130 | // machine, so the bridge holds their feed read-only by key — the same thing any follower does — |
| 131 | 131 | // rather than reaching for a unix socket that only exists when the peer is co-located. |
| 132 | 132 | 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 | } | |
| 133 | 146 | // ⛔ ITS OWN KEYPAIR PER USER. Mastodon indexes actors by publicKey.id, and sharing the primary's |
| 134 | 147 | // key made a registered user resolve AS the primary — a duplicate, not a new account. Distinct |
| 135 | 148 | // 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 |
| 157 | 170 | newId: () => `${Date.now().toString(36)}-${(seq++).toString(36)}`, |
| 158 | 171 | log |
| 159 | 172 | } |
| 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 | } | |
| 160 | 183 | |
| 161 | 184 | // ★ THE KEYLESS READ SIDE. Front the DID exactly as atproto-bridge and favela do: replicate the |
| 162 | 185 | // 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 |
| 376 | 399 | const keys = userKeys ? await userKeys.forUser(username) : null |
| 377 | 400 | const user = users.add({ username, root, feedKey, peer, keys }) |
| 378 | 401 | 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 | } | |
| 379 | 409 | // ⛔ AND START PUBLISHING. Without this a registered user's posts reach the OUTBOX and nobody |
| 380 | 410 | // else: the outbox is a pull surface and no fediverse server backfills one. Every post |
| 381 | 411 | // 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 |
| 702 | 732 | ['admin-server', () => (open.adminServer ? closeServer(open.adminServer) : null)], |
| 703 | 733 | ['admin-control', () => (open.adminControl ? open.adminControl.close() : null)], |
| 704 | 734 | ['registration', () => (open.reg ? open.reg.close() : null)], |
| 735 | ['observation feeds', () => (obsFeeds ? obsFeeds.close() : null)], | |
| 705 | 736 | ['holder', () => (holder ? holder.close() : null)], |
| 706 | 737 | ['did-peer', () => (open.didPeer ? open.didPeer.close() : null)], |
| 707 | 738 | ['did-swarm', () => (open.didSwarm ? open.didSwarm.destroy() : null)], |
| … | … | @@ -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 `&` stays `&`), script/style contents are not text, and the result is bounded. | |
| 6 | ||
| 7 | const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' } | |
| 8 | ||
| 9 | function 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 | ||
| 20 | export 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 | ||
| 35 | export default { htmlToText } |
| … | … | @@ -45,12 +45,16 @@ export function observationName (activityId) { |
| 45 | 45 | } |
| 46 | 46 | |
| 47 | 47 | export class ObservationFeeds { |
| 48 | constructor ({ corestore, storage, overlay = null, log = () => {} } = {}) { | |
| 48 | constructor ({ corestore, storage, overlay = null, swarm = null, log = () => {} } = {}) { | |
| 49 | 49 | if (!corestore) throw new Error('ObservationFeeds: a corestore is required') |
| 50 | 50 | if (!storage) throw new Error('ObservationFeeds: a storage directory is required') |
| 51 | 51 | this.corestore = corestore |
| 52 | 52 | this.storage = storage |
| 53 | 53 | 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)) | |
| 54 | 58 | this._log = log |
| 55 | 59 | this._dir = join(storage, 'observer') |
| 56 | 60 | this._mapPath = join(this._dir, 'feeds.json') |
| … | … | @@ -130,6 +134,12 @@ export class ObservationFeeds { |
| 130 | 134 | if (!known) { map[id] = key; this._saveMap(map) } |
| 131 | 135 | // Bytes written before a restart must be servable again (favela-peer node.js _seedOverlay). |
| 132 | 136 | 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 | } | |
| 133 | 143 | const entry = { key, feed } |
| 134 | 144 | this._feeds.set(id, entry) |
| 135 | 145 | return entry |
| … | … | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | import { verifyRequest } from './http-signature.js' |
| 16 | 16 | import { normalize } from './inbound-store.js' |
| 17 | 17 | import { KIND as OBS } from './observations.js' |
| 18 | import { htmlToText } from './html-text.js' | |
| 18 | 19 | |
| 19 | 20 | const json = (status, doc, type = 'application/json') => ({ |
| 20 | 21 | status, |
| … | … | @@ -548,6 +549,7 @@ async function onFollow (activity, actorUri, ctx) { |
| 548 | 549 | if (!activity.id) return json(400, { error: 'Follow has no id' }) |
| 549 | 550 | |
| 550 | 551 | const added = await ctx.followers.add(actorUri) |
| 552 | observe(ctx, { kind: 'follow', actor: actorUri, subject: null, activity: activity.id, at: Date.now() }) | |
| 551 | 553 | |
| 552 | 554 | // ⛔ AND IT IS AN OBSERVATION. A Follow is dispatched HERE, never through onInbound, so noting it |
| 553 | 555 | // 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) { |
| 628 | 630 | // Delete{Note} — gone at the origin, so gone here. Mastodon broadcasts deletes widely, including for |
| 629 | 631 | // objects we never held, so "removed 0" is the normal case and not worth logging. |
| 630 | 632 | async function onDelete (activity, actorUri, ctx) { |
| 631 | if (!ctx.inbound) return { status: 202, headers: {}, body: '' } | |
| 632 | 633 | 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: '' } | |
| 633 | 637 | try { |
| 634 | 638 | const n = await ctx.inbound.removeObject(target) |
| 635 | 639 | if (n) ctx.log?.({ msg: 'inbox: deleted', count: n, actor: actorUri }) |
| … | … | @@ -639,24 +643,83 @@ async function onDelete (activity, actorUri, ctx) { |
| 639 | 643 | return { status: 202, headers: {}, body: '' } |
| 640 | 644 | } |
| 641 | 645 | |
| 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()). | |
| 655 | function 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 | ||
| 665 | function 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 | ||
| 674 | function 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 | ||
| 642 | 682 | // One inbound activity → one observation, or none. |
| 643 | 683 | // |
| 644 | 684 | // ⛔ ONLY WHAT WE HAVE WORDS FOR. An activity type with no kind is not recorded as the nearest |
| 645 | 685 | // 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. | |
| 646 | 689 | function noteObservation (activity, actorUri, recipient, item, ctx) { |
| 647 | if (!ctx.observations || !recipient || !actorUri) return | |
| 690 | if (!actorUri) return | |
| 648 | 691 | 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 | |
| 649 | 712 | let kind = null |
| 650 | 713 | let subject = null |
| 651 | 714 | |
| 652 | 715 | if (type === 'Like') { kind = OBS.LIKE; subject = item.objectId } |
| 653 | 716 | else if (type === 'Announce') { kind = OBS.REPOST; subject = item.objectId } |
| 654 | 717 | 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 } | |
| 656 | 719 | if (kind === null) return |
| 657 | 720 | |
| 658 | 721 | 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 }) | |
| 660 | 723 | } catch (err) { |
| 661 | 724 | ctx.log?.({ msg: 'inbox: observation not recorded', type, actor: actorUri, error: err.message }) |
| 662 | 725 | } |
| … | … | @@ -669,6 +732,9 @@ async function onUndo (activity, actorUri, ctx) { |
| 669 | 732 | await ctx.followers.remove(actorUri) |
| 670 | 733 | ctx.log?.({ msg: 'inbox: unfollowed', actor: actorUri }) |
| 671 | 734 | } |
| 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) | |
| 672 | 738 | // ⛔ A RETRACTION IS POSITIVE EVIDENCE, never an absence. interaction.md §4.3: absence is ambiguous |
| 673 | 739 | // in a replicated log, so "they un-liked" must not be indistinguishable from "we never saw it". |
| 674 | 740 | // The reader decides what a later UNDO means; the store does not silently forget the earlier row. |
| … | … | @@ -1,12 +1,12 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "ap-bridge", |
| 3 | "version": "0.10.17", | |
| 3 | "version": "0.10.18", | |
| 4 | 4 | "lockfileVersion": 3, |
| 5 | 5 | "requires": true, |
| 6 | 6 | "packages": { |
| 7 | 7 | "": { |
| 8 | 8 | "name": "ap-bridge", |
| 9 | "version": "0.10.17", | |
| 9 | "version": "0.10.18", | |
| 10 | 10 | "license": "MIT", |
| 11 | 11 | "dependencies": { |
| 12 | 12 | "atproto-did-peer": "file:../atproto-did-peer", |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "ap-bridge", |
| 3 | "version": "0.10.17", | |
| 3 | "version": "0.10.18", | |
| 4 | 4 | "description": "ActivityPub membrane \u2014 a second projection of the self-custody atproto peer", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -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 | ||
| 7 | import test from 'brittle' | |
| 8 | import { htmlToText } from '../lib/html-text.js' | |
| 9 | ||
| 10 | test('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 | ||
| 14 | test('entities are decoded once', (t) => { | |
| 15 | t.is(htmlToText('<p>a & b <3 "q" 's' &amp;</p>'), 'a & b <3 "q" \'s\' &') | |
| 16 | }) | |
| 17 | ||
| 18 | test('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 | ||
| 22 | test('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 | }) |
| … | … | @@ -598,3 +598,108 @@ test('★★★ a peer whose source never answers is skipped, and the others sti |
| 598 | 598 | // that is the failure shape this whole area keeps producing. |
| 599 | 599 | t.ok(/did not answer/.test(res.body), 'and the one that did not answer is named') |
| 600 | 600 | }) |
| 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. | |
| 608 | const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public' | |
| 609 | const FK = 'ab'.repeat(32) | |
| 610 | const FLEEKY_POST = 'https://p2p.7rnx.net/ap/users/fleeky/objects/01k2post' | |
| 611 | ||
| 612 | async 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 | } | |
| 622 | const toFleeky = (activity) => signedActivityTo('/ap/users/fleeky/inbox', activity) | |
| 623 | const settle = () => new Promise((resolve) => setTimeout(resolve, 20)) | |
| 624 | ||
| 625 | test('★★★ 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 | ||
| 641 | test('★★ 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 | ||
| 660 | test('⛔ 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 | ||
| 676 | test('★ 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 | ||
| 688 | test('★ 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 | ||
| 700 | test('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 | }) |