OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / 493fdbe

0.10.22: retract a deleted favela post from the fediverse — Delete{Tombstone}
A held feed reports its owner's tombstones (FavelaFeed.ownDeleted, from favela-peer 0.0.67's Feed.tombstones); FavelaSource.listDeleted names each by the object id its Note was delivered under; the publisher's drain sends one Delete{Tombstone} per follower for each post it DELIVERED and has not already retracted, recorded only when somebody was told. Positive evidence only. 396/396. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
493fdbe0517d82ab521906192263eebac5c59da9
by plankatron <fleeky@gmail.com> · 2026-10-15T00:53:20.000Z

Changed files (8) +160 -5

lib/favela-feed.js +13 -0
……@@ -81,6 +81,19 @@ export class FavelaFeed {
8181 }
8282 }
8383
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
8497 /**
8598 * This identity's profile record — `profile/self` on their own feed.
8699 *
lib/favela-source.js +14 -0
……@@ -136,6 +136,20 @@ export class FavelaSource {
136136 return { notes: notes.slice(start, start + limit), cursor: null }
137137 }
138138
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
139153 /** One note by the rkey its own id ends with, or null. Never a different note. */
140154 /**
141155 * ⛔ WRAPPED IN ITS OWN @context. A Note inside a Create inherits the activity's context; one
lib/publisher.js +49 -1
……@@ -206,7 +206,51 @@ export class Publisher {
206206 this.log({ msg: 'publisher: drain', notes: notes.length, delivered: delivered.size, fresh: fresh.length })
207207 const posts = fresh.length ? await this._send(fresh) : { sent: 0 }
208208 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 }
210254 }
211255
212256 // ── likes and reposts ───────────────────────────────────────────────────────────────────────
……@@ -386,6 +430,10 @@ function fingerprint (doc) {
386430
387431const MAX_TRACKED = 500 // bounded: old rkeys can never reappear as new
388432
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.
435const RETRACTED = 'retracted'
436
389437/**
390438 * The rkey of one of OUR OWN notes, relative to this publisher's actor.
391439 *
package-lock.json +3 -3
……@@ -1,12 +1,12 @@
11{
22 "name": "ap-bridge",
3 "version": "0.10.21",
3 "version": "0.10.22",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "ap-bridge",
9 "version": "0.10.21",
9 "version": "0.10.22",
1010 "license": "MIT",
1111 "dependencies": {
1212 "atproto-did-peer": "file:../atproto-did-peer",
……@@ -145,7 +145,7 @@
145145 }
146146 },
147147 "../favela-peer": {
148 "version": "0.0.62",
148 "version": "0.0.67",
149149 "dependencies": {
150150 "anproto": "file:../anproto",
151151 "autobee": "^2.12.1",
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "ap-bridge",
3 "version": "0.10.21",
3 "version": "0.10.22",
44 "description": "ActivityPub membrane \u2014 a second projection of the self-custody atproto peer",
55 "type": "module",
66 "main": "index.js",
test/favela-feed.test.js +10 -0
……@@ -76,3 +76,13 @@ test('⛔ an unknown command is refused rather than guessed', async (t) => {
7676 const f = new FavelaFeed({ feed: heldFeed() })
7777 await t.exception(f.call('post', { body: 'x' }), /unsupported/i)
7878})
79
80// ⛔ The held feed carries its owner's TOMBSTONES — the positive evidence a Delete needs.
81test('★★ 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
86test('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})
test/favela-source.test.js +16 -0
……@@ -184,3 +184,19 @@ test('the summary is rendered, not handed over raw', async (t) => {
184184 t.absent(p.summary.includes('**'), '⛔ or the asterisks show on every follower’s screen')
185185 t.ok(/<strong>|<b>/.test(p.summary), 'markdown became HTML')
186186})
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.
190test('★★ 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
200test('a peer with no ownDeleted reports nothing deleted', async (t) => {
201 t.alike(await src({ networks: ['fediverse'] }, []).listDeleted(), { deleted: [] })
202})
test/publisher.test.js +54 -0
……@@ -488,3 +488,57 @@ test('⛔⛔ with no followers, a post is NOT marked delivered', async (t) => {
488488 t.is(sent.length, 1, 'it goes out now')
489489 t.is((await state.getDelivered()).size, 1, 'and is recorded only once it actually went')
490490})
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
499function 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
513const OBJ = (rkey) => `https://ap.7rnx.net/ap/objects/${rkey}`
514
515test('★★★ 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
526test('★★ 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
532test('⛔ 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
539test('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})