| … | … | @@ -307,6 +307,8 @@ export class Feed { |
| 307 | 307 | // mirror decide which records to go and get instead of getting all of them at once. |
| 308 | 308 | async pointers (collection = null) { return this._log.pointers(collection) } |
| 309 | 309 | 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) } | |
| 310 | 312 | |
| 311 | 313 | // Admitted WRITER cores, excluding any whose device has been revoked. |
| 312 | 314 | async devices () { |
| … | … | @@ -145,6 +145,22 @@ export class RecordLog { |
| 145 | 145 | return out |
| 146 | 146 | } |
| 147 | 147 | |
| 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 | ||
| 148 | 164 | // Pointers we hold whose bytes we cannot obtain. Never merge this into list() — a record that is |
| 149 | 165 | // silently missing from a listing is indistinguishable from one that was never written, and that |
| 150 | 166 | // is the ambiguity this whole layer exists to refuse. |
| … | … | @@ -86,7 +86,7 @@ const mentioned = (node, body) => |
| 86 | 86 | // |
| 87 | 87 | // So the socket refuses the write instead. The check is here rather than in Peer because Peer's job |
| 88 | 88 | // is to describe the feed, and apply's job is to be the authority; this is the door. |
| 89 | const MUTATIONS = new Set(['post', 'follow', 'unfollow', 'like', 'unlike', 'reply', | |
| 89 | const MUTATIONS = new Set(['post', 'post-delete', 'follow', 'unfollow', 'like', 'unlike', 'reply', | |
| 90 | 90 | 'knock', 'admit', 'dismiss', 'unlink', 'react', 'unreact', 'repost', 'unrepost', 'block', 'unblock', |
| 91 | 91 | // ⚠ The namespace ops that SIGN. `namespace-join` is not here: pointing this node at another |
| 92 | 92 | // 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) { |
| 210 | 210 | await peer.update() |
| 211 | 211 | return { name } |
| 212 | 212 | } |
| 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 | } | |
| 213 | 234 | case 'timeline': |
| 214 | 235 | await peer.update() |
| 215 | 236 | return withLikes(peer, await peer.timeline({ limit: a.limit || 50 })) |
| … | … | @@ -783,9 +804,12 @@ const view = (r, mine = null) => ({ |
| 783 | 804 | const withLikes = async (peer, rows) => { |
| 784 | 805 | const mine = await peer.myReactions() |
| 785 | 806 | const reposted = await peer.myReposts() |
| 807 | const me = Peer.nameFor(peer.feed.key) | |
| 786 | 808 | return rows.map((r) => { |
| 787 | 809 | const v = view(r, mine) |
| 788 | 810 | 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 | |
| 789 | 813 | return v |
| 790 | 814 | }) |
| 791 | 815 | } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-peer", |
| 3 | "version": "0.0.66", | |
| 3 | "version": "0.0.67", | |
| 4 | 4 | "description": "The favela daemon \u2014 feeds and documents of markdown records over Hypercore. No atproto, no ActivityPub.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "scripts": { |
| … | … | @@ -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 | ||
| 11 | import test from 'brittle' | |
| 12 | import process from '#node:process' | |
| 13 | import fs from '#node:fs' | |
| 14 | import os from '#node:os' | |
| 15 | import { join } from '#node:path' | |
| 16 | import { FavelaNode } from '../lib/node.js' | |
| 17 | import { dispatch } from '../lib/rpc.js' | |
| 18 | import { saveCredential } from '../lib/device.js' | |
| 19 | import { identity } from './helpers.js' | |
| 20 | ||
| 21 | const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-postdel-')) | |
| 22 | const stubTransport = () => ({ async join () {}, async leave () {}, async destroy () {} }) | |
| 23 | ||
| 24 | async 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 | ||
| 36 | test('★ 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 | ||
| 59 | test('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 | ||
| 73 | test('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 | ||
| 86 | test('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 | }) |