OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / 6c83a03

0.0.67: delete my own post or reply — a tombstone the bridges can retract on
post-delete {name} tombstones my post or reply (refused for a name not mine, idempotent on a second press). deleted-own and Feed.tombstones(collection) list positively-deleted names — the evidence favela-bridge and ap-bridge need to retract from bsky and the fediverse, since absence is ambiguous in a replicated log. Listed rows carry `own`, so a page offers delete on mine only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6c83a03cc3a6062315a7689575c89a3579184855
by plankatron <fleeky@gmail.com> · 2026-10-15T00:50:42.000Z

Changed files (5) +136 -2

lib/feed-base.js +2 -0
……@@ -307,6 +307,8 @@ export class Feed {
307307 // mirror decide which records to go and get instead of getting all of them at once.
308308 async pointers (collection = null) { return this._log.pointers(collection) }
309309 async unresolved (collection = null) { return this._log.unresolved(collection) }
310 // Positively deleted names — see RecordLog.tombstones. What a bridge holding this feed retracts on.
311 async tombstones (collection) { return this._log.tombstones(collection) }
310312
311313 // Admitted WRITER cores, excluding any whose device has been revoked.
312314 async devices () {
lib/log.js +16 -0
……@@ -145,6 +145,22 @@ export class RecordLog {
145145 return out
146146 }
147147
148 /**
149 * Names in `collection` that are POSITIVELY deleted, in byte order.
150 *
151 * ⛔ The evidence a bridge retracts on. A name missing from list() could be "deleted" or "not
152 * replicated to me yet"; a tombstone can only be the first, so this is the one listing that may
153 * send a Delete to bsky or the fediverse.
154 */
155 async tombstones (collection) {
156 const prefix = collection + SEP
157 const map = await this.kv.list(prefix)
158 return Object.keys(map)
159 .filter((k) => splitKey(k) !== null && isTombstone(map[k]))
160 .sort(byteOrder)
161 .map((k) => splitKey(k).name)
162 }
163
148164 // Pointers we hold whose bytes we cannot obtain. Never merge this into list() — a record that is
149165 // silently missing from a listing is indistinguishable from one that was never written, and that
150166 // is the ambiguity this whole layer exists to refuse.
lib/rpc.js +25 -1
……@@ -86,7 +86,7 @@ const mentioned = (node, body) =>
8686//
8787// So the socket refuses the write instead. The check is here rather than in Peer because Peer's job
8888// is to describe the feed, and apply's job is to be the authority; this is the door.
89const MUTATIONS = new Set(['post', 'follow', 'unfollow', 'like', 'unlike', 'reply',
89const MUTATIONS = new Set(['post', 'post-delete', 'follow', 'unfollow', 'like', 'unlike', 'reply',
9090 'knock', 'admit', 'dismiss', 'unlink', 'react', 'unreact', 'repost', 'unrepost', 'block', 'unblock',
9191 // ⚠ The namespace ops that SIGN. `namespace-join` is not here: pointing this node at another
9292 // registry needs no credential, and a node that cannot yet sign must still be able to look.
……@@ -210,6 +210,27 @@ export async function dispatch (node, cmd, a) {
210210 await peer.update()
211211 return { name }
212212 }
213 // ⛔ DELETE MY OWN post or reply — a TOMBSTONE, like every other undo here and like keet's message
214 // delete, so every peer and both bridges can tell "deleted" from "not replicated yet". The name is
215 // looked up in MY feed only: there is no way to name somebody else's record here.
216 case 'post-delete': {
217 const name = String(a.name || '')
218 for (const collection of ['post', 'reply']) {
219 const r = await peer.feed.resolve(collection, name)
220 if (r.status === 'deleted') return { deleted: name, collection, already: true }
221 if (r.pointer) {
222 await peer.feed.del(collection, name)
223 await peer.update()
224 return { deleted: name, collection, already: false }
225 }
226 }
227 throw new Error('no post or reply of yours is named ' + JSON.stringify(name))
228 }
229 // My positively-deleted posts and replies — what the bsky projection retracts on (favela-bridge).
230 case 'deleted-own': {
231 await peer.update()
232 return { post: await peer.feed.tombstones('post'), reply: await peer.feed.tombstones('reply') }
233 }
213234 case 'timeline':
214235 await peer.update()
215236 return withLikes(peer, await peer.timeline({ limit: a.limit || 50 }))
……@@ -783,9 +804,12 @@ const view = (r, mine = null) => ({
783804const withLikes = async (peer, rows) => {
784805 const mine = await peer.myReactions()
785806 const reposted = await peer.myReposts()
807 const me = Peer.nameFor(peer.feed.key)
786808 return rows.map((r) => {
787809 const v = view(r, mine)
788810 v.reposted = reposted.has(Peer.targetId({ feed: r.author, collection: r.collection, name: r.name }))
811 // ⛔ Whether I WROTE it — what lets a page offer delete on my own posts and replies only.
812 v.own = r.author === me
789813 return v
790814 })
791815}
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-peer",
3 "version": "0.0.66",
3 "version": "0.0.67",
44 "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.",
55 "type": "module",
66 "scripts": {
test/post-delete.test.js +92 -0
……@@ -0,0 +1,92 @@
1// Deleting your own post or reply.
2//
3// ⛔ A TOMBSTONE, like every other undo here (unfollow, unlike, unrepost) and like keet's own message
4// delete: absence is ambiguous in a replicated log, so a peer has to be able to tell "deleted" from
5// "not replicated to me yet". And the bridges need the same positive evidence to retract what they
6// already published — bsky's record and the fediverse's Note are deleted only for a name that is
7// KNOWN deleted, never for one that is merely missing from a listing.
8//
9// These drive `dispatch`, the thing the socket and the in-process client actually call.
10
11import test from 'brittle'
12import process from '#node:process'
13import fs from '#node:fs'
14import os from '#node:os'
15import { join } from '#node:path'
16import { FavelaNode } from '../lib/node.js'
17import { dispatch } from '../lib/rpc.js'
18import { saveCredential } from '../lib/device.js'
19import { identity } from './helpers.js'
20
21const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-postdel-'))
22const stubTransport = () => ({ async join () {}, async leave () {}, async destroy () {} })
23
24async function node () {
25 process.env.FAVELA_NAMESPACE = 'own'
26 const storage = tmp()
27 const id = await identity()
28 const cred = await id.device()
29 saveCredential(storage, cred)
30 const n = new FavelaNode(storage, { transport: stubTransport() })
31 await n.ready()
32 await n.link(cred)
33 return n
34}
35
36test('★ deleting my post removes it from my posts and records it as deleted', async (t) => {
37 const n = await node()
38 t.teardown(() => n.close())
39 const { feed } = await dispatch(n, 'whoami', {})
40
41 const keep = await dispatch(n, 'post', { body: 'keep me' })
42 const oops = await dispatch(n, 'post', { body: 'posted twice by accident' })
43 const before = await dispatch(n, 'posts-by', { feed })
44 t.ok(before.length === 2 && before.every((p) => p.own === true), 'my rows are marked own — what offers the delete button')
45
46 const r = await dispatch(n, 'post-delete', { name: oops.name })
47 t.is(r.deleted, oops.name, 'it says what it deleted')
48 t.is(r.collection, 'post')
49
50 const names = (await dispatch(n, 'posts-by', { feed })).map((p) => p.name)
51 t.ok(names.includes(keep.name), 'the other post is untouched')
52 t.absent(names.includes(oops.name), 'the deleted one is gone from my posts')
53
54 const gone = await dispatch(n, 'deleted-own', {})
55 t.alike(gone.post, [oops.name], 'and is listed as POSITIVELY deleted — what a bridge retracts on')
56 t.alike(gone.reply, [], 'no replies deleted')
57})
58
59test('deleting my reply works the same way', async (t) => {
60 const n = await node()
61 t.teardown(() => n.close())
62 const { feed } = await dispatch(n, 'whoami', {})
63 const parent = await dispatch(n, 'post', { body: 'parent' })
64 const rep = await dispatch(n, 'reply', { target: { feed, collection: 'post', name: parent.name }, body: 'a reply' })
65
66 const r = await dispatch(n, 'post-delete', { name: rep.name })
67 t.is(r.collection, 'reply', 'it found the record among my replies')
68 t.alike((await dispatch(n, 'deleted-own', {})).reply, [rep.name])
69 const replies = await dispatch(n, 'replies', { target: { feed, collection: 'post', name: parent.name } })
70 t.absent(replies.some((x) => x.name === rep.name), 'and it is gone from the thread')
71})
72
73test('a name I never wrote is refused, and deleting twice is not an error', async (t) => {
74 const n = await node()
75 t.teardown(() => n.close())
76 await t.exception(dispatch(n, 'post-delete', { name: 'not-a-record-of-mine' }), /no post or reply/, 'nothing to delete is said, not ignored')
77
78 const p = await dispatch(n, 'post', { body: 'once' })
79 await dispatch(n, 'post-delete', { name: p.name })
80 const again = await dispatch(n, 'post-delete', { name: p.name })
81 t.is(again.deleted, p.name, 'a second press finds it already deleted')
82 t.ok(again.already, 'and says so')
83 t.alike((await dispatch(n, 'deleted-own', {})).post, [p.name], 'still one tombstone')
84})
85
86test('a holder of the feed can read the tombstones too — what ap-bridge retracts from', async (t) => {
87 const n = await node()
88 t.teardown(() => n.close())
89 const p = await dispatch(n, 'post', { body: 'gone soon' })
90 await dispatch(n, 'post-delete', { name: p.name })
91 t.alike(await n.peer.feed.tombstones('post'), [p.name], 'Feed.tombstones(collection) lists deleted names')
92})