{"record":{"id":"6b53cc6ac078bc52","repo":"block/buzz","slug":"missing-or-invalid-h-tag","errorCode":null,"errorMessage":"missing or invalid h tag","messagePattern":"missing or invalid h tag","errorType":"validation","errorClass":"IngestError::Rejected","httpStatus":null,"severity":"error","filePath":"crates/buzz-relay/src/handlers/side_effects.rs","lineNumber":318,"sourceCode":"    }\n    Ok(false)\n}\n\n/// Validate an admin kind event BEFORE storage.\npub async fn validate_admin_event(\n    tenant: &TenantContext,\n    kind: u32,\n    event: &Event,\n    state: &Arc<AppState>,\n) -> anyhow::Result<()> {\n    // CREATE_GROUP doesn't need an existing channel — skip h-tag extraction\n    if kind == 9007 {\n        return Ok(());\n    }\n\n    // Extract channel from h tag\n    let channel_id =\n        extract_h_tag_channel(event).ok_or_else(|| anyhow::anyhow!(\"missing or invalid h tag\"))?;\n\n    let actor_bytes = event.pubkey.to_bytes().to_vec();\n\n    // Reject mutations on archived channels — except kind:9002 with archived=false\n    // (unarchive), which must be allowed through so the channel can be restored.\n    let channel = state\n        .db\n        .get_channel(tenant.community(), channel_id)\n        .await\n        .map_err(|_| anyhow::anyhow!(\"channel not found\"))?;\n    let is_unarchive_request = kind == 9002\n        && event.tags.iter().any(|t| {\n            let parts = t.as_slice();\n            parts.len() >= 2 && parts[0] == \"archived\" && parts[1] == \"false\"\n        });\n    if channel.archived_at.is_some() && !is_unarchive_request {\n        return Err(anyhow::anyhow!(\"channel is archived\"));\n    }","sourceCodeStart":300,"sourceCodeEnd":336,"githubUrl":"https://github.com/block/buzz/blob/f956e6fe06a76e50cbd8fba1a162482e752e7f1a/crates/buzz-relay/src/handlers/side_effects.rs#L300-L336","documentation":"validate_admin_event() requires every NIP-29 admin event except kind 9007 (CREATE_GROUP) to carry an 'h' tag whose content parses as a UUID — extract_h_tag_channel() returned None. The h tag is how Buzz scopes group mutations (kinds 9000, 9001, 9002, 9005, 9008, 9021, 9022) to a channel, and it must be the channel's UUID, not its name or id string from another scheme.","triggerScenarios":"Sending PUT_USER/REMOVE_USER/edit-metadata/delete-group/join/leave without an h tag; h tag value is a channel name like \"general\" or a Nostr event id instead of a UUID; h tag content is a UUID with braces/quotes or wrong casing that fails Uuid::parse; tag kind serialized as uppercase 'H'.","commonSituations":"Clients used to NIP-28-style channels passing bech32 or name ids; UI passing the channel display name because the UUID was not threaded through state; JSON double-encoding the tag value (quotes included in the string).","solutions":["Add [\"h\", \"<channel-uuid>\"] (plain hyphenated UUID, e.g. 550e8400-e29b-41d4-a716-446655440000) to the admin event","Resolve the channel's UUID once from the channel list/kind 39000 metadata and reuse it for all admin events","Assert client-side that the h value round-trips through Uuid::parse_str before publishing"],"exampleFix":"// before\nEventBuilder::new(Kind::from(9000), \"\", [\n    Tag::custom(TagKind::Custom(\"p\"), vec![user_hex]),\n    Tag::custom(TagKind::Custom(\"h\"), vec![\"general\"]), // name, not UUID\n])\n\n// after\nlet channel_id: Uuid = fetch_channel(\"general\").await?.id;\nEventBuilder::new(Kind::from(9000), \"\", [\n    Tag::custom(TagKind::Custom(\"p\"), vec![user_hex]),\n    Tag::custom(TagKind::Custom(\"h\"), vec![channel_id.to_string()]),\n])","handlingStrategy":"validation","validationCode":"// Validate the h tag exactly like extract_h_tag_channel before publish\nfn valid_h_tag(tags: &[Tag]) -> bool {\n    tags.iter().any(|t| {\n        t.kind().to_string() == \"h\"\n            && t.content().and_then(|v| v.parse::<Uuid>().ok()).is_some()\n    })\n}\nif kind != 9007 {\n    assert!(valid_h_tag(&event.tags), \"admin events need h = channel UUID\");\n}","typeGuard":"const isUuid = (v: string): boolean =>\n  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);","tryCatchPattern":"match validate_admin_event(&tenant, kind, &event, &state).await {\n    Err(e) if e.to_string().contains(\"missing or invalid h tag\") => {\n        reject(\"admin event requires [\\\"h\\\", \\\"<channel-uuid>\\\"]\", &event.id)\n    }\n    other => other,\n}","preventionTips":["Resolve the channel UUID once at channel selection and thread it into every admin-event builder","Never send channel names, bech32 ids, or event ids in the h tag — UUID only","Add a builder-level assertion so a missing h tag fails at compile/test time in client code"],"tags":["nostr","nip-29","group-admin","h-tag","uuid","validation"],"backgroundTag":"nip29-missing-h-tag","analyzedSha":"f956e6fe06a76e50cbd8fba1a162482e752e7f1a","analyzedAt":"2026-08-16T22:11:40.750Z","schemaVersion":2},"datasetVersion":"2026-08-16T23:17:17.608Z"}