{"record":{"id":"5266242998b86b62","repo":"block/buzz","slug":"missing-p-tag","errorCode":null,"errorMessage":"missing p tag","messagePattern":"missing p tag","errorType":"validation","errorClass":"IngestError::Rejected","httpStatus":null,"severity":"error","filePath":"crates/buzz-relay/src/handlers/side_effects.rs","lineNumber":359,"sourceCode":"            // member that preserves the role they already hold, and only defaults\n            // to Member for a genuinely new member. Defaulting unconditionally to\n            // Member made a bare self-targeted PUT_USER silently demote an owner.\n            let role_str = extract_tag_value(event, \"role\");\n            let requested_role = match role_str {\n                Some(ref s) => match s.parse::<buzz_db::channel::MemberRole>() {\n                    Ok(r) => Some(r),\n                    Err(_) => return Err(anyhow::anyhow!(\"invalid role: {s}\")),\n                },\n                None => None,\n            };\n\n            let members = state.db.get_members(tenant.community(), channel_id).await?;\n            let actor_role: Option<buzz_db::channel::MemberRole> = members\n                .iter()\n                .find(|m| m.pubkey == actor_bytes)\n                .and_then(|m| m.role.parse().ok());\n            let target_pubkey =\n                extract_p_tag(event).ok_or_else(|| anyhow::anyhow!(\"missing p tag\"))?;\n\n            // PUT_USER: open channels allow any authenticated user; private channels\n            // require the actor to be an existing active member. Any active member may\n            // add an ordinary member, guest, or bot, but only owners/admins may grant\n            // an elevated role.\n            if channel.visibility == \"private\" {\n                if actor_role.is_none() {\n                    return Err(anyhow::anyhow!(\"actor not authorized\"));\n                }\n\n                if requested_role.is_some_and(|role| role.is_elevated())\n                    && !actor_role.is_some_and(|role| role.is_elevated())\n                {\n                    return Err(anyhow::anyhow!(\n                        \"only owners/admins may grant elevated roles\"\n                    ));\n                }\n            }","sourceCodeStart":341,"sourceCodeEnd":377,"githubUrl":"https://github.com/block/buzz/blob/f956e6fe06a76e50cbd8fba1a162482e752e7f1a/crates/buzz-relay/src/handlers/side_effects.rs#L341-L377","documentation":"A kind 9000 PUT_USER event had no usable 'p' tag: extract_p_tag() requires a p tag whose content hex-decodes to exactly 32 bytes, and none matched. The p tag names the member being added/updated, so without it the relay cannot tell whose membership to mutate and rejects the event before storage.","triggerScenarios":"PUT_USER published with only h and role tags; p tag value is an npub (bech32) instead of 64-char hex; p tag hex is truncated (not 32 bytes) or has a 0x prefix; tag kind serialized as 'P' (uppercase); multiple p tags where the first is malformed — extraction stops at the first p tag and returns None if it fails.","commonSituations":"Pasting NIP-19 npub strings from a UI into tag builders; key strings trimmed/corrupted in transit; fixtures reusing a placeholder like \"deadbeef\" that is not 64 hex chars.","solutions":["Add [\"p\", \"<64-char hex pubkey>\"] as the target of the membership change","Convert npub → hex before building the tag (NIP-19 decode)","Ensure the p tag is the first p tag and its value round-trips: hex::decode(...).len() == 32"],"exampleFix":"// before\nEventBuilder::new(Kind::from(9000), \"\", [\n    Tag::custom(TagKind::Custom(\"h\"), vec![channel_uuid]),\n    Tag::custom(TagKind::Custom(\"role\"), vec![\"member\"]),\n    Tag::custom(TagKind::Custom(\"p\"), vec![npub_string]), // bech32, not hex\n])\n\n// after\nlet pk_hex = hex::encode(nip19::decode(&npub_string)?.1.to_bytes());\nTag::custom(TagKind::Custom(\"p\"), vec![pk_hex])","handlingStrategy":"validation","validationCode":"// Mirror extract_p_tag before publishing a 9000\nfn valid_p_tag(tags: &[Tag]) -> bool {\n    tags.iter().any(|t| {\n        t.kind().to_string() == \"p\"\n            && t.content()\n                .and_then(|v| hex::decode(v).ok())\n                .map(|b| b.len() == 32)\n                .unwrap_or(false)\n    })\n}\nassert!(valid_p_tag(&event.tags), \"PUT_USER needs p = 64-hex pubkey\");","typeGuard":"const isValidPTag = (tags: string[][]): boolean =>\n  tags.some(t => t[0] === \"p\" && /^[0-9a-f]{64}$/.test(t[1] ?? \"\"));","tryCatchPattern":"match validate_admin_event(&tenant, 9000, &event, &state).await {\n    Err(e) if e.to_string().contains(\"missing p tag\") => {\n        reject(\"PUT_USER requires [\\\"p\\\", \\\"<64-hex pubkey>\\\"]\", &event.id)\n    }\n    other => other,\n}","preventionTips":["Convert all user keys to hex at input; never paste npub strings into tag builders","Make the target user a required parameter of your put_user builder so it cannot be omitted","Remember the first p tag wins for extraction — keep the target as the only/first p tag"],"tags":["nostr","nip-29","group-admin","p-tag","validation"],"backgroundTag":"missing-p-tag","analyzedSha":"f956e6fe06a76e50cbd8fba1a162482e752e7f1a","analyzedAt":"2026-08-16T22:11:40.750Z","schemaVersion":2},"datasetVersion":"2026-08-16T23:17:17.608Z"}