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 c88a561..95ab40d 100644 --- a/docs/05-versioning-history.md +++ b/docs/05-versioning-history.md @@ -86,8 +86,19 @@ 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. What the client keeps is the `kind 5` itself: no tombstone event is + written, the skip is re-derived from the request on every read. The revision + is left out of the chain, the diff pickers and blame, while a successor whose + `parent-rev` names it is re-pointed at the removed revision's nearest + surviving ancestor along the first parent, so the chain does not tear. Where + the removed revision was a *merge*, the ancestors on its other branches stay + referenced as well — otherwise they would resurface as leaves and the page + would show a fork nobody created. 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 8aeffc8..6f3c5a8 100644 --- a/docs/09-security-privacy.md +++ b/docs/09-security-privacy.md @@ -26,7 +26,12 @@ 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, and skips that revision + in the chain, the diff and blame. Nothing is marked deleted anywhere: what + is kept is the `kind 5` request, and the skip is re-derived from it on every + read — the relay we run stores the request 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 560ef88..f4d3e92 100644 --- a/docs/10-roadmap.md +++ b/docs/10-roadmap.md @@ -222,7 +222,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/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/deletion.test.ts b/src/domain/deletion.test.ts new file mode 100644 index 0000000..2058c6d --- /dev/null +++ b/src/domain/deletion.test.ts @@ -0,0 +1,106 @@ +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.GROUP, 'engineering'], + [TAGS.DELETED_EVENT, TARGET], + [TAGS.DELETED_EVENT, OTHER], + ], + }), + 'engineering', + ) + expect(deletion?.targets).toEqual([TARGET, OTHER]) + }) + + // A request without an h tag is not part of this group's state: the + // subscription filters on #h, so one could only arrive from a relay that + // ignores the filter — and then it is exactly the event not to honour. + it('rejects a request without an h tag', () => { + expect( + parseDeletion( + event({ + tags: [ + [TAGS.DELETED_EVENT, TARGET], + [TAGS.DELETED_KIND, String(KINDS.PAGE_REVISION)], + ], + }), + 'engineering', + ), + ).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.GROUP, 'engineering'], + [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.GROUP, 'engineering'], [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..dd2b216 --- /dev/null +++ b/src/domain/deletion.ts @@ -0,0 +1,61 @@ +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 */ + group: string + /** 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 missing or foreign `h`, or a + * `k` that names another kind, means the request is not ours to honour. + * + * The `h` tag is required even though NIP-09 does not define one, because it + * is what makes a request readable back as the group's shared state: the + * subscription asks for `#h`, so a request without it never reaches this app + * in the first place, and only what we publish ourselves (which always carries + * `h`) can arrive. The check is still made here rather than trusted from the + * filter — a relay can deliver whatever it likes, and an event from another + * group must not remove a revision in this one. + * docs/09-security-privacy.md (Forged `h` tags) + * + * The author gate lives where both request and revision are known + * (`SpaceStore`): a request only removes a revision the requester wrote. + */ +export function parseDeletion(event: Event, expectedGroup: string): Deletion | null { + if (event.kind !== KINDS.DELETION_REQUEST) return null + + const group = tagValues(event, TAGS.GROUP)[0] + 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/domain/pages.test.ts b/src/domain/pages.test.ts index cec0ae0..d6f580c 100644 --- a/src/domain/pages.test.ts +++ b/src/domain/pages.test.ts @@ -94,6 +94,155 @@ 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']) + }) + + // The mirror image of the case above: not a surviving merge with a removed + // parent, but a removed *merge*. Following only its first parent would leave + // the second branch referenced by nothing and it would resurface as a leaf — + // a fork the author never made, and one that offers a merge against a base + // the repaired graph can no longer prove. + it('does not resurrect the second branch of a removed merge as a leaf', () => { + const pages = buildPages( + [ + rev({ id: 'r1', createdAt: 100 }), + rev({ id: 'r2', createdAt: 150 }), + rev({ id: 'merge', createdAt: 200, parentRevs: ['r1', 'r2'] }), + rev({ id: 'after', createdAt: 300, parentRevs: ['merge'] }), + ], + new Map(), + new Set(['merge']), + ) + expect(pages[0].head.id).toBe('after') + expect(pages[0].leaves.map((r) => r.id)).toEqual(['after']) + // The successor keeps the arity its author gave it: bridging past the + // removed merge must not turn `after` into a merge revision itself. + expect(pages[0].revisions[0].parentRevs).toEqual(['r1']) + }) + + 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..5871e03 100644 --- a/src/domain/pages.ts +++ b/src/domain/pages.ts @@ -27,6 +27,115 @@ function sortNewestFirst(a: Revision, b: Revision): number { return a.id < b.id ? -1 : 1 } +/** + * The chain with the revisions a NIP-09 request removed taken out of it. + * + * `bridged` holds, per surviving revision, the ancestors it reaches *only* + * through a removed revision and that are not among its own `parentRevs`. + * They are not made parents: a revision's arity is what its author signed, and + * a successor that inherited two parents from a removed merge would wear the + * "merge" badge for a merge nobody made. Marking them as referenced is what + * they are needed for — see `buildPages`. + */ +type RepairedChain = { + revisions: Revision[] + bridged: Map +} + +/** + * 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 along the first-parent path. + * The removed node is skipped, but its ancestors stay reachable — the chain + * does not tear. + * + * A removed *merge* has ancestors off that path as well. They are collected + * into `bridged` rather than dropped, because nothing visible would point at + * them otherwise and they would come back as leaves — a fork out of a deletion. + * + * 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. + */ +function repairChain(revisions: Revision[], deleted: Set): RepairedChain { + if (deleted.size === 0) return { revisions, bridged: new Map() } + const byId = new Map(revisions.map((revision) => [revision.id, revision])) + const visible: Revision[] = [] + const bridged = new Map() + for (const revision of revisions) { + if (deleted.has(revision.id)) continue + const repaired = reconnectParents(revision.parentRevs, byId, deleted) + visible.push({ ...revision, parentRevs: repaired.parents }) + if (repaired.bridged.length > 0) bridged.set(revision.id, repaired.bridged) + } + return { revisions: visible, bridged } +} + +function reconnectParents( + parentRevs: string[], + byId: Map, + deleted: Set, +): { parents: string[]; bridged: string[] } { + const parents: string[] = [] + const bridged: string[] = [] + for (const parentId of parentRevs) { + if (!deleted.has(parentId)) { + if (!parents.includes(parentId)) parents.push(parentId) + continue + } + const first = firstSurvivingAncestor(parentId, byId, deleted) + if (first && !parents.includes(first)) parents.push(first) + for (const id of survivingAncestors(parentId, byId, deleted)) { + if (id !== first && !parents.includes(id) && !bridged.includes(id)) bridged.push(id) + } + } + return { parents, bridged } +} + +/** The new parent: up the first-parent path until a revision survives. */ +function firstSurvivingAncestor( + start: string, + byId: Map, + deleted: Set, +): string { + let current = start + const seen = new Set() + while (deleted.has(current)) { + // A cycle between removed revisions has no surviving ancestor. + if (seen.has(current)) return '' + seen.add(current) + const parent = byId.get(current) + if (!parent) return '' + current = parent.parentRevs[0] ?? '' + } + return current +} + +/** Every surviving revision reachable through the removed ones above `start`. */ +function survivingAncestors( + start: string, + byId: Map, + deleted: Set, +): string[] { + const found: string[] = [] + const seen = new Set() + const queue = [start] + while (queue.length > 0) { + const id = queue.shift()! + if (seen.has(id)) continue + seen.add(id) + if (!deleted.has(id)) { + found.push(id) + continue + } + const revision = byId.get(id) + // Never loaded: the tolerated missing link, and nothing to climb further. + if (revision) queue.push(...revision.parentRevs) + } + return found +} + /** * Builds the pages from all revisions of a group. * @@ -35,6 +144,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 `repairChain`) — 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 +156,11 @@ function sortNewestFirst(a: Revision, b: Revision): number { export function buildPages( revisions: Revision[], placements: Map = new Map(), + deleted: Set = new Set(), ): Page[] { + const { revisions: visible, bridged } = repairChain(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,9 +169,14 @@ 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 — plus + // the ancestors it reaches only through a removed merge, which are + // referenced just as they were before the removal, only not as parents. const referenced = new Set() for (const revision of sorted) { for (const parent of revision.parentRevs) referenced.add(parent) + for (const ancestor of bridged.get(revision.id) ?? []) referenced.add(ancestor) } const leaves = sorted.filter((revision) => !referenced.has(revision.id)) const head = leaves[0] ?? sorted[0] 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/kinds.ts b/src/nostr/kinds.ts index f8ad516..7909afd 100644 --- a/src/nostr/kinds.ts +++ b/src/nostr/kinds.ts @@ -59,13 +59,24 @@ export const KINDS = { export type Kind = (typeof KINDS)[keyof typeof KINDS] /** - * Every kind the app writes as page/comment content. Declared as a group's + * Every kind the app writes into a group. Declared as a group's * `supported_kinds` on creation and on every metadata edit — a group missing * one of these has its writes rejected by a relay that enforces the list. * `src/routes/SpaceOverview.tsx` warns if `PAGE_REVISION`/`PAGE_PLACEMENT` are * missing from it. + * + * `DELETION_REQUEST` is in here although it is not content: it is written with + * an `h` tag into the group like the rest, and a relay enforcing the list + * would otherwise reject the one event whose whole point is to be accepted. + * The relay we run enforces nothing here, so this is a declaration for other + * relays rather than a fix for ours. src/nostr/publish-deletion.ts */ -export const APP_CONTENT_KINDS = [KINDS.PAGE_REVISION, KINDS.COMMENT, KINDS.PAGE_PLACEMENT] +export const APP_CONTENT_KINDS = [ + KINDS.PAGE_REVISION, + KINDS.COMMENT, + KINDS.PAGE_PLACEMENT, + KINDS.DELETION_REQUEST, +] /** Tag names. Single-letter tags are relay-indexed and filterable. */ export const TAGS = { @@ -92,6 +103,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..ff55176 --- /dev/null +++ b/src/nostr/publish-deletion.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools' +import type { Event } 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()) + }) + + // A signer that hands back something it did not sign must not reach the + // relay: the request would be dropped there, and the UI would have reported + // a request that never existed. + it('does not publish an event whose signature does not verify', async () => { + const badSigner: Signer = { + kind: 'dev', + getPublicKey: async () => getPublicKey(secretKey), + // Assembled by hand rather than from `finalizeEvent`: that one stamps + // the event as already verified, and nostr-tools then trusts the stamp + // instead of checking the signature we broke. + signEvent: async (template) => + ({ + ...template, + id: '0'.repeat(64), + pubkey: getPublicKey(secretKey), + sig: '0'.repeat(128), + }) as Event, + } + await expect(publishRevisionDeletion(badSigner, base)).resolves.toEqual({ + ok: false, + reason: 'the event signature is invalid', + }) + expect(client.publish).not.toHaveBeenCalled() + }) + + 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) +} diff --git a/src/nostr/space-store.test.ts b/src/nostr/space-store.test.ts index b96b357..79caf15 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,132 @@ 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() + }) + + // Relays deliver in no particular order, and the request lives on its own + // subscription — so the kind 5 regularly arrives before the revision it + // names. The skip is derived on every rebuild for exactly this reason. + it('skips a revision that arrives after the request naming it', () => { + const store = getSpaceStore(RELAY, GROUP) + const unsubscribe = store.subscribe(() => {}) + + deliver(deletion('d1', 'alice', ['r2'])) + expect(store.getSnapshot().pages).toEqual([]) + + deliver(revision('r1', 'page', [], 'alice', 100)) + deliver(revision('r2', 'page', ['r1'], 'alice', 200)) + deliver(revision('r3', 'page', ['r2'], 'alice', 300)) + + const page = store.getSnapshot().pages[0] + 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.test.tsx b/src/routes/HistoryView.test.tsx new file mode 100644 index 0000000..de78a13 --- /dev/null +++ b/src/routes/HistoryView.test.tsx @@ -0,0 +1,219 @@ +// @vitest-environment jsdom +;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, createElement } from 'react' +import { createRoot } from 'react-dom/client' +import type { Root } from 'react-dom/client' +import { MemoryRouter, Route, Routes } from 'react-router-dom' + +vi.mock('../nostr/space-store', () => ({ useSpace: vi.fn(), forgetEvent: vi.fn() })) +vi.mock('../session/session', () => ({ + SessionProvider: ({ children }: { children: unknown }) => children, + useSession: vi.fn(), +})) +vi.mock('../nostr/publish-deletion', () => ({ publishRevisionDeletion: vi.fn() })) +vi.mock('../nostr/publish-page', () => ({ publishRevision: vi.fn() })) +vi.mock('../nostr/moderation', () => ({ deleteGroupEvent: vi.fn() })) +// The byline resolves names through the profile store, which would open a +// WebSocket to VITE_PROFILE_RELAYS. Nothing here asserts on a name. +vi.mock('../ui/Author', () => ({ + Author: () => null, + AuthorName: () => null, +})) + +import { useSpace } from '../nostr/space-store' +import type { SpaceSnapshot } from '../nostr/space-store' +import { useSession } from '../session/session' +import { publishRevisionDeletion } from '../nostr/publish-deletion' +import { buildPages, buildTree } from '../domain/pages' +import type { Revision } from '../domain/revision' +import { HistoryView } from './HistoryView' + +const ALICE = 'a'.repeat(64) +const BOB = 'b'.repeat(64) +const GROUP = 'engineering' +const SLUG = 'onboarding' + +let root: Root +let space: SpaceSnapshot + +function revision(partial: Partial & { id: string }): Revision { + return { + author: ALICE, + createdAt: 1000, + group: GROUP, + slug: SLUG, + title: 'Onboarding', + parentSlug: null, + order: null, + parentRevs: [], + summary: null, + content: 'hello', + ...partial, + } +} + +/** A space snapshot whose pages are derived the way the store derives them. */ +function snapshot(revisions: Revision[], removed: Set = new Set()): SpaceSnapshot { + const pages = buildPages(revisions, new Map(), removed) + return { + loading: false, + metadata: { + name: 'Engineering', + about: '', + picture: null, + isPublic: false, + isOpen: false, + supportedKinds: [], + }, + admins: [], + members: [ALICE, BOB], + pages, + tree: buildTree(pages), + comments: [], + removedRevisions: revisions.filter((entry) => removed.has(entry.id)), + } +} + +async function render() { + const host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) + const path = `/s/${encodeURIComponent(`localhost:8081'${GROUP}`)}/${SLUG}/history` + await act(async () => { + root.render( + createElement( + MemoryRouter, + { initialEntries: [path] }, + createElement( + Routes, + null, + createElement(Route, { + path: '/s/:group/:slug/history', + element: createElement(HistoryView), + }), + ), + ), + ) + }) +} + +function buttonLabelled(label: string): HTMLButtonElement | undefined { + return [...document.querySelectorAll('button')].find( + (button) => button.textContent?.trim() === label, + ) as HTMLButtonElement | undefined +} + +beforeEach(() => { + space = snapshot([ + revision({ id: 'r1', createdAt: 100 }), + revision({ id: 'r2', createdAt: 200, parentRevs: ['r1'] }), + ]) + vi.mocked(useSpace).mockImplementation(() => space) + vi.mocked(useSession).mockReturnValue({ + session: { status: 'signed-in', pubkey: ALICE, signer: {} }, + ensureSamePubkey: vi.fn().mockResolvedValue({ ok: true }), + } as unknown as ReturnType) + vi.spyOn(window, 'confirm').mockReturnValue(true) +}) + +afterEach(() => { + document.body.innerHTML = '' + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +describe('HistoryView — requesting deletion of one’s own revision', () => { + it('offers the action on one’s own revision', async () => { + await render() + expect(buttonLabelled('Request deletion')).toBeDefined() + }) + + it('never offers it on someone else’s revision', async () => { + space = snapshot([ + revision({ id: 'r1', createdAt: 100, author: BOB }), + revision({ id: 'r2', createdAt: 200, author: BOB, parentRevs: ['r1'] }), + ]) + await render() + expect(buttonLabelled('Request deletion')).toBeUndefined() + }) + + it('never offers it to a signed-out reader', async () => { + vi.mocked(useSession).mockReturnValue({ + session: { status: 'signed-out' }, + ensureSamePubkey: vi.fn(), + } as unknown as ReturnType) + await render() + expect(buttonLabelled('Request deletion')).toBeUndefined() + }) + + // The wording is the ticket's own requirement: the relay may keep the event, + // so the result may never be reported as a deletion that happened. + it('asks first and reports the result as a request, not as a deletion', async () => { + vi.mocked(publishRevisionDeletion).mockResolvedValue({ ok: true, message: 'accepted' }) + await render() + + await act(async () => { + buttonLabelled('Request deletion')?.click() + }) + + expect(window.confirm).toHaveBeenCalledOnce() + expect(vi.mocked(window.confirm).mock.calls[0][0]).toContain('request, not a guarantee') + expect(publishRevisionDeletion).toHaveBeenCalledOnce() + const text = document.body.textContent ?? '' + expect(text).toContain('Deletion requested — not a guarantee') + expect(text).toContain('copies') + expect(text).not.toMatch(/revision (was|is) deleted/i) + }) + + it('publishes nothing when the confirmation is declined', async () => { + vi.mocked(window.confirm).mockReturnValue(false) + await render() + + await act(async () => { + buttonLabelled('Request deletion')?.click() + }) + + expect(publishRevisionDeletion).not.toHaveBeenCalled() + }) + + it('reports a relay rejection instead of hiding it', async () => { + vi.mocked(publishRevisionDeletion).mockResolvedValue({ + ok: false, + reason: 'restricted: not allowed', + }) + await render() + + await act(async () => { + buttonLabelled('Request deletion')?.click() + }) + + expect(document.body.textContent).toContain('restricted: not allowed') + }) +}) + +describe('HistoryView — a page whose revisions were removed', () => { + it('says a revision of this page was removed', async () => { + space = snapshot( + [ + revision({ id: 'r1', createdAt: 100 }), + revision({ id: 'r2', createdAt: 200, parentRevs: ['r1'] }), + ], + new Set(['r1']), + ) + await render() + expect(document.body.textContent).toContain('Removed by their authors') + }) + + // Removing the last revision drops the page itself, which is exactly when + // the explanation matters most: without it the view says only "No revisions + // for this slug", as if the page had never existed. + it('still explains the removal when the page has no revisions left', async () => { + space = snapshot([revision({ id: 'r1', createdAt: 100 })], new Set(['r1'])) + await render() + expect(space.pages).toEqual([]) + const text = document.body.textContent ?? '' + expect(text).toContain('Removed by their authors') + expect(text).toContain('may still hold a copy') + }) +}) diff --git a/src/routes/HistoryView.tsx b/src/routes/HistoryView.tsx index ad280da..876d3df 100644 --- a/src/routes/HistoryView.tsx +++ b/src/routes/HistoryView.tsx @@ -8,6 +8,7 @@ import { DiffView } from '../ui/DiffView' import { Author, AuthorName } 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 ( @@ -44,11 +46,56 @@ export function HistoryView() { const spaceName = space.metadata?.name ?? group.id const page = space.pages.find((entry) => entry.slug === slug) + // Computed off the route's slug, not off `page`: removing the last revision + // of a page makes the page itself disappear, and that is precisely the + // moment the reader has to be told why — see the branch below. + const removedHere = space.removedRevisions.filter((revision) => revision.slug === slug) + + /** + * What the view owes the reader about the deletion request, in both + * branches: the outcome of an action just taken, and what a NIP-09 request + * did and did not achieve. docs/09-security-privacy.md + */ + const feedback = ( + <> + {error ? ( +
+ + {error} + +
+ ) : 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} + + ) + if (!page) { return ( + {feedback}

- {space.loading ? 'loading…' : 'No revisions for this slug.'} + {space.loading + ? 'loading…' + : removedHere.length > 0 + ? 'No revisions left for this slug.' + : 'No revisions for this slug.'}

) @@ -67,6 +114,7 @@ export function HistoryView() { ) if (!ok) return setError(null) + setNotice(null) setBusy(true) try { const same = await ensureSamePubkey() @@ -93,9 +141,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() @@ -175,13 +272,7 @@ export function HistoryView() { {page.title} - {error ? ( -
- - {error} - -
- ) : null} + {feedback} {revisions.length > 1 ? (
@@ -293,6 +384,17 @@ export function HistoryView() { Compare with current ) : null} + {session.status === 'signed-in' && + session.pubkey === revision.author ? ( + + ) : null} {isAdmin ? (