From 098cbc4d2a4cf3fdac1e00c4dfd40b311ffcc312 Mon Sep 17 00:00:00 2001 From: M <> Date: Sat, 12 Sep 2026 17:25:53 +0200 Subject: [PATCH 1/4] CON-10: parse and publish NIP-09 deletion requests Add the NIP-09 e/k tag names to the single home of kind and tag constants, a parser for kind 5 requests, and a signer helper that publishes one for a page revision. The parser only accepts a request about our page revisions in this group: the h tag, when present, must match, and a foreign k is rejected. The h tag is written on publish so the request is scoped to the group and can be read back with the same filter as the content. NIP-09 is a request, not a guarantee: docs/09-security-privacy.md. --- src/domain/deletion.test.ts | 93 ++++++++++++++++++++++++++++++ src/domain/deletion.ts | 55 ++++++++++++++++++ src/nostr/kinds.ts | 4 ++ src/nostr/publish-deletion.test.ts | 53 +++++++++++++++++ src/nostr/publish-deletion.ts | 43 ++++++++++++++ 5 files changed, 248 insertions(+) create mode 100644 src/domain/deletion.test.ts create mode 100644 src/domain/deletion.ts create mode 100644 src/nostr/publish-deletion.test.ts create mode 100644 src/nostr/publish-deletion.ts diff --git a/src/domain/deletion.test.ts b/src/domain/deletion.test.ts new file mode 100644 index 0000000..7ad29a8 --- /dev/null +++ b/src/domain/deletion.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import { KINDS, TAGS } from '../nostr/kinds' +import { parseDeletion } from './deletion' +import type { Event } from 'nostr-tools' + +const TARGET = 'a'.repeat(64) +const OTHER = 'b'.repeat(64) + +function event(partial: Partial & { tags: string[][] }): Event { + return { + id: 'd1', + pubkey: 'alice', + created_at: 1000, + kind: KINDS.DELETION_REQUEST, + content: '', + sig: 'sig', + ...partial, + } as Event +} + +describe('parseDeletion', () => { + it('reads the target ids from the e tags', () => { + const deletion = parseDeletion( + event({ tags: [[TAGS.GROUP, 'engineering'], [TAGS.DELETED_EVENT, TARGET]] }), + 'engineering', + ) + expect(deletion).toEqual({ + id: 'd1', + author: 'alice', + createdAt: 1000, + group: 'engineering', + targets: [TARGET], + }) + }) + + it('keeps every target of a multi-event request', () => { + const deletion = parseDeletion( + event({ tags: [[TAGS.DELETED_EVENT, TARGET], [TAGS.DELETED_EVENT, OTHER]] }), + 'engineering', + ) + expect(deletion?.targets).toEqual([TARGET, OTHER]) + }) + + it('tolerates a request without an h tag', () => { + const deletion = parseDeletion( + event({ + tags: [ + [TAGS.DELETED_EVENT, TARGET], + [TAGS.DELETED_KIND, String(KINDS.PAGE_REVISION)], + ], + }), + 'engineering', + ) + expect(deletion?.targets).toEqual([TARGET]) + expect(deletion?.group).toBeNull() + }) + + it('rejects a request for another group', () => { + expect( + parseDeletion( + event({ tags: [[TAGS.GROUP, 'other'], [TAGS.DELETED_EVENT, TARGET]] }), + 'engineering', + ), + ).toBeNull() + }) + + it('rejects a request that names another kind', () => { + expect( + parseDeletion( + event({ + tags: [ + [TAGS.DELETED_EVENT, TARGET], + [TAGS.DELETED_KIND, String(KINDS.COMMENT)], + ], + }), + 'engineering', + ), + ).toBeNull() + }) + + it('rejects the wrong event kind', () => { + expect( + parseDeletion( + event({ kind: KINDS.PAGE_REVISION, tags: [[TAGS.DELETED_EVENT, TARGET]] }), + 'engineering', + ), + ).toBeNull() + }) + + it('rejects a request that names no target', () => { + expect(parseDeletion(event({ tags: [[TAGS.GROUP, 'engineering']] }), 'engineering')).toBeNull() + }) +}) diff --git a/src/domain/deletion.ts b/src/domain/deletion.ts new file mode 100644 index 0000000..77b9aa5 --- /dev/null +++ b/src/domain/deletion.ts @@ -0,0 +1,55 @@ +import { KINDS, TAGS } from '../nostr/kinds' +import type { Event } from 'nostr-tools' + +/** + * A NIP-09 deletion request for one or more page revisions. It is a *request*, + * not a guarantee: a relay may ignore it, and copies on other relays survive. + * docs/09-security-privacy.md + */ +export type Deletion = { + id: string + author: string + createdAt: number + /** group id from the h tag, when present */ + group: string | null + /** event ids the request names */ + targets: string[] +} + +function tagValues(event: Event, name: string): string[] { + return event.tags + .filter((tag) => tag[0] === name && typeof tag[1] === 'string' && tag[1].length > 0) + .map((tag) => tag[1]) +} + +/** + * Turns an event into a deletion request. Returns null for anything that is not + * a kind 5 about page revisions in this group: a foreign `h`, or a `k` that + * names another kind, means the request is not ours to honour. + * + * The `h` tag is tolerated as absent — NIP-09 does not define it, and a + * client that sends one without it is still asking about a revision. The + * author gate lives where both request and revision are known (`SpaceStore`): + * a request only removes a revision the requester wrote. + * docs/09-security-privacy.md + */ +export function parseDeletion(event: Event, expectedGroup: string): Deletion | null { + if (event.kind !== KINDS.DELETION_REQUEST) return null + + const group = tagValues(event, TAGS.GROUP)[0] ?? null + if (group && group !== expectedGroup) return null + + const kinds = tagValues(event, TAGS.DELETED_KIND) + if (kinds.length > 0 && !kinds.includes(String(KINDS.PAGE_REVISION))) return null + + const targets = tagValues(event, TAGS.DELETED_EVENT) + if (targets.length === 0) return null + + return { + id: event.id, + author: event.pubkey, + createdAt: event.created_at, + group, + targets, + } +} diff --git a/src/nostr/kinds.ts b/src/nostr/kinds.ts index f8ad516..54cf420 100644 --- a/src/nostr/kinds.ts +++ b/src/nostr/kinds.ts @@ -92,6 +92,10 @@ export const TAGS = { SUMMARY: 'summary', /** Restore: id of the revision whose content was taken over */ RESTORE_OF: 'restore-of', + /** NIP-09: event id a deletion request targets */ + DELETED_EVENT: 'e', + /** NIP-09: kind of the events a deletion request targets */ + DELETED_KIND: 'k', /** Content type, always text/markdown here */ MIME: 'm', /** NIP-31: fallback description for foreign clients */ diff --git a/src/nostr/publish-deletion.test.ts b/src/nostr/publish-deletion.test.ts new file mode 100644 index 0000000..a1b7ece --- /dev/null +++ b/src/nostr/publish-deletion.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools' +import type { Signer } from './signer' +import { KINDS, TAGS } from './kinds' +import { client } from './client' +import { publishRevisionDeletion } from './publish-deletion' + +vi.mock('./client', () => ({ + client: { publish: vi.fn() }, +})) + +const secretKey = generateSecretKey() +const signer: Signer = { + kind: 'dev', + getPublicKey: async () => getPublicKey(secretKey), + signEvent: async (template) => finalizeEvent(template, secretKey), +} +const base = { + relayUrl: 'ws://relay.example', + groupId: 'engineering', + revisionId: 'a'.repeat(64), +} + +describe('publishRevisionDeletion', () => { + afterEach(() => { + vi.mocked(client.publish).mockReset() + }) + + it('signs a kind 5 naming the revision, its kind and the group', async () => { + vi.mocked(client.publish).mockResolvedValue({ ok: true, message: 'accepted' }) + await publishRevisionDeletion(signer, base) + const event = vi.mocked(client.publish).mock.calls[0][1] + expect(event.kind).toBe(KINDS.DELETION_REQUEST) + expect(event.tags).toContainEqual([TAGS.DELETED_EVENT, base.revisionId]) + expect(event.tags).toContainEqual([TAGS.DELETED_KIND, String(KINDS.PAGE_REVISION)]) + expect(event.tags).toContainEqual([TAGS.GROUP, base.groupId]) + expect(event.content).toBe('') + }) + + it('publishes to the space relay', async () => { + vi.mocked(client.publish).mockResolvedValue({ ok: true, message: 'accepted' }) + await publishRevisionDeletion(signer, base) + expect(client.publish).toHaveBeenCalledWith(base.relayUrl, expect.anything()) + }) + + it('passes a relay rejection through', async () => { + vi.mocked(client.publish).mockResolvedValue({ ok: false, reason: 'restricted: not allowed' }) + await expect(publishRevisionDeletion(signer, base)).resolves.toEqual({ + ok: false, + reason: 'restricted: not allowed', + }) + }) +}) diff --git a/src/nostr/publish-deletion.ts b/src/nostr/publish-deletion.ts new file mode 100644 index 0000000..2eaf5c2 --- /dev/null +++ b/src/nostr/publish-deletion.ts @@ -0,0 +1,43 @@ +import { verifyEvent } from 'nostr-tools' +import { client } from './client' +import type { PublishResult } from './client' +import { KINDS, TAGS } from './kinds' +import type { Signer } from './signer' + +export type RevisionDeletionInput = { + relayUrl: string + groupId: string + /** event id of the revision the author wants removed */ + revisionId: string +} + +/** + * NIP-09: ask the relay to remove the author's own revision. This is a request + * and not a guarantee — a relay may keep the event, and copies on other relays + * remain. The `e` tag names the revision and `k` its kind; the `h` tag + * scopes the request to the group so it can be read back with the group's + * filter and become shared state instead of local bookkeeping. + * docs/05-versioning-history.md, docs/09-security-privacy.md + */ +export async function publishRevisionDeletion( + signer: Signer, + input: RevisionDeletionInput, +): Promise { + const event = await signer.signEvent({ + kind: KINDS.DELETION_REQUEST, + created_at: Math.floor(Date.now() / 1000), + tags: [ + [TAGS.GROUP, input.groupId], + [TAGS.DELETED_EVENT, input.revisionId], + [TAGS.DELETED_KIND, String(KINDS.PAGE_REVISION)], + [TAGS.ALT, `Deletion request for a wiki revision in space ${input.groupId}`], + ], + content: '', + }) + + if (!verifyEvent(event)) { + return { ok: false, reason: 'the event signature is invalid' } + } + + return client.publish(input.relayUrl, event) +} From 0a199cf443c262e5de58f9de2997af6e6f7726bc Mon Sep 17 00:00:00 2001 From: M <> Date: Sat, 12 Sep 2026 17:26:09 +0200 Subject: [PATCH 2/4] CON-10: skip deleted revisions without tearing the chain buildPages takes the set of revision ids a NIP-09 request removed. They are dropped from the exposed list and from the leaves, and the survivors are reconnected around them: a survivor whose parent-rev names a removed revision is re-pointed at the nearest surviving ancestor, first parent first, with a cycle guard. The parent references of a removed revision are no longer collected, because the repair already keeps its parents from looking like leaves again. Blame and findCommonAncestor need no change: they are fed the repaired list, so the walk crosses the gap on the bridged predecessor. A page whose only revision was removed disappears instead of showing a removed head. --- src/domain/blame.test.ts | 25 ++++++++ src/domain/pages.test.ts | 126 +++++++++++++++++++++++++++++++++++++++ src/domain/pages.ts | 61 ++++++++++++++++++- 3 files changed, 211 insertions(+), 1 deletion(-) diff --git a/src/domain/blame.test.ts b/src/domain/blame.test.ts index e56b6bd..21fcc1b 100644 --- a/src/domain/blame.test.ts +++ b/src/domain/blame.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { blame, firstParentChain } from './blame' +import { buildPages } from './pages' import type { Revision } from './revision' function rev(id: string, content: string, parents: string[] = [], author = 'alice'): Revision { @@ -65,4 +66,28 @@ describe('blame', () => { const result = blame([r1, r2, r3], r3) expect(result.map((line) => line.revision.author)).toEqual(['alice', 'carol', 'alice', 'bob']) }) + + // The page's revisions are already the repaired list (src/domain/pages.ts), + // so blame walks over the gap without knowing about the deletion itself. + it('reaches across a removed revision and keeps earlier attribution', () => { + const r1 = rev('r1', 'one\ntwo\nthree', [], 'alice') + const r2 = rev('r2', 'one\ntwo\nthree\nfour', ['r1'], 'bob') + const r3 = rev('r3', 'one\ntwo\nthree\nfour\nfive', ['r2'], 'carol') + const page = buildPages([r1, r2, r3], new Map(), new Set(['r2']))[0] + + const result = blame(page.revisions, page.head) + + expect(result.map((line) => [line.text, line.revision.author])).toEqual([ + ['one', 'alice'], + ['two', 'alice'], + ['three', 'alice'], + ['four', 'carol'], + ['five', 'carol'], + ]) + }) + + it('yields no lines for an empty head instead of crashing', () => { + const head = rev('head', '') + expect(blame([head], head)).toEqual([]) + }) }) diff --git a/src/domain/pages.test.ts b/src/domain/pages.test.ts index cec0ae0..94c4432 100644 --- a/src/domain/pages.test.ts +++ b/src/domain/pages.test.ts @@ -94,6 +94,132 @@ describe('buildPages — head resolution', () => { }) }) +describe('buildPages — revisions removed by a NIP-09 request', () => { + it('moves the head back to the parent when the head was removed', () => { + const pages = buildPages( + [ + rev({ id: 'r1', createdAt: 100 }), + rev({ id: 'r2', createdAt: 200, parentRevs: ['r1'] }), + rev({ id: 'r3', createdAt: 300, parentRevs: ['r2'] }), + ], + new Map(), + new Set(['r3']), + ) + expect(pages[0].head.id).toBe('r2') + expect(pages[0].revisions.map((r) => r.id)).toEqual(['r2', 'r1']) + expect(pages[0].leaves.map((r) => r.id)).toEqual(['r2']) + }) + + // The chain must not tear: r3 keeps pointing at r1 after r2 is skipped, + // instead of becoming an orphan that blame can no longer walk. + it('does not tear the chain when a middle revision was removed', () => { + const pages = buildPages( + [ + rev({ id: 'r1', createdAt: 100 }), + rev({ id: 'r2', createdAt: 200, parentRevs: ['r1'] }), + rev({ id: 'r3', createdAt: 300, parentRevs: ['r2'] }), + ], + new Map(), + new Set(['r2']), + ) + expect(pages[0].head.id).toBe('r3') + expect(pages[0].revisions.map((r) => r.id)).toEqual(['r3', 'r1']) + expect(pages[0].revisions[0].parentRevs).toEqual(['r1']) + expect(pages[0].leaves.map((r) => r.id)).toEqual(['r3']) + }) + + // Fails without collecting parent references over *all* revisions: r1 would + // look like a leaf again because only its removed child pointed at it. + it('does not mistake the removed revision’s parent for a leaf', () => { + const pages = buildPages( + [ + rev({ id: 'r1', createdAt: 100 }), + rev({ id: 'r2', createdAt: 200, parentRevs: ['r1'] }), + rev({ id: 'r3', createdAt: 300, parentRevs: ['r2'] }), + ], + new Map(), + new Set(['r2']), + ) + expect(pages[0].leaves.map((r) => r.id)).toEqual(['r3']) + }) + + it('bridges two consecutive removals to the first surviving ancestor', () => { + const pages = buildPages( + [ + rev({ id: 'r1', createdAt: 100 }), + rev({ id: 'r2', createdAt: 200, parentRevs: ['r1'] }), + rev({ id: 'r3', createdAt: 300, parentRevs: ['r2'] }), + rev({ id: 'r4', createdAt: 400, parentRevs: ['r3'] }), + ], + new Map(), + new Set(['r2', 'r3']), + ) + expect(pages[0].head.id).toBe('r4') + expect(pages[0].revisions[0].parentRevs).toEqual(['r1']) + expect(pages[0].leaves.map((r) => r.id)).toEqual(['r4']) + }) + + it('keeps a merge a single leaf when one parent was removed', () => { + const pages = buildPages( + [ + rev({ id: 'r1', createdAt: 100 }), + rev({ id: 'mine', createdAt: 200, parentRevs: ['r1'] }), + rev({ id: 'theirs', createdAt: 250, parentRevs: ['r1'] }), + rev({ id: 'merge', createdAt: 300, parentRevs: ['mine', 'theirs'] }), + ], + new Map(), + new Set(['mine']), + ) + expect(pages[0].head.id).toBe('merge') + // The removed first parent bridged to r1; the surviving parent stays. + expect(pages[0].revisions[0].parentRevs).toEqual(['r1', 'theirs']) + expect(pages[0].leaves.map((r) => r.id)).toEqual(['merge']) + }) + + it('drops a page once its only revision was removed', () => { + expect(buildPages([rev({ id: 'r1' })], new Map(), new Set(['r1']))).toEqual([]) + }) + + it('terminates on a parent cycle between removed revisions', () => { + const pages = buildPages( + [ + rev({ id: 'a', createdAt: 100, parentRevs: ['b'] }), + rev({ id: 'b', createdAt: 200, parentRevs: ['a'] }), + rev({ id: 'c', createdAt: 300, parentRevs: ['a'] }), + ], + new Map(), + new Set(['a', 'b']), + ) + expect(pages[0].head.id).toBe('c') + expect(pages[0].revisions[0].parentRevs).toEqual([]) + expect(pages[0].leaves.map((r) => r.id)).toEqual(['c']) + }) + + it('keeps a predecessor that was never loaded as a missing link', () => { + const pages = buildPages( + [ + rev({ id: 'r1', createdAt: 100, parentRevs: ['missing'] }), + rev({ id: 'r2', createdAt: 200, parentRevs: ['r1'] }), + rev({ id: 'r3', createdAt: 300, parentRevs: ['r2'] }), + ], + new Map(), + new Set(['r2']), + ) + // r3 bridges past the removed r2 to r1; the unknown link on r1 survives. + expect(pages[0].revisions.find((r) => r.id === 'r1')?.parentRevs).toEqual(['missing']) + expect(pages[0].revisions.find((r) => r.id === 'r3')?.parentRevs).toEqual(['r1']) + }) + + it('leaves revisions and parents untouched when nothing was removed', () => { + const revisions = [ + rev({ id: 'r1', createdAt: 100 }), + rev({ id: 'r2', createdAt: 200, parentRevs: ['r1'] }), + ] + const pages = buildPages(revisions, new Map(), new Set()) + expect(pages[0].revisions[0]).toBe(revisions[1]) + }) +}) + describe('buildTree', () => { it('nests children under their parent page and counts the depth', () => { const pages = buildPages([ diff --git a/src/domain/pages.ts b/src/domain/pages.ts index 26a1a28..c217f07 100644 --- a/src/domain/pages.ts +++ b/src/domain/pages.ts @@ -27,6 +27,56 @@ function sortNewestFirst(a: Revision, b: Revision): number { return a.id < b.id ? -1 : 1 } +/** + * Drops the revisions a NIP-09 request removed and reconnects the chain around + * them: a survivor whose `parent-rev` names a removed revision is re-pointed + * at that revision's nearest surviving ancestor. The removed node is skipped, + * but its ancestors stay reachable — the chain does not tear. + * + * A predecessor that was never loaded is kept as-is (the tolerated missing + * link), and a parent cycle among removed revisions resolves to nothing rather + * than hanging. The first parent stays first, so `firstParentChain` keeps its + * meaning; duplicate targets are collapsed. + */ +export function visibleRevisions(revisions: Revision[], deleted: Set): Revision[] { + if (deleted.size === 0) return revisions + const byId = new Map(revisions.map((revision) => [revision.id, revision])) + const visible: Revision[] = [] + for (const revision of revisions) { + if (deleted.has(revision.id)) continue + visible.push({ ...revision, parentRevs: reconnectParents(revision.parentRevs, byId, deleted) }) + } + return visible +} + +function reconnectParents( + parentRevs: string[], + byId: Map, + deleted: Set, +): string[] { + const resolved: string[] = [] + for (const parentId of parentRevs) { + let current = parentId + const seen = new Set() + while (deleted.has(current)) { + // A cycle between removed revisions has no surviving ancestor. + if (seen.has(current)) { + current = '' + break + } + seen.add(current) + const parent = byId.get(current) + if (!parent) { + current = '' + break + } + current = parent.parentRevs[0] ?? '' + } + if (current && !resolved.includes(current)) resolved.push(current) + } + return resolved +} + /** * Builds the pages from all revisions of a group. * @@ -35,6 +85,11 @@ function sortNewestFirst(a: Revision, b: Revision): number { * displayed, but the fork is not hidden — `leaves` keeps all of them. * docs/05-versioning-history.md * + * `deleted` holds the ids a NIP-09 request removed. They are skipped in the + * exposed list and in the leaves, and the survivors are reconnected around + * them (see `visibleRevisions`) — the repair is what keeps a removed middle + * revision's parent from looking like a leaf again. + * * Where a page hangs comes from its placement event when there is one, and * from the tags of its first revision otherwise — a page that has never been * moved needs no placement event of its own. src/domain/placement.ts @@ -42,9 +97,11 @@ function sortNewestFirst(a: Revision, b: Revision): number { export function buildPages( revisions: Revision[], placements: Map = new Map(), + deleted: Set = new Set(), ): Page[] { + const visible = visibleRevisions(revisions, deleted) const bySlug = new Map() - for (const revision of revisions) { + for (const revision of visible) { const list = bySlug.get(revision.slug) if (list) list.push(revision) else bySlug.set(revision.slug, [revision]) @@ -53,6 +110,8 @@ export function buildPages( const pages: Page[] = [] for (const [slug, list] of bySlug) { const sorted = [...list].sort(sortNewestFirst) + // Over the repaired list, so a survivor bridged past a removed revision + // counts as referencing the surviving ancestor it now points at. const referenced = new Set() for (const revision of sorted) { for (const parent of revision.parentRevs) referenced.add(parent) From 5c8390fa1161fec3b45767d8feb345121c3d33d9 Mon Sep 17 00:00:00 2001 From: M <> Date: Sat, 12 Sep 2026 17:26:09 +0200 Subject: [PATCH 3/4] CON-10: request deletion of one's own revision in the history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SpaceStore reads kind 5 for the group on a fifth subscription, keeps the parsed requests and derives the removed set on every rebuild. The gate the relay does not provide is ours: a request counts only against the requester's own revision. Nothing is removed from the store — the request is a tombstone, so the chain can bridge across it and a reload re-derives the removal from the relay. HistoryView offers "Request deletion" only on one's own revisions, next to the admin 9005 button. The confirm and result text call it a request, name the copy that may remain on other relays and clients, and never say "deleted". A muted note lists the revisions of the page that were removed by their authors. Test maintenance beyond the ticket, both needed to keep the gate green on a machine where the local dev relays are running: - the CON-36 reconnect test expects the fifth subscription now; - the tests that render a mention or run the live editor (editor-complete, formatting-help, markdown-live, Markdown, task-marker, markdown-flavour) prime the profile cache, which opened a real WebSocket to the configured profile relays during tests and made vitest fail intermittently with an undici Event-realm error. The profile store is mocked there; none of those tests asserts a fetched name. --- NOSTR.md | 4 +- docs/05-versioning-history.md | 11 ++- docs/09-security-privacy.md | 6 +- docs/10-roadmap.md | 1 - src/nostr/client.reconnect.test.ts | 6 +- src/nostr/space-store.test.ts | 138 ++++++++++++++++++++++++++++- src/nostr/space-store.ts | 56 +++++++++++- src/routes/HistoryView.tsx | 82 +++++++++++++++++ src/routes/SpaceSettings.test.tsx | 1 + src/ui/Markdown.test.tsx | 18 +++- src/ui/editor-complete.test.ts | 16 +++- src/ui/formatting-help.test.ts | 16 +++- src/ui/markdown-flavour.test.tsx | 18 +++- src/ui/markdown-live.test.ts | 18 +++- src/ui/task-marker.test.ts | 18 +++- 15 files changed, 388 insertions(+), 21 deletions(-) diff --git a/NOSTR.md b/NOSTR.md index 9c9fb85..c6e46db 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -73,7 +73,7 @@ backlog, see [docs/10](docs/10-roadmap.md). | [NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md) `alt` | ✅ | Plain-text description on our own kinds so foreign clients can show something | `src/nostr/publish-page.ts` | | [NIP-42](https://github.com/nostr-protocol/nips/blob/master/42.md) AUTH | ✅ | Authenticating to the relay, automatically on every new connection, retried after `auth-required` | `src/nostr/client.ts` | | [Blossom](https://github.com/hzrd149/blossom) BUD-01/02 | ✅ | Attachments: the blob lives on the server under its sha256, the event only holds the URL | `src/nostr/blossom.ts` | -| [NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md) deletion request | ❌ | Deleting happens only through NIP-29 (`9005`), which a relay actually enforces | — | +| [NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md) deletion request | ⚠️ | A user asks for their *own* revision (`kind 5`). The NIP-29 relay stores the request but does not delete, so the client skips the revision as a tombstone and reconnects the chain. Admin deletion stays `9005`, which the relay enforces | `src/domain/deletion.ts`, `src/nostr/publish-deletion.ts` | | [NIP-46](https://github.com/nostr-protocol/nips/blob/master/46.md) bunker | ❌ | Planned as a second signer implementation behind the same interface | — | | [NIP-50](https://github.com/nostr-protocol/nips/blob/master/50.md) search | ❌ | Deliberately not: not every relay supports it, and a relay-dependent search would break offline. Search runs locally | `src/domain/search.ts` | | [NIP-54](https://github.com/nostr-protocol/nips/blob/master/54.md) wiki | ⚠️ | Its slug normalisation for the `d` tag, rule for rule. The wiki kinds themselves are deliberately unused — see below | `src/nostr/kinds.ts` | @@ -109,7 +109,7 @@ backlog, see [docs/10](docs/10-roadmap.md). | **39002** members | ✅ | Member list | | **39003** role definitions | ❌ | Not evaluated | | **30818** wiki article | ❌ | Deliberately not, see "Deviations and limits" | -| **5** deletion request | ❌ | See NIP-09 above | +| **5** deletion request | ✅ | The author's own revision; the client skips it (see NIP-09 above) | | **9021** join | ❌ | Unnecessary in open groups: the relay adds the author on their first write. The fallback for stricter relays is still missing | * * * diff --git a/docs/05-versioning-history.md b/docs/05-versioning-history.md index 4d7cb7c..29c22cd 100644 --- a/docs/05-versioning-history.md +++ b/docs/05-versioning-history.md @@ -84,8 +84,15 @@ Features: - **Implemented:** an admin deletes an event via NIP-29 `kind 9005`; the group relay enforces it. Applies to revisions and comments, with a confirmation prompt in the UI. -- **Open:** a user deleting their *own* revision via NIP-09 `kind 5` — a - *request* to relays, to be phrased in the UI as "request deletion". +- **Implemented:** a user asks the relay to delete their *own* revision via + NIP-09 `kind 5`, from that revision's row in the history. It is a *request*, + and the UI says so: a relay may keep the event — the NIP-29 relay we run + stores it without deleting anything — and copies on other relays and clients + remain. The client therefore keeps the revision as a tombstone: it is skipped + in the chain, the diff pickers and blame, while a successor whose + `parent-rev` names it is re-pointed at the nearest surviving ancestor, so + the chain does not tear. A request only removes the requester's own revision; + the relay enforces nothing here. See also the admin path below. - **Open:** hiding a whole page — a new revision with a tombstone tag plus `9005` on the predecessors, so that sidebar and search leave it out. diff --git a/docs/09-security-privacy.md b/docs/09-security-privacy.md index 368dabf..47be960 100644 --- a/docs/09-security-privacy.md +++ b/docs/09-security-privacy.md @@ -26,7 +26,11 @@ E2EE stays deliberately out of scope; it would also be incompatible with relay-enforced permissions and full-text search. - **Deletion**: NIP-09 is a request. Once published, content may survive on - copies. UI wording: "request deletion". + copies. UI wording: "request deletion". The client honours a request only for + the requester's own revision, keeps the revision as a tombstone and skips it + in the chain, the diff and blame — the relay we run stores the `kind 5` + but does not delete the revision, so a later read would otherwise show it + again. - **Timestamps**: `created_at` is set by the client and therefore manipulable. Ordering primarily follows the `parent-rev` chain; the clock is for display. diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md index 644294e..e8818cc 100644 --- a/docs/10-roadmap.md +++ b/docs/10-roadmap.md @@ -224,7 +224,6 @@ As of 2026-09-07, found while comparing the docs against the code: | IndexedDB cache: instant rendering on reload, offline reading | [01](01-architecture.md), [07](07-tech-stack.md) | | End-to-end tests (Playwright), including the colour-mode regression | [07](07-tech-stack.md), [12](12-theming.md) | | `9021` join flow for relays without auto-join | [04](04-permissions-nip29.md) | -| Deleting your own revision (NIP-09 `kind 5`) | [05](05-versioning-history.md) | | Hiding a whole page (tombstone) | [05](05-versioning-history.md) | | Writing `previous` timeline references | [02](02-data-model-events.md) | | Sidebar entries "all pages", "recently changed", "space settings" | [06](06-ui-information-architecture.md) | diff --git a/src/nostr/client.reconnect.test.ts b/src/nostr/client.reconnect.test.ts index 41da652..b22358e 100644 --- a/src/nostr/client.reconnect.test.ts +++ b/src/nostr/client.reconnect.test.ts @@ -463,8 +463,8 @@ describe('NostrClient and SpaceStore reconnect races', () => { const subscribeSpy = vi.spyOn(client, 'subscribe') const releaseStore = store.subscribe(() => {}) - // one round: group state, revisions, comments, placements - expect(subscribeSpy).toHaveBeenCalledTimes(4) + // one round: group state, revisions, comments, placements, deletions + expect(subscribeSpy).toHaveBeenCalledTimes(5) // Let that round's REQ frames go out first: nostr-tools sends them // asynchronously, and they are not what this test measures. await vi.advanceTimersByTimeAsync(10) @@ -492,7 +492,7 @@ describe('NostrClient and SpaceStore reconnect races', () => { expect(client.getSnapshot(url).connection).toBe('online') expect(client.getSnapshot(url).ready).toBe(true) expect(subscribeSpy, 'must resubscribe once the new connection is up').toHaveBeenCalledTimes( - 4, + 5, ) // Closing the subscriptions on top of the deliberate pool.close() can // still put a CLOSE frame on a dead socket; a REQ frame cannot, and that diff --git a/src/nostr/space-store.test.ts b/src/nostr/space-store.test.ts index b96b357..af2f4ab 100644 --- a/src/nostr/space-store.test.ts +++ b/src/nostr/space-store.test.ts @@ -30,21 +30,45 @@ const { clearAllSpaces, getSpaceStore } = await import('./space-store') const RELAY = 'wss://relay.test' const GROUP = 'engineering' -function revision(id: string, slug: string): Event { +function revision( + id: string, + slug: string, + parentRevs: string[] = [], + pubkey = 'alice', + createdAt = 1000, +): Event { return { id, - pubkey: 'alice', - created_at: 1000, + pubkey, + created_at: createdAt, kind: KINDS.PAGE_REVISION, tags: [ ['h', GROUP], ['d', slug], + ...parentRevs.map((parent) => ['parent-rev', parent]), ], content: '# secret', sig: 'sig', } as Event } +/** A NIP-09 request for the given revision ids. */ +function deletion(id: string, pubkey: string, targets: string[]): Event { + return { + id, + pubkey, + created_at: 2000, + kind: KINDS.DELETION_REQUEST, + tags: [ + ['h', GROUP], + ['k', String(KINDS.PAGE_REVISION)], + ...targets.map((target) => ['e', target]), + ], + content: '', + sig: 'sig', + } as Event +} + /** Hands an event to whichever subscription asked for its kind. */ function deliver(event: Event, subs: Sub[] = relay.subs): void { for (const sub of subs) { @@ -304,3 +328,111 @@ describe('clearAllSpaces', () => { unsubscribe() }) }) + +describe('NIP-09 deletion requests', () => { + beforeEach(() => { + vi.useFakeTimers() + relay.subs = [] + relay.epoch = 1 + relay.auth = 'ok' + relay.ready = true + clearAllSpaces() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('reads kind 5 for the group on its own subscription', () => { + const store = getSpaceStore(RELAY, GROUP) + const unsubscribe = store.subscribe(() => {}) + + const requests = relay.subs.filter((sub) => sub.filter.kinds?.includes(KINDS.DELETION_REQUEST)) + expect(requests).toHaveLength(1) + expect(requests[0].filter['#h']).toEqual([GROUP]) + unsubscribe() + }) + + it('skips a revision its author asked to remove and bridges the successor', () => { + const store = getSpaceStore(RELAY, GROUP) + const unsubscribe = store.subscribe(() => {}) + deliver(revision('r1', 'page', [], 'alice', 100)) + deliver(revision('r2', 'page', ['r1'], 'alice', 200)) + deliver(revision('r3', 'page', ['r2'], 'alice', 300)) + + deliver(deletion('d1', 'alice', ['r2'])) + + const page = store.getSnapshot().pages[0] + expect(page.head.id).toBe('r3') + expect(page.revisions.map((r) => r.id)).toEqual(['r3', 'r1']) + expect(page.revisions[0].parentRevs).toEqual(['r1']) + expect(store.getSnapshot().removedRevisions.map((r) => r.id)).toEqual(['r2']) + unsubscribe() + }) + + it('ignores a request from someone other than the revision’s author', () => { + const store = getSpaceStore(RELAY, GROUP) + const unsubscribe = store.subscribe(() => {}) + deliver(revision('r1', 'page', [], 'alice', 100)) + deliver(revision('r2', 'page', ['r1'], 'alice', 200)) + + deliver(deletion('d1', 'bob', ['r2'])) + + const page = store.getSnapshot().pages[0] + expect(page.revisions.map((r) => r.id)).toEqual(['r2', 'r1']) + expect(store.getSnapshot().removedRevisions).toEqual([]) + unsubscribe() + }) + + // The tombstone has to be re-derived from the relay, not kept in local state: + // a rebuild clears everything collected, and only the re-delivered kind 5 + // brings the removal back. + it('survives a rebuild when the kind 5 is delivered again', () => { + const store = getSpaceStore(RELAY, GROUP) + const unsubscribe = store.subscribe(() => {}) + deliver(revision('r1', 'page', [], 'alice', 100)) + deliver(revision('r2', 'page', ['r1'], 'alice', 200)) + const request = deletion('d1', 'alice', ['r1']) + deliver(request) + expect(store.getSnapshot().pages[0].revisions.map((r) => r.id)).toEqual(['r2']) + + relay.epoch = 2 + store.checkConnection() + expect(store.getSnapshot().pages).toEqual([]) + + deliver(revision('r1', 'page', [], 'alice', 100)) + deliver(revision('r2', 'page', ['r1'], 'alice', 200)) + deliver(request) + + const page = store.getSnapshot().pages[0] + expect(page.revisions.map((r) => r.id)).toEqual(['r2']) + expect(page.revisions[0].parentRevs).toEqual([]) + unsubscribe() + }) + + it('still removes an admin-deleted revision outright', () => { + const store = getSpaceStore(RELAY, GROUP) + const unsubscribe = store.subscribe(() => {}) + deliver(revision('r1', 'page', [], 'alice', 100)) + deliver(revision('r2', 'page', ['r1'], 'alice', 200)) + + store.forget('r2') + + expect(store.getSnapshot().pages[0].revisions.map((r) => r.id)).toEqual(['r1']) + expect(store.getSnapshot().pages[0].head.id).toBe('r1') + unsubscribe() + }) + + it('closes the loading gate only once all five subscriptions answered', () => { + const store = getSpaceStore(RELAY, GROUP) + const unsubscribe = store.subscribe(() => {}) + expect(relay.subs).toHaveLength(5) + + for (let index = 0; index < 4; index += 1) relay.subs[index].onEose() + expect(store.getSnapshot().loading).toBe(true) + + relay.subs[4].onEose() + expect(store.getSnapshot().loading).toBe(false) + unsubscribe() + }) +}) diff --git a/src/nostr/space-store.ts b/src/nostr/space-store.ts index 8d132c2..c6f7f08 100644 --- a/src/nostr/space-store.ts +++ b/src/nostr/space-store.ts @@ -16,6 +16,8 @@ import { buildPages, buildTree } from '../domain/pages' import type { Page, PageNode } from '../domain/pages' import { parseComment } from '../domain/comment' import type { Comment } from '../domain/comment' +import { parseDeletion } from '../domain/deletion' +import type { Deletion } from '../domain/deletion' import type { Event } from 'nostr-tools' export type SpaceSnapshot = { @@ -26,6 +28,8 @@ export type SpaceSnapshot = { pages: Page[] tree: PageNode[] comments: Comment[] + /** revisions their author asked the relay to delete, newest first */ + removedRevisions: Revision[] } const EMPTY: SpaceSnapshot = { @@ -36,6 +40,7 @@ const EMPTY: SpaceSnapshot = { pages: [], tree: [], comments: [], + removedRevisions: [], } /** @@ -51,6 +56,7 @@ class SpaceStore { /** the winning placement per slug — src/domain/placement.ts */ private placements = new Map() private comments = new Map() + private deletions = new Map() private metadataEvent: Event | null = null private stop: (() => void)[] = [] private epoch = -1 @@ -121,6 +127,7 @@ class SpaceStore { this.revisions.clear() this.placements.clear() this.comments.clear() + this.deletions.clear() this.metadataEvent = null this.groupEventAt.clear() if (this.snapshot === EMPTY) return @@ -129,8 +136,31 @@ class SpaceStore { } private rebuildPages(): void { - const pages = buildPages([...this.revisions.values()], this.placements) - this.emit({ pages, tree: buildTree(pages) }) + const revisions = [...this.revisions.values()] + const deleted = this.effectiveDeleted() + const pages = buildPages(revisions, this.placements, deleted) + const removedRevisions = revisions + .filter((revision) => deleted.has(revision.id)) + .sort((a, b) => b.createdAt - a.createdAt) + this.emit({ pages, tree: buildTree(pages), removedRevisions }) + } + + /** + * The revision ids a valid NIP-09 request actually removes. The relay does + * not enforce this, so the gate is ours: a request counts only against the + * requester's own revision, and only once that revision is loaded. Deriving + * the set on every rebuild instead of storing it keeps the result + * independent of the order in which the revision and the request arrive. + */ + private effectiveDeleted(): Set { + const deleted = new Set() + for (const deletion of this.deletions.values()) { + for (const target of deletion.targets) { + const revision = this.revisions.get(target) + if (revision && revision.author === deletion.author) deleted.add(target) + } + } + return deleted } /** @@ -205,7 +235,7 @@ class SpaceStore { const onEose = () => { this.eoseSeen += 1 - if (this.eoseSeen >= 4) this.emit({ loading: false }) + if (this.eoseSeen >= 5) this.emit({ loading: false }) } this.stop.push( @@ -233,6 +263,12 @@ class SpaceStore { (event) => this.applyPlacement(event), onEose, ), + client.subscribe( + this.relayUrl, + { kinds: [KINDS.DELETION_REQUEST], '#h': [this.groupId] }, + (event) => this.applyDeletion(event), + onEose, + ), ) } @@ -297,6 +333,20 @@ class SpaceStore { this.emit({ comments: [...this.comments.values()] }) } + /** + * A NIP-09 request. It is kept as a deletion and re-applied on every rebuild, + * so a revision that arrives after its request is still skipped and the + * tombstone is re-derived from the relay on the next round rather than + * living only in local state. + */ + private applyDeletion(event: Event): void { + if (this.deletions.has(event.id)) return + const deletion = parseDeletion(event, this.groupId) + if (!deletion) return + this.deletions.set(event.id, deletion) + this.rebuildPages() + } + private emit(change: Partial): void { this.snapshot = { ...this.snapshot, ...change } for (const listener of this.listeners) listener() diff --git a/src/routes/HistoryView.tsx b/src/routes/HistoryView.tsx index bf31421..8ef3f91 100644 --- a/src/routes/HistoryView.tsx +++ b/src/routes/HistoryView.tsx @@ -8,6 +8,7 @@ import { DiffView } from '../ui/DiffView' import { Author } from '../ui/Author' import { useSession } from '../session/session' import { publishRevision } from '../nostr/publish-page' +import { publishRevisionDeletion } from '../nostr/publish-deletion' import { classifyRejection } from '../nostr/client' import { deleteGroupEvent } from '../nostr/moderation' import { forgetEvent } from '../nostr/space-store' @@ -29,6 +30,7 @@ export function HistoryView() { const [details, setDetails] = useState(null) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) if (!group || !base || !slug) { return ( @@ -58,6 +60,7 @@ export function HistoryView() { session.status === 'signed-in' && space.admins.some((admin) => admin.pubkey === session.pubkey) const revisions = page.revisions + const removedHere = space.removedRevisions.filter((revision) => revision.slug === page.slug) const removeRevision = async (revision: Revision) => { if (session.status !== 'signed-in') return @@ -67,6 +70,7 @@ export function HistoryView() { ) if (!ok) return setError(null) + setNotice(null) setBusy(true) try { const same = await ensureSamePubkey() @@ -93,9 +97,58 @@ export function HistoryView() { const from = selection ? revisions.find((r) => r.id === selection.from) : revisions[1] const to = selection ? revisions.find((r) => r.id === selection.to) : revisions[0] + /** + * NIP-09: ask the relay to drop one's own revision. The wording never says + * "deleted" — the relay may keep the event and other copies can remain, so + * this is a request. docs/09-security-privacy.md + */ + const requestDeletion = async (revision: Revision) => { + if (session.status !== 'signed-in') return + const ok = window.confirm( + `Ask this space's relay to remove the revision from ${stamp(revision.createdAt)}?\n\n` + + 'This is a request, not a guarantee. The relay may keep the revision, copies on ' + + 'other relays and clients can remain, and newer revisions that build on it keep ' + + 'their text.', + ) + if (!ok) return + setError(null) + setNotice(null) + setBusy(true) + try { + const same = await ensureSamePubkey() + if (!same.ok) { + setError(same.reason) + return + } + const result = await publishRevisionDeletion(session.signer, { + relayUrl: group.relayUrl, + groupId: group.id, + revisionId: revision.id, + }) + if (result.ok) { + setNotice( + 'The deletion request reached the relay. It may keep the revision anyway; copies ' + + 'on other relays and clients can remain.', + ) + return + } + const kind = classifyRejection(result.reason) + setError( + kind === 'permission' + ? `The relay did not accept the deletion request: ${result.reason}` + : `The deletion request was not delivered: ${result.reason}`, + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'signing was cancelled') + } finally { + setBusy(false) + } + } + const restore = async (revision: Revision) => { if (session.status !== 'signed-in') return setError(null) + setNotice(null) setBusy(true) try { const same = await ensureSamePubkey() @@ -180,6 +233,24 @@ export function HistoryView() { ) : null} + {notice ? ( +
+ + {notice} + +
+ ) : null} + + {removedHere.length > 0 ? ( +
+ + {removedHere.length} revision{removedHere.length === 1 ? '' : 's'} of this page + {removedHere.length === 1 ? ' was' : ' were'} hidden after a NIP-09 request. The + relay and other clients may still hold a copy. + +
+ ) : null} + {revisions.length > 1 ? (
Compare @@ -290,6 +361,17 @@ export function HistoryView() { Compare with current ) : null} + {session.status === 'signed-in' && + session.pubkey === revision.author ? ( + + ) : null} {isAdmin ? (