mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
Addresses the interface half of #6467. This is the contract for how a keyless agent asks a host to act on its behalf — and only that. No host, no transport, no signing, no Desktop code; the only implementation is a test double. An agent that holds no secret key still has to read its channels, answer a mention, react, keep its profile, address its memory, and create the agents it needs. This PR defines the vocabulary for those requests and the rules both sides must follow. **#6467's open question, answered: per-operation, not "sign this".** A raw signing primitive tells the host *who* is asking but not *what for*, so the only policies it can express are all-or-nothing. A closed set of named operations lets a host serve channel reads while refusing agent creation, and gives any later policy layer something to attach to. The cost is that adding an operation means changing the contract — which is the point. Signing, publishing, and the auth/encryption NIPs are deliberately *not* operations; they are mechanisms inside whoever holds the key, and a test pins that none of those names resolve. **Nine operations in v1:** read a channel (whole channel, one thread, or mentions-only — these differ by filter, not permission, so they share one operation), post, reply, react, set profile, derive a storage address, and create / update / delete agents. Presence and typing are deferred as housekeeping a host may decline regardless; streaming is deferred, so waking on a mention is a polled mentions-only read. Each operation has typed, validated arguments and a typed outcome. **What the contract enforces structurally rather than by instruction:** - *Retry means identical bytes.* A request is validated and serialized exactly once and the transport only ever sees that frozen form. There is no way to re-render it, so a retry can't drift and be mistaken for a conflicting request. - *Read cursors are opaque.* Host-issued strings, validated for shape only, never parsed or compared. The host owns ordering and paging stability instead of being locked into timestamps. - *Reads return signed Nostr events.* A keyless agent verifies authorship and content locally and trusts the host only for completeness and authorization. Reply ancestry and mentions are derived from the signed tags, so nothing can disagree with the signature. - *Validation is the only door to a response.* The transport primitive hands back an unjudged envelope; the only response a caller can actually obtain is one already checked against the request it answers — request id correlates, the outcome's operation matches, identifiers and cursors are well-formed. A mismatch counts as "no answer", not a failure verdict. The raw primitive can't be invoked from outside the crate (pinned by compile-failure checks), so skipping that step isn't an option. - *Authority is never asserted by a body.* The envelope has no requester, owner, scope, or credential field; all of that comes from the session the host authenticated. Ownership of a created agent is implicitly the requester, never a field — which is what lets agents own agents while the chain still ends at a human. - *No secret crosses the wire.* Agent creation returns a pubkey and handle, never the minted key. Every payload type rejects unknown fields, and a test pins the exact JSON key set of all eighteen argument and outcome types, so no field can be added without a reviewer touching that table. Two honest limits: a string can physically hold secret text, so keeping secrets out of message content is host policy; and nothing stops a host from *holding* keys — it just may not hand them over. - *Null is never legal; omission is the only spelling of absence.* Every optional member rejects an explicit null. Otherwise null and omitted would be indistinguishable, and a contradiction check on the response envelope could be skipped by sending one (a real hole in an earlier revision). Coverage is derived by walking every path in the real fixtures, so new optional members are covered automatically. Serializers that emit null by default (Go without `omitempty`, Python's default dump) need configuring — called out in the module docs. **Wire mapping.** One POST endpoint, JSON body is the frozen request, opaque bearer credential in the auth header. Every verdict the host reached — including failure and including a rejected credential — comes back as a well-formed envelope with 200, because a second copy of the verdict in the status line could only disagree with it. Clients still try to parse an envelope regardless of status (intermediaries may remap) and fall back to the status only when none is present. A rejected credential is a host verdict carrying the promise "the action did not run"; transport errors are reserved for "nothing can be concluded about side effects." Binding the credential to a specific agent and conversation is the host's job and deliberately unenforceable here. **Relationship to #6543.** Envelope, request-id rules, protocol version, the three-way succeeded / failed / indeterminate result, error codes, and the agent-management argument shapes carry over. Changed deliberately: pubkeys are a validated type rather than bare strings, "capability" became "action" throughout, and a new "unsupported" error lets a host decline a best-effort operation without it reading as a fault. Both PRs land in the same module path — whichever merges second needs a hand reconcile, which I own as the author of #6543. **Validation at `7c853e0fb`.** 299 tests in the sdk crate (full package run), workspace tests green apart from the pre-existing relay timeout flake, which I reproduced on unmodified `main`. Clippy with warnings denied, fmt, warning-free docs, and the pre-push gate all pass. Review findings were closed with mutation tests — each fix verified by reverting it and watching a named test fail. One of those exposed a gap in my own coverage (response-side fields weren't pinned); that's fixed and floored separately from the request side so neither can mask the other. **Size.** About 5,400 lines across 8 files, roughly half tests. Every source file is under the repo's 1,000-line cap except the test file; #6759 splits it mechanically, is stacked on this branch, retargets to `main` after merge, and lands before PR 2 of the stack. --------- Signed-off-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen <baxen@squareup.com> Signed-off-by: ss-core-02 <cca2c695c61d409321106cc399009c2cc674d72cc8900b2999a68fe54577a411@buzz.block.builderlab.xyz> Co-authored-by: ss-dev-00 <a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971@buzz.block.builderlab.xyz> Co-authored-by: ss-core-02 <cca2c695c61d409321106cc399009c2cc674d72cc8900b2999a68fe54577a411@buzz.block.builderlab.xyz>
114 lines
3.7 KiB
Rust
114 lines
3.7 KiB
Rust
#![deny(unsafe_code)]
|
|
#![warn(missing_docs)]
|
|
//! `buzz-sdk` — typed Nostr event builders for Buzz operations.
|
|
//!
|
|
//! # Mental Model
|
|
//!
|
|
//! ```text
|
|
//! caller params → builder fn → validates → EventBuilder → caller signs → Event
|
|
//! ```
|
|
//!
|
|
//! Each builder function validates its inputs and returns an [`nostr::EventBuilder`].
|
|
//! The caller signs with their own keys: `builder.sign_with_keys(&keys)?`.
|
|
//! No keys are held here. No network calls are made.
|
|
|
|
pub mod broker;
|
|
pub mod builders;
|
|
pub mod mentions;
|
|
pub mod nip_oa;
|
|
|
|
pub use builders::*;
|
|
|
|
/// Re-export kind constants so consumers don't need buzz-core directly.
|
|
pub use buzz_core::kind;
|
|
|
|
/// Thread reference for reply builders (NIP-10 markers).
|
|
///
|
|
/// - Direct reply (root == parent): emits `["e", root, "", "reply"]`
|
|
/// - Nested reply (root ≠ parent): emits `["e", root, "", "root"]` + `["e", parent, "", "reply"]`
|
|
pub struct ThreadRef {
|
|
/// The root event of the thread.
|
|
pub root_event_id: nostr::EventId,
|
|
/// The immediate parent being replied to.
|
|
pub parent_event_id: nostr::EventId,
|
|
}
|
|
|
|
/// Metadata for diff/patch messages (kind 40008).
|
|
pub struct DiffMeta {
|
|
/// Repository URL — required, must start with `http://` or `https://`.
|
|
pub repo_url: String,
|
|
/// Commit SHA — required, minimum 7 hex characters.
|
|
pub commit_sha: String,
|
|
/// Optional file path within the repository.
|
|
pub file_path: Option<String>,
|
|
/// Optional parent commit SHA — minimum 7 hex chars if present.
|
|
pub parent_commit: Option<String>,
|
|
/// Optional branch pair `(source, target)` — both or neither.
|
|
pub branch: Option<(String, String)>,
|
|
/// Optional pull request number — must be positive.
|
|
pub pr_number: Option<u32>,
|
|
/// Optional programming language identifier.
|
|
pub language: Option<String>,
|
|
/// Optional human-readable description.
|
|
pub description: Option<String>,
|
|
/// Whether the diff was truncated due to size.
|
|
pub truncated: bool,
|
|
/// Optional alt text for accessibility.
|
|
pub alt_text: Option<String>,
|
|
}
|
|
|
|
/// Vote direction for `build_vote`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum VoteDirection {
|
|
/// Upvote — content `"+"`.
|
|
Up,
|
|
/// Downvote — content `"-"`.
|
|
Down,
|
|
}
|
|
|
|
/// A NIP-30 custom emoji tag payload.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CustomEmoji {
|
|
/// The shortcode without surrounding colons.
|
|
pub shortcode: String,
|
|
/// Image URL for this custom emoji.
|
|
pub url: String,
|
|
}
|
|
|
|
/// Return a channel name without client-rendered leading hash prefixes.
|
|
pub use buzz_core::channel::canonical_channel_name;
|
|
/// Channel type.
|
|
pub use buzz_core::channel::ChannelType as ChannelKind;
|
|
/// Channel visibility.
|
|
pub use buzz_core::channel::ChannelVisibility as Visibility;
|
|
/// Member role.
|
|
pub use buzz_core::channel::MemberRole;
|
|
|
|
/// Errors returned by SDK builder functions.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum SdkError {
|
|
/// Content exceeds the maximum allowed size.
|
|
#[error("content exceeds maximum size of {max} bytes (got {got})")]
|
|
ContentTooLarge {
|
|
/// Maximum allowed bytes.
|
|
max: usize,
|
|
/// Actual byte count.
|
|
got: usize,
|
|
},
|
|
/// A tag could not be constructed.
|
|
#[error("invalid tag: {0}")]
|
|
InvalidTag(String),
|
|
/// Emoji string exceeds 64 characters.
|
|
#[error("emoji exceeds maximum length of 64 characters")]
|
|
EmojiTooLong,
|
|
/// More than 50 mentions were supplied.
|
|
#[error("too many mentions (max 50)")]
|
|
TooManyMentions,
|
|
/// Diff metadata failed validation.
|
|
#[error("invalid diff metadata: {0}")]
|
|
InvalidDiffMeta(String),
|
|
/// Input failed validation (e.g. malformed pubkey).
|
|
#[error("invalid input: {0}")]
|
|
InvalidInput(String),
|
|
}
|