| … | … | @@ -81,6 +81,19 @@ export class FavelaFeed { |
| 81 | 81 | } |
| 82 | 82 | } |
| 83 | 83 | |
| 84 | /** | |
| 85 | * The posts and replies this feed's owner DELETED — tombstones, read off the held feed. | |
| 86 | * | |
| 87 | * ⛔ POSITIVE evidence, the only kind a Delete may be sent on: a post missing from ownPosts() could | |
| 88 | * be deleted or not replicated yet. A held feed from a favela-peer without `tombstones()` reports | |
| 89 | * none rather than failing — it simply cannot say. | |
| 90 | */ | |
| 91 | async ownDeleted () { | |
| 92 | await this.feed.update() | |
| 93 | if (typeof this.feed.tombstones !== 'function') return { post: [], reply: [] } | |
| 94 | return { post: await this.feed.tombstones(POST), reply: await this.feed.tombstones('reply') } | |
| 95 | } | |
| 96 | ||
| 84 | 97 | /** |
| 85 | 98 | * This identity's profile record — `profile/self` on their own feed. |
| 86 | 99 | * |
| … | … | @@ -136,6 +136,20 @@ export class FavelaSource { |
| 136 | 136 | return { notes: notes.slice(start, start + limit), cursor: null } |
| 137 | 137 | } |
| 138 | 138 | |
| 139 | /** | |
| 140 | * `{ deleted: [{ rkey, id }] }` — this identity's deleted posts, each named by the object id its Note | |
| 141 | * was delivered under. The publisher retracts the ones it delivered. | |
| 142 | * | |
| 143 | * ⛔ Not gated on consent: a retraction publishes nothing. A peer that cannot report tombstones (the | |
| 144 | * socket client) yields none — never a guess from what is merely absent. | |
| 145 | */ | |
| 146 | async listDeleted () { | |
| 147 | if (typeof this.peer.ownDeleted !== 'function') return { deleted: [] } | |
| 148 | const gone = await this.peer.ownDeleted() | |
| 149 | const names = (gone && Array.isArray(gone.post)) ? gone.post : [] | |
| 150 | return { deleted: names.map((rkey) => ({ rkey, id: objectIdFor({ host: this.host, base: this.base, rkey }) })) } | |
| 151 | } | |
| 152 | ||
| 139 | 153 | /** One note by the rkey its own id ends with, or null. Never a different note. */ |
| 140 | 154 | /** |
| 141 | 155 | * ⛔ WRAPPED IN ITS OWN @context. A Note inside a Create inherits the activity's context; one |
| … | … | @@ -206,7 +206,51 @@ export class Publisher { |
| 206 | 206 | this.log({ msg: 'publisher: drain', notes: notes.length, delivered: delivered.size, fresh: fresh.length }) |
| 207 | 207 | const posts = fresh.length ? await this._send(fresh) : { sent: 0 } |
| 208 | 208 | const interactions = await this._drainInteractions(delivered) |
| 209 | return { ...posts, interactions: interactions.sent } | |
| 209 | const deletes = await this._drainDeletes(delivered) | |
| 210 | return { ...posts, interactions: interactions.sent, deleted: deletes.sent } | |
| 211 | } | |
| 212 | ||
| 213 | // ── deletes ───────────────────────────────────────────────────────────────────────────────── | |
| 214 | // | |
| 215 | // ⛔ ON A TOMBSTONE ONLY. `_drainInteractions` explains why absence cannot drive an undo: a paged | |
| 216 | // listing cannot prove a record is gone. A favela feed CAN — its owner's delete is a tombstone that | |
| 217 | // replicates — so a source that reports `listDeleted()` gives the positive evidence, and only a post | |
| 218 | // we actually delivered (and have not already retracted) gets a Delete. | |
| 219 | // | |
| 220 | // ⚠ Recorded per post, and only when somebody was told — the same rule as _send. | |
| 221 | async _drainDeletes (delivered) { | |
| 222 | if (typeof this.records.listDeleted !== 'function') return { sent: 0 } | |
| 223 | let listed | |
| 224 | try { listed = await this.records.listDeleted() } catch (err) { | |
| 225 | this.log({ msg: 'publisher: deletes unavailable', error: err.message }) | |
| 226 | return { sent: 0 } | |
| 227 | } | |
| 228 | const due = (listed.deleted || []).filter((d) => d && d.rkey && delivered.has(d.rkey) && delivered.get(d.rkey) !== RETRACTED) | |
| 229 | if (!due.length) return { sent: 0 } | |
| 230 | ||
| 231 | const recipients = await this.followers.list() | |
| 232 | if (!recipients.length) { | |
| 233 | this.log({ msg: 'publisher: deletes — nobody to tell', held: due.length }) | |
| 234 | return { sent: 0 } | |
| 235 | } | |
| 236 | this.log({ msg: 'publisher: outgoing deletes', count: due.length, followers: recipients.length }) | |
| 237 | for (const { rkey, id } of due) { | |
| 238 | const activity = { | |
| 239 | '@context': 'https://www.w3.org/ns/activitystreams', | |
| 240 | id: `${this.actorId.replace(/\/actor$/, '')}/activities/${this.newId()}`, | |
| 241 | type: 'Delete', | |
| 242 | actor: this.actorId, | |
| 243 | to: ['https://www.w3.org/ns/activitystreams#Public'], | |
| 244 | object: { id, type: 'Tombstone' } | |
| 245 | } | |
| 246 | await Promise.all(recipients.map((to) => | |
| 247 | this.deliver({ to, activity }) | |
| 248 | .then(() => this.log({ msg: 'published', verb: 'Delete', to, object: id })) | |
| 249 | .catch((err) => this.log({ msg: 'publish delivery failed', verb: 'Delete', to, object: id, error: err.message })) | |
| 250 | )) | |
| 251 | await this.state.markDelivered([[rkey, RETRACTED]]) | |
| 252 | } | |
| 253 | return { sent: due.length } | |
| 210 | 254 | } |
| 211 | 255 | |
| 212 | 256 | // ── likes and reposts ─────────────────────────────────────────────────────────────────────── |
| … | … | @@ -386,6 +430,10 @@ function fingerprint (doc) { |
| 386 | 430 | |
| 387 | 431 | const MAX_TRACKED = 500 // bounded: old rkeys can never reappear as new |
| 388 | 432 | |
| 433 | // The delivered-map value for a post we RETRACTED. Not a fingerprint any Note can produce (those are | |
| 434 | // JSON arrays), so a retracted post is never mistaken for one delivered and unchanged, or re-sent. | |
| 435 | const RETRACTED = 'retracted' | |
| 436 | ||
| 389 | 437 | /** |
| 390 | 438 | * The rkey of one of OUR OWN notes, relative to this publisher's actor. |
| 391 | 439 | * |
| … | … | @@ -1,12 +1,12 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "ap-bridge", |
| 3 | "version": "0.10.21", | |
| 3 | "version": "0.10.22", | |
| 4 | 4 | "lockfileVersion": 3, |
| 5 | 5 | "requires": true, |
| 6 | 6 | "packages": { |
| 7 | 7 | "": { |
| 8 | 8 | "name": "ap-bridge", |
| 9 | "version": "0.10.21", | |
| 9 | "version": "0.10.22", | |
| 10 | 10 | "license": "MIT", |
| 11 | 11 | "dependencies": { |
| 12 | 12 | "atproto-did-peer": "file:../atproto-did-peer", |
| … | … | @@ -145,7 +145,7 @@ |
| 145 | 145 | } |
| 146 | 146 | }, |
| 147 | 147 | "../favela-peer": { |
| 148 | "version": "0.0.62", | |
| 148 | "version": "0.0.67", | |
| 149 | 149 | "dependencies": { |
| 150 | 150 | "anproto": "file:../anproto", |
| 151 | 151 | "autobee": "^2.12.1", |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "ap-bridge", |
| 3 | "version": "0.10.21", | |
| 3 | "version": "0.10.22", | |
| 4 | 4 | "description": "ActivityPub membrane \u2014 a second projection of the self-custody atproto peer", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -76,3 +76,13 @@ test('⛔ an unknown command is refused rather than guessed', async (t) => { |
| 76 | 76 | const f = new FavelaFeed({ feed: heldFeed() }) |
| 77 | 77 | await t.exception(f.call('post', { body: 'x' }), /unsupported/i) |
| 78 | 78 | }) |
| 79 | ||
| 80 | // ⛔ The held feed carries its owner's TOMBSTONES — the positive evidence a Delete needs. | |
| 81 | test('★★ ownDeleted reads the held feed\'s tombstoned posts', async (t) => { | |
| 82 | const feed = { ...heldFeed(), async tombstones (c) { return c === 'post' ? ['01gone'] : [] } } | |
| 83 | t.alike(await new FavelaFeed({ feed }).ownDeleted(), { post: ['01gone'], reply: [] }) | |
| 84 | }) | |
| 85 | ||
| 86 | test('a held feed from before tombstones() existed reports none, not an error', async (t) => { | |
| 87 | t.alike(await new FavelaFeed({ feed: heldFeed() }).ownDeleted(), { post: [], reply: [] }) | |
| 88 | }) |
| … | … | @@ -184,3 +184,19 @@ test('the summary is rendered, not handed over raw', async (t) => { |
| 184 | 184 | t.absent(p.summary.includes('**'), '⛔ or the asterisks show on every follower’s screen') |
| 185 | 185 | t.ok(/<strong>|<b>/.test(p.summary), 'markdown became HTML') |
| 186 | 186 | }) |
| 187 | ||
| 188 | // ⛔ The source names each deleted post by the SAME object id it was delivered under — a Delete for | |
| 189 | // a different id retracts nothing. | |
| 190 | test('★★ listDeleted gives each deleted post\'s rkey and its object id', async (t) => { | |
| 191 | const s = src({ networks: ['fediverse'] }, []) | |
| 192 | s.peer.ownDeleted = async () => ({ post: ['01gone'], reply: [] }) | |
| 193 | const { deleted } = await s.listDeleted() | |
| 194 | t.is(deleted.length, 1) | |
| 195 | t.is(deleted[0].rkey, '01gone') | |
| 196 | const note = await s.project({ name: '01gone', body: 'x', createdAt: '2026-09-07T00:00:00Z' }) | |
| 197 | t.is(deleted[0].id, note.id, 'the id its Note was delivered under') | |
| 198 | }) | |
| 199 | ||
| 200 | test('a peer with no ownDeleted reports nothing deleted', async (t) => { | |
| 201 | t.alike(await src({ networks: ['fediverse'] }, []).listDeleted(), { deleted: [] }) | |
| 202 | }) |
| … | … | @@ -488,3 +488,57 @@ test('⛔⛔ with no followers, a post is NOT marked delivered', async (t) => { |
| 488 | 488 | t.is(sent.length, 1, 'it goes out now') |
| 489 | 489 | t.is((await state.getDelivered()).size, 1, 'and is recorded only once it actually went') |
| 490 | 490 | }) |
| 491 | ||
| 492 | // ── deletes ────────────────────────────────────────────────────────────────────────────────────── | |
| 493 | // | |
| 494 | // ⛔ A Delete goes out ONLY for a post the source reports POSITIVELY deleted (a favela tombstone) and | |
| 495 | // that we actually delivered. The header of _drainInteractions says why absence is not enough: a | |
| 496 | // paged listing cannot prove a record is gone, and retracting something still there is worse than | |
| 497 | // leaving something deleted. | |
| 498 | ||
| 499 | function makeDel (deleted, { seen = [], followers = ['https://prsm.space/users/fleeky'] } = {}) { | |
| 500 | const sent = [] | |
| 501 | const p = new Publisher({ | |
| 502 | records: { ...fakeRecords([]), async listDeleted () { return { deleted } } }, | |
| 503 | followers: new MemoryFollowerStore(followers), | |
| 504 | deliver: async (d) => { sent.push(d) }, | |
| 505 | actorId: ACTOR, | |
| 506 | newId: (() => { let n = 0; return () => `id${n++}` })(), | |
| 507 | log: () => {}, | |
| 508 | state: new MemoryState(seen, true) | |
| 509 | }) | |
| 510 | return { p, sent } | |
| 511 | } | |
| 512 | ||
| 513 | const OBJ = (rkey) => `https://ap.7rnx.net/ap/objects/${rkey}` | |
| 514 | ||
| 515 | test('★★★ a delivered post that was deleted is retracted with Delete{Tombstone}, once', async (t) => { | |
| 516 | const { p, sent } = makeDel([{ rkey: '3d', id: OBJ('3d') }], { seen: [['3d', 'fp']], followers: ['https://a.net/u/1', 'https://b.net/u/2'] }) | |
| 517 | await settle(p) | |
| 518 | t.is(sent.length, 2, 'one Delete per follower') | |
| 519 | t.is(sent[0].activity.type, 'Delete') | |
| 520 | t.is(sent[0].activity.actor, ACTOR) | |
| 521 | t.alike(sent[0].activity.object, { id: OBJ('3d'), type: 'Tombstone' }) | |
| 522 | await settle(p) | |
| 523 | t.is(sent.length, 2, 'and never again') | |
| 524 | }) | |
| 525 | ||
| 526 | test('★★ a deleted post that was never delivered sends nothing', async (t) => { | |
| 527 | const { p, sent } = makeDel([{ rkey: '3d', id: OBJ('3d') }]) | |
| 528 | await settle(p) | |
| 529 | t.is(sent.length, 0) | |
| 530 | }) | |
| 531 | ||
| 532 | test('⛔ with nobody to tell, the Delete is not recorded as sent', async (t) => { | |
| 533 | const { p, sent } = makeDel([{ rkey: '3d', id: OBJ('3d') }], { seen: [['3d', 'fp']], followers: [] }) | |
| 534 | await settle(p) | |
| 535 | t.is(sent.length, 0) | |
| 536 | t.is((await p.state.getDelivered()).get('3d'), 'fp', 'still recorded as the delivered post, not as retracted') | |
| 537 | }) | |
| 538 | ||
| 539 | test('a source with no listDeleted publishes as before', async (t) => { | |
| 540 | const { p, delivered } = make([note('3c', 'z')], { primed: true }) | |
| 541 | await settle(p) | |
| 542 | t.is(delivered.length, 1) | |
| 543 | t.is(delivered[0].activity.type, 'Create') | |
| 544 | }) |