OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface commits / 6449b33

0.0.69: delete my own post or reply — asks first, says where else it goes
A "delete" link on rows the daemon marks own (posts and replies), to GET /delete: a page saying it is removed from favela, bsky and the fediverse, and that copies others already took cannot be recalled. Its form posts /post/delete → post-delete {name}; a refusal returns with the reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6449b334f7c85a1c610994c7185a9a72bfff9433
by plankatron <fleeky@gmail.com> · 2026-10-15T00:52:17.000Z

Changed files (5) +96 -2

lib/pages.js +28 -1
……@@ -746,6 +746,11 @@ export function post (row, profiles, files = null, from = '/') {
746746 // Every post is a way into its own conversation. The thread page is where replies live, because a
747747 // timeline that inlined them would stop being a timeline.
748748 const threadBtn = '<a class="go" href="/t/' + encodeURIComponent(target) + '">thread</a>'
749 // ⛔ MY posts and replies only (the daemon marks `own`), and a LINK to a page that asks first: the
750 // delete reaches bsky and the fediverse, so it is never one press beside like.
751 const deleteBtn = row.own === true && (row.collection === 'post' || row.collection === 'reply')
752 ? '<a class="go" href="/delete?target=' + encodeURIComponent(target) + '&from=' + encodeURIComponent(from) + '">delete</a>'
753 : ''
749754
750755 /**
751756 * ⛔ TWO COLUMNS — avatar in a gutter, everything else beside it. Every mature client in this
……@@ -759,7 +764,7 @@ export function post (row, profiles, files = null, from = '/') {
759764 '<div class="post-col">' +
760765 '<div class="meta">' + byline + '<span class="sep">·</span><time>' + when + '</time></div>' +
761766 render(row.body || '', blobResolver(files)) +
762 '<div class="reactions">' + likeBtn + repostBtn + quoteBtn + threadBtn + emoji +
767 '<div class="reactions">' + likeBtn + repostBtn + quoteBtn + threadBtn + deleteBtn + emoji +
763768 '</div></div></article>'
764769}
765770
……@@ -2087,6 +2092,28 @@ export function bridgeRelease (name, message = null) {
20872092 '<p><a href="/settings">keep it</a></p>')
20882093}
20892094
2095/**
2096 * Delete one of my posts or replies — the page that asks first.
2097 *
2098 * ⛔ IT SAYS WHERE ELSE IT GOES. The delete is a tombstone every peer honours, and the bridges retract
2099 * the copies they published: bsky's record is deleted and the fediverse is sent a Delete. What it cannot
2100 * do is recall copies somebody else already took — a screenshot, a server that ignores Delete — and a
2101 * person should meet that before the button, as keet's own delete leaves the original in the log.
2102 */
2103export function deletePost (name, from = '/', message = null) {
2104 const warn = message ? '<p class="warn">' + esc(message) + '</p>' : ''
2105 return layout('delete post', warn +
2106 '<h1>Delete this post?</h1>' +
2107 '<p>It disappears from favela for everyone who holds your feed, and it is deleted from bsky and ' +
2108 'Mastodon (the fediverse) wherever it was published.</p>' +
2109 '<p class="who">Copies other people or servers already took cannot be recalled.</p>' +
2110 '<form method="post" action="/post/delete">' +
2111 '<input type="hidden" name="name" value="' + esc(name) + '">' +
2112 '<input type="hidden" name="from" value="' + esc(from) + '">' +
2113 '<button>delete it</button></form>' +
2114 '<p><a href="' + esc(from) + '">keep it</a></p>')
2115}
2116
20902117/**
20912118 * Ask a bridge to carry this identity — and put what it grants on-chain.
20922119 *
lib/router.js +18 -0
……@@ -1126,6 +1126,14 @@ export async function route (req, res, ctx) {
11261126 return seeOther(res, '/settings')
11271127 }
11281128
1129 // ⛔ ASKS FIRST. The delete reaches bsky and the fediverse, so the post's link lands here and only
1130 // this page's form deletes. Only the record's NAME travels on: the daemon looks it up in MY feed.
1131 if (req.method === 'GET' && path === '/delete') {
1132 const t = parseTarget(url.searchParams.get('target'))
1133 if (!t) return seeOther(res, '/')
1134 return send(res, pages.deletePost(t.name, local(url.searchParams.get('from')), url.searchParams.get('m')))
1135 }
1136
11291137 if (req.method === 'GET' && path === '/knocks') {
11301138 return send(res, pages.knocks(await client.call('knocks')))
11311139 }
……@@ -1194,6 +1202,16 @@ export async function route (req, res, ctx) {
11941202 if (req.method === 'POST') {
11951203 const form = await body(req)
11961204
1205 if (path === '/post/delete') {
1206 const back = local(form.from)
1207 try {
1208 await client.call('post-delete', { name: String(form.name || '') })
1209 } catch (err) {
1210 const page = back.split('#')[0]
1211 return seeOther(res, page + (page.includes('?') ? '&' : '?') + 'm=' + encodeURIComponent(err.message))
1212 }
1213 return seeOther(res, back)
1214 }
11971215 if (path === '/follow') {
11981216 if (/^[0-9a-f]{64}$/i.test(form.feed || '')) await client.call('follow', { feed: form.feed })
11991217 return seeOther(res, '/following')
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-surface",
3 "version": "0.0.68",
3 "version": "0.0.69",
44 "description": "The favela renderer \u2014 server-side HTML over the daemon's socket. No storage, no keys, no swarm.",
55 "type": "module",
66 "main": "index.js",
test/pages.test.js +8 -0
……@@ -915,3 +915,11 @@ test('★ a bsky notification links to the actor\'s bsky profile and says bluesk
915915 t.ok(/bluesky/.test(html), 'and says where it came from')
916916 t.absent(/\/u\/did:/.test(html), 'never as a favela profile path')
917917})
918
919// ⛔ Delete is offered on MY posts and replies only — the daemon marks a row `own`.
920test('★ my own post offers delete; somebody else\'s does not', (t) => {
921 const base = { author: 'ab'.repeat(32), collection: 'post', name: 'p1', body: 'hello', createdAt: '2026-10-14T00:00:00Z' }
922 t.ok(post({ ...base, own: true }, {}).includes('href="/delete?target='), 'mine: a delete link (to the confirm page)')
923 t.absent(post({ ...base, own: false }, {}).includes('/delete?'), 'theirs: none')
924 t.absent(post(base, {}).includes('/delete?'), 'unknown ownership: none')
925})
test/routes.test.js +41 -0
……@@ -1717,6 +1717,47 @@ test('⛔ a missing passphrase never reaches the daemon', async (t) => {
17171717 t.absent(calls.find((x) => x.cmd === 'did-carry'), 'nothing permanent was attempted')
17181718})
17191719
1720// ── deleting my own post ─────────────────────────────────────────────────────────────────────
1721//
1722// ⛔ TWO STEPS, deliberately. The delete reaches bsky and the fediverse, and neither can be taken back
1723// there, so the link opens a page that says so and only its form deletes. A one-press button next to
1724// like is how a post gets deleted by the thumb that meant to like it.
1725
1726test('★ GET /delete asks first — a page with the consequences, and nothing deleted yet', async (t) => {
1727 reset()
1728 answers = {}
1729 const target = 'ab'.repeat(32) + '/post/p1'
1730 const r = await request('GET', '/delete?target=' + encodeURIComponent(target) + '&from=' + encodeURIComponent('/u/x'), { headers: { cookie } })
1731 t.is(r.status, 200)
1732 t.ok(r.body.includes('action="/post/delete"'), 'the page holds the form that deletes')
1733 t.ok(r.body.includes('name="name" value="p1"'), 'naming the record')
1734 t.ok(/bsky/.test(r.body) && /Mastodon|fediverse/.test(r.body), 'and says where else it is removed from')
1735 t.is(called('post-delete').length, 0, 'and looking at it deleted nothing')
1736})
1737
1738test('★ POST /post/delete reaches the daemon and returns you where you were', async (t) => {
1739 reset()
1740 answers = { 'post-delete': { deleted: 'p1', collection: 'post' } }
1741 const r = await request('POST', '/post/delete', {
1742 body: 'name=p1&from=' + encodeURIComponent('/u/' + 'ab'.repeat(32)),
1743 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
1744 })
1745 t.alike(called('post-delete').map((c) => c.args), [{ name: 'p1' }])
1746 t.is(r.status, 303)
1747 t.is(String(r.headers.location), '/u/' + 'ab'.repeat(32))
1748})
1749
1750test('⛔ a refused delete is SAID, not swallowed', async (t) => {
1751 reset()
1752 answers = { 'post-delete': new Error('no post or reply of yours is named "zz"') }
1753 const r = await request('POST', '/post/delete', {
1754 body: 'name=zz&from=%2F',
1755 headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }
1756 })
1757 t.is(r.status, 303)
1758 t.ok(/[?&]m=/.test(String(r.headers.location)), 'the reason travels to the page')
1759})
1760
17201761test('shut down', async (t) => {
17211762 await new Promise((r) => server.close(r))
17221763 await new Promise((r) => daemon.close(r))