Skip to content
Draft
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
121 changes: 121 additions & 0 deletions docs/NIP-09-Cascade-Extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# NIP-09 Cascade Delete Extension

An extension to NIP-09 (Event Deletion Request) adding support for cascade deletions of related events and DAGs.

## Motivation

NIP-09 allows deleting individual events by ID. However, composite resources like git repositories consist of many related events (pushes, branches, PRs, issues) and DAG data (bundles, archives). Deleting these one-by-one is impractical. This extension enables deleting all resources associated with an identifier in a single request.

## New Tag Types

### `c` - Cascade Delete Events by Tag Match

```json
["c", "<tag-name>", "<tag-value>"]
```

Deletes all events containing the specified tag with the matching value. Single-letter tag enables client querying via `#c`.

### `d` - Cascade Delete DAGs

```json
["d", "<tag-name>"]
```

Before deleting events, extracts DAG roots from the specified tag in matching events and deletes those DAGs. Single-letter tag enables client querying via `#d`.

## Security Model

### Ownership Verification

The tag value **must** begin with the requestor's pubkey followed by a separator (`:` or `/`):

```
<owner-pubkey>:<resource-name>
<owner-pubkey>/resource/path
```

The relay extracts the pubkey prefix and verifies it matches the deletion event's signer. This ensures only the resource owner can trigger cascade deletion.

### Implicit Consent

When a user creates an event with a tag like `["r", "someone-else:repo"]`, they are:
1. Associating their event with a resource owned by `someone-else`
2. Implicitly granting that owner the ability to cascade-delete their event
3. Acknowledging the owner controls the resource lifecycle

This is analogous to contributing to someone's GitHub repository—if they delete the repo, your contributions go with it.

### Temporal Boundary (Tombstone)

The deletion event is stored as a tombstone. Only events created **before** the tombstone's `created_at` are deleted. This:
- Prevents deletion of future events
- Allows the same identifier to be reused for a new resource
- Provides an audit trail for clients

## Example: Git Repository Deletion

### Repository Structure

A git repository creates multiple event types:

| Kind | Purpose | Tags |
|------|---------|------|
| 16629 | Permission/Metadata | `["r", "pubkey:repo-name"]` |
| 73 | Push Events | `["r", "pubkey:repo-name"]`, `["bundle", "<dag-root>"]`, `["archive", "<dag-root>"]` |
| 16630 | Branch Events | `["r", "pubkey:repo-name"]` |

### Deletion Request

```json
{
"kind": 5,
"pubkey": "<owner-pubkey>",
"created_at": 1734889200,
"tags": [
["c", "r", "<owner-pubkey>:my-repo"],
["d", "bundle"],
["d", "archive"]
],
"content": "Deleting repository",
"sig": "<signature>"
}
```

### Execution Flow

1. **Verify signature** - Standard NIP-01 signature validation
2. **Verify ownership** - Extract `<owner-pubkey>` from tag value, confirm it matches event pubkey
3. **Collect DAG roots** - Query events with `["r", "<owner-pubkey>:my-repo"]`, extract `bundle` and `archive` tag values
4. **Delete events** - Remove all events with matching `r` tag created before tombstone
5. **Delete DAGs** - Remove all collected DAG data
6. **Store tombstone** - Persist the Kind 5 event for future reference

### Result

All repository events and data are deleted. If the owner creates a new repository with the same name afterward, it's treated as a separate resource (events have `created_at` after the tombstone).

## Supported Tag Value Formats

```
pubkey:name → owner: pubkey
pubkey/name → owner: pubkey
pubkey:name:branch → owner: pubkey
pubkey/path/to/thing → owner: pubkey
```

The first segment before any `:` or `/` is always the owner pubkey.

## Client Usage

Clients can query for Kind 5 tombstones to determine if a resource was deleted:

```json
{
"kinds": [5],
"authors": ["<owner-pubkey>"],
"#c": ["r", "<owner-pubkey>:repo-name"]
}
```

If a tombstone exists, the client should hide cached data for that resource created before the tombstone's timestamp.
172 changes: 168 additions & 4 deletions lib/handlers/nostr/kind5/kind5handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package kind5

