diff --git a/docs/NIP-09-Cascade-Extension.md b/docs/NIP-09-Cascade-Extension.md new file mode 100644 index 0000000..4ada7f7 --- /dev/null +++ b/docs/NIP-09-Cascade-Extension.md @@ -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", "", ""] +``` + +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", ""] +``` + +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 `/`): + +``` +: +/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", ""]`, `["archive", ""]` | +| 16630 | Branch Events | `["r", "pubkey:repo-name"]` | + +### Deletion Request + +```json +{ + "kind": 5, + "pubkey": "", + "created_at": 1734889200, + "tags": [ + ["c", "r", ":my-repo"], + ["d", "bundle"], + ["d", "archive"] + ], + "content": "Deleting repository", + "sig": "" +} +``` + +### Execution Flow + +1. **Verify signature** - Standard NIP-01 signature validation +2. **Verify ownership** - Extract `` from tag value, confirm it matches event pubkey +3. **Collect DAG roots** - Query events with `["r", ":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": [""], + "#c": ["r", ":repo-name"] +} +``` + +If a tombstone exists, the client should hide cached data for that resource created before the tombstone's timestamp. diff --git a/lib/handlers/nostr/kind5/kind5handler.go b/lib/handlers/nostr/kind5/kind5handler.go index 936cc01..9e210a7 100644 --- a/lib/handlers/nostr/kind5/kind5handler.go +++ b/lib/handlers/nostr/kind5/kind5handler.go @@ -2,6 +2,7 @@ package kind5 import ( "fmt" + "strings" jsoniter "github.com/json-iterator/go" @@ -35,7 +36,10 @@ 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] @@ -43,7 +47,6 @@ func BuildKind5Handler(store stores.Store) func(read lib_nostr.KindReader, write 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 } @@ -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) @@ -65,11 +68,172 @@ func BuildKind5Handler(store stores.Store) func(read lib_nostr.KindReader, write } } + // Process cascade deletions + // Format: ["c", "", ""] (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 ":" or "/" 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", ""] 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}, diff --git a/lib/stores/badgerhold/badgerhold.go b/lib/stores/badgerhold/badgerhold.go index 923016e..6341fd2 100644 --- a/lib/stores/badgerhold/badgerhold.go +++ b/lib/stores/badgerhold/badgerhold.go @@ -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) diff --git a/lib/stores/stores.go b/lib/stores/stores.go index 57efe9f..f553b0e 100644 --- a/lib/stores/stores.go +++ b/lib/stores/stores.go @@ -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