{"record":{"id":"e93261ce4f94d938","repo":"block/buzz","slug":"target-event-belongs-to-a-different-channel","errorCode":null,"errorMessage":"target event belongs to a different channel","messagePattern":"target event belongs to a different channel","errorType":"validation","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"crates/buzz-relay/src/handlers/side_effects.rs","lineNumber":601,"sourceCode":"                    } else {\n                        None\n                    }\n                })\n                .ok_or_else(|| anyhow::anyhow!(\"missing e tag for target event\"))?;\n\n            // Verify the target event exists and belongs to the h-tag channel\n            // BEFORE storage. Fail closed: missing target → reject.\n            let target_event = state\n                .db\n                .get_event_by_id_for_event_write(tenant.community(), &target_id)\n                .await\n                .map_err(|e| anyhow::anyhow!(\"db error looking up target: {e}\"))?\n                .ok_or_else(|| anyhow::anyhow!(\"target event not found\"))?;\n\n            match target_event.channel_id {\n                Some(target_ch) if target_ch != channel_id => {\n                    return Err(anyhow::anyhow!(\n                        \"target event belongs to a different channel\"\n                    ));\n                }\n                None => {\n                    return Err(anyhow::anyhow!(\"target event has no channel\"));\n                }\n                _ => {} // Same channel — OK\n            }\n\n            // Check if actor is the event author.\n            // For relay-signed REST messages, the real author is in the p tag.\n            let author =\n                effective_message_author(&target_event.event, &state.relay_keypair.public_key());\n            if author_delete_can_use_self_delete_path(&author, &actor_bytes, event) {\n                // Author deleting their own message: re-gate on membership/open visibility so that\n                // a removed private-channel member cannot mutate old messages after access is revoked.\n                let is_member = state\n                    .is_member_cached(tenant.community(), channel_id, &actor_bytes)\n                    .await?;","sourceCodeStart":583,"sourceCodeEnd":619,"githubUrl":"https://github.com/block/buzz/blob/dad5a33865fc81a2e55b3b60746632f615ec1e3a/crates/buzz-relay/src/handlers/side_effects.rs#L583-L619","documentation":"The kind:9005 delete handler requires the delete event's h tag (the channel scope) to match the stored channel_id of the e-tag target. If the target message lives in a different channel, the relay rejects the delete before storage. This prevents a member of channel A from deleting messages in channel B by forging an h tag, and keeps NIP-29 channel scoping intact.","triggerScenarios":"A client builds the 9005 event with an h tag copied from the wrong channel while its e tag points at a message in another channel; channel UUIDs mistyped or swapped; code that hardcodes a 'general' channel h tag for all deletes.","commonSituations":"UI delete buttons reusing a stale channel id after the user switched channels; tooling that forwards deletes between bots without rewriting the h tag; typos in UUID constants.","solutions":["Derive the h tag from the target event itself: fetch the target first and copy its channel (h) tag into the delete event.","Never hardcode the channel in delete paths — bind it to the message being deleted.","Log the delete's (h, e) pair next to the target's channel_id on failure to find the mismatch source.","Fix the client so both tags come from the same message context, then re-publish."],"exampleFix":"// before: hardcoded channel tag\nawait publish({ kind: 9005, tags: [['e', targetId], ['h', 'general-channel-uuid']] });\n// after: take the channel from the target event\nconst target = await relay.queryOne({ ids: [targetId] });\nconst channel = target.tags.find(([k]) => k === 'h')?.[1];\nawait publish({ kind: 9005, tags: [['e', targetId], ['h', channel]] });","handlingStrategy":"validation","validationCode":"// build the h tag from the target's own channel, not from client state\nconst target = await relay.queryOne({ ids: [targetId] });\nconst targetChannel = target?.tags.find(([k]) => k === 'h')?.[1];\nif (!targetChannel || targetChannel !== deleteChannelArg) {\n  throw new Error(`channel mismatch: target lives in ${targetChannel}`);\n}","typeGuard":"function deleteTagsConsistent(tags: string[][]): boolean {\n  const e = tags.find(([k]) => k === 'e')?.[1];\n  const h = tags.find(([k]) => k === 'h')?.[1];\n  return typeof e === 'string' && e.length === 64 && typeof h === 'string' && h.length > 0;\n}","tryCatchPattern":null,"preventionTips":["Always copy h from the message object being deleted; never from route state or a default channel.","Add a UI-level invariant: the delete menu is bound to the message row, so the channel travels with it.","Log (h, e) pairs on delete failures to catch swapped-UUID regressions quickly."],"tags":["buzz-relay","nostr","delete-event","channel-scoping","authorization"],"backgroundTag":"channel-scope-mismatch","analyzedSha":"dad5a33865fc81a2e55b3b60746632f615ec1e3a","analyzedAt":"2026-08-20T04:38:24.874Z","contentChangedAt":"2026-08-20T04:38:24.874Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}