mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(acp): gate relay-signed workflow messages on their attributed author (#6129)
## Problem Scheduled workflow `send_message` actions fire and land in the channel with correct `p` tags for the mentioned agents — but the agents never wake. The wake-up is silently dropped. **Root cause:** workflow messages are signed by the **relay keypair** (`workflow_sink.rs` signs with `state.relay_keypair`), so `event.pubkey` is the relay's pubkey, not the workflow owner. In `buzz-acp`, the inbound author gate (`author_allowed`) runs **before** the `p`-tag mention check. Under the default `respond_to = owner-only`, the relay pubkey is neither the owner nor a sibling, so every workflow wake-up dies at the gate with a debug-level `"inbound author gate — dropping event"`. The relay-side comment even says the mention `p` tags exist *"so mentioned agents are woken (wake is p-tag gated)"* — but wake is also author-gated, and that path was missed. ## Fix Gate relay-signed workflow messages on their **attributed author** — the pubkey that created the workflow — instead of the relay pubkey: - **Relay:** `workflow_sink.rs` now emits an explicit `buzz:workflow-owner` tag carrying `workflow.owner_pubkey` (the workflow creator, which the executor already passes as `author_pubkey` and whose channel access the relay verifies before emitting). Ownership is never inferred from `p`-tag order; mention `p` tags play no role in attribution. - **Harness:** at startup, `buzz-acp` fetches the relay's NIP-11 `self` pubkey (new `RestClient::fetch_relay_self`, public `/info` endpoint). Best-effort: fetch failure just logs a warning and preserves pre-fix behavior. - **Gate:** an event that is (a) authored by the relay `self` key, (b) tagged `buzz:workflow`, and (c) carries a well-formed `buzz:workflow-owner` pubkey is gated on that owner, through the exact same owner/sibling/allowlist policy as a direct author. ## Security notes (all fail closed) - No NIP-11 `self` pubkey → no exemption. - `buzz:workflow` / `buzz:workflow-owner` tags on a non-relay-signed event → ignored (a member cannot forge the exemption; the relay verifies signatures on submission and only the relay holds its key). - Relay-signed event without the tags, or with a malformed owner value (not 64-hex) → plain author gate. - Who is @mentioned in the message has no bearing on whose authority is evaluated. - A workflow owned by a random channel member still cannot wake an owner-only agent — the owner's pubkey must pass the same policy. ## Testing - 7 unit tests (`workflow_attributed_author_tests`) covering attribution, fail-closed paths, p-tag independence, malformed owner values, and the forgery case. - Extended the PG-gated `workflow_send_message_p_tags_mentioned_member` integration test to assert the `buzz:workflow-owner` tag. - `cargo test -p buzz-acp`: 785 passed, 0 failed. `cargo test -p buzz-relay --lib workflow_sink`: 17 passed. Clippy + fmt clean. (9 pre-existing `buzz-relay` failures in unrelated `api::media`/`api::admin` tests fail identically on the base commit without this change.) Found while debugging scheduled automations in a Buzz review-pipeline channel: two cron workflows fired daily @mentions at agents that never responded, while direct human @mentions woke them instantly. --------- Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Fizz <3a9f8a30fbb462abec1e2977b2280a7ae50c7ff794433790be15bd48bfd52d0b@buzz.block.builderlab.xyz>
This commit is contained in:
+389
-1
@@ -2025,6 +2025,19 @@ async fn tokio_main() -> Result<()> {
|
||||
}
|
||||
let owner_cache = OwnerCache::new(startup_owner.clone());
|
||||
|
||||
// Relay `self` pubkey (NIP-11), used to recognize relay-signed workflow
|
||||
// messages in the inbound author gate. Best-effort: `None` simply means
|
||||
// workflow messages get no attributed-author exemption (pre-fix behavior),
|
||||
// so a fetch failure degrades gracefully instead of blocking startup.
|
||||
let relay_self: Option<String> = relay.rest_client().fetch_relay_self().await;
|
||||
match &relay_self {
|
||||
Some(pk) => tracing::info!("relay self pubkey: {pk}"),
|
||||
None => tracing::warn!(
|
||||
"relay self pubkey unavailable (NIP-11 fetch failed or no stable relay key) — \
|
||||
relay-signed workflow messages will be dropped by the author gate"
|
||||
),
|
||||
}
|
||||
|
||||
let mut relay_observer_control_rx = None;
|
||||
let mut relay_observer_publisher_task = None;
|
||||
let mut relay_observer_publisher = None;
|
||||
@@ -2836,7 +2849,31 @@ async fn tokio_main() -> Result<()> {
|
||||
// explicit pubkey list on top, for external people;
|
||||
// it never revokes same-owner team bots.
|
||||
{
|
||||
let author = buzz_event.event.pubkey.to_hex();
|
||||
// Relay-signed workflow messages (workflow
|
||||
// `send_message` actions) are authored by the
|
||||
// relay keypair, not the workflow owner — the
|
||||
// plain author gate would drop them and the
|
||||
// scheduled @mention would silently never wake
|
||||
// the agent. Gate them on their *attributed*
|
||||
// author (the `buzz:workflow-owner` tag — the
|
||||
// pubkey that created the workflow) instead.
|
||||
// See `workflow_attributed_author`
|
||||
// for the recognition + trust argument.
|
||||
let author = match workflow_attributed_author(
|
||||
&buzz_event.event,
|
||||
relay_self.as_deref(),
|
||||
) {
|
||||
Some(attributed) => {
|
||||
tracing::debug!(
|
||||
channel_id = %buzz_event.channel_id,
|
||||
relay_author = %buzz_event.event.pubkey.to_hex(),
|
||||
attributed_author = %attributed,
|
||||
"relay-signed workflow message — gating on attributed author"
|
||||
);
|
||||
attributed
|
||||
}
|
||||
None => buzz_event.event.pubkey.to_hex(),
|
||||
};
|
||||
// DM hardening: resolve channel type (fail-closed
|
||||
// to DM) so allowlist/anyone modes cannot be
|
||||
// exercised by non-owner authors inside DMs.
|
||||
@@ -3517,6 +3554,90 @@ fn event_mentions_agent(event: &nostr::Event, agent_pubkey_hex: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// If `event` is a relay-signed workflow message, return its *attributed*
|
||||
/// author for inbound author gating; otherwise `None`.
|
||||
///
|
||||
/// Workflow `send_message` actions are signed by the **relay keypair**
|
||||
/// (`event.pubkey` = the relay's NIP-11 `self` key), not by the human who owns
|
||||
/// the workflow — so the plain author gate would drop them even though they
|
||||
/// carry `p` tags meant to wake mentioned agents. The relay attributes the
|
||||
/// message to the **workflow owner** (the pubkey that created the workflow,
|
||||
/// `workflow.owner_pubkey` relay-side) via the explicit `buzz:workflow-owner`
|
||||
/// tag emitted by `workflow_sink.rs`, and it has already verified that owner's
|
||||
/// access to the destination channel before emitting the event.
|
||||
///
|
||||
/// Recognition requires ALL of the following, failing closed otherwise:
|
||||
/// 1. kind `9` (stream message) — the only kind the workflow sink emits;
|
||||
/// 2. a known, syntactically valid relay `self` pubkey (fetched from NIP-11
|
||||
/// at startup) — no `relay_self`, no exemption;
|
||||
/// 3. `event.pubkey` == relay `self`, with a **valid event signature**
|
||||
/// verified here. The relay verifies signatures on submission, but this
|
||||
/// gate re-checks locally so the exemption never rests on an upstream
|
||||
/// guarantee it can't see;
|
||||
/// 4. **exactly one** tag exactly equal to `["buzz:workflow", "true"]` — no
|
||||
/// duplicates, no extra fields, no other value;
|
||||
/// 5. **exactly one** tag exactly equal to `["buzz:workflow-owner", <pubkey>]`
|
||||
/// where the owner parses as a full pubkey — no duplicates, no extra
|
||||
/// fields. Mention `p` tags are never used for attribution, so who is
|
||||
/// @mentioned in the message text has no bearing on whose authority the
|
||||
/// gate evaluates.
|
||||
///
|
||||
/// The returned pubkey is gated exactly like a direct author: owner/sibling
|
||||
/// under `owner-only`, plus the explicit list under `allowlist`. A workflow
|
||||
/// owned by a random channel member therefore still cannot wake an
|
||||
/// owner-only agent.
|
||||
fn workflow_attributed_author(event: &nostr::Event, relay_self: Option<&str>) -> Option<String> {
|
||||
// 1. Kind gate first — cheapest check, and everything below only makes
|
||||
// sense for the kind:9 messages the workflow sink emits.
|
||||
if event.kind.as_u16() as u32 != KIND_STREAM_MESSAGE {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 2. Relay identity must be known AND syntactically valid.
|
||||
let relay_self = nostr::PublicKey::from_hex(relay_self?).ok()?;
|
||||
if event.pubkey != relay_self {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 4. Exactly one marker tag, exactly ["buzz:workflow", "true"]. Collect
|
||||
// every tag with the marker key so duplicates or shape/value mismatches
|
||||
// (extra fields, wrong value) disqualify instead of being skipped over.
|
||||
let markers: Vec<&[String]> = event
|
||||
.tags
|
||||
.iter()
|
||||
.map(|t| t.as_slice())
|
||||
.filter(|s| s.first().map(|k| k.as_str()) == Some("buzz:workflow"))
|
||||
.collect();
|
||||
if markers.len() != 1 || markers[0] != ["buzz:workflow", "true"] {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 5. Exactly one owner tag, exactly ["buzz:workflow-owner", <pubkey>].
|
||||
// The owner must parse as a full pubkey — not merely look hex-ish —
|
||||
// before it is fed into the owner/sibling/allowlist comparison.
|
||||
let owners: Vec<&[String]> = event
|
||||
.tags
|
||||
.iter()
|
||||
.map(|t| t.as_slice())
|
||||
.filter(|s| s.first().map(|k| k.as_str()) == Some("buzz:workflow-owner"))
|
||||
.collect();
|
||||
let [owner_tag] = owners.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
let [_, owner_value] = owner_tag else {
|
||||
return None;
|
||||
};
|
||||
let owner = nostr::PublicKey::from_hex(owner_value).ok()?;
|
||||
|
||||
// 3. Signature check last — it is the most expensive step, so only pay
|
||||
// for it once every structural requirement has already passed.
|
||||
if event.verify().is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(owner.to_hex())
|
||||
}
|
||||
|
||||
fn is_owner_control_command(
|
||||
event: &nostr::Event,
|
||||
kind_u32: u32,
|
||||
@@ -5673,6 +5794,273 @@ mod author_gate_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod workflow_attributed_author_tests {
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
|
||||
/// Build a kind:9 event signed by `signer` with the given extra tags.
|
||||
fn make_event(signer: &Keys, tags: Vec<Tag>) -> nostr::Event {
|
||||
EventBuilder::new(Kind::from(KIND_STREAM_MESSAGE as u16), "wake up")
|
||||
.tags(tags)
|
||||
.sign_with_keys(signer)
|
||||
.expect("sign test event")
|
||||
}
|
||||
|
||||
fn workflow_tags(owner_hex: &str, mention_hex: &str) -> Vec<Tag> {
|
||||
vec![
|
||||
Tag::parse(["p", owner_hex]).unwrap(),
|
||||
Tag::parse(["h", "3204e3f9-fd09-4e95-b749-76966794c287"]).unwrap(),
|
||||
Tag::parse(["buzz:workflow", "true"]).unwrap(),
|
||||
Tag::parse(["buzz:workflow-owner", owner_hex]).unwrap(),
|
||||
Tag::parse(["p", mention_hex]).unwrap(),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_signed_workflow_message_attributes_to_workflow_owner_tag() {
|
||||
let relay = Keys::generate();
|
||||
let owner = Keys::generate().public_key().to_hex();
|
||||
let agent = Keys::generate().public_key().to_hex();
|
||||
let event = make_event(&relay, workflow_tags(&owner, &agent));
|
||||
assert_eq!(
|
||||
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
|
||||
Some(owner),
|
||||
"a relay-signed buzz:workflow event must attribute to the \
|
||||
buzz:workflow-owner tag, not any mentioned agent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attribution_ignores_p_tags_entirely() {
|
||||
// Only the explicit buzz:workflow-owner tag attributes; p tags
|
||||
// (owner attribution + mentions) must have no effect on the gate.
|
||||
let relay = Keys::generate();
|
||||
let someone = Keys::generate().public_key().to_hex();
|
||||
let event = make_event(
|
||||
&relay,
|
||||
vec![
|
||||
Tag::parse(["p", &someone]).unwrap(),
|
||||
Tag::parse(["buzz:workflow", "true"]).unwrap(),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
|
||||
None,
|
||||
"without a buzz:workflow-owner tag there is no attributed author, \
|
||||
even when p tags are present"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_owner_tag_value_attributes_to_no_one() {
|
||||
let relay = Keys::generate();
|
||||
let event = make_event(
|
||||
&relay,
|
||||
vec![
|
||||
Tag::parse(["buzz:workflow", "true"]).unwrap(),
|
||||
Tag::parse(["buzz:workflow-owner", "not-a-pubkey"]).unwrap(),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
|
||||
None,
|
||||
"a buzz:workflow-owner value that is not 64-hex must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_relay_self_means_no_exemption() {
|
||||
let relay = Keys::generate();
|
||||
let owner = Keys::generate().public_key().to_hex();
|
||||
let agent = Keys::generate().public_key().to_hex();
|
||||
let event = make_event(&relay, workflow_tags(&owner, &agent));
|
||||
assert_eq!(
|
||||
workflow_attributed_author(&event, None),
|
||||
None,
|
||||
"without a known relay self pubkey the exemption must not apply (fail closed)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_relay_author_gets_no_exemption_even_with_workflow_tag() {
|
||||
// A member forging the buzz:workflow tag on their own event must not
|
||||
// be able to attribute it to someone else via a p tag.
|
||||
let forger = Keys::generate();
|
||||
let relay = Keys::generate();
|
||||
let owner = Keys::generate().public_key().to_hex();
|
||||
let agent = Keys::generate().public_key().to_hex();
|
||||
let event = make_event(&forger, workflow_tags(&owner, &agent));
|
||||
assert_eq!(
|
||||
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
|
||||
None,
|
||||
"a buzz:workflow tag on a non-relay-signed event must be ignored"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_signed_message_without_workflow_tag_gets_no_exemption() {
|
||||
let relay = Keys::generate();
|
||||
let owner = Keys::generate().public_key().to_hex();
|
||||
let event = make_event(&relay, vec![Tag::parse(["p", &owner]).unwrap()]);
|
||||
assert_eq!(
|
||||
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
|
||||
None,
|
||||
"relay-signed events without the buzz:workflow tag keep the plain author gate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_message_without_owner_tag_attributes_to_no_one() {
|
||||
let relay = Keys::generate();
|
||||
let event = make_event(&relay, vec![Tag::parse(["buzz:workflow", "true"]).unwrap()]);
|
||||
assert_eq!(
|
||||
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
|
||||
None,
|
||||
"a workflow message with no buzz:workflow-owner tag has no attributed \
|
||||
author and must fall through to the plain (relay-pubkey) author gate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_marker_tags_disqualify() {
|
||||
let relay = Keys::generate();
|
||||
let owner = Keys::generate().public_key().to_hex();
|
||||
let event = make_event(
|
||||
&relay,
|
||||
vec![
|
||||
Tag::parse(["buzz:workflow", "true"]).unwrap(),
|
||||
Tag::parse(["buzz:workflow", "true"]).unwrap(),
|
||||
Tag::parse(["buzz:workflow-owner", &owner]).unwrap(),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
|
||||
None,
|
||||
"more than one buzz:workflow marker tag must fail closed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_value_mismatch_disqualifies() {
|
||||
let relay = Keys::generate();
|
||||
let owner = Keys::generate().public_key().to_hex();
|
||||
for bad_marker in [
|
||||
Tag::parse(["buzz:workflow", "false"]).unwrap(),
|
||||
Tag::parse(["buzz:workflow"]).unwrap(),
|
||||
Tag::parse(["buzz:workflow", "true", "extra"]).unwrap(),
|
||||
] {
|
||||
let event = make_event(
|
||||
&relay,
|
||||
vec![
|
||||
bad_marker.clone(),
|
||||
Tag::parse(["buzz:workflow-owner", &owner]).unwrap(),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
|
||||
None,
|
||||
"marker tag {:?} is not exactly [\"buzz:workflow\", \"true\"] and must fail closed",
|
||||
bad_marker.as_slice()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_owner_tags_disqualify() {
|
||||
// Two owner tags — even with identical values — are ambiguous
|
||||
// provenance and must not attribute to anyone.
|
||||
let relay = Keys::generate();
|
||||
let owner = Keys::generate().public_key().to_hex();
|
||||
let other = Keys::generate().public_key().to_hex();
|
||||
for second_owner in [&owner, &other] {
|
||||
let event = make_event(
|
||||
&relay,
|
||||
vec![
|
||||
Tag::parse(["buzz:workflow", "true"]).unwrap(),
|
||||
Tag::parse(["buzz:workflow-owner", &owner]).unwrap(),
|
||||
Tag::parse(["buzz:workflow-owner", second_owner]).unwrap(),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
|
||||
None,
|
||||
"duplicate buzz:workflow-owner tags must fail closed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_tag_with_extra_fields_disqualifies() {
|
||||
let relay = Keys::generate();
|
||||
let owner = Keys::generate().public_key().to_hex();
|
||||
let event = make_event(
|
||||
&relay,
|
||||
vec![
|
||||
Tag::parse(["buzz:workflow", "true"]).unwrap(),
|
||||
Tag::parse(["buzz:workflow-owner", &owner, "extra"]).unwrap(),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
|
||||
None,
|
||||
"an owner tag with extra fields is not the exact shape the relay \
|
||||
emits and must fail closed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_kind_disqualifies() {
|
||||
let relay = Keys::generate();
|
||||
let owner = Keys::generate().public_key().to_hex();
|
||||
let agent = Keys::generate().public_key().to_hex();
|
||||
let event = EventBuilder::new(Kind::from(1u16), "wake up")
|
||||
.tags(workflow_tags(&owner, &agent))
|
||||
.sign_with_keys(&relay)
|
||||
.expect("sign test event");
|
||||
assert_eq!(
|
||||
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
|
||||
None,
|
||||
"only kind:9 stream messages may use the workflow exemption"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_event_fails_signature_check() {
|
||||
// Alter the content after signing: pubkey still matches relay_self
|
||||
// and the tags are pristine, but the signature no longer covers the
|
||||
// event — the local verify must reject it.
|
||||
let relay = Keys::generate();
|
||||
let owner = Keys::generate().public_key().to_hex();
|
||||
let agent = Keys::generate().public_key().to_hex();
|
||||
let event = make_event(&relay, workflow_tags(&owner, &agent));
|
||||
let mut json = serde_json::to_value(&event).expect("event to JSON");
|
||||
json["content"] = serde_json::Value::String("tampered".into());
|
||||
let tampered: nostr::Event = serde_json::from_value(json).expect("tampered event parses");
|
||||
assert_eq!(
|
||||
workflow_attributed_author(&tampered, Some(&relay.public_key().to_hex())),
|
||||
None,
|
||||
"a tampered event must fail the local signature check"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntactically_invalid_relay_self_means_no_exemption() {
|
||||
let relay = Keys::generate();
|
||||
let owner = Keys::generate().public_key().to_hex();
|
||||
let agent = Keys::generate().public_key().to_hex();
|
||||
let event = make_event(&relay, workflow_tags(&owner, &agent));
|
||||
let long_not_hex = "zz".repeat(32);
|
||||
for bad_self in ["", "not-hex", long_not_hex.as_str()] {
|
||||
assert_eq!(
|
||||
workflow_attributed_author(&event, Some(bad_self)),
|
||||
None,
|
||||
"an invalid NIP-11 self value {bad_self:?} must disable the exemption"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod observer_snapshot_race_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -410,6 +410,37 @@ impl RestClient {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Fetch the relay's own signing pubkey from the public NIP-11 `/info`
|
||||
/// document (the `self` field, hex, normalized to lowercase).
|
||||
///
|
||||
/// Used by the inbound author gate to recognize relay-signed workflow
|
||||
/// messages (`buzz:workflow`-tagged kind:9 events authored by the relay
|
||||
/// keypair) and gate them on their *attributed* author instead.
|
||||
///
|
||||
/// Returns `None` when the document is unreachable, unparseable, or has
|
||||
/// no valid `self` field (e.g. the relay runs with an ephemeral key).
|
||||
/// Callers must treat `None` as "no relay-signed exemption" — fail closed
|
||||
/// to the plain author gate, never guess a pubkey.
|
||||
pub async fn fetch_relay_self(&self) -> Option<String> {
|
||||
let url = format!("{}/info", self.base_url);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("Accept", "application/nostr+json")
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let doc: Value = resp.json().await.ok()?;
|
||||
let self_hex = doc.get("self")?.as_str()?;
|
||||
if self_hex.len() != 64 || !self_hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
Some(self_hex.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// Query events via the HTTP bridge: `POST /query` with NIP-98 auth.
|
||||
///
|
||||
/// Accepts a slice of `nostr::Filter` (serialized as JSON array).
|
||||
|
||||
@@ -255,6 +255,9 @@ impl ActionSink for RelayActionSink {
|
||||
// - `p` tag attributes the message to the workflow owner
|
||||
// - `h` tag scopes to the channel (NIP-29, canonical UUID)
|
||||
// - `buzz:workflow` tag prevents recursive workflow triggering
|
||||
// - `buzz:workflow-owner` tag names the workflow owner explicitly,
|
||||
// so consumers (e.g. the ACP inbound author gate) can attribute
|
||||
// the message without inferring ownership from `p`-tag order
|
||||
// - one `p` tag per `@Name` that resolves to a channel member,
|
||||
// so mentioned agents are woken (wake is `p`-tag gated)
|
||||
let mut tags = vec![
|
||||
@@ -264,6 +267,8 @@ impl ActionSink for RelayActionSink {
|
||||
.map_err(|e| ActionSinkError::EventBuild(format!("h tag: {e}")))?,
|
||||
Tag::parse(["buzz:workflow", "true"])
|
||||
.map_err(|e| ActionSinkError::EventBuild(format!("workflow tag: {e}")))?,
|
||||
Tag::parse(["buzz:workflow-owner", &author_pubkey_hex])
|
||||
.map_err(|e| ActionSinkError::EventBuild(format!("workflow owner tag: {e}")))?,
|
||||
];
|
||||
|
||||
// Resolve `@Name` mentions to channel-member pubkeys and append a
|
||||
@@ -707,5 +712,18 @@ mod integration_tests {
|
||||
p_tag_targets.contains(&agent_hex.as_str()),
|
||||
"mentioned member {agent_hex} must be p-tagged so it wakes; got {p_tag_targets:?}"
|
||||
);
|
||||
|
||||
let owner_tag = stored
|
||||
.event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|t| t.as_slice().first().map(|s| s.as_str()) == Some("buzz:workflow-owner"))
|
||||
.and_then(|t| t.as_slice().get(1).map(|s| s.as_str()));
|
||||
assert_eq!(
|
||||
owner_tag,
|
||||
Some(author_hex.as_str()),
|
||||
"workflow owner must be named explicitly via buzz:workflow-owner \
|
||||
so consumers never infer ownership from p-tag order"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user