Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions NOSTR.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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 |

* * *
Expand Down
15 changes: 13 additions & 2 deletions docs/05-versioning-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 6 additions & 1 deletion docs/09-security-privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 0 additions & 1 deletion docs/10-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
25 changes: 25 additions & 0 deletions src/domain/blame.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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([])
})
})
106 changes: 106 additions & 0 deletions src/domain/deletion.test.ts
Original file line number Diff line number Diff line change
@@ -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<Event> & { 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()
})
})
61 changes: 61 additions & 0 deletions src/domain/deletion.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
Loading
Loading