mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
broker: make validation the only door to a response
Four follow-ups from the delta review, all about closing the gap between what
the contract says and what it structurally allows.
`PreparedRequest::request()` is gone. It handed back the typed value whose
reserialization freezing the bytes exists to prevent, so an implementation
could have sent a second rendering of the same request and drawn a spurious
requestId conflict on retry. A transport needs correlation metadata, not the
value: `request_id()` and `action()` remain.
Response correlation is no longer advisory. `BrokerClient` is now the transport
primitive — `send` takes frozen bytes and returns an envelope unjudged — and
the blanket, unoverridable `BrokerClientExt::execute` is what callers use. It
runs `validate_for` and yields a `ValidatedResponse`, the only response type a
caller can obtain, so a mismatched envelope cannot arrive as `Ok` because an
implementation forgot a step. `send` additionally takes a `Dispatch` token
whose field is private to this module, which is what stops a caller from
skipping `execute` and calling the primitive itself; verified by probing all
three construction paths (tuple constructor, `Default`, struct literal) from
outside the crate. It does not defend against an implementation that stashes
its own input — that is deliberate code, not a forgotten check.
`actions.rs` is split into `actions/{mod,args,outcomes}.rs` for review
ergonomics: `Action` and the shared validators in the parent, one file per side
of a call. No logic moved — the item set is identical and the only content
changes are intra-doc link paths.
Dropped `only_housekeeping_actions_are_best_effort`, which asserted a constant
against itself.
Addresses the interface half of #6467.
Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
499
crates/buzz-sdk/src/broker/actions/args.rs
Normal file
499
crates/buzz-sdk/src/broker/actions/args.rs
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
//! Argument types — one per [`Action`], plus the tagged union that pairs a
|
||||||
|
//! wire action name with its arguments.
|
||||||
|
//!
|
||||||
|
//! Every type here is `deny_unknown_fields`, so a credential or environment map
|
||||||
|
//! smuggled into a payload fails to deserialize rather than reaching an
|
||||||
|
//! executor. Each carries a `validated()` returning a normalized copy; the
|
||||||
|
//! shared validators live in the [parent module](super).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
channel, content, cursor, event_id, is_false, limit, mentions, optional, required, respond_to,
|
||||||
|
validate_slug, Action, PubkeyHex, MAX_ABOUT_CHARS, MAX_EMOJI_CHARS, MAX_NAME_CHARS,
|
||||||
|
MAX_PROMPT_CHARS, MAX_SCALAR_CHARS,
|
||||||
|
};
|
||||||
|
use crate::SdkError;
|
||||||
|
|
||||||
|
/// Arguments for `channel.read` — the one read action.
|
||||||
|
///
|
||||||
|
/// Reads are actions like any other, because #6467 requires a host with no
|
||||||
|
/// relay route of its own to be able to serve them.
|
||||||
|
///
|
||||||
|
/// One action covers channel, thread, and mention-feed scope, because they
|
||||||
|
/// differ only by filter and a name per scope would split one permission —
|
||||||
|
/// *may this agent see this channel* — across three policy decisions.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct ChannelReadArgs {
|
||||||
|
/// Channel to read.
|
||||||
|
pub channel_id: String,
|
||||||
|
/// Narrow to one thread by its root event.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub root_event_id: Option<String>,
|
||||||
|
/// Narrow to messages mentioning the requester — the wake path.
|
||||||
|
///
|
||||||
|
/// The requester is never named; see [`crate::broker::BrokerRequest`] on why no body
|
||||||
|
/// names its own subject.
|
||||||
|
#[serde(default, skip_serializing_if = "is_false")]
|
||||||
|
pub mentions_only: bool,
|
||||||
|
/// Opaque position to resume from, as returned in [`super::outcomes::MessagePage::next_cursor`].
|
||||||
|
///
|
||||||
|
/// Absent on a first read, which starts at the host's default window. A
|
||||||
|
/// cursor is **opaque**: callers must round-trip it verbatim and must not
|
||||||
|
/// parse, compare, or synthesize one. That is deliberate — a timestamp
|
||||||
|
/// cursor cannot page safely when more events than `limit` share one
|
||||||
|
/// second, and it would commit every future host to one relay ordering
|
||||||
|
/// strategy. The host defines ordering and cursor stability, including
|
||||||
|
/// whether a cursor stays valid across restarts.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cursor: Option<String>,
|
||||||
|
/// Maximum events to return, capped at [`super::MAX_PAGE_LIMIT`].
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelReadArgs {
|
||||||
|
/// Read a whole channel from the host's default window.
|
||||||
|
#[must_use]
|
||||||
|
pub fn channel(channel_id: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
channel_id: channel_id.into(),
|
||||||
|
..Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate and normalize.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`SdkError::InvalidInput`] for a malformed channel UUID, a
|
||||||
|
/// malformed root event id, an over-long or non-printable cursor, or an
|
||||||
|
/// out-of-range limit.
|
||||||
|
pub fn validated(&self) -> Result<Self, SdkError> {
|
||||||
|
Ok(Self {
|
||||||
|
channel_id: channel(&self.channel_id)?,
|
||||||
|
root_event_id: self
|
||||||
|
.root_event_id
|
||||||
|
.as_deref()
|
||||||
|
.map(|id| event_id(id, "rootEventId"))
|
||||||
|
.transpose()?,
|
||||||
|
mentions_only: self.mentions_only,
|
||||||
|
cursor: self.cursor.as_deref().map(cursor).transpose()?,
|
||||||
|
limit: limit(self.limit)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Write arguments ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Arguments for `message.post`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct MessagePostArgs {
|
||||||
|
/// Channel to post in.
|
||||||
|
pub channel_id: String,
|
||||||
|
/// Message body.
|
||||||
|
pub content: String,
|
||||||
|
/// Pubkeys to notify.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub mentions: Vec<PubkeyHex>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessagePostArgs {
|
||||||
|
/// Validate and normalize.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`SdkError::InvalidInput`] for a malformed channel UUID or empty
|
||||||
|
/// content, [`SdkError::ContentTooLarge`] for oversized content, and
|
||||||
|
/// [`SdkError::TooManyMentions`] past [`super::MAX_MENTIONS`].
|
||||||
|
pub fn validated(&self) -> Result<Self, SdkError> {
|
||||||
|
Ok(Self {
|
||||||
|
channel_id: channel(&self.channel_id)?,
|
||||||
|
content: content(&self.content)?,
|
||||||
|
mentions: mentions(&self.mentions)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arguments for `message.reply`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct MessageReplyArgs {
|
||||||
|
/// Channel containing the parent.
|
||||||
|
pub channel_id: String,
|
||||||
|
/// Event being replied to.
|
||||||
|
pub reply_to_event_id: String,
|
||||||
|
/// Reply body.
|
||||||
|
pub content: String,
|
||||||
|
/// Pubkeys to notify.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub mentions: Vec<PubkeyHex>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessageReplyArgs {
|
||||||
|
/// Validate and normalize.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`SdkError::InvalidInput`] for a malformed channel UUID, a
|
||||||
|
/// malformed event id, or empty content; [`SdkError::ContentTooLarge`] for
|
||||||
|
/// oversized content; [`SdkError::TooManyMentions`] past [`super::MAX_MENTIONS`].
|
||||||
|
pub fn validated(&self) -> Result<Self, SdkError> {
|
||||||
|
Ok(Self {
|
||||||
|
channel_id: channel(&self.channel_id)?,
|
||||||
|
reply_to_event_id: event_id(&self.reply_to_event_id, "replyToEventId")?,
|
||||||
|
content: content(&self.content)?,
|
||||||
|
mentions: mentions(&self.mentions)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arguments for `reaction.add`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct ReactionAddArgs {
|
||||||
|
/// Channel containing the target.
|
||||||
|
pub channel_id: String,
|
||||||
|
/// Event being reacted to.
|
||||||
|
pub target_event_id: String,
|
||||||
|
/// Reaction payload — an emoji or a `:shortcode:`.
|
||||||
|
pub reaction: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReactionAddArgs {
|
||||||
|
/// Validate and normalize.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`SdkError::InvalidInput`] for a malformed channel UUID, a
|
||||||
|
/// malformed event id, or an empty reaction, and [`SdkError::EmojiTooLong`]
|
||||||
|
/// past [`MAX_EMOJI_CHARS`].
|
||||||
|
pub fn validated(&self) -> Result<Self, SdkError> {
|
||||||
|
let reaction = self.reaction.trim();
|
||||||
|
if reaction.is_empty() {
|
||||||
|
return Err(SdkError::InvalidInput("reaction must not be empty".into()));
|
||||||
|
}
|
||||||
|
if reaction.chars().count() > MAX_EMOJI_CHARS {
|
||||||
|
return Err(SdkError::EmojiTooLong);
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
channel_id: channel(&self.channel_id)?,
|
||||||
|
target_event_id: event_id(&self.target_event_id, "targetEventId")?,
|
||||||
|
reaction: reaction.to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arguments for `profile.set`.
|
||||||
|
///
|
||||||
|
/// Only the requester's own profile is addressable, so there is no subject
|
||||||
|
/// field. Absent fields are left as they are; the host does not clear them.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct ProfileSetArgs {
|
||||||
|
/// Replacement display name.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
/// Replacement bio.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub about: Option<String>,
|
||||||
|
/// Replacement avatar URL.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub picture: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfileSetArgs {
|
||||||
|
/// Validate and normalize, requiring at least one field to change.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`SdkError::InvalidInput`] for an over-long field or a request
|
||||||
|
/// that changes nothing.
|
||||||
|
pub fn validated(&self) -> Result<Self, SdkError> {
|
||||||
|
let normalized = Self {
|
||||||
|
display_name: optional(self.display_name.as_ref(), "display name", MAX_NAME_CHARS)?,
|
||||||
|
about: optional(self.about.as_ref(), "about", MAX_ABOUT_CHARS)?,
|
||||||
|
picture: optional(self.picture.as_ref(), "picture", MAX_SCALAR_CHARS)?,
|
||||||
|
};
|
||||||
|
if normalized.display_name.is_none()
|
||||||
|
&& normalized.about.is_none()
|
||||||
|
&& normalized.picture.is_none()
|
||||||
|
{
|
||||||
|
return Err(SdkError::InvalidInput(
|
||||||
|
"include at least one profile field to set".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arguments for `storage.address`.
|
||||||
|
///
|
||||||
|
/// Deriving a record's address needs the secret this contract exists to avoid
|
||||||
|
/// holding, which is why #6467 lists it among the operations to route through
|
||||||
|
/// the interface rather than compute locally.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct StorageAddressArgs {
|
||||||
|
/// Memory slug — `core` or `mem/…`, per NIP-AE.
|
||||||
|
pub slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StorageAddressArgs {
|
||||||
|
/// Validate and normalize.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`SdkError::InvalidInput`] when the slug fails the NIP-AE
|
||||||
|
/// grammar.
|
||||||
|
pub fn validated(&self) -> Result<Self, SdkError> {
|
||||||
|
let slug = required(&self.slug, "slug", 255)?;
|
||||||
|
validate_slug(&slug).map_err(|e| SdkError::InvalidInput(e.to_string()))?;
|
||||||
|
Ok(Self { slug })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agent arguments ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Which agent an update or delete targets — exactly one selector, so a host
|
||||||
|
/// never has to guess which of two names wins.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum AgentTarget {
|
||||||
|
/// Target by agent pubkey.
|
||||||
|
Pubkey(PubkeyHex),
|
||||||
|
/// Target by the agent's current name.
|
||||||
|
Name(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentTarget {
|
||||||
|
/// Validate and normalize the selector.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`SdkError::InvalidInput`] for an empty or over-long name.
|
||||||
|
pub fn validated(&self) -> Result<Self, SdkError> {
|
||||||
|
match self {
|
||||||
|
Self::Pubkey(pubkey) => Ok(Self::Pubkey(PubkeyHex::parse(pubkey.as_str())?)),
|
||||||
|
Self::Name(name) => Ok(Self::Name(required(name, "agent name", MAX_NAME_CHARS)?)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arguments for `agents.create`.
|
||||||
|
///
|
||||||
|
/// There is no owner field: the owner is whoever the host authenticated, so an
|
||||||
|
/// agent creating an agent owns it. See the [module docs](super) on ownership
|
||||||
|
/// recursion, and [`crate::broker::BrokerRequest`] on why no body names its own subject.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct AgentsCreateArgs {
|
||||||
|
/// Channel the new agent is attached to.
|
||||||
|
pub channel_id: String,
|
||||||
|
/// Name for the new agent.
|
||||||
|
pub display_name: String,
|
||||||
|
/// Instructions the new agent runs with.
|
||||||
|
pub system_prompt: String,
|
||||||
|
/// Preferred harness id; the host refuses a runtime it cannot resolve.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub runtime: Option<String>,
|
||||||
|
/// Inference provider.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub provider: Option<String>,
|
||||||
|
/// Model identifier, interpreted relative to the runtime.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model: Option<String>,
|
||||||
|
/// Inbound author gate mode; absent = the host's owner-only default.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub respond_to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentsCreateArgs {
|
||||||
|
/// Validate and normalize.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`SdkError::InvalidInput`] for a malformed channel UUID, an
|
||||||
|
/// empty or over-long name or prompt, or an unsupported respond-to mode.
|
||||||
|
pub fn validated(&self) -> Result<Self, SdkError> {
|
||||||
|
Ok(Self {
|
||||||
|
channel_id: channel(&self.channel_id)?,
|
||||||
|
display_name: required(&self.display_name, "display name", MAX_NAME_CHARS)?,
|
||||||
|
system_prompt: required(&self.system_prompt, "system prompt", MAX_PROMPT_CHARS)?,
|
||||||
|
runtime: optional(self.runtime.as_ref(), "runtime", MAX_SCALAR_CHARS)?,
|
||||||
|
provider: optional(self.provider.as_ref(), "provider", MAX_SCALAR_CHARS)?,
|
||||||
|
model: optional(self.model.as_ref(), "model", MAX_SCALAR_CHARS)?,
|
||||||
|
respond_to: respond_to(self.respond_to.as_ref())?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arguments for `agents.update`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct AgentsUpdateArgs {
|
||||||
|
/// Which agent to patch.
|
||||||
|
pub target: AgentTarget,
|
||||||
|
/// Rename the agent.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
/// Replacement instructions.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub system_prompt: Option<String>,
|
||||||
|
/// Harness id to pin.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub runtime: Option<String>,
|
||||||
|
/// Inference provider.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub provider: Option<String>,
|
||||||
|
/// Model identifier.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model: Option<String>,
|
||||||
|
/// Inbound author gate mode.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub respond_to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentsUpdateArgs {
|
||||||
|
/// Validate and normalize, requiring at least one field to change.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`SdkError::InvalidInput`] for a malformed target, an over-long
|
||||||
|
/// field, an unsupported respond-to mode, or a request that changes nothing.
|
||||||
|
pub fn validated(&self) -> Result<Self, SdkError> {
|
||||||
|
let normalized = Self {
|
||||||
|
target: self.target.validated()?,
|
||||||
|
display_name: optional(self.display_name.as_ref(), "display name", MAX_NAME_CHARS)?,
|
||||||
|
system_prompt: optional(
|
||||||
|
self.system_prompt.as_ref(),
|
||||||
|
"system prompt",
|
||||||
|
MAX_PROMPT_CHARS,
|
||||||
|
)?,
|
||||||
|
runtime: optional(self.runtime.as_ref(), "runtime", MAX_SCALAR_CHARS)?,
|
||||||
|
provider: optional(self.provider.as_ref(), "provider", MAX_SCALAR_CHARS)?,
|
||||||
|
model: optional(self.model.as_ref(), "model", MAX_SCALAR_CHARS)?,
|
||||||
|
respond_to: respond_to(self.respond_to.as_ref())?,
|
||||||
|
};
|
||||||
|
let unchanged = normalized.display_name.is_none()
|
||||||
|
&& normalized.system_prompt.is_none()
|
||||||
|
&& normalized.runtime.is_none()
|
||||||
|
&& normalized.provider.is_none()
|
||||||
|
&& normalized.model.is_none()
|
||||||
|
&& normalized.respond_to.is_none();
|
||||||
|
if unchanged {
|
||||||
|
return Err(SdkError::InvalidInput(
|
||||||
|
"include at least one field to update".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arguments for `agents.delete`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct AgentsDeleteArgs {
|
||||||
|
/// Which agent to remove.
|
||||||
|
pub target: AgentTarget,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentsDeleteArgs {
|
||||||
|
/// Validate and normalize.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`SdkError::InvalidInput`] for a malformed target selector.
|
||||||
|
pub fn validated(&self) -> Result<Self, SdkError> {
|
||||||
|
Ok(Self {
|
||||||
|
target: self.target.validated()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Action union ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// An action name paired with its strictly typed arguments.
|
||||||
|
///
|
||||||
|
/// Flattened into [`crate::broker::BrokerRequest`], so the wire form is
|
||||||
|
/// `{ "action": "message.post", "args": { … } }` and an args shape can never be
|
||||||
|
/// paired with the wrong action name.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "action", content = "args")]
|
||||||
|
pub enum ActionArgs {
|
||||||
|
/// Read a channel, thread, or mention feed.
|
||||||
|
#[serde(rename = "channel.read")]
|
||||||
|
ChannelRead(ChannelReadArgs),
|
||||||
|
/// Post a message.
|
||||||
|
#[serde(rename = "message.post")]
|
||||||
|
MessagePost(MessagePostArgs),
|
||||||
|
/// Reply to a message.
|
||||||
|
#[serde(rename = "message.reply")]
|
||||||
|
MessageReply(MessageReplyArgs),
|
||||||
|
/// React to a message.
|
||||||
|
#[serde(rename = "reaction.add")]
|
||||||
|
ReactionAdd(ReactionAddArgs),
|
||||||
|
/// Set the requester's profile.
|
||||||
|
#[serde(rename = "profile.set")]
|
||||||
|
ProfileSet(ProfileSetArgs),
|
||||||
|
/// Derive an encrypted-memory address.
|
||||||
|
#[serde(rename = "storage.address")]
|
||||||
|
StorageAddress(StorageAddressArgs),
|
||||||
|
/// Mint a managed agent.
|
||||||
|
#[serde(rename = "agents.create")]
|
||||||
|
AgentsCreate(AgentsCreateArgs),
|
||||||
|
/// Patch a managed agent.
|
||||||
|
#[serde(rename = "agents.update")]
|
||||||
|
AgentsUpdate(AgentsUpdateArgs),
|
||||||
|
/// Remove a managed agent.
|
||||||
|
#[serde(rename = "agents.delete")]
|
||||||
|
AgentsDelete(AgentsDeleteArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionArgs {
|
||||||
|
/// The action these args belong to.
|
||||||
|
#[must_use]
|
||||||
|
pub fn action(&self) -> Action {
|
||||||
|
match self {
|
||||||
|
Self::ChannelRead(_) => Action::ChannelRead,
|
||||||
|
Self::MessagePost(_) => Action::MessagePost,
|
||||||
|
Self::MessageReply(_) => Action::MessageReply,
|
||||||
|
Self::ReactionAdd(_) => Action::ReactionAdd,
|
||||||
|
Self::ProfileSet(_) => Action::ProfileSet,
|
||||||
|
Self::StorageAddress(_) => Action::StorageAddress,
|
||||||
|
Self::AgentsCreate(_) => Action::AgentsCreate,
|
||||||
|
Self::AgentsUpdate(_) => Action::AgentsUpdate,
|
||||||
|
Self::AgentsDelete(_) => Action::AgentsDelete,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the arguments in place.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Propagates the per-action validation error.
|
||||||
|
pub fn validate(&self) -> Result<(), SdkError> {
|
||||||
|
self.validated().map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a normalized copy with every field validated.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Propagates the per-action validation error.
|
||||||
|
pub fn validated(&self) -> Result<Self, SdkError> {
|
||||||
|
Ok(match self {
|
||||||
|
Self::ChannelRead(args) => Self::ChannelRead(args.validated()?),
|
||||||
|
Self::MessagePost(args) => Self::MessagePost(args.validated()?),
|
||||||
|
Self::MessageReply(args) => Self::MessageReply(args.validated()?),
|
||||||
|
Self::ReactionAdd(args) => Self::ReactionAdd(args.validated()?),
|
||||||
|
Self::ProfileSet(args) => Self::ProfileSet(args.validated()?),
|
||||||
|
Self::StorageAddress(args) => Self::StorageAddress(args.validated()?),
|
||||||
|
Self::AgentsCreate(args) => Self::AgentsCreate(args.validated()?),
|
||||||
|
Self::AgentsUpdate(args) => Self::AgentsUpdate(args.validated()?),
|
||||||
|
Self::AgentsDelete(args) => Self::AgentsDelete(args.validated()?),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
310
crates/buzz-sdk/src/broker/actions/mod.rs
Normal file
310
crates/buzz-sdk/src/broker/actions/mod.rs
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
//! Broker actions — the closed set of operations an agent may ask a host to perform.
|
||||||
|
//!
|
||||||
|
//! An action is the unit of policy; see the [module docs](super) for why this is
|
||||||
|
//! an operation enum rather than a signing primitive.
|
||||||
|
//!
|
||||||
|
//! Two consequences of that choice are visible in the types below. Every args
|
||||||
|
//! and outcome type is `deny_unknown_fields`, so a credential or environment map
|
||||||
|
//! smuggled into a payload fails to deserialize instead of reaching an executor.
|
||||||
|
//! And the wire key set of every type is pinned by test, so a secret-bearing
|
||||||
|
//! field cannot be added without a test failing.
|
||||||
|
//!
|
||||||
|
//! [`Action`] and the shared validators live here; the payload types are split
|
||||||
|
//! into [`args`] and [`outcomes`] so each side of a call reviews on its own.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::SdkError;
|
||||||
|
use buzz_core::engram::validate_slug;
|
||||||
|
|
||||||
|
pub mod args;
|
||||||
|
pub mod outcomes;
|
||||||
|
|
||||||
|
pub use args::{
|
||||||
|
ActionArgs, AgentTarget, AgentsCreateArgs, AgentsDeleteArgs, AgentsUpdateArgs, ChannelReadArgs,
|
||||||
|
MessagePostArgs, MessageReplyArgs, ProfileSetArgs, ReactionAddArgs, StorageAddressArgs,
|
||||||
|
};
|
||||||
|
pub use outcomes::{
|
||||||
|
ActionOutcome, AgentsCreateOutcome, AgentsDeleteOutcome, AgentsUpdateOutcome, BrokerMessage,
|
||||||
|
EventPublished, MessagePage, StorageAddress,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Maximum characters in a display name or agent name.
|
||||||
|
pub const MAX_NAME_CHARS: usize = 120;
|
||||||
|
|
||||||
|
/// Maximum characters in a system prompt.
|
||||||
|
pub const MAX_PROMPT_CHARS: usize = 20_000;
|
||||||
|
|
||||||
|
/// Maximum characters in a short scalar field (runtime, provider, model).
|
||||||
|
pub const MAX_SCALAR_CHARS: usize = 300;
|
||||||
|
|
||||||
|
/// Maximum characters in a profile `about` blurb.
|
||||||
|
pub const MAX_ABOUT_CHARS: usize = 2_000;
|
||||||
|
|
||||||
|
/// Maximum bytes of message content, matching the SDK's channel-message cap.
|
||||||
|
pub const MAX_CONTENT_BYTES: usize = 64 * 1024;
|
||||||
|
|
||||||
|
/// Maximum characters in a reaction payload (emoji or `:shortcode:`).
|
||||||
|
pub const MAX_EMOJI_CHARS: usize = 66;
|
||||||
|
|
||||||
|
/// Maximum mentions attachable to one message.
|
||||||
|
pub const MAX_MENTIONS: usize = 50;
|
||||||
|
|
||||||
|
/// Maximum events a single read may return.
|
||||||
|
pub const MAX_PAGE_LIMIT: u32 = 500;
|
||||||
|
|
||||||
|
/// Maximum accepted length of a read cursor, in bytes.
|
||||||
|
pub const MAX_CURSOR_LEN: usize = 256;
|
||||||
|
|
||||||
|
/// Inbound author gate modes a requester may ask for.
|
||||||
|
///
|
||||||
|
/// `allowlist` is deliberately absent: it needs a pubkey list this request
|
||||||
|
/// shape does not carry, and a mode without its list would mint an agent
|
||||||
|
/// nobody can talk to.
|
||||||
|
pub const RESPOND_TO_MODES: [&str; 2] = ["owner-only", "anyone"];
|
||||||
|
|
||||||
|
/// A public key in lowercase hex — the only identity this contract has.
|
||||||
|
///
|
||||||
|
/// #6467 asks for identity to be separable from signing. This type is that
|
||||||
|
/// separation made structural: it holds 64 hex characters of public key and has
|
||||||
|
/// no counterpart in this module for the corresponding secret.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(try_from = "String", into = "String")]
|
||||||
|
pub struct PubkeyHex(String);
|
||||||
|
|
||||||
|
impl PubkeyHex {
|
||||||
|
/// Parse a 64-character hex public key, normalizing to lowercase.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`SdkError::InvalidInput`] unless `value` is exactly 64 hex
|
||||||
|
/// characters.
|
||||||
|
pub fn parse(value: impl AsRef<str>) -> Result<Self, SdkError> {
|
||||||
|
let value = value.as_ref().trim();
|
||||||
|
if value.len() != 64 || !value.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
return Err(SdkError::InvalidInput(
|
||||||
|
"pubkey must be 64 hex characters".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Self(value.to_ascii_lowercase()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hex representation.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<String> for PubkeyHex {
|
||||||
|
type Error = SdkError;
|
||||||
|
|
||||||
|
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||||
|
Self::parse(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PubkeyHex> for String {
|
||||||
|
fn from(value: PubkeyHex) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for PubkeyHex {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An action name the broker can dispatch.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Action {
|
||||||
|
/// Read messages from a channel, thread, or mention feed after a cursor.
|
||||||
|
ChannelRead,
|
||||||
|
/// Post a top-level channel message.
|
||||||
|
MessagePost,
|
||||||
|
/// Reply to an existing message.
|
||||||
|
MessageReply,
|
||||||
|
/// React to an existing message.
|
||||||
|
ReactionAdd,
|
||||||
|
/// Publish the requester's own profile metadata.
|
||||||
|
ProfileSet,
|
||||||
|
/// Derive the address of one encrypted-memory record.
|
||||||
|
StorageAddress,
|
||||||
|
/// Mint a managed agent owned by the requester.
|
||||||
|
AgentsCreate,
|
||||||
|
/// Patch a managed agent the requester owns.
|
||||||
|
AgentsUpdate,
|
||||||
|
/// Remove a managed agent the requester owns.
|
||||||
|
AgentsDelete,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action {
|
||||||
|
/// Every action in this protocol version, in wire-name order.
|
||||||
|
pub const ALL: [Self; 9] = [
|
||||||
|
Self::AgentsCreate,
|
||||||
|
Self::AgentsDelete,
|
||||||
|
Self::AgentsUpdate,
|
||||||
|
Self::ChannelRead,
|
||||||
|
Self::MessagePost,
|
||||||
|
Self::MessageReply,
|
||||||
|
Self::ProfileSet,
|
||||||
|
Self::ReactionAdd,
|
||||||
|
Self::StorageAddress,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Stable wire name.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::ChannelRead => "channel.read",
|
||||||
|
Self::MessagePost => "message.post",
|
||||||
|
Self::MessageReply => "message.reply",
|
||||||
|
Self::ReactionAdd => "reaction.add",
|
||||||
|
Self::ProfileSet => "profile.set",
|
||||||
|
Self::StorageAddress => "storage.address",
|
||||||
|
Self::AgentsCreate => "agents.create",
|
||||||
|
Self::AgentsUpdate => "agents.update",
|
||||||
|
Self::AgentsDelete => "agents.delete",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The action contract version this build implements.
|
||||||
|
#[must_use]
|
||||||
|
pub fn current_version(self) -> u16 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a host may refuse this action without harming the agent.
|
||||||
|
///
|
||||||
|
/// #6467 requires non-essential signed housekeeping to be skippable, so an
|
||||||
|
/// agent can still run where it is unavailable. See
|
||||||
|
/// [`super::BrokerErrorCode::Unsupported`] for how a caller reacts.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_best_effort(self) -> bool {
|
||||||
|
matches!(self, Self::ReactionAdd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a wire name.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`SdkError::InvalidInput`] for an unknown action name.
|
||||||
|
pub fn parse(name: &str) -> Result<Self, SdkError> {
|
||||||
|
Self::ALL
|
||||||
|
.into_iter()
|
||||||
|
.find(|action| action.as_str() == name)
|
||||||
|
.ok_or_else(|| SdkError::InvalidInput(format!("unknown broker action \"{name}\"")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared validators ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn is_false(value: &bool) -> bool {
|
||||||
|
!*value
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required(value: &str, label: &str, max: usize) -> Result<String, SdkError> {
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err(SdkError::InvalidInput(format!("{label} must not be empty")));
|
||||||
|
}
|
||||||
|
if value.chars().count() > max {
|
||||||
|
return Err(SdkError::InvalidInput(format!(
|
||||||
|
"{label} is too long (max {max} characters)"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(value.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional(value: Option<&String>, label: &str, max: usize) -> Result<Option<String>, SdkError> {
|
||||||
|
value.map(|value| required(value, label, max)).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn channel(value: &str) -> Result<String, SdkError> {
|
||||||
|
let value = required(value, "channel", 128)?;
|
||||||
|
uuid::Uuid::parse_str(&value)
|
||||||
|
.map_err(|_| SdkError::InvalidInput(format!("invalid channel UUID: {value}")))?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event_id(value: &str, label: &str) -> Result<String, SdkError> {
|
||||||
|
let value = required(value, label, 64)?;
|
||||||
|
if value.len() != 64 || !value.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
return Err(SdkError::InvalidInput(format!(
|
||||||
|
"{label} must be 64 hex characters"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(value.to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content(value: &str) -> Result<String, SdkError> {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return Err(SdkError::InvalidInput("content must not be empty".into()));
|
||||||
|
}
|
||||||
|
if value.len() > MAX_CONTENT_BYTES {
|
||||||
|
return Err(SdkError::ContentTooLarge {
|
||||||
|
max: MAX_CONTENT_BYTES,
|
||||||
|
got: value.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(value.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mentions(values: &[PubkeyHex]) -> Result<Vec<PubkeyHex>, SdkError> {
|
||||||
|
if values.len() > MAX_MENTIONS {
|
||||||
|
return Err(SdkError::TooManyMentions);
|
||||||
|
}
|
||||||
|
values
|
||||||
|
.iter()
|
||||||
|
.map(|pubkey| PubkeyHex::parse(pubkey.as_str()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn limit(value: Option<u32>) -> Result<Option<u32>, SdkError> {
|
||||||
|
match value {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(0) => Err(SdkError::InvalidInput("limit must be at least 1".into())),
|
||||||
|
Some(limit) if limit > MAX_PAGE_LIMIT => Err(SdkError::InvalidInput(format!(
|
||||||
|
"limit exceeds {MAX_PAGE_LIMIT} (got {limit})"
|
||||||
|
))),
|
||||||
|
Some(limit) => Ok(Some(limit)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate an opaque read cursor: printable ASCII, bounded, never parsed.
|
||||||
|
///
|
||||||
|
/// The bound exists so a host cannot be made to store an unbounded token; the
|
||||||
|
/// character set keeps it safe to log. Nothing here interprets the value.
|
||||||
|
fn cursor(value: &str) -> Result<String, SdkError> {
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err(SdkError::InvalidInput(
|
||||||
|
"cursor must not be empty (omit it to start from the host's default window)".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if value.len() > MAX_CURSOR_LEN {
|
||||||
|
return Err(SdkError::InvalidInput(format!(
|
||||||
|
"cursor exceeds {MAX_CURSOR_LEN} bytes (got {})",
|
||||||
|
value.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !value.bytes().all(|b| (0x21..=0x7e).contains(&b)) {
|
||||||
|
return Err(SdkError::InvalidInput(
|
||||||
|
"cursor must be printable ASCII without spaces".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(value.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn respond_to(value: Option<&String>) -> Result<Option<String>, SdkError> {
|
||||||
|
let value = optional(value, "respond-to", MAX_SCALAR_CHARS)?;
|
||||||
|
if let Some(mode) = value.as_deref() {
|
||||||
|
if !RESPOND_TO_MODES.contains(&mode) {
|
||||||
|
return Err(SdkError::InvalidInput(format!(
|
||||||
|
"respond-to must be one of {}",
|
||||||
|
RESPOND_TO_MODES.join(", ")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
260
crates/buzz-sdk/src/broker/actions/outcomes.rs
Normal file
260
crates/buzz-sdk/src/broker/actions/outcomes.rs
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
//! Outcome types — the success payload of each [`Action`], and the tagged union
|
||||||
|
//! that pairs a wire action name with its outcome.
|
||||||
|
//!
|
||||||
|
//! Outcomes are shared where actions agree on what success means: the four
|
||||||
|
//! event-publishing actions all return [`EventPublished`]. Every type is
|
||||||
|
//! `deny_unknown_fields` with its exact wire key set pinned by test, which is
|
||||||
|
//! what enforces the no-secret rule — see the [contract docs](crate::broker).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
channel, cursor, event_id, required, Action, PubkeyHex, MAX_NAME_CHARS, MAX_PAGE_LIMIT,
|
||||||
|
};
|
||||||
|
use crate::SdkError;
|
||||||
|
use nostr::Event;
|
||||||
|
|
||||||
|
/// One message returned by a read: the signed Nostr event, verbatim.
|
||||||
|
///
|
||||||
|
/// The event is authoritative. It is carried whole — signature and tags
|
||||||
|
/// included — rather than reduced to a projection, because Schnorr verification
|
||||||
|
/// is local and needs no relay: see [`Self::verify`]. A keyless agent therefore
|
||||||
|
/// still gets independently verifiable authorship and content, and only has to
|
||||||
|
/// trust the host for *completeness* (that no message was withheld) and for
|
||||||
|
/// authorization. Reducing to a projection would have widened that trust to
|
||||||
|
/// cover authorship too, and dropped the tags later consumers need.
|
||||||
|
///
|
||||||
|
/// The wire form is the event's own JSON, so this adds no envelope of its own.
|
||||||
|
/// Ancestry and mentions are exposed as derived accessors rather than sibling
|
||||||
|
/// fields, so there is nothing that can disagree with the signed bytes.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct BrokerMessage(pub Event);
|
||||||
|
|
||||||
|
impl BrokerMessage {
|
||||||
|
/// The signed event.
|
||||||
|
#[must_use]
|
||||||
|
pub fn event(&self) -> &Event {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify the event's id and Schnorr signature.
|
||||||
|
///
|
||||||
|
/// This is the caller's provenance check and it is entirely local. A host
|
||||||
|
/// that fabricated or altered a message fails here regardless of what it
|
||||||
|
/// claims. It is deliberately *not* called by
|
||||||
|
/// [`crate::broker::BrokerResponse::validate_for`]: whether to pay for verification,
|
||||||
|
/// and what to do when it fails, is the caller's policy.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`SdkError::InvalidInput`] when the id does not match the
|
||||||
|
/// content or the signature does not match the author.
|
||||||
|
pub fn verify(&self) -> Result<(), SdkError> {
|
||||||
|
self.0.verify().map_err(|e| {
|
||||||
|
SdkError::InvalidInput(format!("broker returned an unverifiable event: {e}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The author's pubkey, in this contract's identity type.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`SdkError::InvalidInput`] if the event's author is not
|
||||||
|
/// expressible as 64 hex characters.
|
||||||
|
pub fn author(&self) -> Result<PubkeyHex, SdkError> {
|
||||||
|
PubkeyHex::parse(self.0.pubkey.to_hex())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NIP-10 `root`/`reply` ancestry, parsed from the signed tags.
|
||||||
|
#[must_use]
|
||||||
|
pub fn thread(&self) -> buzz_core::nip10::ThreadMarkers {
|
||||||
|
buzz_core::nip10::parse_thread_markers(&self.0.tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pubkeys this message mentions, from the signed `p` tags.
|
||||||
|
#[must_use]
|
||||||
|
pub fn mentions(&self) -> Vec<String> {
|
||||||
|
self.0
|
||||||
|
.tags
|
||||||
|
.public_keys()
|
||||||
|
.map(nostr::PublicKey::to_hex)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of any read action.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct MessagePage {
|
||||||
|
/// Messages in the host's declared order.
|
||||||
|
pub messages: Vec<BrokerMessage>,
|
||||||
|
/// Opaque cursor to pass as [`super::args::ChannelReadArgs::cursor`] on the next call.
|
||||||
|
///
|
||||||
|
/// Absent when the host has nothing further, which is how a caller learns
|
||||||
|
/// to stop rather than by comparing lengths against a limit it may not have
|
||||||
|
/// set.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub next_cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of an action that published one event.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct EventPublished {
|
||||||
|
/// The published event's id (hex).
|
||||||
|
pub event_id: String,
|
||||||
|
/// The published event's kind.
|
||||||
|
pub kind: u32,
|
||||||
|
/// Creation time the host stamped, Unix seconds.
|
||||||
|
pub created_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of `storage.address`.
|
||||||
|
///
|
||||||
|
/// Addressing material only. A `d` tag is a keyed hash of the slug, so it
|
||||||
|
/// identifies a record without revealing the slug or the key that derived it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct StorageAddress {
|
||||||
|
/// Author the record is addressed under.
|
||||||
|
pub author_pubkey: PubkeyHex,
|
||||||
|
/// Event kind holding the record.
|
||||||
|
pub kind: u32,
|
||||||
|
/// Derived `d` tag (64 hex characters).
|
||||||
|
pub d_tag: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of a successful `agents.create`.
|
||||||
|
///
|
||||||
|
/// Carries the new agent's **public** key only: pubkey, name, channel, and
|
||||||
|
/// nothing else. There is no field for the minted secret — it stays on the
|
||||||
|
/// host — and `deny_unknown_fields` plus a test pinning this exact key set is
|
||||||
|
/// what enforces that rather than a comment.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct AgentsCreateOutcome {
|
||||||
|
/// The new agent's pubkey.
|
||||||
|
pub agent_pubkey: PubkeyHex,
|
||||||
|
/// The new agent's name as stored.
|
||||||
|
pub display_name: String,
|
||||||
|
/// Channel the agent was attached to.
|
||||||
|
pub channel_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of a successful `agents.update`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct AgentsUpdateOutcome {
|
||||||
|
/// The patched agent's pubkey.
|
||||||
|
pub agent_pubkey: PubkeyHex,
|
||||||
|
/// The agent's name after the update.
|
||||||
|
pub display_name: String,
|
||||||
|
/// Names of the fields the host actually changed, sorted.
|
||||||
|
pub updated_fields: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of a successful `agents.delete`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct AgentsDeleteOutcome {
|
||||||
|
/// The removed agent's pubkey.
|
||||||
|
pub agent_pubkey: PubkeyHex,
|
||||||
|
/// The removed agent's name.
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An action-specific success payload.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "action", content = "outcome")]
|
||||||
|
pub enum ActionOutcome {
|
||||||
|
/// `channel.read` succeeded.
|
||||||
|
#[serde(rename = "channel.read")]
|
||||||
|
ChannelRead(MessagePage),
|
||||||
|
/// `message.post` succeeded.
|
||||||
|
#[serde(rename = "message.post")]
|
||||||
|
MessagePost(EventPublished),
|
||||||
|
/// `message.reply` succeeded.
|
||||||
|
#[serde(rename = "message.reply")]
|
||||||
|
MessageReply(EventPublished),
|
||||||
|
/// `reaction.add` succeeded.
|
||||||
|
#[serde(rename = "reaction.add")]
|
||||||
|
ReactionAdd(EventPublished),
|
||||||
|
/// `profile.set` succeeded.
|
||||||
|
#[serde(rename = "profile.set")]
|
||||||
|
ProfileSet(EventPublished),
|
||||||
|
/// `storage.address` succeeded.
|
||||||
|
#[serde(rename = "storage.address")]
|
||||||
|
StorageAddress(StorageAddress),
|
||||||
|
/// `agents.create` succeeded.
|
||||||
|
#[serde(rename = "agents.create")]
|
||||||
|
AgentsCreate(AgentsCreateOutcome),
|
||||||
|
/// `agents.update` succeeded.
|
||||||
|
#[serde(rename = "agents.update")]
|
||||||
|
AgentsUpdate(AgentsUpdateOutcome),
|
||||||
|
/// `agents.delete` succeeded.
|
||||||
|
#[serde(rename = "agents.delete")]
|
||||||
|
AgentsDelete(AgentsDeleteOutcome),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionOutcome {
|
||||||
|
/// The action that produced this outcome.
|
||||||
|
#[must_use]
|
||||||
|
pub fn action(&self) -> Action {
|
||||||
|
match self {
|
||||||
|
Self::ChannelRead(_) => Action::ChannelRead,
|
||||||
|
Self::MessagePost(_) => Action::MessagePost,
|
||||||
|
Self::MessageReply(_) => Action::MessageReply,
|
||||||
|
Self::ReactionAdd(_) => Action::ReactionAdd,
|
||||||
|
Self::ProfileSet(_) => Action::ProfileSet,
|
||||||
|
Self::StorageAddress(_) => Action::StorageAddress,
|
||||||
|
Self::AgentsCreate(_) => Action::AgentsCreate,
|
||||||
|
Self::AgentsUpdate(_) => Action::AgentsUpdate,
|
||||||
|
Self::AgentsDelete(_) => Action::AgentsDelete,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the identifiers and cursors this outcome asserts.
|
||||||
|
///
|
||||||
|
/// A well-typed outcome can still carry a malformed id or an unusable
|
||||||
|
/// cursor, and a caller that trusted either would fail later and further
|
||||||
|
/// away. Signature verification is deliberately *not* here — that is
|
||||||
|
/// [`BrokerMessage::verify`], and whether to pay for it is the caller's
|
||||||
|
/// choice.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`SdkError::InvalidInput`] for a malformed event id, `d` tag,
|
||||||
|
/// channel UUID, or cursor, an empty name, or an over-long page.
|
||||||
|
pub fn validate(&self) -> Result<(), SdkError> {
|
||||||
|
match self {
|
||||||
|
Self::ChannelRead(page) => {
|
||||||
|
if page.messages.len() > MAX_PAGE_LIMIT as usize {
|
||||||
|
return Err(SdkError::InvalidInput(format!(
|
||||||
|
"page holds {} messages, over the {MAX_PAGE_LIMIT} cap",
|
||||||
|
page.messages.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
page.next_cursor.as_deref().map(cursor).transpose()?;
|
||||||
|
}
|
||||||
|
Self::MessagePost(published)
|
||||||
|
| Self::MessageReply(published)
|
||||||
|
| Self::ReactionAdd(published)
|
||||||
|
| Self::ProfileSet(published) => {
|
||||||
|
event_id(&published.event_id, "eventId")?;
|
||||||
|
}
|
||||||
|
Self::StorageAddress(address) => {
|
||||||
|
event_id(&address.d_tag, "dTag")?;
|
||||||
|
}
|
||||||
|
Self::AgentsCreate(outcome) => {
|
||||||
|
channel(&outcome.channel_id)?;
|
||||||
|
required(&outcome.display_name, "display name", MAX_NAME_CHARS)?;
|
||||||
|
}
|
||||||
|
Self::AgentsUpdate(AgentsUpdateOutcome { display_name, .. })
|
||||||
|
| Self::AgentsDelete(AgentsDeleteOutcome { display_name, .. }) => {
|
||||||
|
required(display_name, "display name", MAX_NAME_CHARS)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
|
||||||
use super::{BrokerResponse, PreparedRequest};
|
use super::{BrokerResponse, BrokerResult, PreparedRequest};
|
||||||
|
|
||||||
/// Path of the single broker endpoint.
|
/// Path of the single broker endpoint.
|
||||||
pub const BROKER_ACTION_PATH: &str = "/v1/action";
|
pub const BROKER_ACTION_PATH: &str = "/v1/action";
|
||||||
@@ -88,12 +88,15 @@ pub enum BrokerTransportError {
|
|||||||
/// An envelope arrived but did not validate against the request that was
|
/// An envelope arrived but did not validate against the request that was
|
||||||
/// sent — wrong `requestId`, wrong action, or a malformed outcome.
|
/// sent — wrong `requestId`, wrong action, or a malformed outcome.
|
||||||
///
|
///
|
||||||
/// See [`BrokerResponse::validate_for`].
|
/// Produced by [`ValidatedResponse::validate`], which every
|
||||||
|
/// [`BrokerClientExt::execute`] call runs. A host that answers something
|
||||||
|
/// other than what was asked has given no verdict at all, which is why this
|
||||||
|
/// is a transport failure rather than a `Failed` result.
|
||||||
#[error("malformed broker response: {0}")]
|
#[error("malformed broker response: {0}")]
|
||||||
MalformedResponse(String),
|
MalformedResponse(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A future returned by [`BrokerClient::execute`].
|
/// A future returned by [`BrokerClient::send`].
|
||||||
///
|
///
|
||||||
/// Spelled as a boxed future rather than `async fn` in the trait because this
|
/// Spelled as a boxed future rather than `async fn` in the trait because this
|
||||||
/// trait must be object-safe: the harness holds one client and must not know
|
/// trait must be object-safe: the harness holds one client and must not know
|
||||||
@@ -101,28 +104,150 @@ pub enum BrokerTransportError {
|
|||||||
pub type BrokerFuture<'a> =
|
pub type BrokerFuture<'a> =
|
||||||
Pin<Box<dyn Future<Output = Result<BrokerResponse, BrokerTransportError>> + Send + 'a>>;
|
Pin<Box<dyn Future<Output = Result<BrokerResponse, BrokerTransportError>> + Send + 'a>>;
|
||||||
|
|
||||||
|
/// A future returned by [`BrokerClientExt::execute`].
|
||||||
|
pub type ValidatedFuture<'a> =
|
||||||
|
Pin<Box<dyn Future<Output = Result<ValidatedResponse, BrokerTransportError>> + Send + 'a>>;
|
||||||
|
|
||||||
|
/// A host response that has been checked against the request it answers.
|
||||||
|
///
|
||||||
|
/// The only way to obtain one is [`ValidatedResponse::validate`], which is what
|
||||||
|
/// [`BrokerClientExt::execute`] calls. That is the point: correlation is not
|
||||||
|
/// advice an implementation may skip, because a caller using `execute` cannot be
|
||||||
|
/// handed anything else, and an implementation cannot produce this type without
|
||||||
|
/// passing the request it is answering.
|
||||||
|
///
|
||||||
|
/// [`BrokerResponse::validate_for`] remains public for a host validating its own
|
||||||
|
/// output, but a client never has to remember to call it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ValidatedResponse(BrokerResponse);
|
||||||
|
|
||||||
|
impl ValidatedResponse {
|
||||||
|
/// Check `response` against the request it claims to answer.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`BrokerTransportError::MalformedResponse`] when the response
|
||||||
|
/// does not correlate, carries an outcome for a different action, or asserts
|
||||||
|
/// a malformed identifier. A response that fails here is not a host verdict
|
||||||
|
/// — nothing can be concluded about side effects from it.
|
||||||
|
pub fn validate(
|
||||||
|
response: BrokerResponse,
|
||||||
|
request: &PreparedRequest,
|
||||||
|
) -> Result<Self, BrokerTransportError> {
|
||||||
|
response
|
||||||
|
.validate_for(request)
|
||||||
|
.map_err(|e| BrokerTransportError::MalformedResponse(e.to_string()))?;
|
||||||
|
Ok(Self(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The terminal disposition the host reached.
|
||||||
|
#[must_use]
|
||||||
|
pub fn result(&self) -> &BrokerResult {
|
||||||
|
&self.0.result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The correlated `requestId`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn request_id(&self) -> &str {
|
||||||
|
&self.0.request_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the host replayed a previously recorded outcome.
|
||||||
|
#[must_use]
|
||||||
|
pub fn replayed(&self) -> bool {
|
||||||
|
self.0.replayed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The underlying envelope, for logging or re-serialization.
|
||||||
|
#[must_use]
|
||||||
|
pub fn envelope(&self) -> &BrokerResponse {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume this wrapper, yielding the validated envelope.
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_envelope(self) -> BrokerResponse {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permission to call [`BrokerClient::send`], which only
|
||||||
|
/// [`BrokerClientExt::execute`] can mint.
|
||||||
|
///
|
||||||
|
/// This is what makes validation *structurally* the only door rather than the
|
||||||
|
/// recommended one. `send` must be public — an out-of-crate implementation has
|
||||||
|
/// to define it — but a caller must not be able to invoke it and receive an
|
||||||
|
/// uncorrelated envelope. A token with a private field satisfies both: any crate
|
||||||
|
/// can accept one in a signature, only this module can construct one, and
|
||||||
|
/// `execute` constructs it on the caller's behalf.
|
||||||
|
///
|
||||||
|
/// An implementation should ignore its value; it carries no data.
|
||||||
|
///
|
||||||
|
/// What this does not do: an implementation, having legitimately received a
|
||||||
|
/// token, could stash the envelope it saw in its own state and expose it. That
|
||||||
|
/// is not a bypass worth designing against — code that does it has deliberately
|
||||||
|
/// implemented a transport in order to exfiltrate its own input, which is a
|
||||||
|
/// different thing from a caller forgetting to correlate a response. Closing the
|
||||||
|
/// accidental path is the goal.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Dispatch(());
|
||||||
|
|
||||||
/// Something that can execute broker requests.
|
/// Something that can execute broker requests.
|
||||||
///
|
///
|
||||||
/// One method, because there is one endpoint. Every operation is an
|
/// One method, because there is one endpoint. Every operation is an
|
||||||
/// [`super::Action`] inside the request, so adding an action never changes this
|
/// [`super::Action`] inside the request, so adding an action never changes this
|
||||||
/// trait and an implementation never needs updating to carry one.
|
/// trait and an implementation never needs updating to carry one.
|
||||||
///
|
///
|
||||||
/// The argument is a [`PreparedRequest`] rather than a typed request so that an
|
/// This trait is the **transport primitive**: frozen bytes out, one envelope
|
||||||
/// implementation cannot reserialize between attempts; see the
|
/// back. It is not the caller's interface. Callers use
|
||||||
/// [`super::BrokerRequest`] retry contract.
|
/// [`BrokerClientExt::execute`], which is where response correlation happens,
|
||||||
|
/// which no implementation can replace, and which holds the only [`Dispatch`]
|
||||||
|
/// token `send` will accept.
|
||||||
///
|
///
|
||||||
/// Implementations must be usable as `dyn BrokerClient`.
|
/// Implementations must be usable as `dyn BrokerClient`.
|
||||||
pub trait BrokerClient: Send + Sync {
|
pub trait BrokerClient: Send + Sync {
|
||||||
/// Send `request`'s frozen bytes and return the host's terminal response.
|
/// Send `request`'s frozen bytes and return whatever envelope came back.
|
||||||
///
|
///
|
||||||
/// Implementations should call [`BrokerResponse::validate_for`] before
|
/// An implementation's whole job is transport: send [`PreparedRequest::body`]
|
||||||
/// returning `Ok`, so a caller never has to consider a response that does
|
/// verbatim, parse an envelope regardless of HTTP status, and return it
|
||||||
/// not answer its request.
|
/// unjudged. It must not attempt to interpret the verdict, and it need not
|
||||||
|
/// correlate the response — [`BrokerClientExt::execute`] does that for every
|
||||||
|
/// implementation.
|
||||||
|
///
|
||||||
|
/// The [`Dispatch`] argument is why this cannot be called directly by a
|
||||||
|
/// caller outside this module; that is deliberate.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns [`BrokerTransportError`] when no usable response could be
|
/// Returns [`BrokerTransportError`] when no envelope could be obtained or
|
||||||
/// obtained. A host that answered — even to refuse — returns `Ok` with the
|
/// parsed. A host that answered — even to refuse — returns `Ok`, with the
|
||||||
/// verdict in [`BrokerResponse::result`].
|
/// verdict in [`BrokerResponse::result`].
|
||||||
fn execute<'a>(&'a self, request: &'a PreparedRequest) -> BrokerFuture<'a>;
|
fn send<'a>(&'a self, request: &'a PreparedRequest, dispatch: Dispatch) -> BrokerFuture<'a>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The caller-facing half of [`BrokerClient`]: send, then validate.
|
||||||
|
///
|
||||||
|
/// Blanket-implemented for every [`BrokerClient`], including `dyn BrokerClient`,
|
||||||
|
/// and **not overridable** — coherence forbids a second implementation, so there
|
||||||
|
/// is exactly one definition of what validating a response means and no client
|
||||||
|
/// can weaken it.
|
||||||
|
pub trait BrokerClientExt: BrokerClient {
|
||||||
|
/// Send `request` and return a response already checked against it.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`BrokerTransportError`] when no envelope arrived, or
|
||||||
|
/// [`BrokerTransportError::MalformedResponse`] when the envelope that
|
||||||
|
/// arrived does not answer `request`. Both mean the same thing to a caller:
|
||||||
|
/// no verdict, so nothing is known about side effects.
|
||||||
|
fn execute<'a>(&'a self, request: &'a PreparedRequest) -> ValidatedFuture<'a>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: BrokerClient + ?Sized> BrokerClientExt for C {
|
||||||
|
fn execute<'a>(&'a self, request: &'a PreparedRequest) -> ValidatedFuture<'a> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let response = self.send(request, Dispatch(())).await?;
|
||||||
|
ValidatedResponse::validate(response, request)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,8 @@ pub use actions::{
|
|||||||
ProfileSetArgs, PubkeyHex, ReactionAddArgs, StorageAddress, StorageAddressArgs,
|
ProfileSetArgs, PubkeyHex, ReactionAddArgs, StorageAddress, StorageAddressArgs,
|
||||||
};
|
};
|
||||||
pub use client::{
|
pub use client::{
|
||||||
BrokerClient, BrokerFuture, BrokerTransportError, BROKER_ACTION_PATH, BROKER_CREDENTIAL_HEADER,
|
BrokerClient, BrokerClientExt, BrokerFuture, BrokerTransportError, Dispatch, ValidatedFuture,
|
||||||
|
ValidatedResponse, BROKER_ACTION_PATH, BROKER_CREDENTIAL_HEADER,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Wire `type` discriminator for a broker request payload.
|
/// Wire `type` discriminator for a broker request payload.
|
||||||
@@ -140,7 +141,7 @@ pub const MAX_REQUEST_ID_LEN: usize = 128;
|
|||||||
/// Retrying means resending the identical serialized request with the same
|
/// Retrying means resending the identical serialized request with the same
|
||||||
/// `requestId`, which is why a client never sends this type directly: call
|
/// `requestId`, which is why a client never sends this type directly: call
|
||||||
/// [`Self::prepare`] to freeze it into a [`PreparedRequest`] and hand *that* to
|
/// [`Self::prepare`] to freeze it into a [`PreparedRequest`] and hand *that* to
|
||||||
/// [`BrokerClient::execute`]. The host hashes the bytes it receives and compares
|
/// [`BrokerClientExt::execute`]. The host hashes the bytes it receives and compares
|
||||||
/// that digest against the digest recorded under the same idempotency key:
|
/// that digest against the digest recorded under the same idempotency key:
|
||||||
///
|
///
|
||||||
/// - same key, same digest → the recorded outcome is replayed, nothing re-runs
|
/// - same key, same digest → the recorded outcome is replayed, nothing re-runs
|
||||||
@@ -282,13 +283,16 @@ pub fn validate_request_id(request_id: &str) -> Result<(), SdkError> {
|
|||||||
|
|
||||||
/// A validated request together with the exact bytes to send.
|
/// A validated request together with the exact bytes to send.
|
||||||
///
|
///
|
||||||
/// This is what [`BrokerClient::execute`] takes, so the retry contract is
|
/// This is what [`BrokerClient::send`] takes, so the retry contract is
|
||||||
/// structural rather than documented: the first attempt and every retry send
|
/// structural rather than documented: the first attempt and every retry send
|
||||||
/// `body` verbatim, and no implementation gets the chance to reserialize.
|
/// `body` verbatim, and no implementation gets the chance to reserialize.
|
||||||
///
|
///
|
||||||
/// Construct one with [`BrokerRequest::prepare`]. The typed [`BrokerRequest`] is
|
/// Construct one with [`BrokerRequest::prepare`]. The typed [`BrokerRequest`] is
|
||||||
/// kept alongside so a client can correlate the response without reparsing —
|
/// retained privately and **deliberately not exposed**: an implementation that
|
||||||
/// see [`BrokerResponse::validate_for`].
|
/// could reach the typed value could serialize it again, which is exactly the
|
||||||
|
/// possibility freezing the bytes removes. What an implementation legitimately
|
||||||
|
/// needs is correlation metadata, so that — and only that — is public:
|
||||||
|
/// [`Self::request_id`] and [`Self::action`].
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct PreparedRequest {
|
pub struct PreparedRequest {
|
||||||
request: BrokerRequest,
|
request: BrokerRequest,
|
||||||
@@ -302,12 +306,6 @@ impl PreparedRequest {
|
|||||||
&self.body
|
&self.body
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The typed request these bytes encode.
|
|
||||||
#[must_use]
|
|
||||||
pub fn request(&self) -> &BrokerRequest {
|
|
||||||
&self.request
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The idempotency key the host keys replay on.
|
/// The idempotency key the host keys replay on.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn request_id(&self) -> &str {
|
pub fn request_id(&self) -> &str {
|
||||||
@@ -584,10 +582,12 @@ impl BrokerResponse {
|
|||||||
/// host (or a confused proxy) could return a `message.post` success to a
|
/// host (or a confused proxy) could return a `message.post` success to a
|
||||||
/// `channel.read`, and a caller matching on the outcome enum would quietly
|
/// `channel.read`, and a caller matching on the outcome enum would quietly
|
||||||
/// take the wrong branch. This is the check that makes such a response
|
/// take the wrong branch. This is the check that makes such a response
|
||||||
/// unusable instead of merely surprising, which is why
|
/// unusable instead of merely surprising.
|
||||||
/// [`BrokerClient::execute`] implementations are expected to run it before
|
///
|
||||||
/// returning `Ok` and to report a failure as
|
/// A client does not call this directly and cannot forget to:
|
||||||
/// [`BrokerTransportError::MalformedResponse`].
|
/// [`BrokerClientExt::execute`] runs it for every implementation and returns
|
||||||
|
/// a [`ValidatedResponse`], which is the only response type a caller can
|
||||||
|
/// obtain. This method stays public for a host validating its own output.
|
||||||
///
|
///
|
||||||
/// Signature verification of read results is deliberately not included; see
|
/// Signature verification of read results is deliberately not included; see
|
||||||
/// [`BrokerMessage::verify`].
|
/// [`BrokerMessage::verify`].
|
||||||
|
|||||||
@@ -921,7 +921,9 @@ fn preparing_a_request_freezes_the_bytes_every_attempt_sends() {
|
|||||||
prepared.body(),
|
prepared.body(),
|
||||||
"body is frozen, not re-rendered"
|
"body is frozen, not re-rendered"
|
||||||
);
|
);
|
||||||
assert_eq!(prepared.request(), &request);
|
// Correlation metadata is all a transport gets. There is deliberately no
|
||||||
|
// accessor for the typed request: one would let an implementation serialize
|
||||||
|
// the value a second time, which is the possibility freezing removes.
|
||||||
assert_eq!(prepared.request_id(), "req-idem");
|
assert_eq!(prepared.request_id(), "req-idem");
|
||||||
assert_eq!(prepared.action(), Action::MessagePost);
|
assert_eq!(prepared.action(), Action::MessagePost);
|
||||||
|
|
||||||
@@ -977,39 +979,29 @@ request_id_conflict,action_failed,outcome_unknown,internal"
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Only housekeeping is best-effort. A host refusing a read or a write has
|
|
||||||
/// broken the agent, and the agent must not treat that as routine.
|
|
||||||
#[test]
|
|
||||||
fn only_housekeeping_actions_are_best_effort() {
|
|
||||||
for action in Action::ALL {
|
|
||||||
assert_eq!(
|
|
||||||
action.is_best_effort(),
|
|
||||||
action == Action::ReactionAdd,
|
|
||||||
"{} best-effort classification",
|
|
||||||
action.as_str()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Client trait ────────────────────────────────────────────────────────────
|
// ── Client trait ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// A test double, and the only implementation in this crate. It exists to prove
|
/// A test double, and the only implementation in this crate. It exists to prove
|
||||||
/// the trait is object-safe and usable behind `dyn`, which is what lets an
|
/// the trait is object-safe and usable behind `dyn`, which is what lets an
|
||||||
/// in-process host and an HTTP client be interchangeable.
|
/// in-process host and an HTTP client be interchangeable.
|
||||||
|
///
|
||||||
|
/// Note what it does *not* do: it never calls `validate_for`. It cannot — it has
|
||||||
|
/// no way to build a [`ValidatedResponse`] except through the blanket
|
||||||
|
/// [`BrokerClientExt::execute`], which is the whole point of splitting the
|
||||||
|
/// trait. A deliberately hostile implementation is still forced through the
|
||||||
|
/// same check.
|
||||||
struct DoubleBroker {
|
struct DoubleBroker {
|
||||||
response: Result<BrokerResponse, BrokerTransportError>,
|
response: Result<BrokerResponse, BrokerTransportError>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BrokerClient for DoubleBroker {
|
impl BrokerClient for DoubleBroker {
|
||||||
fn execute<'a>(&'a self, request: &'a PreparedRequest) -> BrokerFuture<'a> {
|
fn send<'a>(&'a self, request: &'a PreparedRequest, _: Dispatch) -> BrokerFuture<'a> {
|
||||||
// A real implementation sends `request.body()` verbatim. The double
|
// A real implementation sends `request.body()` verbatim. The double
|
||||||
// stands in for a host that answers the request it was given.
|
// stands in for a host that answers under the id it was asked with, and
|
||||||
let response = self.response.clone().and_then(|mut response| {
|
// returns the envelope unjudged.
|
||||||
|
let response = self.response.clone().map(|mut response| {
|
||||||
response.request_id = request.request_id().to_string();
|
response.request_id = request.request_id().to_string();
|
||||||
response
|
response
|
||||||
.validate_for(request)
|
|
||||||
.map_err(|e| BrokerTransportError::MalformedResponse(e.to_string()))?;
|
|
||||||
Ok(response)
|
|
||||||
});
|
});
|
||||||
Box::pin(async move { response })
|
Box::pin(async move { response })
|
||||||
}
|
}
|
||||||
@@ -1033,7 +1025,7 @@ fn block_on<F: std::future::Future>(future: F) -> F::Output {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_client_trait_is_object_safe_and_returns_a_host_verdict() {
|
fn the_client_trait_is_object_safe_and_returns_a_validated_host_verdict() {
|
||||||
let request = prepared(ActionArgs::ChannelRead(ChannelReadArgs {
|
let request = prepared(ActionArgs::ChannelRead(ChannelReadArgs {
|
||||||
channel_id: CHANNEL.into(),
|
channel_id: CHANNEL.into(),
|
||||||
mentions_only: true,
|
mentions_only: true,
|
||||||
@@ -1049,12 +1041,12 @@ fn the_client_trait_is_object_safe_and_returns_a_host_verdict() {
|
|||||||
})),
|
})),
|
||||||
)),
|
)),
|
||||||
});
|
});
|
||||||
|
// `execute` is available on `dyn BrokerClient` and is the only way to a
|
||||||
|
// `ValidatedResponse` — the caller does no correlation of its own.
|
||||||
let response = block_on(succeeded.execute(&request)).expect("double answers");
|
let response = block_on(succeeded.execute(&request)).expect("double answers");
|
||||||
response
|
assert_eq!(response.request_id(), "req-1");
|
||||||
.validate_for(&request)
|
assert!(response.result().outcome().is_some());
|
||||||
.expect("double's response answers the request");
|
assert!(!response.replayed());
|
||||||
assert_eq!(response.request_id, "req-1");
|
|
||||||
assert!(response.result.outcome().is_some());
|
|
||||||
|
|
||||||
// A refusal — including a rejected credential — is still an answer: `Ok`
|
// A refusal — including a rejected credential — is still an answer: `Ok`
|
||||||
// with the verdict in the envelope.
|
// with the verdict in the envelope.
|
||||||
@@ -1070,7 +1062,7 @@ fn the_client_trait_is_object_safe_and_returns_a_host_verdict() {
|
|||||||
});
|
});
|
||||||
let response =
|
let response =
|
||||||
block_on(refused.execute(&request)).expect("a refusal is not a transport error");
|
block_on(refused.execute(&request)).expect("a refusal is not a transport error");
|
||||||
assert_eq!(response.result.error().map(|e| e.code), Some(code));
|
assert_eq!(response.result().error().map(|e| e.code), Some(code));
|
||||||
}
|
}
|
||||||
|
|
||||||
// No usable answer at all is a transport error, and says nothing about side
|
// No usable answer at all is a transport error, and says nothing about side
|
||||||
@@ -1088,8 +1080,16 @@ fn the_client_trait_is_object_safe_and_returns_a_host_verdict() {
|
|||||||
});
|
});
|
||||||
assert_eq!(block_on(broken.execute(&request)).unwrap_err(), error);
|
assert_eq!(block_on(broken.execute(&request)).unwrap_err(), error);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A host answering the wrong action is rejected by the client, not passed on.
|
/// The double returns whatever it is given, unvalidated — a hostile client
|
||||||
|
/// cannot do otherwise. `execute` is still the only door, so the mismatch
|
||||||
|
/// surfaces as a transport failure and never reaches a caller as `Ok`.
|
||||||
|
#[test]
|
||||||
|
fn a_client_cannot_hand_back_a_response_that_answers_a_different_request() {
|
||||||
|
let request = prepared(ActionArgs::ChannelRead(ChannelReadArgs::channel(CHANNEL)));
|
||||||
|
|
||||||
|
// Wrong action for this request.
|
||||||
let confused: Box<dyn BrokerClient> = Box::new(DoubleBroker {
|
let confused: Box<dyn BrokerClient> = Box::new(DoubleBroker {
|
||||||
response: Ok(BrokerResponse::new(
|
response: Ok(BrokerResponse::new(
|
||||||
"placeholder",
|
"placeholder",
|
||||||
@@ -1099,8 +1099,27 @@ fn the_client_trait_is_object_safe_and_returns_a_host_verdict() {
|
|||||||
})),
|
})),
|
||||||
)),
|
)),
|
||||||
});
|
});
|
||||||
|
// The envelope is well-formed in isolation — that is exactly why `send`
|
||||||
|
// cannot be the caller's door. `execute` is the only reachable one (a
|
||||||
|
// `Dispatch` token cannot be built outside the client module), and it
|
||||||
|
// rejects the mismatch rather than passing it on.
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
block_on(confused.execute(&request)).unwrap_err(),
|
block_on(confused.execute(&request)).unwrap_err(),
|
||||||
BrokerTransportError::MalformedResponse(_)
|
BrokerTransportError::MalformedResponse(_)
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Malformed identifiers inside an otherwise well-shaped outcome, too.
|
||||||
|
let bad_cursor: Box<dyn BrokerClient> = Box::new(DoubleBroker {
|
||||||
|
response: Ok(BrokerResponse::new(
|
||||||
|
"placeholder",
|
||||||
|
BrokerResult::succeeded(ActionOutcome::ChannelRead(MessagePage {
|
||||||
|
messages: vec![],
|
||||||
|
next_cursor: Some("not a cursor".into()),
|
||||||
|
})),
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
block_on(bad_cursor.execute(&request)).unwrap_err(),
|
||||||
|
BrokerTransportError::MalformedResponse(_)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user