import (
"fmt"
"strings"

jsoniter "github.com/json-iterator/go"

Expand Down Expand Up @@ -35,15 +36,17 @@ func BuildKind5Handler(store stores.Store) func(read lib_nostr.KindReader, write
return
}

// Inside handleKindFiveEvents, within the for loop that processes each deletion request
// Track if any deletion was processed
anyDeletionProcessed := false

// Process standard "e" tag deletions (NIP-09)
for _, tag := range env.Event.Tags {
if tag[0] == "e" && len(tag) > 1 {
eventID := tag[1]
// Retrieve the public key of the event to be deleted
pubKey, err := extractPubKeyFromEventID(store, eventID)
if err != nil {
logging.Infof("Failed to extract public key for event %s: %v", eventID, err)
// Decide how to handle this error; continue to next tag, respond with an error, etc.
write("NOTICE", fmt.Sprintf("Failed to extract public key for event %s: %v, the event doesn't exist", eventID, err))
continue
}
Expand All @@ -54,9 +57,9 @@ func BuildKind5Handler(store stores.Store) func(read lib_nostr.KindReader, write
if pubKey == env.Event.PubKey {
if err := store.DeleteEvent(eventID); err != nil {
logging.Infof("Error deleting event %s: %v", eventID, err)
// Optionally, handle individual delete failures
} else {
write("OK", env.Event.ID, true, "Deletion processed")
anyDeletionProcessed = true
logging.Infof("Deleted event %s", eventID)
}
} else {
logging.Infof("Public key mismatch for event %s, deletion request ignored", eventID)
Expand All @@ -65,11 +68,172 @@ func BuildKind5Handler(store stores.Store) func(read lib_nostr.KindReader, write
}
}

// Process cascade deletions
// Format: ["c", "<tag-name>", "<tag-value>"] (c = cascade)
// Example: ["c", "r", "pubkey:reponame"]
// This deletes all events with the specified tag that were created before this deletion event
cascadeDagTags := extractCascadeDagTags(env.Event.Tags)

for _, tag := range env.Event.Tags {
if tag[0] == "c" && len(tag) >= 3 {
tagName := tag[1]
tagValue := tag[2]

// Verify ownership: requestor pubkey must match the owner portion of the tag value
// For format "owner-pubkey:resource-name", the owner is the first part before ":"
if !verifyOwnership(env.Event.PubKey, tagValue) {
logging.Infof("Cascade delete denied: pubkey %s does not own resource %s", env.Event.PubKey, tagValue)
write("NOTICE", fmt.Sprintf("Not authorized to cascade delete: %s", tagValue))
continue
}

logging.Infof("Processing cascade delete for tag [%s=%s] by owner %s", tagName, tagValue, env.Event.PubKey)

// First, query events to collect DAG roots before deleting
var dagRootsToDelete []string
if len(cascadeDagTags) > 0 {
dagRootsToDelete = collectDagRootsFromEvents(store, tagName, tagValue, int64(env.Event.CreatedAt), cascadeDagTags)
}

// Delete all events with this tag value created before this deletion event
deletedEventIDs, err := store.DeleteEventsByTag(tagName, tagValue, int64(env.Event.CreatedAt))
if err != nil {
logging.Infof("Error during cascade delete for [%s=%s]: %v", tagName, tagValue, err)
write("NOTICE", fmt.Sprintf("Error during cascade delete: %v", err))
continue
}

logging.Infof("Cascade deleted %d events with tag [%s=%s]", len(deletedEventIDs), tagName, tagValue)

// Delete the collected DAG roots
dagRootsDeleted := 0
for _, dagRoot := range dagRootsToDelete {
if err := store.DeleteDag(dagRoot); err != nil {
logging.Infof("Error deleting DAG %s: %v", dagRoot, err)
} else {
dagRootsDeleted++
logging.Infof("Deleted DAG: %s", dagRoot)
}
}

if len(cascadeDagTags) > 0 {
logging.Infof("Deleted %d DAGs for cascade delete", dagRootsDeleted)
}

anyDeletionProcessed = true
}
}

// Store the deletion event as a tombstone record
if err := store.StoreEvent(&env.Event); err != nil {
write("NOTICE", "Failed to store the deletion event")
return
}

if anyDeletionProcessed {
write("OK", env.Event.ID, true, "Deletion processed and tombstone stored")
} else {
write("OK", env.Event.ID, true, "Deletion event stored as tombstone")
}
}

return handler
}

// verifyOwnership checks if the requestor owns the resource identified by tagValue.
// For tag values in the format "owner-pubkey:resource-name" or "owner-pubkey/resource-name",
// verifies that the requestor's pubkey matches the owner-pubkey portion.
// This is a generic ownership model that works for any "<owner>:<resource>" or "<owner>/<resource>" pattern.
// Supports multiple separators and nested paths like:
// - pubkey:repo-name
// - pubkey/repo-name
// - pubkey:repo:branch
// - pubkey/repo/sub/path
func verifyOwnership(requestorPubkey, tagValue string) bool {
// Find the first separator (either ":" or "/")
colonIdx := strings.Index(tagValue, ":")
slashIdx := strings.Index(tagValue, "/")

var separatorIdx int
if colonIdx < 0 && slashIdx < 0 {
// No separator found, can't verify ownership
return false
} else if colonIdx < 0 {
separatorIdx = slashIdx
} else if slashIdx < 0 {
separatorIdx = colonIdx
} else {
// Both found, use whichever comes first
if colonIdx < slashIdx {
separatorIdx = colonIdx
} else {
separatorIdx = slashIdx
}
}

ownerPubkey := tagValue[:separatorIdx]
return ownerPubkey == requestorPubkey
}

// extractCascadeDagTags extracts tag names from ["d", "<tag-name>"] entries (d = DAG cascade)
// These specify which tags in the deleted events contain DAG roots to also delete
func extractCascadeDagTags(tags nostr.Tags) []string {
var dagTags []string
for _, tag := range tags {
if tag[0] == "d" && len(tag) >= 2 {
dagTags = append(dagTags, tag[1])
}
}
return dagTags
}

// collectDagRootsFromEvents queries events with the specified tag and extracts DAG roots
// from the specified dag tag names (e.g., "bundle", "archive")
func collectDagRootsFromEvents(store stores.Store, tagName string, tagValue string, beforeTimestamp int64, dagTagNames []string) []string {
// Query events with this tag
filter := nostr.Filter{
Tags: map[string][]string{
tagName: {tagValue},
},
Until: func() *nostr.Timestamp {
t := nostr.Timestamp(beforeTimestamp)
return &t
}(),
}

events, err := store.QueryEvents(filter)
if err != nil {
logging.Infof("Error querying events for DAG collection: %v", err)
return nil
}

// Build a set of DAG roots to delete
dagRoots := make(map[string]struct{})
dagTagSet := make(map[string]struct{})
for _, tagName := range dagTagNames {
dagTagSet[tagName] = struct{}{}
}

for _, event := range events {
for _, tag := range event.Tags {
if len(tag) >= 2 {
if _, isDagTag := dagTagSet[tag[0]]; isDagTag {
dagRoots[tag[1]] = struct{}{}
}
}
}
}

// Convert to slice
result := make([]string, 0, len(dagRoots))
for root := range dagRoots {
result = append(result, root)
}

logging.Infof("Collected %d unique DAG roots from %d events", len(result), len(events))
return result
}

func extractPubKeyFromEventID(store stores.Store, eventID string) (string, error) {
events, err := store.QueryEvents(nostr.Filter{
IDs: []string{eventID},
Expand Down
49 changes: 49 additions & 0 deletions lib/stores/badgerhold/badgerhold.go
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,55 @@ func (store *BadgerholdStore) DeleteEvent(eventID string) error {
return nil
}

// DeleteEventsByTag deletes all events that have a specific tag with a specific value
// and were created before the given timestamp. Returns the IDs of deleted events.
// This is used for cascade deletions where all events with a matching tag (e.g., "r" tag
// for repository identifier) should be deleted as part of a parent resource deletion.
func (store *BadgerholdStore) DeleteEventsByTag(tagName string, tagValue string, beforeTimestamp int64) ([]string, error) {
// Find all tag entries matching the tag name and value
var tagEntries []types.TagEntry
err := store.Database.Find(&tagEntries, badgerhold.Where("TagName").Eq(tagName).And("TagValue").Eq(tagValue))
if err != nil && err != badgerhold.ErrNotFound {
return nil, fmt.Errorf("failed to query tag entries: %w", err)
}

if len(tagEntries) == 0 {
return []string{}, nil
}

// Collect unique event IDs
eventIDs := make(map[string]struct{})
for _, entry := range tagEntries {
eventIDs[entry.EventID] = struct{}{}
}

// Filter events by timestamp and delete them
var deletedEventIDs []string
for eventID := range eventIDs {
// Fetch the event to check its timestamp
var event types.NostrEvent
err := store.Database.Get(eventID, &event)
if err != nil {
if err == badgerhold.ErrNotFound {
continue // Event already deleted or doesn't exist
}
logging.Infof("Failed to get event %s for cascade delete: %v", eventID, err)
continue
}

// Only delete events created before the tombstone timestamp
if int64(event.CreatedAt) < beforeTimestamp {
if err := store.DeleteEvent(eventID); err != nil {
logging.Infof("Failed to delete event %s during cascade: %v", eventID, err)
continue
}
deletedEventIDs = append(deletedEventIDs, eventID)
}
}

return deletedEventIDs, nil
}

// Blossom Blobs (unchunked data)
func (store *BadgerholdStore) StoreBlob(data []byte, hash []byte, publicKey string) error {
encodedHash := hex.EncodeToString(hash)
Expand Down
1 change: 1 addition & 0 deletions lib/stores/stores.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type Store interface {
QueryEvents(filter nostr.Filter) ([]*nostr.Event, error)
StoreEvent(event *nostr.Event) error
DeleteEvent(eventID string) error
DeleteEventsByTag(tagName string, tagValue string, beforeTimestamp int64) ([]string, error)
QueryBlobs(mimeType string) ([]string, error)

// Moderation
Expand Down